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
Mohamed Boudra 0ba2f6b194 feat(cli): add paseo terminal command group for managing workspace terminals
New CLI commands: ls, create, kill, capture (tmux capture-pane style with
line range slicing and ANSI stripping), and send-keys (with special token
interpretation). Server-side adds capture_terminal_request RPC and
list-all-terminals support (optional CWD). Includes e2e tests and skill docs.
2026-03-30 17:05:14 +07:00
Mohamed Boudra 5b22aedaff feat(app): polish file explorer tree with material icons and visual refinements
Replace lucide file icons with colored SVGs from material-icon-theme covering
53 language/filetype-specific icons. Add chevron expand indicators for
directories, indent guide lines, rounded rows, and restyle the header toolbar
to match the git diff pane pattern.
2026-03-30 17:05:14 +07:00
Mohamed Boudra e3b6e2dcfa Create FUNDING.yml 2026-03-30 17:29:31 +08:00
Mohamed Boudra 0d9bda12e6 fix(app): centralize window controls padding into useWindowControlsPadding hook
Replaces useTrafficLightPadding with a role-based useWindowControlsPadding
hook that absorbs all layout-conditional logic (sidebar state, focus mode,
explorer state). Consumers no longer make platform or layout decisions —
they just apply the resolved padding values.

Fixes Windows title bar overlay buttons overlapping source control icons
when the left sidebar is open, and right padding not shifting to the
explorer sidebar header when it's open.

Also fixes borderless header using borderBottomWidth:0 (layout shift)
instead of transparent, and double-click-to-maximize bounce on the
open project screen caused by nested drag handlers.
2026-03-30 15:17:27 +07:00
Mohamed Boudra a94bc0720c refactor(server): rename worktree destroy → teardown, add port to env, extract session code
- Rename `worktree.destroy` config field to `worktree.teardown` (breaking)
- Rename getWorktreeDestroyCommands/runWorktreeDestroyCommands → teardown
- Add PASEO_WORKTREE_PORT to teardown env from persisted metadata (omit if missing)
- Extract worktree orchestration from session.ts into worktree-session.ts
- Document teardown lifecycle hooks in worktree docs
2026-03-30 14:53:27 +07:00
Mohamed Boudra 7504d20b67 refactor(server): extract SpeechService and defer init until after listen
Speech runtime was leaking 8+ individual resolvers into bootstrap and
blocking server startup for ~3s with synchronous Sherpa native model
loading. Refactor into a self-contained SpeechService that owns its
full lifecycle (init, download, monitor, cleanup) and defer start()
until after httpServer.listen() so native loading doesn't block
accepting connections. Server startup drops from ~3.2s to ~0.5s.

Also removes unused client-triggered speech model download/list path
(CLI commands, session handlers, message types) — the service manages
its own models.
2026-03-30 14:07:55 +07:00
Mohamed Boudra db39417be9 fix(app): remove redundant overflow hidden causing iOS sidebar scroll flicker
The inner sidebarContent had overflow: "hidden" while the parent
mobileSidebar already clips. On iOS, nested overflow clipping on
Animated.View with transforms and scroll containers causes rendering
artifacts during scroll.
2026-03-30 13:45:26 +07:00
86 changed files with 8505 additions and 1540 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [boudra]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+109
View File
@@ -0,0 +1,109 @@
# File Icons
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
## Adding a new icon
1. Find the icon name in the material-icon-theme manifest:
```bash
node -e "
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
console.log('languageIds:', m.languageIds['YOUR_LANG']);
"
```
2. Verify the SVG exists:
```bash
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
```
3. Add two things to `material-file-icons.ts`:
- The SVG string in `SVG_ICONS`:
```ts
"icon_name": `<svg ...>...</svg>`,
```
- The extension mapping in `EXTENSION_TO_ICON`:
```ts
"ext": "icon_name",
```
4. Run `npm run typecheck` to verify.
## Currently included icons
53 unique icons covering these extensions:
| Extension(s) | Icon |
|---|---|
| `ts` | typescript |
| `tsx` | react_ts |
| `js` | javascript |
| `jsx` | react |
| `py` | python |
| `go` | go |
| `rs` | rust |
| `rb` | ruby |
| `java` | java |
| `kt` | kotlin |
| `c` | c |
| `cpp` | cpp |
| `h` | h |
| `hpp` | hpp |
| `cs` | csharp |
| `swift` | swift |
| `dart` | dart |
| `ex`, `exs` | elixir |
| `erl` | erlang |
| `hs` | haskell |
| `clj` | clojure |
| `scala` | scala |
| `ml` | ocaml |
| `r` | r |
| `lua` | lua |
| `zig` | zig |
| `nix` | nix |
| `php` | php |
| `html` | html |
| `css` | css |
| `scss` | sass |
| `less` | less |
| `json` | json |
| `yml`, `yaml` | yaml |
| `xml` | xml |
| `toml` | toml |
| `md`, `markdown` | markdown |
| `sql` | database |
| `graphql`, `gql` | graphql |
| `sh`, `bash` | console |
| `tf` | terraform |
| `hcl` | hcl |
| `vue` | vue |
| `svelte` | svelte |
| `astro` | astro |
| `wasm` | webassembly |
| `svg` | svg |
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
| `txt` | document |
| `conf`, `cfg`, `ini` | settings |
| `lock` | lock |
| `groovy` | groovy |
| `gradle` | gradle |
+103
View File
@@ -14831,6 +14831,13 @@
"node": ">=10"
}
},
"node_modules/chroma-js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz",
"integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==",
"dev": true,
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/chrome-launcher": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz",
@@ -15855,6 +15862,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/deep-rename-keys": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/deep-rename-keys/-/deep-rename-keys-0.2.1.tgz",
"integrity": "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"kind-of": "^3.0.2",
"rename-keys": "^1.1.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-rename-keys/node_modules/kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
"integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-buffer": "^1.1.5"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -18423,6 +18457,13 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"dev": true,
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -25292,6 +25333,25 @@
"node": ">=10"
}
},
"node_modules/material-icon-theme": {
"version": "5.32.0",
"resolved": "https://registry.npmjs.org/material-icon-theme/-/material-icon-theme-5.32.0.tgz",
"integrity": "sha512-SxJxCcnk6cJIbd+AxmoeghXJ24joXGmUzjiGci16sX4mXZdXprGEzM6ZZ0VHGAofxNlMqznEbExINwFLsxf8eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chroma-js": "^3.1.2",
"events": "^3.3.0",
"fast-deep-equal": "^3.1.3",
"svgson": "^5.3.1"
},
"engines": {
"vscode": "^1.55.0"
},
"funding": {
"url": "https://github.com/sponsors/material-extensions"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -30156,6 +30216,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/rename-keys": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz",
"integrity": "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -32144,6 +32214,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svgson": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/svgson/-/svgson-5.3.1.tgz",
"integrity": "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"deep-rename-keys": "^0.2.1",
"xml-reader": "2.4.3"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -34550,6 +34631,16 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/xml-lexer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/xml-lexer/-/xml-lexer-0.2.2.tgz",
"integrity": "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^2.0.0"
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
@@ -34560,6 +34651,17 @@
"node": ">=12"
}
},
"node_modules/xml-reader": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz",
"integrity": "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^2.0.0",
"xml-lexer": "^0.2.2"
}
},
"node_modules/xml2js": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz",
@@ -34947,6 +35049,7 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
+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();
}
});
});
+1
View File
@@ -111,6 +111,7 @@
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"material-icon-theme": "^5.32.0",
"playwright": "^1.56.1",
"typescript": "~5.9.2",
"vitest": "^3.2.4",
@@ -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 };
}
@@ -17,6 +17,7 @@ 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";
import { useWindowControlsPadding } from "@/utils/desktop-window";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -336,12 +337,13 @@ function SidebarContent({
onOpenFile,
}: SidebarContentProps) {
const { theme } = useUnistyles();
const padding = useWindowControlsPadding("explorerSidebar");
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={styles.header} testID="explorer-header">
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
@@ -26,18 +26,17 @@ import Animated, {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import { SvgXml } from "react-native-svg";
import {
ChevronDown,
ChevronRight,
Copy,
Download,
File,
FileText,
Folder,
FolderOpen,
Image as ImageIcon,
MoreVertical,
RotateCw,
X,
} from "lucide-react-native";
import { getFileIconSvg } from "@/components/material-file-icons";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
@@ -65,7 +64,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "size", label: "Size" },
];
const INDENT_PER_LEVEL = 12;
const INDENT_PER_LEVEL = 16;
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
@@ -384,7 +383,6 @@ export function FileExplorerPane({
({ item }: ListRenderItemInfo<TreeRow>) => {
const entry = item.entry;
const depth = item.depth;
const displayKind = getEntryDisplayKind(entry);
const isDirectory = entry.kind === "directory";
const isExpanded = isDirectory && expandedPaths.has(entry.path);
const isSelected = selectedEntryPath === entry.path;
@@ -399,16 +397,30 @@ export function FileExplorerPane({
(hovered || pressed || isSelected) && styles.entryRowActive,
]}
>
{depth > 0 &&
Array.from({ length: depth }, (_, i) => (
<View
key={i}
style={[
styles.indentGuide,
{
left: theme.spacing[3] + i * INDENT_PER_LEVEL + 4,
},
]}
/>
))}
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{loading ? (
<ActivityIndicator size="small" />
{isDirectory ? (
loading ? (
<ActivityIndicator size="small" />
) : (
<View style={[styles.chevron, isExpanded && styles.chevronExpanded]}>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</View>
)
) : (
renderEntryIcon(isDirectory ? "directory" : displayKind, {
foreground: theme.colors.foregroundMuted,
primary: theme.colors.primary,
directoryOpen: isExpanded,
})
<SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />
)}
</View>
<Text style={styles.entryName} numberOfLines={1}>
@@ -552,27 +564,31 @@ export function FileExplorerPane({
) : (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<View style={styles.paneHeaderLeft} />
<View style={styles.paneHeaderRight}>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
</Pressable>
</View>
<Pressable
onPress={handleSortCycle}
style={({ hovered, pressed }) => [
styles.sortTrigger,
(hovered || pressed) && styles.sortTriggerHovered,
]}
>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={({ hovered, pressed }) => [
styles.iconButton,
(hovered || pressed) && styles.iconButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Refresh files"
>
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Animated.View>
</Pressable>
</View>
<FlatList
ref={treeListRef}
@@ -609,99 +625,6 @@ export function FileExplorerPane({
);
}
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]);
const TEXT_EXTENSIONS = new Set([
"txt",
"md",
"markdown",
"ts",
"tsx",
"js",
"jsx",
"json",
"yml",
"yaml",
"toml",
"py",
"rb",
"go",
"rs",
"java",
"kt",
"c",
"cpp",
"cc",
"h",
"hpp",
"cs",
"swift",
"php",
"html",
"css",
"scss",
"less",
"xml",
"sh",
"bash",
"zsh",
"ini",
"cfg",
"conf",
]);
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string; directoryOpen?: boolean },
) {
const color = colors.foreground;
switch (kind) {
case "directory":
return colors.directoryOpen ? (
<FolderOpen size={18} color={colors.primary} />
) : (
<Folder size={18} color={colors.primary} />
);
case "image":
return <ImageIcon size={18} color={color} />;
case "text":
return <FileText size={18} color={color} />;
default:
return <File size={18} color={color} />;
}
}
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
if (entry.kind === "directory") {
return "directory";
}
const extension = getExtension(entry.name);
if (extension === null) {
return "other";
}
if (IMAGE_EXTENSIONS.has(extension)) {
return "image";
}
if (TEXT_EXTENSIONS.has(extension)) {
return "text";
}
return "other";
}
function getExtension(name: string): string | null {
const index = name.lastIndexOf(".");
if (index === -1 || index === name.length - 1) {
return null;
}
return name.slice(index + 1).toLowerCase();
}
function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] {
const sorted = [...entries];
sorted.sort((a, b) => {
@@ -842,50 +765,38 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0,
},
paneHeader: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
paddingHorizontal: theme.spacing[3],
paddingRight: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
paneHeaderLeft: {
flex: 1,
minWidth: 0,
},
paneHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
previewHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
sortButton: {
height: 28,
sortTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
gap: theme.spacing[1],
marginLeft: theme.spacing[3] - theme.spacing[1],
paddingHorizontal: theme.spacing[1],
height: 24,
borderRadius: theme.borderRadius.base,
},
sortButtonText: {
color: theme.colors.foregroundMuted,
sortTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
sortTriggerText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
treeList: {
flex: 1,
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
centerState: {
@@ -936,8 +847,16 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
entryRowActive: {
backgroundColor: theme.colors.surface1,
},
indentGuide: {
position: "absolute",
top: 0,
bottom: 0,
width: 1,
backgroundColor: theme.colors.surface2,
},
entryInfo: {
@@ -947,6 +866,16 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
minWidth: 0,
},
chevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
chevronExpanded: {
transform: [{ rotate: "90deg" }],
},
entryIcon: {
flexShrink: 0,
},
@@ -7,8 +7,7 @@ import {
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
} from "@/constants/layout";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { usePanelStore } from "@/stores/panel-store";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
interface ScreenHeaderProps {
left?: ReactNode;
@@ -26,15 +25,10 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const trafficLightPadding = useTrafficLightPadding();
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;
const baseHorizontalPadding = theme.spacing[2];
const collapsedSidebarInset =
!isMobile && !desktopAgentListOpen && trafficLightPadding.side
? trafficLightPadding
: { left: 0, right: 0 };
const dragHandlers = useDesktopDragHandlers();
@@ -45,8 +39,8 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }:
style={[
styles.row,
{
paddingLeft: baseHorizontalPadding + collapsedSidebarInset.left,
paddingRight: baseHorizontalPadding + collapsedSidebarInset.right,
paddingLeft: baseHorizontalPadding + padding.left,
paddingRight: baseHorizontalPadding + padding.right,
},
borderless && styles.borderless,
]}
@@ -90,6 +84,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
borderless: {
borderBottomWidth: 0,
borderBottomColor: "transparent",
},
}));
+3 -6
View File
@@ -35,7 +35,7 @@ import {
type SidebarProjectEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window";
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
import { Combobox } from "@/components/ui/combobox";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
@@ -632,7 +632,7 @@ function DesktopSidebar({
}: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const dragHandlers = useDesktopDragHandlers();
const trafficLightPadding = useTrafficLightPadding();
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
@@ -682,9 +682,7 @@ function DesktopSidebar({
return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
{trafficLightPadding.side === 'left' ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null}
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
<View style={styles.sidebarHeader} {...dragHandlers}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
@@ -811,7 +809,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarContent: {
flex: 1,
minHeight: 0,
overflow: "hidden",
},
desktopSidebar: {
position: "relative",
@@ -0,0 +1,141 @@
// Auto-generated from material-icon-theme. Do not edit manually.
const SVG_ICONS: Record<string, string> = {
"_default": `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
"astro": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>`,
"c": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>`,
"clojure": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>`,
"console": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>`,
"cpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
"csharp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
"css": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>`,
"dart": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>`,
"database": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>`,
"document": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>`,
"elixir": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>`,
"erlang": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>`,
"go": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00acc1" d="M2 12h4v2H2zm-2 4h6v2H0zm4 4h2v2H4zm16.954-5H14v3h3.239a4.42 4.42 0 0 1-3.531 2 2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 15.292 13a2.73 2.73 0 0 1 1.749.584l2.962-1.185A5.6 5.6 0 0 0 15.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 6.4 6.4 0 0 0 .003-1.5"/><path fill="#00acc1" d="M26.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 5.614 5.614 0 0 0-5.659-6.5m2.681 6.137A4.515 4.515 0 0 1 24.708 20a2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 26.292 13a2.65 2.65 0 0 1 2.053.858 2.86 2.86 0 0 1 .628 2.28Z"/></svg>`,
"gradle": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0097a7" d="M16 10v2h6c-2 0-3-2-6-2"/><path fill="#0097a7" d="M26 4h-2a4 4 0 0 0-4 4h4a1 1 0 0 1 2 0v4H16v-2h-5.317A2.683 2.683 0 0 0 8 12.683v2.634A2.683 2.683 0 0 0 10.683 18H16v2h-5.98A4.02 4.02 0 0 1 6 16v-2c-2 0-4 4-4 8 0 5 1 6 2 6h4v-4h4v4h4v-4h4v4h4v-6a2 2 0 0 0 2-2v-2a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4m-4 12h-2a2 2 0 0 1-2-2h6a2 2 0 0 1-2 2"/></svg>`,
"graphql": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M6 20h20v2H6z"/><circle cx="7" cy="21" r="3" fill="#ec407a"/><circle cx="16" cy="27" r="3" fill="#ec407a"/><circle cx="25" cy="21" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 10h20v2H6z"/><circle cx="7" cy="11" r="3" fill="#ec407a"/><circle cx="16" cy="5" r="3" fill="#ec407a"/><circle cx="25" cy="11" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 12h2v10H6zm18-2h2v12h-2z"/><path fill="#ec407a" d="m5.014 19.41 11.674 6.866L15.674 28 4 21.134z"/><path fill="#ec407a" d="M26.688 21.724 15.014 28.59 14 26.866 25.674 20zM5.124 10.382l11.415-7.29 1.077 1.686L6.2 12.068z"/><path fill="#ec407a" d="m25.798 12.067-11.415-7.29 1.077-1.685 11.415 7.29zM6.2 19.932l11.416 7.29-1.077 1.686-11.415-7.29z"/><path fill="#ec407a" d="m26.875 21.619-11.415 7.29-1.077-1.687 11.415-7.289zM5.877 22.6 16.04 3.686l1.762.946L7.638 23.546z"/><path fill="#ec407a" d="M24.361 23.545 14.197 4.633l1.761-.947 10.165 18.913z"/></svg>`,
"groovy": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#26c6da" d="M19.322 2a6.5 6.5 0 0 1 4.352 1.419 4.55 4.55 0 0 1 1.685 3.662 5.82 5.82 0 0 1-1.886 4.275 6.04 6.04 0 0 1-4.34 1.846 4.15 4.15 0 0 1-2.385-.649 1.91 1.91 0 0 1-.936-1.603 1.6 1.6 0 0 1 .356-1.024 1.1 1.1 0 0 1 .861-.447q.469 0 .468.504a.79.79 0 0 0 .358.693 1.43 1.43 0 0 0 .826.245 3.1 3.1 0 0 0 2.39-1.573 5.66 5.66 0 0 0 1.154-3.39 2.64 2.64 0 0 0-.891-2.064 3.28 3.28 0 0 0-2.293-.812 6.18 6.18 0 0 0-4.086 1.736 12.9 12.9 0 0 0-3.215 4.557 13.4 13.4 0 0 0-1.233 5.36 5.86 5.86 0 0 0 1.091 3.723 3.53 3.53 0 0 0 2.905 1.372q3.058 0 5.848-4.002l2.935-.388q.546-.07.545.246a8 8 0 0 1-.423 1.24q-.421 1.097-1.152 3.668A12.7 12.7 0 0 0 26 17.72v1.66a14.2 14.2 0 0 1-4.055 2.57 10.38 10.38 0 0 1-2.764 5.931 6.7 6.7 0 0 1-4.806 2.11 3.3 3.3 0 0 1-2.012-.55 1.8 1.8 0 0 1-.718-1.514q0-2.685 5.634-5.212.532-1.766 1.152-3.507a8.6 8.6 0 0 1-2.853 2.323 7.4 7.4 0 0 1-3.48 1.01 5.46 5.46 0 0 1-4.366-2.093A8.1 8.1 0 0 1 6 15.122a11.6 11.6 0 0 1 1.966-6.426 14.7 14.7 0 0 1 5.162-4.862A12.44 12.44 0 0 1 19.322 2m-2.407 22.17q-4.055 1.875-4.054 3.695a.87.87 0 0 0 .999.97q1.964 0 3.055-4.665"/></svg>`,
"h": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M18.5 11a5.49 5.49 0 0 0-4.5 2.344V4H8v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
"haskell": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g stroke-width="2.422"><path fill="#ef5350" d="m23.928 240.5 59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z"/><path fill="#ffa726" d="m83.869 240.5 59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z"/><path fill="#ffee58" d="m228.72 188.08-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924-19.98-29.953h99.901v29.953z"/></g></svg>`,
"hcl": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#eceff1" d="M18 1.2V14h-4v-4l-4 2v16.37l4 2.43V18h4v4l4-2V3.63z"/><path fill="#eceff1" d="M14 1.2 2 8.49v15.02l4 2.43v-15.2l8-4.86zm12 4.86v15.2l-8 4.86v4.68l12-7.29V8.49z"/></svg>`,
"hpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 6V2h-2v4h-6V2h-2v4h-4v2h4v4h2V8h6v4h2V8h4V6zm-15.5 5A5.49 5.49 0 0 0 8 13.344V4H2v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
"html": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#e65100" d="m4 4 2 22 10 2 10-2 2-22Zm19.72 7H11.28l.29 3h11.86l-.802 9.335L15.99 25l-6.635-1.646L8.93 19h3.02l.19 2 3.86.77 3.84-.77.29-4H8.84L8 8h16Z"/></svg>`,
"image": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M8.5 6h4l-4-4zM3.875 1H9.5l4 4v8.6c0 .773-.616 1.4-1.375 1.4h-8.25c-.76 0-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4 1.375-1.4M4 13.6h8V8l-2.625 2.8L8 9.4zm1.25-7.7c-.76 0-1.375.627-1.375 1.4s.616 1.4 1.375 1.4c.76 0 1.375-.627 1.375-1.4S6.009 5.9 5.25 5.9"/></svg>`,
"java": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="M4 26h24v2H4zM28 4H7a1 1 0 0 0-1 1v13a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-4h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 8h-4V6h4Z"/></svg>`,
"javascript": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2 2v12h12V2zm6 6h1v4a1.003 1.003 0 0 1-1 1H7a1.003 1.003 0 0 1-1-1v-1h1v1h1zm3 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
"json": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>`,
"kotlin": `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><defs><linearGradient id="a" x1="1.725" x2="22.185" y1="22.67" y2="1.982" gradientTransform="translate(1.306 1.129)scale(.89324)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7c4dff"/><stop offset=".5" stop-color="#d500f9"/><stop offset="1" stop-color="#ef5350"/></linearGradient></defs><path fill="url(#a)" d="M2.975 2.976v18.048h18.05v-.03l-4.478-4.511-4.48-4.515 4.48-4.515 4.443-4.477z"/></svg>`,
"less": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0277bd" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3z"/></svg>`,
"lock": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffd54f" d="M25 12h-3V8a6 6 0 0 0-12 0v4H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V13a1 1 0 0 0-1-1M14 8a2 2 0 0 1 4 0v4h-4Zm2 17a4 4 0 1 1 4-4 4 4 0 0 1-4 4"/></svg>`,
"lua": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="M30 6a3.86 3.86 0 0 1-1.167 2.833 4.024 4.024 0 0 1-5.666 0A3.86 3.86 0 0 1 22 6a3.86 3.86 0 0 1 1.167-2.833 4.024 4.024 0 0 1 5.666 0A3.86 3.86 0 0 1 30 6m-9.208 5.208A10.6 10.6 0 0 0 13 8a10.6 10.6 0 0 0-7.792 3.208A10.6 10.6 0 0 0 2 19a10.6 10.6 0 0 0 3.208 7.792A10.6 10.6 0 0 0 13 30a10.6 10.6 0 0 0 7.792-3.208A10.6 10.6 0 0 0 24 19a10.6 10.6 0 0 0-3.208-7.792m-1.959 7.625a4.024 4.024 0 0 1-5.666 0 4.024 4.024 0 0 1 0-5.666 4.024 4.024 0 0 1 5.666 0 4.024 4.024 0 0 1 0 5.666"/></svg>`,
"markdown": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"/></svg>`,
"nix": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g stroke-width=".395"><path fill="#1976d2" d="M133.347 451.499c0-.295-2.752-5.283-6.116-11.084s-6.116-10.776-6.116-11.055 9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694s-11.836.21-26.302.154c-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.364.96h-13.23c-10.895 0-13.228-.095-13.228-.538zm167.58-125.61c-.134-.216 1.189-2.863 2.939-5.882 6.924-11.944 84.29-145.75 96.49-166.88 7.143-12.371 13.143-22.465 13.334-22.433.362.062 25.86 43.105 25.86 43.655 0 .174-6.761 11.952-15.025 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.563.564 30.213.564h29.996l.957 1.48c.527.814 3.296 5.547 6.155 10.518s5.45 9.29 5.757 9.597c.705.705.703.724-.16 1.572-.396.388-3.36 5.323-6.588 10.965-3.228 5.643-6.056 10.387-6.285 10.543s-19.695.171-43.256.034l-42.84-.249-.803 1.15c-.442.632-7.505 12.736-15.696 26.897l-14.892 25.747h-15.486c-8.518 0-20.015.116-25.551.259-6.55.168-10.15.121-10.308-.135zm-133.75-157.86c-56.373-.055-102.5-.182-102.5-.282s5.617-10.132 12.481-22.294L89.64 123.34h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427s-14.797-25.917-14.797-26.04 2.682-4.853 5.96-10.51 6.003-10.578 6.056-10.934c.086-.586 1.375-.648 13.572-.648 7.412 0 13.463.143 13.446.317-.018.174.22.707.53 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.117.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z"/><path fill="#64b5f6" d="M23.046 238.939c-6.098 10.563-6.69 11.711-6.224 12.078.282.224 3.18 5.044 6.44 10.712s6.016 10.355 6.123 10.417c.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.473.273-1.08 3.094-14.637 26.574l-15.166 26.269 12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956s23.257-39.831 51.492-88.624c11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.096-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.023.037c-.012.003-1.622-.001-1.826 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.567.03-1.147-.03-1.29-.132-.141-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.2 21.131 89.393 155.03 96.276 167 1.503 2.613 2.92 4.803 3.443 5.348.9-1.249 3.532-5.63 7.954-13.219a1343 1343 0 0 1 10.05-17.76l6.606-11.443c.691-1.403.753-1.818.652-2.117-.161-.48-6.903-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.99-26.32a.73.73 0 0 1-.01-.366l-.426-.913 21.636-36.976c3.69-6.307 6.425-11.042 9.471-16.29 9.158-15.948 12.036-21.189 11.895-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.144.035-.42.112-5.455.195-11.19.185s-11.22.024-12.187.073l-1.76.089-14.998 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.271.05-.256-.148-7.144-11.902-15.306-26.119L279.4 48.817c-.116-.186-.444-.744-.458-.752-.476-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.043.48.328 1.05 25.338 43.735 25.772 43.985.206.119 14.178.239 31.05.266 26.65.044 30.749.152 31.234.832.307.43 9.987 17.214 21.513 37.296s21.152 36.627 21.394 36.767 5.926.243 12.633.23c6.705-.013 12.4.099 12.657.246.131.076.381-.141.851-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.44-9.774-.196-.712-.066-.822 1.155-.98 1.956-.252 57.397-.057 58.071.205.237.092.79-.569 2.593-3.497 1.866-3.067 5.03-8.524 11.001-18.866 7.22-12.505 13.043-22.784 12.941-22.843s-.77-.051-1.489.016l-.046.001c-4.451.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.12.01-2.095.076-2.66.172a.3.3 0 0 0-.062.083z"/></g></svg>`,
"ocaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m12.019 15.021.003-.008c-.005-.021-.006-.026-.003.008"/><path fill="#ff9800" d="M4.51 3.273a2.523 2.523 0 0 0-2.524 2.523V11.3c.361-.13.88-.898 1.043-1.085.285-.327.337-.743.478-1.006C3.83 8.612 3.886 8.2 4.62 8.2c.342 0 .478.08.71.39.16.216.438.615.568.882.15.307.396.724.503.808q.122.095.233.137c.119.044.218-.037.297-.1.102-.082.145-.247.24-.467.135-.317.283-.697.367-.83.146-.23.195-.501.352-.633.232-.195.535-.208.618-.225.466-.092.677.225.907.43.15.133.355.403.5.765.114.283.26.544.32.707.059.158.203.41.289.713.077.275.286.486.365.616 0 0 .121.34.858.65.16.067.482.176.674.246.32.116.63.101 1.025.054.281 0 .434-.408.562-.734.075-.193.148-.745.197-.902.048-.153-.064-.27.031-.405.112-.156.178-.164.242-.368.138-.436.936-.458 1.384-.458.374 0 .327.363.96.239.364-.072.714.046 1.1.149.324.086.63.184.812.398.119.139.412.834.113.863.029.035.05.099.104.134-.067.262-.357.075-.518.041-.217-.045-.37.007-.583.101-.363.162-.894.143-1.21.407-.27.223-.269.721-.394 1 0 0-.348.895-1.106 1.443-.194.14-.574.477-1.4.605a5.3 5.3 0 0 1-1.1.043c-.186-.009-.362-.018-.549-.02-.11-.002-.48-.013-.461.022l-.041.103.024.138c.015.083.019.149.022.225.006.157-.013.32-.005.478.017.328.138.627.154.958.017.368.199.758.375 1.059.067.114.169.128.213.269.052.161.003.333.028.505.1.668.292 1.366.592 1.97l.008.014c.371-.062.743-.196 1.226-.267.885-.132 2.115-.064 2.906-.138 2-.188 3.085.82 4.882.407V5.796a2.523 2.523 0 0 0-2.523-2.523zm-.907 11.144q-.022 0-.046.003c-.159.025-.313.08-.412.24-.08.13-.108.355-.164.505-.064.175-.176.338-.274.505-.18.305-.504.581-.644.879-.028.06-.053.13-.076.2v3.402c.163.028.333.062.524.113 1.407.375 1.75.407 3.13.25l.13-.018c.105-.22.187-.968.255-1.2.054-.178.127-.32.155-.5.026-.173-.003-.337-.017-.493-.04-.393.285-.533.44-.87.14-.304.22-.651.336-.963.11-.298.284-.721.579-.872-.036-.041-.617-.06-.772-.076a5 5 0 0 1-.5-.07c-.314-.064-.656-.126-.965-.2a10 10 0 0 1-.947-.328c-.298-.138-.503-.497-.732-.507m5.737.83c-.74.149-.97.876-1.32 1.451-.192.319-.396.59-.548.928-.14.312-.128.657-.368.924a2.55 2.55 0 0 0-.528.922c-.023.067-.088.776-.158.943l1.101-.078c1.026.07.73.464 2.332.378l2.529-.078a7 7 0 0 0-.228-.588c-.07-.147-.16-.434-.218-.56a3.5 3.5 0 0 0-.309-.526c-.184-.215-.227-.23-.28-.503-.095-.473-.344-1.33-.637-1.923-.151-.306-.403-.562-.634-.784-.2-.195-.655-.522-.734-.505z"/></svg>`,
"php": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09q-.42.33-1.29.33h-.87l.53-2.76zm-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59zm7.25-8.33-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18s.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35zM18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76zm-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75z"/></svg>`,
"python": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"/><path fill="#fdd835" d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"/></svg>`,
"r": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1976d2" d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1 1 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z"/></svg>`,
"react": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00bcd4" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#00bcd4" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#00bcd4" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#00bcd4" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
"react_ts": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#0288d1" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#0288d1" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#0288d1" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
"ruby": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f44336" d="M18.041 3.177c2.24.382 2.879 1.919 2.843 3.527V6.67l-1.013 13.266-13.132.897h.008c-1.093-.044-3.518-.151-3.634-3.545l1.217-2.222 2.462 5.74 2.097-6.77-.045.009.018-.018 6.85 2.186L13.945 9.3l6.53-.409-5.144-4.212 2.71-1.51v.009M3.113 17.252v.017zM6.916 6.874c2.63-2.622 6.033-4.168 7.34-2.844 1.297 1.306-.072 4.523-2.702 7.135-2.666 2.613-6.015 4.248-7.322 2.933-1.306-1.324.036-4.612 2.675-7.224z"/></svg>`,
"rust": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff7043" d="m30 12-4-2V6h-4l-2-4-4 2-4-2-2 4H6v4l-4 2 2 4-2 4 4 2v4h4l2 4 4-2 4 2 2-4h4v-4l4-2-2-4ZM6 16a9.9 9.9 0 0 1 .842-4H10v8H6.842A9.9 9.9 0 0 1 6 16m10 10a9.98 9.98 0 0 1-7.978-4H16v-2h-2v-2h4c.819.819.297 2.308 1.179 3.37a1.89 1.89 0 0 0 1.46.63h3.34A9.98 9.98 0 0 1 16 26m-2-12v-2h4a1 1 0 0 1 0 2Zm11.158 6H24a2.006 2.006 0 0 1-2-2 2 2 0 0 0-2-2 3 3 0 0 0 3-3q0-.08-.004-.161A3.115 3.115 0 0 0 19.83 10H8.022a9.986 9.986 0 0 1 17.136 10"/></svg>`,
"sass": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M27.837 5.673a4.33 4.33 0 0 0-2.293-2.701c-2.362-1.261-6.11-1.298-9.548-.092a26.3 26.3 0 0 0-8.76 4.966c-2.752 2.542-3.438 4.925-3.189 6.194.523 2.668 3.274 4.539 5.485 6.042.418.284.822.559 1.175.816-1.429.76-4.261 2.444-5.088 4.248a3.88 3.88 0 0 0-.118 3.332A2.37 2.37 0 0 0 6.869 29.8a5.6 5.6 0 0 0 1.49.2 6.35 6.35 0 0 0 5.19-2.856 6.74 6.74 0 0 0 .864-5.382 7.3 7.3 0 0 1 2.044-.03 3.92 3.92 0 0 1 2.816 1.311 1.82 1.82 0 0 1 .423 1.262 1.55 1.55 0 0 1-.772 1.05c-.234.14-.586.355-.504.803.036.194.198.633.894.512a2.93 2.93 0 0 0 2.145-2.651 4 4 0 0 0-1.197-2.904 5.94 5.94 0 0 0-4.396-1.626 10.6 10.6 0 0 0-2.672.304 20 20 0 0 0-2.203-1.846c-1.712-1.3-3.33-2.529-3.235-4.26.125-2.263 2.468-4.532 6.964-6.744 4.016-1.976 7.254-2.037 8.944-1.438a2 2 0 0 1 1.204.883 2.77 2.77 0 0 1-.36 2.47 9.71 9.71 0 0 1-7.425 4.304 3.86 3.86 0 0 1-3.238-.757c-.278-.302-.593-.645-1.074-.383q-.565.31-.225 1.189a3.9 3.9 0 0 0 2.407 1.92 11.7 11.7 0 0 0 7.128-.671c3.527-1.35 6.681-5.202 5.756-8.787M11.895 24.475a4 4 0 0 1-.192.468 4.5 4.5 0 0 1-.753 1.081 2.83 2.83 0 0 1-2.533 1.107c-.056-.032-.078-.146-.085-.193a3.28 3.28 0 0 1 1.076-2.284 11.3 11.3 0 0 1 2.644-1.933 3.85 3.85 0 0 1-.157 1.754"/></svg>`,
"scala": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="m6.457 9.894 12.523 5.163-.456 1.211L6 11.105Zm7.02-3.091L26 11.966l-.457 1.21L13.02 8.015ZM6.465 18.885l12.524 5.163-.457 1.21L6.01 20.097Zm7.007-3.086 12.524 5.163-.456 1.21-12.524-5.162Z"/><path fill="#f44336" d="M6 24.07V30l19.997-3.106V20.96zM6 5.11v5.99l20-3.11V2zm0 9.96v5.03l20-3.11v-5.03z"/></svg>`,
"settings": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.6.6 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1q.09.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64zm-1.98-1.71c.04.31.05.52.05.73s-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2"/></svg>`,
"svelte": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><path fill="#ff5722" d="M175.94 24.328c-13.037.252-26.009 3.872-37.471 11.174L79.912 72.818a67.13 67.13 0 0 0-30.355 44.906 70.8 70.8 0 0 0 6.959 45.445 67.2 67.2 0 0 0-10.035 25.102 71.54 71.54 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.36 67.36 0 0 0 30.355-44.906 70.77 70.77 0 0 0-6.982-45.422 67.65 67.65 0 0 0 10.059-25.102 71.63 71.63 0 0 0-12.236-54.156v-.18c-15.324-21.925-40.453-33.727-65.342-33.246zm5.137 28.68a46.5 46.5 0 0 1 36.09 19.969 42.98 42.98 0 0 1 7.365 32.557 45 45 0 0 1-1.393 5.455l-1.123 3.37-2.986-2.247a75.9 75.9 0 0 0-22.902-11.45l-2.244-.651.201-2.246a13.16 13.16 0 0 0-2.379-8.711 13.99 13.99 0 0 0-14.953-5.412 12.8 12.8 0 0 0-3.594 1.572l-58.578 37.25a12.24 12.24 0 0 0-5.502 8.15 13.1 13.1 0 0 0 2.246 9.834 14.03 14.03 0 0 0 14.93 5.569 13.5 13.5 0 0 0 3.594-1.573l22.453-14.234a41.8 41.8 0 0 1 11.898-5.232 46.48 46.48 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.363 32.557 40.42 40.42 0 0 1-18.254 27.078l-58.58 37.316a43 43 0 0 1-11.898 5.23A46.545 46.545 0 0 1 82.81 227.14a42.98 42.98 0 0 1-7.341-32.557 38 38 0 0 1 1.39-5.41l1.102-3.37 3.008 2.246a75.9 75.9 0 0 0 22.836 11.361l2.244.65-.201 2.247a13.25 13.25 0 0 0 2.447 8.644 14.03 14.03 0 0 0 15.043 5.569 13.1 13.1 0 0 0 3.592-1.573l58.467-37.316a12.17 12.17 0 0 0 5.502-8.173 12.96 12.96 0 0 0-2.246-9.811 14.03 14.03 0 0 0-15.043-5.568 12.8 12.8 0 0 0-3.592 1.57l-22.453 14.258a42.9 42.9 0 0 1-11.877 5.209 46.52 46.52 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.297-32.557A40.42 40.42 0 0 1 96.798 96.98l58.646-37.316a42.8 42.8 0 0 1 11.811-5.21 46.5 46.5 0 0 1 13.822-1.444z"/></svg>`,
"svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffb300" d="M29.168 14.03a2.7 2.7 0 0 0-1.968-.83 2.51 2.51 0 0 0-1.929.8h-4.443l3.078-3.078a2.835 2.835 0 0 0 2.857-2.842 2.6 2.6 0 0 0-.831-1.969 2.82 2.82 0 0 0-2.014-.788 2.67 2.67 0 0 0-1.968.788 2.36 2.36 0 0 0-.812 1.922L18 11.17V6.726a2.51 2.51 0 0 0 .8-1.929 2.7 2.7 0 0 0-.832-1.968 2.745 2.745 0 0 0-3.936 0 2.7 2.7 0 0 0-.832 1.968 2.51 2.51 0 0 0 .8 1.93v4.443l-3.138-3.138a2.36 2.36 0 0 0-.812-1.922 2.66 2.66 0 0 0-1.968-.788 2.83 2.83 0 0 0-2.014.788 2.6 2.6 0 0 0-.831 1.969 2.74 2.74 0 0 0 .831 2.013 2.8 2.8 0 0 0 2.026.829l3.078 3.078H6.729a2.51 2.51 0 0 0-1.929-.8 2.7 2.7 0 0 0-1.968.831 2.745 2.745 0 0 0 0 3.937 2.7 2.7 0 0 0 1.968.832 2.51 2.51 0 0 0 1.929-.8h4.443l-3.078 3.077a2.835 2.835 0 0 0-2.857 2.842 2.6 2.6 0 0 0 .831 1.969 2.82 2.82 0 0 0 2.014.788 2.67 2.67 0 0 0 1.968-.788 2.36 2.36 0 0 0 .812-1.922L14 20.827v4.444a2.51 2.51 0 0 0-.8 1.929 2.784 2.784 0 0 0 4.768 1.968A2.7 2.7 0 0 0 18.8 27.2a2.51 2.51 0 0 0-.8-1.929v-4.444l3.138 3.138a2.36 2.36 0 0 0 .812 1.922 2.66 2.66 0 0 0 1.968.788 2.83 2.83 0 0 0 2.014-.788 2.6 2.6 0 0 0 .831-1.969 2.74 2.74 0 0 0-.831-2.013 2.8 2.8 0 0 0-2.026-.829L20.828 18h4.443a2.51 2.51 0 0 0 1.93.8 2.784 2.784 0 0 0 1.967-4.769Z"/></svg>`,
"swift": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff6e40" d="M17.087 19.721c-2.36 1.36-5.59 1.5-8.86.1a13.8 13.8 0 0 1-6.23-5.32c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17"/></svg>`,
"terraform": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#5c6bc0" d="m2 10 8 4V6L2 2zm10 5 8 4v-8l-8-4zm0 11 8 4v-8l-8-4zm10-14v8l8-4V8z"/></svg>`,
"toml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cfd8dc" d="M4 6V4h8v2H9v7H7V6z"/><path fill="#ef5350" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z"/></svg>`,
"typescript": `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 16 16"><path fill="#0288d1" d="M2 2v12h12V2zm4 6h3v1H8v4H7V9H6zm5 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
"vue": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#41b883" d="M1.791 3.851 12 21.471 22.209 3.936V3.85H18.24l-6.18 10.616L5.906 3.851z"/><path fill="#35495e" d="m5.907 3.851 6.152 10.617L18.24 3.851h-3.723L12.084 8.03 9.66 3.85z"/></svg>`,
"webassembly": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M22 18h4v4h-4z"/><path fill="#7c4dff" d="M20 2a4 4 0 0 1-8 0H2v28h28V2Zm-2 24h-2v2h-4v-2h-2v2H6v-2H4V16h2v10h4V16h2v10h4V16h2Zm10 2h-2v-4h-4v4h-2V18h2v-2h4v2h2Z"/></svg>`,
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8bc34a" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5 3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41zm11.16 0-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41z"/></svg>`,
"yaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff5252" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2m12 16v-2H9v2zm-4-4v-2H6v2z"/></svg>`,
"zig": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f9a825" d="M2 8h6v4H2zm8 0h12v4H10zm0 12h12v4H10zm14 0h2v4h-2zM8 20l-3 4H2V12h4v8zm14-8h-6l-6 8h6z"/><path fill="#f9a825" d="M16 20h-6l-6 8m12-16h6l6-8m2 4v16h-4V12h-2l3-4z"/></svg>`,
};
const EXTENSION_TO_ICON: Record<string, string> = {
"astro": "astro",
"bash": "console",
"c": "c",
"cfg": "settings",
"clj": "clojure",
"conf": "settings",
"cpp": "cpp",
"cs": "csharp",
"css": "css",
"dart": "dart",
"erl": "erlang",
"ex": "elixir",
"exs": "elixir",
"gif": "image",
"go": "go",
"gql": "graphql",
"gradle": "gradle",
"graphql": "graphql",
"groovy": "groovy",
"h": "h",
"hcl": "hcl",
"hpp": "hpp",
"hs": "haskell",
"html": "html",
"ico": "image",
"ini": "settings",
"java": "java",
"jpeg": "image",
"jpg": "image",
"js": "javascript",
"json": "json",
"jsx": "react",
"kt": "kotlin",
"less": "less",
"lock": "lock",
"lua": "lua",
"markdown": "markdown",
"md": "markdown",
"ml": "ocaml",
"nix": "nix",
"php": "php",
"png": "image",
"py": "python",
"r": "r",
"rb": "ruby",
"rs": "rust",
"scala": "scala",
"scss": "sass",
"sh": "console",
"sql": "database",
"svelte": "svelte",
"svg": "svg",
"swift": "swift",
"tf": "terraform",
"toml": "toml",
"ts": "typescript",
"tsx": "react_ts",
"txt": "document",
"vue": "vue",
"wasm": "webassembly",
"webp": "image",
"xml": "xml",
"yaml": "yaml",
"yml": "yaml",
"zig": "zig",
};
export function getFileIconSvg(fileName: string): string {
const ext = getExtension(fileName);
if (ext) {
const iconName = EXTENSION_TO_ICON[ext];
if (iconName && SVG_ICONS[iconName]) return SVG_ICONS[iconName];
}
return SVG_ICONS["_default"];
}
function getExtension(name: string): string | null {
const idx = name.lastIndexOf(".");
if (idx === -1 || idx === name.length - 1) return null;
return name.slice(idx + 1).toLowerCase();
}
@@ -34,6 +34,7 @@ import {
FolderPlus,
FolderGit2,
GitPullRequest,
Globe,
Monitor,
MoreVertical,
Plus,
@@ -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) {
@@ -695,7 +697,7 @@ function ProjectHeaderRow({
prepareWorkspaceTab({
serverId: serverId!,
workspaceId: workspace.id,
target: { kind: "draft", draftId: "new" },
target: { kind: "setup", workspaceId: workspace.id },
}) as any,
);
},
@@ -855,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 || isMobile) ? (
<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>
);
}
@@ -32,7 +32,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ResizeHandle } from "@/components/resize-handle";
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
import { usePanelStore } from "@/stores/panel-store";
import { useTrafficLightPadding } from "@/utils/desktop-window";
import { useWindowControlsPadding } from "@/utils/desktop-window";
import {
computeTabDropPreview,
type TabDropPreview,
@@ -809,8 +809,7 @@ function SplitPaneView({
const { theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const isFocusModeEnabled = usePanelStore((s) => s.desktop.focusModeEnabled);
const trafficLightPadding = useTrafficLightPadding();
const padding = useWindowControlsPadding("tabRow");
const paneState = useMemo(
() =>
deriveWorkspacePaneState({
@@ -885,11 +884,7 @@ function SplitPaneView({
<View
style={[
styles.paneTabs,
isFocusModeEnabled &&
trafficLightPadding.side && {
paddingLeft: trafficLightPadding.left,
paddingRight: trafficLightPadding.right,
},
{ paddingLeft: padding.left, paddingRight: padding.right },
]}
>
<WorkspaceDesktopTabsRow
@@ -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,
},
}));
-11
View File
@@ -60,14 +60,3 @@ export function getIsDesktop(): boolean {
}
return result;
}
// Get traffic light padding values (only non-zero on desktop macOS)
export function getTrafficLightPadding(): { left: number; top: number } {
if (!getIsDesktopMac()) {
return { left: 0, top: 0 };
}
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
@@ -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;
}
@@ -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],
},
}));
@@ -26,9 +26,9 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
}, [isMobile, openAgentList]);
return (
<View style={styles.container} {...dragHandlers}>
<View style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<View style={styles.content} {...dragHandlers}>
<View style={styles.logo}>
<PaseoLogo size={56} />
</View>
@@ -79,6 +79,9 @@ 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";
}
@@ -139,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";
}
@@ -152,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";
}
@@ -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 });
@@ -76,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)}`;
}
@@ -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}`;
}
+53 -15
View File
@@ -10,6 +10,7 @@ import {
} from "@/constants/layout";
import { getDesktopWindow } from "@/desktop/electron/window";
import { isDesktop } from "@/desktop/host";
import { usePanelStore } from "@/stores/panel-store";
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
export async function toggleMaximize() {
@@ -123,7 +124,15 @@ export function useDesktopDragHandlers(): DesktopDragViewProps {
}, [isActive]);
}
export function useTrafficLightPadding(): { left: number; right: number; top: number; side: 'left' | 'right' | null } {
type RawWindowControlsPadding = {
left: number;
right: number;
top: number;
};
type WindowControlsPaddingRole = "sidebar" | "header" | "tabRow" | "explorerSidebar";
function useRawWindowControlsPadding(): RawWindowControlsPadding {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
@@ -178,23 +187,52 @@ export function useTrafficLightPadding(): { left: number; right: number; top: nu
};
}, []);
if (!getIsDesktop() || isFullscreen) {
return { left: 0, right: 0, top: 0, side: null };
}
return useMemo((): RawWindowControlsPadding => {
if (!getIsDesktop() || isFullscreen) {
return { left: 0, right: 0, top: 0 };
}
if (getIsDesktopMac()) {
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
right: 0,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
};
}
if (getIsDesktopMac()) {
return {
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
right: 0,
top: DESKTOP_TRAFFIC_LIGHT_HEIGHT,
side: 'left',
left: 0,
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
};
}, [isFullscreen]);
}
export function useWindowControlsPadding(
role: WindowControlsPaddingRole,
): { left: number; right: number; top: number } {
const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen);
const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const rawPadding = useRawWindowControlsPadding();
const sidebarClosed = !sidebarOpen;
let left = 0;
let right = 0;
let top = 0;
if (role === "sidebar") {
left = rawPadding.left;
top = rawPadding.top;
} else if (role === "header") {
left = sidebarClosed ? rawPadding.left : 0;
right = explorerOpen ? 0 : rawPadding.right;
} else if (role === "tabRow") {
left = sidebarClosed && focusModeEnabled ? rawPadding.left : 0;
right = focusModeEnabled && !explorerOpen ? rawPadding.right : 0;
} else if (role === "explorerSidebar") {
right = rawPadding.right;
}
return {
left: 0,
right: DESKTOP_WINDOW_CONTROLS_WIDTH,
top: DESKTOP_WINDOW_CONTROLS_HEIGHT,
side: 'right',
};
return useMemo(() => ({ left, right, top }), [left, right, top]);
}
@@ -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;
}
@@ -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}`;
}
+4
View File
@@ -8,6 +8,7 @@ import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createTerminalCommand } from "./commands/terminal/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
@@ -143,6 +144,9 @@ export function createCli(): Command {
// Chat commands
program.addCommand(createChatCommand());
// Terminal commands
program.addCommand(createTerminalCommand());
// Loop commands
program.addCommand(createLoopCommand());
@@ -1,63 +0,0 @@
import type { Command } from "commander";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechDownloadRow {
modelId: string;
status: "downloaded";
}
const speechDownloadSchema: OutputSchema<SpeechDownloadRow> = {
idField: "modelId",
columns: [
{ header: "MODEL", field: "modelId", width: 36 },
{ header: "STATUS", field: "status", width: 12, color: () => "green" },
],
};
export type SpeechDownloadResult = ListResult<SpeechDownloadRow>;
export interface SpeechDownloadOptions extends CommandOptions {
host?: string;
model?: string[];
}
export async function runSpeechDownloadCommand(
options: SpeechDownloadOptions,
_command: Command,
): Promise<SpeechDownloadResult> {
const client = await connectToDaemon({ host: options.host });
try {
const response = await client.downloadSpeechModels({
modelIds: options.model && options.model.length > 0 ? options.model : undefined,
});
if (response.error) {
const commandError: CommandError = {
code: "SPEECH_MODELS_DOWNLOAD_FAILED",
message: response.error,
};
throw commandError;
}
return {
type: "list",
data: response.downloadedModelIds.map((modelId) => ({
modelId,
status: "downloaded" as const,
})),
schema: speechDownloadSchema,
};
} catch (error) {
if (typeof error === "object" && error && "code" in error && "message" in error) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
const commandError: CommandError = {
code: "SPEECH_MODELS_DOWNLOAD_FAILED",
message: `Failed to download speech models: ${message}`,
};
throw commandError;
} finally {
await client.close().catch(() => {});
}
}
+1 -18
View File
@@ -1,22 +1,5 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { runSpeechModelsCommand } from "./models.js";
import { runSpeechDownloadCommand } from "./download.js";
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
export function createSpeechCommand(): Command {
const speech = new Command("speech").description("Manage local speech models");
addJsonAndDaemonHostOptions(
speech.command("models").description("List local speech model download status"),
).action(withOutput(runSpeechModelsCommand));
addJsonAndDaemonHostOptions(
speech
.command("download")
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, []),
).action(withOutput(runSpeechDownloadCommand));
return speech;
return new Command("speech").description("Speech commands");
}
@@ -1,67 +0,0 @@
import type { Command } from "commander";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechModelListItem {
id: string;
kind: string;
status: "downloaded" | "missing";
modelDir: string;
missingFiles: string;
}
const speechModelsSchema: OutputSchema<SpeechModelListItem> = {
idField: "id",
columns: [
{ header: "MODEL", field: "id", width: 36 },
{ header: "KIND", field: "kind", width: 12 },
{
header: "STATUS",
field: "status",
width: 12,
color: (value) => (value === "downloaded" ? "green" : "yellow"),
},
{ header: "MODEL DIR", field: "modelDir", width: 44 },
{ header: "MISSING FILES", field: "missingFiles", width: 40 },
],
};
export type SpeechModelsResult = ListResult<SpeechModelListItem>;
export interface SpeechModelsOptions extends CommandOptions {
host?: string;
}
export async function runSpeechModelsCommand(
options: SpeechModelsOptions,
_command: Command,
): Promise<SpeechModelsResult> {
const client = await connectToDaemon({ host: options.host });
try {
const response = await client.listSpeechModels();
const rows: SpeechModelListItem[] = response.models
.slice()
.sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id))
.map((model) => ({
id: model.id,
kind: model.kind,
status: model.isDownloaded ? "downloaded" : "missing",
modelDir: model.modelDir,
missingFiles: model.missingFiles?.join(", ") ?? "",
}));
return {
type: "list",
data: rows,
schema: speechModelsSchema,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const commandError: CommandError = {
code: "SPEECH_MODELS_LIST_FAILED",
message: `Failed to list speech models: ${message}`,
};
throw commandError;
} finally {
await client.close().catch(() => {});
}
}
@@ -0,0 +1,99 @@
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
connectTerminalClient,
resolveTerminalId,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
export interface TerminalCaptureOptions extends TerminalCommandOptions {
start?: string;
end?: string;
scrollback?: boolean;
ansi?: boolean;
}
export async function runCaptureCommand(
terminalId: string,
_options: TerminalCaptureOptions,
command: Command,
): Promise<void> {
const options = command.optsWithGlobals() as TerminalCaptureOptions;
try {
const payload = await executeCaptureCommand(terminalId, options);
if (options.json) {
process.stdout.write(
JSON.stringify(
{
terminalId: payload.terminalId,
lines: payload.lines,
totalLines: payload.totalLines,
},
null,
2,
) + "\n",
);
return;
}
if (payload.lines.length > 0) {
process.stdout.write(payload.lines.join("\n") + "\n");
}
} catch (err) {
const output = renderError(toCommandError(err), {
format: options.json ? "json" : "table",
noColor: options.color === false,
});
process.stderr.write(output + "\n");
process.exit(1);
}
}
async function executeCaptureCommand(
terminalId: string,
options: TerminalCaptureOptions,
): Promise<{ terminalId: string; lines: string[]; totalLines: number }> {
const { client } = await connectTerminalClient(options.host);
try {
const resolvedId = await resolveTerminalId(client, terminalId);
if (!resolvedId) {
throw {
code: "TERMINAL_NOT_FOUND",
message: `No terminal found matching: ${terminalId}`,
details: "Use `paseo terminal ls --all` to list available terminals.",
};
}
const start = options.scrollback ? 0 : parseLineNumber("--start", options.start);
const end = parseLineNumber("--end", options.end);
return await client.captureTerminal(resolvedId, {
...(start === undefined ? {} : { start }),
...(end === undefined ? {} : { end }),
stripAnsi: !options.ansi,
});
} catch (err) {
throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err);
} finally {
await client.close().catch(() => {});
}
}
function parseLineNumber(flag: string, value?: string): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
throw {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
details: "Use an integer line number.",
};
}
return parsed;
}
@@ -0,0 +1,41 @@
import type { Command } from "commander";
import type { SingleResult, CommandError } from "../../output/index.js";
import {
connectTerminalClient,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
export interface TerminalCreateOptions extends TerminalCommandOptions {
cwd?: string;
name?: string;
}
export async function runCreateCommand(
options: TerminalCreateOptions,
_command: Command,
): Promise<SingleResult<TerminalRow>> {
const { client } = await connectTerminalClient(options.host);
const cwd = options.cwd ?? process.cwd();
try {
const payload = await client.createTerminal(cwd, options.name);
if (!payload.terminal) {
const error: CommandError = {
code: "TERMINAL_CREATE_FAILED",
message: payload.error ?? "Failed to create terminal",
};
throw error;
}
return {
type: "single",
data: toTerminalRow(payload.terminal),
schema: terminalSchema,
};
} catch (err) {
throw toTerminalCommandError("TERMINAL_CREATE_FAILED", "create terminal", err);
} finally {
await client.close().catch(() => {});
}
}
@@ -0,0 +1,59 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addDaemonHostOption, addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCaptureCommand } from "./capture.js";
import { runCreateCommand } from "./create.js";
import { runKillCommand } from "./kill.js";
import { runLsCommand } from "./ls.js";
import { runSendKeysCommand } from "./send-keys.js";
export function createTerminalCommand(): Command {
const terminal = new Command("terminal").description("Manage workspace terminals");
addJsonAndDaemonHostOptions(
terminal
.command("ls")
.description("List terminals")
.option("--all", "List terminals across all workspaces")
.option("--cwd <path>", "Workspace directory"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
terminal
.command("create")
.description("Create a terminal")
.option("--cwd <path>", "Workspace directory")
.option("--name <name>", "Terminal name"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(
terminal
.command("kill")
.description("Kill a terminal")
.argument("<terminal-id>", "Terminal ID, ID prefix, or name"),
).action(withOutput(runKillCommand));
addDaemonHostOption(
terminal
.command("capture")
.description("Capture terminal output")
.argument("<terminal-id>", "Terminal ID, ID prefix, or name")
.option("--start <n>", "Capture start line")
.option("--end <n>", "Capture end line")
.option("-S, --scrollback", "Capture from the beginning of scrollback")
.option("--ansi", "Preserve ANSI escape codes")
.option("--json", "Output in JSON format"),
).action(runCaptureCommand);
addDaemonHostOption(
terminal
.command("send-keys")
.description("Send keys to a terminal")
.argument("<terminal-id>", "Terminal ID, ID prefix, or name")
.argument("<keys...>", "Keys to send")
.option("-l, --literal", "Send raw keys without interpreting special tokens")
.option("--json", "Output in JSON format"),
).action(runSendKeysCommand);
return terminal;
}
@@ -0,0 +1,51 @@
import type { Command } from "commander";
import type { CommandError, SingleResult } from "../../output/index.js";
import {
connectTerminalClient,
resolveTerminalId,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
import { terminalKillSchema, type TerminalKillRow } from "./schema.js";
export async function runKillCommand(
terminalId: string,
options: TerminalCommandOptions,
_command: Command,
): Promise<SingleResult<TerminalKillRow>> {
const { client } = await connectTerminalClient(options.host);
try {
const resolvedId = await requireTerminalId(client, terminalId);
const payload = await client.killTerminal(resolvedId);
return {
type: "single",
data: {
terminalId: payload.terminalId,
success: payload.success,
},
schema: terminalKillSchema,
};
} catch (err) {
throw toTerminalCommandError("TERMINAL_KILL_FAILED", "kill terminal", err);
} finally {
await client.close().catch(() => {});
}
}
async function requireTerminalId(
client: Awaited<ReturnType<typeof connectTerminalClient>>["client"],
terminalId: string,
): Promise<string> {
const resolvedId = await resolveTerminalId(client, terminalId);
if (resolvedId) {
return resolvedId;
}
const error: CommandError = {
code: "TERMINAL_NOT_FOUND",
message: `No terminal found matching: ${terminalId}`,
details: "Use `paseo terminal ls --all` to list available terminals.",
};
throw error;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
connectTerminalClient,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js";
export interface TerminalLsOptions extends TerminalCommandOptions {
all?: boolean;
cwd?: string;
}
export async function runLsCommand(
options: TerminalLsOptions,
_command: Command,
): Promise<ListResult<TerminalRow>> {
const { client } = await connectTerminalClient(options.host);
const cwd = options.all ? undefined : (options.cwd ?? process.cwd());
try {
const payload = cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd);
return {
type: "list",
data: payload.terminals.map((terminal) => toTerminalRow(terminal, payload.cwd ?? cwd)),
schema: terminalSchema,
};
} catch (err) {
throw toTerminalCommandError("TERMINAL_LIST_FAILED", "list terminals", err);
} finally {
await client.close().catch(() => {});
}
}
@@ -0,0 +1,44 @@
import type { OutputSchema } from "../../output/index.js";
export interface TerminalRow {
id: string;
name: string;
cwd: string;
}
export interface TerminalKillRow {
terminalId: string;
success: boolean;
}
export const terminalSchema: OutputSchema<TerminalRow> = {
idField: "id",
columns: [
{ header: "ID", field: (row) => row.id.slice(0, 8), width: 8 },
{ header: "NAME", field: "name", width: 24 },
{ header: "CWD", field: "cwd", width: 48 },
],
};
export const terminalKillSchema: OutputSchema<TerminalKillRow> = {
idField: "terminalId",
columns: [
{ header: "ID", field: (row) => row.terminalId.slice(0, 8), width: 8 },
{ header: "SUCCESS", field: "success", width: 8 },
],
};
export function toTerminalRow(
terminal: {
id: string;
name: string;
cwd?: string;
},
cwd?: string,
): TerminalRow {
return {
id: terminal.id,
name: terminal.name,
cwd: terminal.cwd ?? cwd ?? "-",
};
}
@@ -0,0 +1,99 @@
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
connectTerminalClient,
resolveTerminalId,
toTerminalCommandError,
type TerminalCommandOptions,
} from "./shared.js";
export interface TerminalSendKeysOptions extends TerminalCommandOptions {
literal?: boolean;
}
export async function runSendKeysCommand(
terminalId: string,
keys: string[],
_options: TerminalSendKeysOptions,
command: Command,
): Promise<void> {
const options = command.optsWithGlobals() as TerminalSendKeysOptions;
try {
const payload = await executeSendKeysCommand(terminalId, keys, options);
if (options.json) {
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
}
} catch (err) {
const output = renderError(toCommandError(err), {
format: options.json ? "json" : "table",
noColor: options.color === false,
});
process.stderr.write(output + "\n");
process.exit(1);
}
}
async function executeSendKeysCommand(
terminalId: string,
keys: string[],
options: TerminalSendKeysOptions,
): Promise<{ terminalId: string; keysSent: number }> {
const { client } = await connectTerminalClient(options.host);
try {
const resolvedId = await resolveTerminalId(client, terminalId);
if (!resolvedId) {
throw {
code: "TERMINAL_NOT_FOUND",
message: `No terminal found matching: ${terminalId}`,
details: "Use `paseo terminal ls --all` to list available terminals.",
};
}
const data = keys.map((key) => resolveKeyToken(key, options.literal === true)).join("");
client.sendTerminalInput(resolvedId, { type: "input", data });
return {
terminalId: resolvedId,
keysSent: data.length,
};
} catch (err) {
throw toTerminalCommandError("TERMINAL_SEND_KEYS_FAILED", "send terminal keys", err);
} finally {
await client.close().catch(() => {});
}
}
function resolveKeyToken(key: string, literal: boolean): string {
if (literal) {
return key;
}
switch (key) {
case "Enter":
return "\r";
case "Tab":
return "\t";
case "Escape":
return "\u001b";
case "Space":
return " ";
case "BSpace":
return "\u007f";
case "C-c":
return "\u0003";
case "C-d":
return "\u0004";
case "C-z":
return "\u001a";
case "C-l":
return "\u000c";
case "C-a":
return "\u0001";
case "C-e":
return "\u0005";
default:
return key;
}
}
@@ -0,0 +1,87 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
export interface TerminalCommandOptions extends CommandOptions {
host?: string;
}
interface TerminalLike {
id: string;
name?: string | null;
}
export async function connectTerminalClient(host?: string) {
const daemonHost = getDaemonHost({ host });
try {
const client = await connectToDaemon({ host });
return { client, daemonHost };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
}
export function toTerminalCommandError(code: string, action: string, err: unknown): CommandError {
if (err && typeof err === "object" && "code" in err && "message" in err) {
return err as CommandError;
}
const message = err instanceof Error ? err.message : String(err);
const rpcCode =
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
? err.code
: undefined;
return {
code: rpcCode ?? code,
message: `Failed to ${action}: ${message}`,
};
}
export async function resolveTerminalId(
client: Awaited<ReturnType<typeof connectToDaemon>>,
idOrName: string,
): Promise<string | null> {
const payload = await client.listTerminals();
return resolveTerminalIdentifier(idOrName, payload.terminals);
}
function resolveTerminalIdentifier(idOrName: string, terminals: TerminalLike[]): string | null {
if (!idOrName || terminals.length === 0) {
return null;
}
const query = idOrName.toLowerCase();
const exactMatch = terminals.find((terminal) => terminal.id === idOrName);
if (exactMatch) {
return exactMatch.id;
}
const prefixMatches = terminals.filter((terminal) => terminal.id.toLowerCase().startsWith(query));
if (prefixMatches.length === 1 && prefixMatches[0]) {
return prefixMatches[0].id;
}
if (prefixMatches.length > 1) {
return null;
}
const nameMatches = terminals.filter((terminal) => terminal.name?.toLowerCase() === query);
if (nameMatches.length === 1 && nameMatches[0]) {
return nameMatches[0].id;
}
const partialNameMatches = terminals.filter((terminal) =>
terminal.name?.toLowerCase().includes(query),
);
if (partialNameMatches.length === 1 && partialNameMatches[0]) {
return partialNameMatches[0].id;
}
return null;
}
@@ -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();
+55 -32
View File
@@ -36,16 +36,16 @@ import type {
ProjectIconResponse,
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
WorkspaceSetupStatusResponseMessage,
ListCommandsResponse,
ListProviderModelsResponseMessage,
ListAvailableProvidersResponse,
SpeechModelsListResponse,
SpeechModelsDownloadResponse,
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
TerminalState,
KillTerminalResponse,
CaptureTerminalResponse,
TerminalInput,
SessionInboundMessage,
SessionOutboundMessage,
@@ -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;
@@ -217,8 +222,6 @@ type FileExplorerPayload = FileExplorerResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
@@ -241,6 +244,7 @@ type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type CaptureTerminalPayload = CaptureTerminalResponse["payload"];
type ChatCreatePayload = Extract<
SessionOutboundMessage,
{ type: "chat/create/response" }
@@ -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({
@@ -2487,32 +2507,6 @@ export class DaemonClient {
});
}
async listSpeechModels(requestId?: string): Promise<SpeechModelsListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "speech_models_list_request",
},
responseType: "speech_models_list_response",
timeout: 30000,
});
}
async downloadSpeechModels(options?: {
modelIds?: string[];
requestId?: string;
}): Promise<SpeechModelsDownloadPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "speech_models_download_request",
modelIds: options?.modelIds,
},
responseType: "speech_models_download_response",
timeout: 30 * 60 * 1000,
});
}
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
async listCommands(
@@ -2739,11 +2733,11 @@ export class DaemonClient {
});
}
async listTerminals(cwd: string, requestId?: string): Promise<ListTerminalsPayload> {
async listTerminals(cwd?: string, requestId?: string): Promise<ListTerminalsPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "list_terminals_request",
cwd,
...(cwd === undefined ? {} : { cwd }),
requestId: resolvedRequestId,
});
return this.sendCorrelatedRequest({
@@ -2857,6 +2851,29 @@ export class DaemonClient {
});
}
async captureTerminal(
terminalId: string,
options?: { start?: number; end?: number; stripAnsi?: boolean },
requestId?: string,
): Promise<CaptureTerminalPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({
type: "capture_terminal_request",
terminalId,
...(options?.start === undefined ? {} : { start: options.start }),
...(options?.end === undefined ? {} : { end: options.end }),
...(options?.stripAnsi === undefined ? {} : { stripAnsi: options.stripAnsi }),
requestId: resolvedRequestId,
});
return this.sendCorrelatedRequest({
requestId: resolvedRequestId,
message,
responseType: "capture_terminal_response",
timeout: 10000,
options: { skipQueue: true },
});
}
async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
@@ -3528,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,
});
}
@@ -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,
});
}
+64 -24
View File
@@ -91,7 +91,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { initializeSpeechRuntime } from "./speech/speech-runtime.js";
import { createSpeechService } from "./speech/speech-runtime.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
@@ -113,6 +113,13 @@ import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
import {
ServiceRouteStore,
createServiceProxyMiddleware,
createServiceProxyUpgradeHandler,
} from "./service-proxy.js";
import { ServiceHealthMonitor } from "./service-health-monitor.js";
import { createServiceStatusEmitter } from "./service-status-projection.js";
import {
createVoiceMcpSocketBridgeManager,
type VoiceMcpSocketBridgeManager,
@@ -189,6 +196,7 @@ export interface PaseoDaemon {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager;
serviceRouteStore: ServiceRouteStore;
start(): Promise<void>;
stop(): Promise<void>;
getListenTarget(): ListenTarget | null;
@@ -218,6 +226,20 @@ export async function createPaseoDaemon(
const app = express();
let boundListenTarget: ListenTarget | null = null;
const serviceRouteStore = new ServiceRouteStore();
let wsServer: VoiceAssistantWebSocketServer | null = null;
const serviceHealthMonitor = new ServiceHealthMonitor({
routeStore: serviceRouteStore,
onChange: createServiceStatusEmitter({
sessions: () =>
wsServer?.listActiveSessions().map((session) => ({
emit: (message) => session.emitServerMessage(message),
})) ?? [],
routeStore: serviceRouteStore,
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
}),
});
// Host allowlist / DNS rebinding protection (vite-like semantics).
// For non-TCP (unix sockets), skip host validation.
if (listenTarget.type === "tcp") {
@@ -231,6 +253,14 @@ export async function createPaseoDaemon(
});
}
// Service proxy — intercepts requests for registered *.localhost hostnames
// and forwards them to the corresponding local service port. Placed after
// the host allowlist (*.localhost is already allowed) but before CORS and
// the rest of the routes so proxied requests skip unnecessary middleware.
app.use(
createServiceProxyMiddleware({ routeStore: serviceRouteStore, logger }),
);
// CORS - allow same-origin + configured origins
const allowedOrigins = new Set([
...config.corsAllowedOrigins,
@@ -352,6 +382,16 @@ export async function createPaseoDaemon(
const httpServer = createHTTPServer(app);
// Service proxy WebSocket upgrade handler — must be registered before the
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
// service-bound upgrades are forwarded first. The handler is a no-op for
// requests that don't match a registered service route.
const serviceProxyUpgradeHandler = createServiceProxyUpgradeHandler({
routeStore: serviceRouteStore,
logger,
});
httpServer.on("upgrade", serviceProxyUpgradeHandler);
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
path.join(config.paseoHome, "projects", "projects.json"),
@@ -424,9 +464,7 @@ export async function createPaseoDaemon(
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
);
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
let wsServer: VoiceAssistantWebSocketServer | null = null;
let voiceMcpBridgeManager: VoiceMcpSocketBridgeManager | null = null;
let unsubscribeSpeechReadiness: (() => void) | null = null;
// Create in-memory transport for Session's Agent MCP client (voice assistant tools)
const createInMemoryAgentMcpTransport = async (): Promise<InMemoryTransport> => {
@@ -434,6 +472,9 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
serviceRouteStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome,
enableVoiceTools: false,
resolveSpeakHandler: (callerAgentId) =>
@@ -460,6 +501,9 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
serviceRouteStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome,
callerAgentId,
enableVoiceTools: false,
@@ -580,6 +624,9 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
serviceRouteStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
paseoHome: config.paseoHome,
callerAgentId,
voiceOnly: true,
@@ -589,21 +636,12 @@ export async function createPaseoDaemon(
});
},
});
const {
resolveVoiceTurnDetection,
resolveVoiceStt,
resolveVoiceTts,
resolveDictationStt,
getSpeechReadiness,
subscribeSpeechReadiness,
cleanup: cleanupSpeechRuntime,
localModelConfig,
} = await initializeSpeechRuntime({
const speechService = createSpeechService({
logger,
openaiConfig: config.openai,
speechConfig: config.speech,
});
logger.info({ elapsed: elapsed() }, "Speech runtime initialized");
logger.info({ elapsed: elapsed() }, "Speech service created");
wsServer = new VoiceAssistantWebSocketServer(
httpServer,
@@ -615,7 +653,7 @@ export async function createPaseoDaemon(
config.paseoHome,
createInMemoryAgentMcpTransport,
{ allowedOrigins, allowedHosts: config.allowedHosts },
{ turnDetection: resolveVoiceTurnDetection, stt: resolveVoiceStt, tts: resolveVoiceTts },
speechService,
terminalManager,
{
voiceAgentMcpStdio: {
@@ -633,9 +671,6 @@ export async function createPaseoDaemon(
},
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
stt: resolveDictationStt,
localModels: localModelConfig ?? undefined,
getSpeechReadiness,
},
config.agentProviderSettings,
daemonVersion,
@@ -652,10 +687,10 @@ export async function createPaseoDaemon(
loopService,
scheduleService,
checkoutDiffManager,
serviceRouteStore,
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
(hostname) => serviceHealthMonitor.getStatusForHostname(hostname),
);
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot);
});
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
@@ -741,9 +776,15 @@ export async function createPaseoDaemon(
httpServer.listen(listenTarget.path);
}
});
// Start speech service after listening so synchronous Sherpa native
// model loading doesn't block the server from accepting connections.
speechService.start();
serviceHealthMonitor.start();
};
const stop = async () => {
serviceHealthMonitor.stop();
await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
@@ -752,9 +793,7 @@ export async function createPaseoDaemon(
runtimeSettings: config.agentProviderSettings,
});
terminalManager.killAll();
unsubscribeSpeechReadiness?.();
unsubscribeSpeechReadiness = null;
cleanupSpeechRuntime();
speechService.stop();
await scheduleService.stop().catch(() => undefined);
await relayTransport?.stop().catch(() => undefined);
if (wsServer) {
@@ -777,6 +816,7 @@ export async function createPaseoDaemon(
agentManager,
agentStorage,
terminalManager,
serviceRouteStore,
start,
stop,
getListenTarget: () => boundListenTarget,
@@ -730,7 +730,7 @@ describe("daemon E2E", () => {
});
describe("archivePaseoWorktree", () => {
test("archives worktree by running destroy commands and shutting down worktree terminals", async () => {
test("archives worktree by running teardown commands and shutting down worktree terminals", async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
@@ -749,7 +749,7 @@ describe("daemon E2E", () => {
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const destroyMarkerPath = path.join(repoRoot, "destroy-marker.txt");
const teardownMarkerPath = path.join(repoRoot, "teardown-marker.txt");
writeFileSync(
path.join(repoRoot, "paseo.json"),
JSON.stringify({
@@ -760,12 +760,12 @@ describe("daemon E2E", () => {
command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null',
},
],
destroy: [`echo "$PASEO_WORKTREE_PATH" > "${destroyMarkerPath}"`],
teardown: [`echo "$PASEO_WORKTREE_PATH" > "${teardownMarkerPath}"`],
},
}),
);
execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + destroy'", {
execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + teardown'", {
cwd: repoRoot,
stdio: "pipe",
});
@@ -813,8 +813,8 @@ describe("daemon E2E", () => {
expect(archive.removedAgents).toContain(agent.id);
expect(existsSync(agent.cwd)).toBe(false);
expect(existsSync(destroyMarkerPath)).toBe(true);
expect(readFileSync(destroyMarkerPath, "utf8").trim()).toBe(agent.cwd);
expect(existsSync(teardownMarkerPath)).toBe(true);
expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd);
const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
expect(afterArchiveDirectories).not.toContain(agent.cwd);
@@ -415,12 +415,17 @@ async function subscribeRawTerminal(
describe("daemon E2E terminal", () => {
let ctx: DaemonTestContext;
let tempDirs: string[];
beforeEach(async () => {
ctx = await createDaemonTestContext();
tempDirs = [];
});
afterEach(async () => {
for (const dir of tempDirs) {
rmSync(dir, { recursive: true, force: true });
}
await ctx.cleanup();
}, 60000);
@@ -1055,4 +1060,161 @@ describe("daemon E2E terminal", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 40000);
describe("capture", () => {
test("captures visible terminal output as plain text", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo hello world\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("hello world"), 15000);
const capture = await ctx.client.captureTerminal(terminalId);
expect(capture.lines.join("\n")).toContain("hello world");
expect(capture.totalLines).toBeGreaterThan(0);
}, 15000);
test("captures with start/end line range", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo line1\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line1"), 15000);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo line2\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line2"), 15000);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo line3\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line3"), 15000);
const fullCapture = await ctx.client.captureTerminal(terminalId);
const rangedCapture = await ctx.client.captureTerminal(terminalId, {
start: 0,
end: 2,
});
expect(rangedCapture.lines).toHaveLength(3);
expect(rangedCapture.totalLines).toBe(fullCapture.totalLines);
}, 15000);
test("supports negative line indices", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo alpha\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("alpha"), 15000);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo beta\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("beta"), 15000);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "echo gamma\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("gamma"), 15000);
const capture = await ctx.client.captureTerminal(terminalId, {
start: -3,
});
expect(capture.lines).toHaveLength(3);
}, 15000);
test("strips ANSI by default", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await ctx.client.createTerminal(cwd);
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: "printf '\\033[31mred text\\033[0m\\n'\r",
});
await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000);
const capture = await ctx.client.captureTerminal(terminalId);
const capturedText = capture.lines.join("\n");
expect(capturedText).toContain("red text");
expect(capturedText).not.toContain("\u001b[31m");
}, 15000);
test("returns empty for non-existent terminal", async () => {
const capture = await ctx.client.captureTerminal("terminal-does-not-exist");
expect(capture.lines).toEqual([]);
expect(capture.totalLines).toBe(0);
});
});
describe("list terminals across directories", () => {
test("lists terminals from all directories when cwd is omitted", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
tempDirs.push(cwd1, cwd2);
const firstCreated = await ctx.client.createTerminal(cwd1, "first-terminal");
const secondCreated = await ctx.client.createTerminal(cwd2, "second-terminal");
const list = await ctx.client.listTerminals();
expect(list).not.toHaveProperty("cwd");
expect(list.terminals).toEqual(
expect.arrayContaining([
{
id: firstCreated.terminal!.id,
name: "first-terminal",
},
{
id: secondCreated.terminal!.id,
name: "second-terminal",
},
]),
);
});
test("lists terminals for specific directory when cwd is provided", async () => {
const cwd1 = tmpCwd();
const cwd2 = tmpCwd();
tempDirs.push(cwd1, cwd2);
const firstCreated = await ctx.client.createTerminal(cwd1, "cwd-one-terminal");
await ctx.client.createTerminal(cwd2, "cwd-two-terminal");
const list = await ctx.client.listTerminals(cwd1);
expect(list.cwd).toBe(cwd1);
expect(list.terminals).toEqual([
{
id: firstCreated.terminal!.id,
name: "cwd-one-terminal",
},
]);
});
});
});
@@ -0,0 +1,455 @@
import net from "node:net";
import { scheduler } from "node:timers/promises";
import { afterEach, describe, expect, it, vi } from "vitest";
import { findFreePort, ServiceRouteStore } from "./service-proxy.js";
import {
ServiceHealthMonitor,
type ServiceStatusEntry,
} from "./service-health-monitor.js";
type TcpServerHandle = {
port: number;
server: net.Server;
};
async function startTcpServer(): Promise<TcpServerHandle> {
const server = net.createServer((socket) => {
socket.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to resolve TCP server address");
}
return { port: address.port, server };
}
async function closeServer(server: net.Server): Promise<void> {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function advancePoll(ms: number): Promise<void> {
await vi.advanceTimersByTimeAsync(ms);
for (let i = 0; i < 5; i += 1) {
await scheduler.yield();
}
}
describe("ServiceHealthMonitor", () => {
const servers = new Set<net.Server>();
afterEach(async () => {
vi.useRealTimers();
for (const server of servers) {
await closeServer(server);
}
servers.clear();
});
it("marks a healthy port as running after successful TCP connect", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
workspaceId: "workspace-a",
serviceName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
});
monitor.start();
await advancePoll(1_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: healthy.port,
status: "running",
},
]);
});
it("marks an unreachable port as stopped after consecutive failures", async () => {
vi.useFakeTimers();
const deadPort = await findFreePort();
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: deadPort,
workspaceId: "workspace-a",
serviceName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
failuresBeforeStopped: 2,
});
monitor.start();
await advancePoll(1_000);
expect(onChange).not.toHaveBeenCalled();
await advancePoll(1_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: deadPort,
status: "stopped",
},
]);
});
it("does not emit when status has not changed", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
workspaceId: "workspace-a",
serviceName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
});
monitor.start();
await advancePoll(3_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(1);
});
it("respects startup grace period — does not probe newly registered routes for 5 seconds", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
workspaceId: "workspace-a",
serviceName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 5_000,
});
monitor.start();
await advancePoll(4_000);
expect(onChange).not.toHaveBeenCalled();
await advancePoll(1_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: healthy.port,
status: "running",
},
]);
});
it("requires 2 consecutive failures before marking stopped (debounce)", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
workspaceId: "workspace-a",
serviceName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
failuresBeforeStopped: 2,
});
monitor.start();
await advancePoll(1_000);
expect(onChange).toHaveBeenCalledTimes(1);
await closeServer(healthy.server);
servers.delete(healthy.server);
await advancePoll(1_000);
expect(onChange).toHaveBeenCalledTimes(1);
await advancePoll(1_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(2);
expect(onChange).toHaveBeenLastCalledWith("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: healthy.port,
status: "stopped",
},
]);
});
it("stops probing routes that are removed from the store", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
workspaceId: "workspace-a",
serviceName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
failuresBeforeStopped: 2,
});
monitor.start();
await advancePoll(1_000);
expect(onChange).toHaveBeenCalledTimes(1);
routeStore.removeRoute("api.localhost");
await closeServer(healthy.server);
servers.delete(healthy.server);
await advancePoll(3_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(1);
});
it("calls onChange with workspaceId and full service list when status transitions", async () => {
vi.useFakeTimers();
const api = await startTcpServer();
const web = await startTcpServer();
servers.add(api.server);
servers.add(web.server);
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: api.port,
workspaceId: "workspace-a",
serviceName: "api",
});
routeStore.registerRoute({
hostname: "web.localhost",
port: web.port,
workspaceId: "workspace-a",
serviceName: "web",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
});
monitor.start();
await advancePoll(1_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: api.port,
status: "running",
},
{
serviceName: "web",
hostname: "web.localhost",
port: web.port,
status: "running",
},
]);
});
it("getStatusForHostname returns current status after probe", async () => {
vi.useFakeTimers();
const healthy = await startTcpServer();
servers.add(healthy.server);
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: healthy.port,
workspaceId: "workspace-a",
serviceName: "api",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
});
expect(monitor.getStatusForHostname("api.localhost")).toBeNull();
monitor.start();
await advancePoll(1_000);
monitor.stop();
expect(monitor.getStatusForHostname("api.localhost")).toBe("running");
expect(monitor.getStatusForHostname("unknown.localhost")).toBeNull();
});
it("coalesces multiple service changes in same workspace into one onChange call per poll cycle", async () => {
vi.useFakeTimers();
const api = await startTcpServer();
const web = await startTcpServer();
servers.add(api.server);
servers.add(web.server);
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: api.port,
workspaceId: "workspace-a",
serviceName: "api",
});
routeStore.registerRoute({
hostname: "web.localhost",
port: web.port,
workspaceId: "workspace-a",
serviceName: "web",
});
const onChange = vi.fn<(workspaceId: string, services: ServiceStatusEntry[]) => void>();
const monitor = new ServiceHealthMonitor({
routeStore,
onChange,
pollIntervalMs: 1_000,
probeTimeoutMs: 100,
graceMs: 0,
failuresBeforeStopped: 2,
});
monitor.start();
await advancePoll(1_000);
expect(onChange).toHaveBeenCalledTimes(1);
onChange.mockClear();
await closeServer(api.server);
await closeServer(web.server);
servers.delete(api.server);
servers.delete(web.server);
await advancePoll(1_000);
expect(onChange).not.toHaveBeenCalled();
await advancePoll(1_000);
monitor.stop();
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: api.port,
status: "stopped",
},
{
serviceName: "web",
hostname: "web.localhost",
port: web.port,
status: "stopped",
},
]);
});
});
@@ -0,0 +1,203 @@
import net from "node:net";
import type { ServiceRouteEntry, ServiceRouteStore } from "./service-proxy.js";
export interface ServiceStatusEntry {
serviceName: string;
hostname: string;
port: number;
status: "running" | "stopped";
}
type RouteHealthState = {
status: ServiceStatusEntry["status"] | null;
consecutiveFailures: number;
registeredAt: number;
};
export class ServiceHealthMonitor {
private readonly routeStore: ServiceRouteStore;
private readonly onChange: (
workspaceId: string,
services: ServiceStatusEntry[],
) => void;
private readonly pollIntervalMs: number;
private readonly probeTimeoutMs: number;
private readonly graceMs: number;
private readonly failuresBeforeStopped: number;
private readonly routeStates = new Map<string, RouteHealthState>();
private readonly lastEmittedSnapshots = new Map<string, string>();
private intervalHandle: NodeJS.Timeout | null = null;
private pollInFlight = false;
constructor({
routeStore,
onChange,
pollIntervalMs = 3_000,
probeTimeoutMs = 500,
graceMs = 5_000,
failuresBeforeStopped = 2,
}: {
routeStore: ServiceRouteStore;
onChange: (workspaceId: string, services: ServiceStatusEntry[]) => void;
pollIntervalMs?: number;
probeTimeoutMs?: number;
graceMs?: number;
failuresBeforeStopped?: number;
}) {
this.routeStore = routeStore;
this.onChange = onChange;
this.pollIntervalMs = pollIntervalMs;
this.probeTimeoutMs = probeTimeoutMs;
this.graceMs = graceMs;
this.failuresBeforeStopped = failuresBeforeStopped;
}
start(): void {
if (this.intervalHandle) {
return;
}
const now = Date.now();
for (const route of this.routeStore.listRoutes()) {
this.getOrCreateState(route.hostname, now);
}
this.intervalHandle = setInterval(() => {
void this.poll();
}, this.pollIntervalMs);
}
stop(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
}
}
private async poll(): Promise<void> {
if (this.pollInFlight) {
return;
}
this.pollInFlight = true;
try {
const routes = this.routeStore.listRoutes();
const activeHostnames = new Set(routes.map((route) => route.hostname));
const changedWorkspaceIds = new Set<string>();
const now = Date.now();
for (const route of routes) {
const state = this.getOrCreateState(route.hostname, now);
if (now - state.registeredAt < this.graceMs) {
continue;
}
const isHealthy = await this.probeRoute(route.port);
const previousStatus = state.status;
if (isHealthy) {
state.consecutiveFailures = 0;
state.status = "running";
} else {
state.consecutiveFailures += 1;
if (state.consecutiveFailures >= this.failuresBeforeStopped) {
state.status = "stopped";
}
}
if (state.status !== null && state.status !== previousStatus) {
changedWorkspaceIds.add(route.workspaceId);
}
}
this.pruneRemovedRoutes(activeHostnames);
for (const workspaceId of changedWorkspaceIds) {
const services = this.buildWorkspaceServiceList(workspaceId);
const snapshot = JSON.stringify(services);
if (snapshot === this.lastEmittedSnapshots.get(workspaceId)) {
continue;
}
this.lastEmittedSnapshots.set(workspaceId, snapshot);
this.onChange(workspaceId, services);
}
} finally {
this.pollInFlight = false;
}
}
private getOrCreateState(hostname: string, registeredAt: number): RouteHealthState {
const existing = this.routeStates.get(hostname);
if (existing) {
return existing;
}
const state: RouteHealthState = {
status: null,
consecutiveFailures: 0,
registeredAt,
};
this.routeStates.set(hostname, state);
return state;
}
private pruneRemovedRoutes(activeHostnames: Set<string>): void {
for (const hostname of this.routeStates.keys()) {
if (activeHostnames.has(hostname)) {
continue;
}
this.routeStates.delete(hostname);
}
}
private buildWorkspaceServiceList(workspaceId: string): ServiceStatusEntry[] {
return this.routeStore
.listRoutesForWorkspace(workspaceId)
.flatMap((route) => {
const state = this.routeStates.get(route.hostname);
if (!state?.status) {
return [];
}
return [this.toServiceStatusEntry(route, state.status)];
});
}
getStatusForHostname(hostname: string): ServiceStatusEntry["status"] | null {
return this.routeStates.get(hostname)?.status ?? null;
}
private toServiceStatusEntry(
route: ServiceRouteEntry,
status: ServiceStatusEntry["status"],
): ServiceStatusEntry {
return {
serviceName: route.serviceName,
hostname: route.hostname,
port: route.port,
status,
};
}
private probeRoute(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ host: "127.0.0.1", port });
let settled = false;
const finish = (healthy: boolean) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(healthy);
};
socket.setTimeout(this.probeTimeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
}
@@ -0,0 +1,478 @@
import { describe, it, expect, afterEach } from "vitest";
import http from "node:http";
import net from "node:net";
import express from "express";
import WebSocket, { WebSocketServer } from "ws";
import pino from "pino";
import {
ServiceRouteStore,
createServiceProxyMiddleware,
createServiceProxyUpgradeHandler,
findFreePort,
} from "./service-proxy.js";
const logger = pino({ level: "silent" });
// ---------------------------------------------------------------------------
// Helpers for cleanup
// ---------------------------------------------------------------------------
function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
// ---------------------------------------------------------------------------
// ServiceRouteStore
// ---------------------------------------------------------------------------
describe("ServiceRouteStore", () => {
it("registerRoute and findRoute with exact match", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "editor.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor",
});
const route = store.findRoute("editor.localhost");
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
});
it("findRoute strips port from host header", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "editor.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor",
});
const route = store.findRoute("editor.localhost:6767");
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
});
it("findRoute subdomain match", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "editor.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor",
});
const route = store.findRoute("fix-auth.editor.localhost");
expect(route).toEqual({ hostname: "editor.localhost", port: 3000 });
});
it("listRoutes returns enriched entries", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
serviceName: "docs",
});
const routes = store.listRoutes();
expect(routes).toHaveLength(2);
expect(routes).toContainEqual({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web",
});
expect(routes).toContainEqual({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
serviceName: "docs",
});
});
it("listRoutesForWorkspace returns only routes for that workspace", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
serviceName: "docs",
});
store.registerRoute({
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "api",
});
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([
{
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web",
},
{
hostname: "c.localhost",
port: 5000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "api",
},
]);
});
it("removeRoute works", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "editor.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor",
});
store.removeRoute("editor.localhost");
expect(store.findRoute("editor.localhost")).toBeNull();
});
it("removeRoute cleans up workspace index", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "editor.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor",
});
store.removeRoute("editor.localhost");
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("removeRoutesForPort works", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "api",
});
store.registerRoute({
hostname: "c.localhost",
port: 4000,
workspaceId: "/repo/.paseo/worktrees/feature-b",
serviceName: "docs",
});
store.removeRoutesForPort(3000);
expect(store.findRoute("a.localhost")).toBeNull();
expect(store.findRoute("b.localhost")).toBeNull();
expect(store.findRoute("c.localhost")).toEqual({
hostname: "c.localhost",
port: 4000,
});
});
it("removeRoutesForPort cleans up workspace index", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "a.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "web",
});
store.registerRoute({
hostname: "b.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "api",
});
store.removeRoutesForPort(3000);
expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]);
});
it("findRoute returns null for unknown hosts", () => {
const store = new ServiceRouteStore();
store.registerRoute({
hostname: "editor.localhost",
port: 3000,
workspaceId: "/repo/.paseo/worktrees/feature-a",
serviceName: "editor",
});
expect(store.findRoute("unknown.example.com")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// HTTP proxy
// ---------------------------------------------------------------------------
describe("HTTP proxy", () => {
const servers: http.Server[] = [];
afterEach(async () => {
await Promise.all(servers.map(closeServer));
servers.length = 0;
});
/** Start a real HTTP server that echoes back a known body and records received headers. */
async function startUpstream(): Promise<{
port: number;
server: http.Server;
receivedHeaders: () => http.IncomingHttpHeaders;
}> {
const port = await findFreePort();
let lastHeaders: http.IncomingHttpHeaders = {};
const server = http.createServer((req, res) => {
lastHeaders = req.headers;
res.writeHead(200, { "content-type": "text/plain" });
res.end("upstream-ok");
});
await new Promise<void>((resolve) =>
server.listen(port, "127.0.0.1", resolve),
);
servers.push(server);
return {
port,
server,
receivedHeaders: () => lastHeaders,
};
}
/** Start an Express app with the service proxy middleware and an optional fallback. */
async function startProxy(
routeStore: ServiceRouteStore,
opts?: { fallback?: boolean },
): Promise<{ port: number; server: http.Server }> {
const port = await findFreePort();
const app = express();
app.use(createServiceProxyMiddleware({ routeStore, logger }));
if (opts?.fallback) {
app.use((_req, res) => {
res.status(404).send("no route");
});
}
const server = http.createServer(app);
await new Promise<void>((resolve) =>
server.listen(port, "127.0.0.1", resolve),
);
servers.push(server);
return { port, server };
}
/** Simple HTTP GET helper that returns status code and body. */
function httpGet(
port: number,
host: string,
path = "/",
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.get(
{ hostname: "127.0.0.1", port, path, headers: { host } },
(res) => {
let body = "";
res.on("data", (chunk: Buffer) => (body += chunk.toString()));
res.on("end", () =>
resolve({ status: res.statusCode ?? 0, body }),
);
},
);
req.on("error", reject);
});
}
it("proxies requests to the correct upstream based on Host header", async () => {
const upstream = await startUpstream();
const routeStore = new ServiceRouteStore();
routeStore.addRoute("test-service.localhost", upstream.port);
const proxy = await startProxy(routeStore);
const res = await httpGet(
proxy.port,
`test-service.localhost:${proxy.port}`,
);
expect(res.status).toBe(200);
expect(res.body).toBe("upstream-ok");
const headers = upstream.receivedHeaders();
expect(headers["x-forwarded-for"]).toBeDefined();
expect(headers["x-forwarded-host"]).toBe("test-service.localhost");
});
it("falls through when no route matches", async () => {
const routeStore = new ServiceRouteStore();
const proxy = await startProxy(routeStore, { fallback: true });
const res = await httpGet(
proxy.port,
`unknown.localhost:${proxy.port}`,
);
expect(res.status).toBe(404);
expect(res.body).toBe("no route");
});
it("returns 502 when upstream is down", async () => {
// Get a port that nothing is listening on
const deadPort = await findFreePort();
const routeStore = new ServiceRouteStore();
routeStore.addRoute("dead-service.localhost", deadPort);
const proxy = await startProxy(routeStore);
const res = await httpGet(
proxy.port,
`dead-service.localhost:${proxy.port}`,
);
expect(res.status).toBe(502);
expect(res.body).toBe("502 Bad Gateway");
});
});
// ---------------------------------------------------------------------------
// WebSocket proxy
// ---------------------------------------------------------------------------
describe("WebSocket proxy", () => {
const httpServers: http.Server[] = [];
const wsServers: WebSocketServer[] = [];
const wsClients: WebSocket[] = [];
afterEach(async () => {
for (const ws of wsClients) {
if (ws.readyState === WebSocket.OPEN) ws.close();
}
wsClients.length = 0;
for (const wss of wsServers) {
wss.close();
}
wsServers.length = 0;
await Promise.all(httpServers.map(closeServer));
httpServers.length = 0;
});
it("proxies WebSocket connections to the correct upstream", async () => {
// 1. Start a real WebSocket echo server
const upstreamPort = await findFreePort();
const upstreamServer = http.createServer();
const wss = new WebSocketServer({ server: upstreamServer });
wsServers.push(wss);
wss.on("connection", (ws) => {
ws.on("message", (data) => {
ws.send(`echo: ${data.toString()}`);
});
});
await new Promise<void>((resolve) =>
upstreamServer.listen(upstreamPort, "127.0.0.1", resolve),
);
httpServers.push(upstreamServer);
// 2. Create the proxy server with the upgrade handler
const routeStore = new ServiceRouteStore();
routeStore.addRoute("ws-service.localhost", upstreamPort);
const proxyPort = await findFreePort();
const proxyServer = http.createServer((_req, res) => {
res.writeHead(404);
res.end();
});
const upgradeHandler = createServiceProxyUpgradeHandler({
routeStore,
logger,
});
proxyServer.on("upgrade", upgradeHandler);
await new Promise<void>((resolve) =>
proxyServer.listen(proxyPort, "127.0.0.1", resolve),
);
httpServers.push(proxyServer);
// 3. Connect a WebSocket client through the proxy
const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, {
headers: { host: `ws-service.localhost:${proxyPort}` },
});
wsClients.push(ws);
await new Promise<void>((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
// 4. Send a message and verify echo
const reply = await new Promise<string>((resolve, reject) => {
ws.on("message", (data) => resolve(data.toString()));
ws.on("error", reject);
ws.send("hello proxy");
});
expect(reply).toBe("echo: hello proxy");
});
});
// ---------------------------------------------------------------------------
// findFreePort
// ---------------------------------------------------------------------------
describe("findFreePort", () => {
it("returns a number", async () => {
const port = await findFreePort();
expect(typeof port).toBe("number");
expect(port).toBeGreaterThan(0);
expect(port).toBeLessThan(65536);
});
it("returns a port that is actually available", async () => {
const port = await findFreePort();
// Verify we can bind a server to it
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.listen(port, "127.0.0.1", () => resolve());
server.on("error", reject);
});
const addr = server.address();
expect(addr).not.toBeNull();
expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(
port,
);
await new Promise<void>((resolve) => server.close(() => resolve()));
});
});
+321
View File
@@ -0,0 +1,321 @@
import http from "node:http";
import net from "node:net";
import type { IncomingMessage } from "node:http";
import type { Logger } from "pino";
import type { RequestHandler } from "express";
// ---------------------------------------------------------------------------
// Hop-by-hop headers that must not be forwarded
// ---------------------------------------------------------------------------
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"transfer-encoding",
"keep-alive",
"upgrade",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
]);
// ---------------------------------------------------------------------------
// ServiceRouteStore
// ---------------------------------------------------------------------------
export interface ServiceRoute {
hostname: string;
port: number;
}
export interface ServiceRouteEntry extends ServiceRoute {
workspaceId: string;
serviceName: string;
}
export class ServiceRouteStore {
private routes = new Map<string, ServiceRouteEntry>();
private workspaceHostnames = new Map<string, Set<string>>();
addRoute(hostname: string, port: number): void {
this.registerRoute({
hostname,
port,
workspaceId: "",
serviceName: hostname,
});
}
registerRoute(entry: ServiceRouteEntry): void {
const previous = this.routes.get(entry.hostname);
if (previous) {
this.removeHostnameFromWorkspaceIndex(previous.workspaceId, previous.hostname);
}
const storedEntry = { ...entry };
this.routes.set(storedEntry.hostname, storedEntry);
this.addHostnameToWorkspaceIndex(storedEntry.workspaceId, storedEntry.hostname);
}
removeRoute(hostname: string): void {
const entry = this.routes.get(hostname);
if (!entry) {
return;
}
this.routes.delete(hostname);
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
}
removeRoutesForPort(port: number): void {
for (const [hostname, entry] of this.routes) {
if (entry.port === port) {
this.routes.delete(hostname);
this.removeHostnameFromWorkspaceIndex(entry.workspaceId, hostname);
}
}
}
findRoute(host: string): ServiceRoute | null {
// Strip port suffix from the Host header value
const hostname = host.replace(/:\d+$/, "");
// 1. Exact match
const exactRoute = this.routes.get(hostname);
if (exactRoute !== undefined) {
return { hostname: exactRoute.hostname, port: exactRoute.port };
}
// 2. Subdomain match — walk up the labels looking for a registered parent
const parts = hostname.split(".");
for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(i).join(".");
const candidateRoute = this.routes.get(candidate);
if (candidateRoute !== undefined) {
return { hostname: candidateRoute.hostname, port: candidateRoute.port };
}
}
return null;
}
listRoutes(): ServiceRouteEntry[] {
return Array.from(this.routes.values()).map((entry) => ({ ...entry }));
}
listRoutesForWorkspace(workspaceId: string): ServiceRouteEntry[] {
const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) {
return [];
}
const routes: ServiceRouteEntry[] = [];
for (const hostname of hostnames) {
const entry = this.routes.get(hostname);
if (entry) {
routes.push({ ...entry });
}
}
return routes;
}
private addHostnameToWorkspaceIndex(workspaceId: string, hostname: string): void {
const hostnames = this.workspaceHostnames.get(workspaceId) ?? new Set<string>();
hostnames.add(hostname);
this.workspaceHostnames.set(workspaceId, hostnames);
}
private removeHostnameFromWorkspaceIndex(workspaceId: string, hostname: string): void {
const hostnames = this.workspaceHostnames.get(workspaceId);
if (!hostnames) {
return;
}
hostnames.delete(hostname);
if (hostnames.size === 0) {
this.workspaceHostnames.delete(workspaceId);
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripHopByHopHeaders(
rawHeaders: http.IncomingHttpHeaders,
): Record<string, string | string[]> {
const out: Record<string, string | string[]> = {};
for (const [key, value] of Object.entries(rawHeaders)) {
if (value === undefined) continue;
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
out[key] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// createServiceProxyMiddleware
// ---------------------------------------------------------------------------
export function createServiceProxyMiddleware({
routeStore,
logger,
}: {
routeStore: ServiceRouteStore;
logger: Logger;
}): RequestHandler {
return (req, res, next) => {
const hostHeader = req.headers.host;
if (!hostHeader) {
next();
return;
}
const route = routeStore.findRoute(hostHeader);
if (!route) {
next();
return;
}
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] =
req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = req.protocol;
const proxyReq = http.request(
{
hostname: "127.0.0.1",
port: route.port,
path: req.originalUrl,
method: req.method,
headers: forwardedHeaders,
},
(proxyRes) => {
const responseHeaders = stripHopByHopHeaders(proxyRes.headers);
res.writeHead(proxyRes.statusCode ?? 502, responseHeaders);
proxyRes.pipe(res, { end: true });
},
);
proxyReq.on("error", (err) => {
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Service proxy: upstream unreachable",
);
if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain" });
res.end("502 Bad Gateway");
}
});
req.pipe(proxyReq, { end: true });
};
}
// ---------------------------------------------------------------------------
// createServiceProxyUpgradeHandler
// ---------------------------------------------------------------------------
export function createServiceProxyUpgradeHandler({
routeStore,
logger,
}: {
routeStore: ServiceRouteStore;
logger: Logger;
}): (req: IncomingMessage, socket: net.Socket, head: Buffer) => void {
return (req, socket, head) => {
const hostHeader = req.headers.host;
if (!hostHeader) {
return;
}
const route = routeStore.findRoute(hostHeader);
if (!route) {
return;
}
const targetSocket = net.connect(
{ host: "127.0.0.1", port: route.port },
() => {
// Reconstruct the raw HTTP upgrade request to send to the target
const forwardedHeaders = stripHopByHopHeaders(req.headers);
forwardedHeaders["x-forwarded-for"] =
req.socket.remoteAddress ?? "127.0.0.1";
forwardedHeaders["x-forwarded-host"] = hostHeader.replace(/:\d+$/, "");
forwardedHeaders["x-forwarded-proto"] = "http";
// Re-include upgrade and connection headers — they are required for
// WebSocket handshake even though they are hop-by-hop.
forwardedHeaders["connection"] = "Upgrade";
forwardedHeaders["upgrade"] = req.headers.upgrade ?? "websocket";
const headerLines: string[] = [];
headerLines.push(
`${req.method ?? "GET"} ${req.url ?? "/"} HTTP/${req.httpVersion}`,
);
for (const [key, value] of Object.entries(forwardedHeaders)) {
if (Array.isArray(value)) {
for (const v of value) {
headerLines.push(`${key}: ${v}`);
}
} else {
headerLines.push(`${key}: ${value}`);
}
}
headerLines.push("\r\n");
targetSocket.write(headerLines.join("\r\n"));
if (head.length > 0) {
targetSocket.write(head);
}
// Pipe in both directions
targetSocket.pipe(socket);
socket.pipe(targetSocket);
},
);
targetSocket.on("error", (err) => {
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Service proxy: WebSocket upstream unreachable",
);
socket.end();
});
socket.on("error", () => {
targetSocket.destroy();
});
};
}
// ---------------------------------------------------------------------------
// findFreePort
// ---------------------------------------------------------------------------
export function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to get assigned port"));
return;
}
const { port } = address;
server.close((err) => {
if (err) {
reject(err);
} else {
resolve(port);
}
});
});
server.on("error", reject);
});
}
@@ -0,0 +1,269 @@
import { describe, expect, it, vi } from "vitest";
import { ServiceRouteStore } from "./service-proxy.js";
import {
buildWorkspaceServicePayloads,
createServiceStatusEmitter,
} from "./service-status-projection.js";
describe("service-status-projection", () => {
it("buildWorkspaceServicePayloads returns service payloads from workspace routes", () => {
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
});
routeStore.registerRoute({
hostname: "docs.localhost",
port: 3002,
workspaceId: "workspace-b",
serviceName: "docs",
});
routeStore.registerRoute({
hostname: "web.localhost",
port: 3003,
workspaceId: "workspace-a",
serviceName: "web",
});
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "stopped",
},
{
serviceName: "web",
hostname: "web.localhost",
port: 3003,
url: "http://web.localhost:6767",
status: "stopped",
},
]);
});
it("computes URLs with and without a daemon port", () => {
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
});
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767)).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "stopped",
},
]);
expect(buildWorkspaceServicePayloads(routeStore, "workspace-a", null)).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: null,
status: "stopped",
},
]);
});
it("createServiceStatusEmitter emits updates to all active sessions", () => {
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
});
const sessionA = { emit: vi.fn() };
const sessionB = { emit: vi.fn() };
const emitUpdate = createServiceStatusEmitter({
sessions: () => [sessionA, sessionB],
routeStore,
daemonPort: 6767,
});
emitUpdate("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
status: "running",
},
]);
expect(sessionA.emit).toHaveBeenCalledWith({
type: "service_status_update",
payload: {
workspaceId: "workspace-a",
services: [
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "running",
},
],
},
});
expect(sessionB.emit).toHaveBeenCalledWith({
type: "service_status_update",
payload: {
workspaceId: "workspace-a",
services: [
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "running",
},
],
},
});
});
it("uses resolveStatus to set initial service status when provided", () => {
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
});
routeStore.registerRoute({
hostname: "web.localhost",
port: 3003,
workspaceId: "workspace-a",
serviceName: "web",
});
const statuses = new Map<string, "running" | "stopped">([
["api.localhost", "running"],
]);
expect(
buildWorkspaceServicePayloads(routeStore, "workspace-a", 6767, (hostname) =>
statuses.get(hostname) ?? null,
),
).toEqual([
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: "http://api.localhost:6767",
status: "running",
},
{
serviceName: "web",
hostname: "web.localhost",
port: 3003,
url: "http://web.localhost:6767",
status: "stopped",
},
]);
});
it("emits workspace-specific batches", () => {
const routeStore = new ServiceRouteStore();
routeStore.registerRoute({
hostname: "api.localhost",
port: 3001,
workspaceId: "workspace-a",
serviceName: "api",
});
routeStore.registerRoute({
hostname: "web.localhost",
port: 3002,
workspaceId: "workspace-a",
serviceName: "web",
});
routeStore.registerRoute({
hostname: "docs.localhost",
port: 3003,
workspaceId: "workspace-b",
serviceName: "docs",
});
const session = { emit: vi.fn() };
const emitUpdate = createServiceStatusEmitter({
sessions: () => [session],
routeStore,
daemonPort: null,
});
emitUpdate("workspace-a", [
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
status: "running",
},
{
serviceName: "web",
hostname: "web.localhost",
port: 3002,
status: "stopped",
},
]);
emitUpdate("workspace-b", [
{
serviceName: "docs",
hostname: "docs.localhost",
port: 3003,
status: "running",
},
]);
expect(session.emit).toHaveBeenNthCalledWith(1, {
type: "service_status_update",
payload: {
workspaceId: "workspace-a",
services: [
{
serviceName: "api",
hostname: "api.localhost",
port: 3001,
url: null,
status: "running",
},
{
serviceName: "web",
hostname: "web.localhost",
port: 3002,
url: null,
status: "stopped",
},
],
},
});
expect(session.emit).toHaveBeenNthCalledWith(2, {
type: "service_status_update",
payload: {
workspaceId: "workspace-b",
services: [
{
serviceName: "docs",
hostname: "docs.localhost",
port: 3003,
url: null,
status: "running",
},
],
},
});
});
});
@@ -0,0 +1,86 @@
import type {
ServiceStatusUpdateMessage,
SessionOutboundMessage,
WorkspaceServicePayload,
} from "../shared/messages.js";
import type { ServiceStatusEntry } from "./service-health-monitor.js";
import type { ServiceRouteStore } from "./service-proxy.js";
type SessionEmitter = {
emit(message: SessionOutboundMessage): void;
};
function resolveDaemonPort(daemonPort: number | null | (() => number | null)): number | null {
if (typeof daemonPort === "function") {
return daemonPort();
}
return daemonPort;
}
function toServiceUrl(hostname: string, daemonPort: number | null): string | null {
if (daemonPort === null) {
return null;
}
return `http://${hostname}:${daemonPort}`;
}
export function buildWorkspaceServicePayloads(
routeStore: ServiceRouteStore,
workspaceId: string,
daemonPort: number | null,
resolveStatus?: (hostname: string) => "running" | "stopped" | null,
): WorkspaceServicePayload[] {
return routeStore.listRoutesForWorkspace(workspaceId).map((route) => ({
serviceName: route.serviceName,
hostname: route.hostname,
port: route.port,
url: toServiceUrl(route.hostname, daemonPort),
status: resolveStatus?.(route.hostname) ?? "stopped",
}));
}
function buildServiceStatusUpdateMessage(params: {
workspaceId: string;
services: WorkspaceServicePayload[];
}): ServiceStatusUpdateMessage {
return {
type: "service_status_update",
payload: {
workspaceId: params.workspaceId,
services: params.services,
},
};
}
export function createServiceStatusEmitter({
sessions,
routeStore,
daemonPort,
}: {
sessions: () => SessionEmitter[];
routeStore: ServiceRouteStore;
daemonPort: number | null | (() => number | null);
}): (workspaceId: string, services: ServiceStatusEntry[]) => void {
return (workspaceId, services) => {
const resolvedDaemonPort = resolveDaemonPort(daemonPort);
const serviceStatusByHostname = new Map(
services.map((service) => [service.hostname, service.status] as const),
);
const projected = buildWorkspaceServicePayloads(routeStore, workspaceId, resolvedDaemonPort).map(
(service) => ({
...service,
status: serviceStatusByHostname.get(service.hostname) ?? service.status,
}),
);
const message = buildServiceStatusUpdateMessage({
workspaceId,
services: projected,
});
for (const session of sessions()) {
session.emit(message);
}
};
}
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ import type { PaseoSpeechConfig } from "../bootstrap.js";
import type { InitializedLocalSpeech } from "./providers/local/runtime.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech-provider.js";
import type { TurnDetectionProvider } from "./turn-detection-provider.js";
import { initializeSpeechRuntime } from "./speech-runtime.js";
import { createSpeechService } from "./speech-runtime.js";
const { initializeLocalSpeechServicesMock } = vi.hoisted(() => ({
initializeLocalSpeechServicesMock: vi.fn<(args: unknown) => Promise<InitializedLocalSpeech>>(),
@@ -70,7 +70,7 @@ function createSpeechConfig(providers: PaseoSpeechConfig["providers"]): PaseoSpe
return { providers };
}
describe("initializeSpeechRuntime readiness", () => {
describe("createSpeechService readiness", () => {
beforeEach(() => {
initializeLocalSpeechServicesMock.mockReset();
});
@@ -92,7 +92,7 @@ describe("initializeSpeechRuntime readiness", () => {
cleanup: () => {},
});
const runtime = await initializeSpeechRuntime({
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: true, explicit: true },
@@ -101,14 +101,16 @@ describe("initializeSpeechRuntime readiness", () => {
voiceTts: { provider: "local", enabled: false, explicit: true },
}),
});
runtime.start();
await runtime.ready;
const readiness = runtime.getSpeechReadiness();
const readiness = runtime.getReadiness();
expect(readiness.dictation.available).toBe(true);
expect(readiness.realtimeVoice.reasonCode).toBe("disabled");
expect(readiness.voiceFeature.available).toBe(true);
expect(readiness.voiceFeature.reasonCode).toBe("ready");
runtime.cleanup();
runtime.stop();
});
it("keeps voice feature available when only realtime voice is enabled and ready", async () => {
@@ -130,7 +132,7 @@ describe("initializeSpeechRuntime readiness", () => {
cleanup: () => {},
});
const runtime = await initializeSpeechRuntime({
const runtime = createSpeechService({
logger: pino({ level: "silent" }),
speechConfig: createSpeechConfig({
dictationStt: { provider: "local", enabled: false, explicit: true },
@@ -139,13 +141,15 @@ describe("initializeSpeechRuntime readiness", () => {
voiceTts: { provider: "local", enabled: true, explicit: true },
}),
});
runtime.start();
await runtime.ready;
const readiness = runtime.getSpeechReadiness();
const readiness = runtime.getReadiness();
expect(readiness.realtimeVoice.available).toBe(true);
expect(readiness.dictation.reasonCode).toBe("disabled");
expect(readiness.voiceFeature.available).toBe(true);
expect(readiness.voiceFeature.reasonCode).toBe("ready");
runtime.cleanup();
runtime.stop();
});
});
@@ -336,25 +336,23 @@ function resolveEffectiveProviderIds(params: {
};
}
export type InitializedSpeechRuntime = {
resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
resolveVoiceStt: () => SpeechToTextProvider | null;
resolveVoiceTts: () => TextToSpeechProvider | null;
export type SpeechService = {
resolveStt: () => SpeechToTextProvider | null;
resolveTts: () => TextToSpeechProvider | null;
resolveTurnDetection: () => TurnDetectionProvider | null;
resolveDictationStt: () => SpeechToTextProvider | null;
getSpeechReadiness: () => SpeechReadinessSnapshot;
subscribeSpeechReadiness: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
cleanup: () => void;
localModelConfig: {
modelsDir: string;
defaultModelIds: LocalSpeechModelId[];
} | null;
getReadiness: () => SpeechReadinessSnapshot;
onReadinessChange: (listener: (snapshot: SpeechReadinessSnapshot) => void) => () => void;
start: () => void;
stop: () => void;
ready: Promise<void>;
};
export async function initializeSpeechRuntime(params: {
export function createSpeechService(params: {
logger: Logger;
openaiConfig?: PaseoOpenAIConfig;
speechConfig?: PaseoSpeechConfig;
}): Promise<InitializedSpeechRuntime> {
}): SpeechService {
const logger = params.logger.child({ module: "speech-runtime" });
const speechConfig = params.speechConfig ?? null;
const openaiConfig = params.openaiConfig;
@@ -397,6 +395,14 @@ export async function initializeSpeechRuntime(params: {
const readinessListeners = new Set<(snapshot: SpeechReadinessSnapshot) => void>();
let lastReadinessFingerprint: string | null = null;
let lastPublishedReadinessSnapshot: SpeechReadinessSnapshot | null = null;
let started = false;
let readySettled = false;
let resolveReady!: () => void;
let rejectReady!: (error: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const computeReadinessSnapshot = (): SpeechReadinessSnapshot => {
const realtimeVoice = buildRealtimeVoiceReadiness({
@@ -658,16 +664,36 @@ export async function initializeSpeechRuntime(params: {
}
};
await runReconcile();
const snapshot = computeReadinessSnapshot();
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
if (missingLocalModelIds.length > 0) {
startBackgroundDownload();
const start = (): void => {
if (started || stopped) {
return;
}
scheduleMonitor();
}
started = true;
void (async () => {
try {
await runReconcile();
const snapshot = computeReadinessSnapshot();
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
if (missingLocalModelIds.length > 0) {
startBackgroundDownload();
}
scheduleMonitor();
}
if (!readySettled) {
readySettled = true;
resolveReady();
}
} catch (error) {
if (!readySettled) {
readySettled = true;
rejectReady(error);
}
logger.error({ err: error }, "Speech runtime failed during initial reconcile");
}
})();
};
const cleanup = (): void => {
const stop = (): void => {
stopped = true;
if (monitorTimeout) {
clearTimeout(monitorTimeout);
@@ -677,13 +703,14 @@ export async function initializeSpeechRuntime(params: {
};
return {
resolveVoiceTurnDetection: () => turnDetectionService,
resolveVoiceStt: () => sttService,
resolveVoiceTts: () => ttsService,
resolveTurnDetection: () => turnDetectionService,
resolveStt: () => sttService,
resolveTts: () => ttsService,
resolveDictationStt: () => dictationSttService,
getSpeechReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
subscribeSpeechReadiness,
cleanup,
localModelConfig,
getReadiness: () => lastPublishedReadinessSnapshot ?? computeReadinessSnapshot(),
onReadinessChange: subscribeSpeechReadiness,
start,
stop,
ready,
};
}
+45 -33
View File
@@ -33,11 +33,8 @@ import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import type { Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
import type { ServiceRouteStore } from "./service-proxy.js";
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import {
computeShouldNotifyClient,
@@ -243,18 +240,13 @@ export class VoiceAssistantWebSocketServer {
private readonly pushTokenStore: PushTokenStore;
private readonly pushService: PushService;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly stt: Resolvable<SpeechToTextProvider | null>;
private readonly tts: Resolvable<TextToSpeechProvider | null>;
private readonly turnDetection: Resolvable<TurnDetectionProvider | null>;
private readonly speech: SpeechService | null;
private readonly terminalManager: TerminalManager | null;
private readonly serviceRouteStore: ServiceRouteStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
private readonly dictation: {
finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
localModels?: {
modelsDir: string;
defaultModelIds: LocalSpeechModelId[];
};
getSpeechReadiness?: () => SpeechReadinessSnapshot;
} | null;
private readonly voice: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
@@ -288,6 +280,7 @@ export class VoiceAssistantWebSocketServer {
private readonly inboundSessionRequestCounts = new Map<string, number>();
private readonly requestLatencies = new Map<string, number[]>();
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
private unsubscribeSpeechReadiness: (() => void) | null = null;
constructor(
server: HTTPServer,
@@ -299,11 +292,7 @@ export class VoiceAssistantWebSocketServer {
paseoHome: string,
createAgentMcpTransport: AgentMcpTransportFactory,
wsConfig: WebSocketServerConfig,
speech?: {
turnDetection: Resolvable<TurnDetectionProvider | null>;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
},
speech?: SpeechService | null,
terminalManager?: TerminalManager | null,
voice?: {
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
@@ -312,12 +301,6 @@ export class VoiceAssistantWebSocketServer {
},
dictation?: {
finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
localModels?: {
modelsDir: string;
defaultModelIds: LocalSpeechModelId[];
};
getSpeechReadiness?: () => SpeechReadinessSnapshot;
},
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
daemonVersion?: string,
@@ -328,6 +311,9 @@ export class VoiceAssistantWebSocketServer {
loopService?: LoopService,
scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager,
serviceRouteStore?: ServiceRouteStore | null,
getDaemonTcpPort?: () => number | null,
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null,
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -358,17 +344,21 @@ export class VoiceAssistantWebSocketServer {
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.createAgentMcpTransport = createAgentMcpTransport;
this.turnDetection = speech?.turnDetection ?? null;
this.stt = speech?.stt ?? null;
this.tts = speech?.tts ?? null;
this.speech = speech ?? null;
this.terminalManager = terminalManager ?? null;
this.voice = voice ?? null;
this.dictation = dictation ?? null;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.serviceRouteStore = serviceRouteStore ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceStatus = resolveServiceStatus ?? null;
this.serverCapabilities = buildServerCapabilities({
readiness: this.dictation?.getSpeechReadiness?.() ?? null,
readiness: this.speech?.getReadiness() ?? null,
});
this.unsubscribeSpeechReadiness = this.speech?.onReadinessChange((snapshot) => {
this.publishSpeechReadiness(snapshot);
}) ?? null;
const pushLogger = this.logger.child({ module: "push" });
this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
@@ -433,6 +423,16 @@ export class VoiceAssistantWebSocketServer {
}
}
public listActiveSessions(): Session[] {
return Array.from(
new Set(
[...this.sessions.values(), ...this.externalSessionsByKey.values()].map(
(connection) => connection.session,
),
),
);
}
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
this.updateServerCapabilities(buildServerCapabilities({ readiness }));
}
@@ -457,6 +457,8 @@ export class VoiceAssistantWebSocketServer {
}
public async close(): Promise<void> {
this.unsubscribeSpeechReadiness?.();
this.unsubscribeSpeechReadiness = null;
if (this.runtimeMetricsInterval) {
clearInterval(this.runtimeMetricsInterval);
this.runtimeMetricsInterval = null;
@@ -658,12 +660,15 @@ export class VoiceAssistantWebSocketServer {
scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager,
createAgentMcpTransport: this.createAgentMcpTransport,
stt: this.stt,
tts: this.tts,
stt: () => this.speech?.resolveStt() ?? null,
tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager,
serviceRouteStore: this.serviceRouteStore ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
resolveServiceStatus: this.resolveServiceStatus ?? undefined,
voice: {
...(this.voice ?? {}),
turnDetection: this.turnDetection,
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
},
voiceBridge: {
registerVoiceSpeakHandler: (agentId, handler) => {
@@ -681,7 +686,14 @@ export class VoiceAssistantWebSocketServer {
ensureVoiceMcpSocketForAgent: this.voice?.ensureVoiceMcpSocketForAgent,
removeVoiceMcpSocketForAgent: this.voice?.removeVoiceMcpSocketForAgent,
},
dictation: this.dictation ?? undefined,
dictation:
this.dictation || this.speech
? {
finalTimeoutMs: this.dictation?.finalTimeoutMs,
stt: () => this.speech?.resolveDictationStt() ?? null,
getSpeechReadiness: () => this.speech!.getReadiness(),
}
: undefined,
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
});
@@ -5,7 +5,12 @@ import { join } from "path";
import { tmpdir } from "os";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import { createAgentWorktree, runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import {
createAgentWorktree,
runAsyncWorktreeBootstrap,
spawnWorktreeServices,
} from "./worktree-bootstrap.js";
import { ServiceRouteStore } from "./service-proxy.js";
describe("runAsyncWorktreeBootstrap", () => {
let tempDir: string;
@@ -56,7 +61,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-streaming-setup",
baseBranch: "main",
@@ -69,7 +74,8 @@ describe("runAsyncWorktreeBootstrap", () => {
await runAsyncWorktreeBootstrap({
agentId: "agent-test",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
@@ -113,12 +119,15 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(persistedSetupItems[0].detail.commands[0]).toMatchObject({
index: 1,
command: 'echo "line-one"; echo "line-two" 1>&2',
log: expect.stringContaining("line-one"),
status: "completed",
exitCode: 0,
});
expect(persistedSetupItems[0].detail.commands[0]?.log).toContain("line-two");
expect(persistedSetupItems[0].detail.commands[1]).toMatchObject({
index: 2,
command: 'echo "line-three"',
log: "line-three\n",
status: "completed",
exitCode: 0,
});
@@ -160,7 +169,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-live-failure",
baseBranch: "main",
@@ -172,7 +181,8 @@ describe("runAsyncWorktreeBootstrap", () => {
await expect(
runAsyncWorktreeBootstrap({
agentId: "agent-live-failure",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
@@ -210,7 +220,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-large-output",
baseBranch: "main",
@@ -221,7 +231,8 @@ describe("runAsyncWorktreeBootstrap", () => {
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-large-output",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
@@ -244,6 +255,64 @@ describe("runAsyncWorktreeBootstrap", () => {
expect(persistedSetupItem.detail.log).toContain("prefix-");
expect(persistedSetupItem.detail.log).toContain("-suffix");
expect(persistedSetupItem.detail.log).toContain("...<output truncated in the middle>...");
expect(persistedSetupItem.detail.commands[0]?.log).toContain("prefix-");
expect(persistedSetupItem.detail.commands[0]?.log).toContain("-suffix");
expect(persistedSetupItem.detail.commands[0]?.log).toContain(
"...<output truncated in the middle>...",
);
});
it("keeps only the final carriage-return-updated content in command logs", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [
`node -e "process.stdout.write('fetch 1/3\\\\rfetch 2/3\\\\rfetch 3/3\\\\nready\\\\n')"`,
],
},
}),
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add carriage return setup'", {
cwd: repoDir,
stdio: "pipe",
});
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-carriage-return",
baseBranch: "main",
worktreeSlug: "feature-carriage-return",
paseoHome,
});
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-carriage-return",
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: null,
appendTimelineItem: async (item) => {
persisted.push(item);
return true;
},
emitLiveTimelineItem: async () => true,
});
const persistedSetupItem = persisted.find(
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
item.type === "tool_call" && item.name === "paseo_worktree_setup",
);
expect(persistedSetupItem?.detail.type).toBe("worktree_setup");
if (!persistedSetupItem || persistedSetupItem.detail.type !== "worktree_setup") {
throw new Error("Expected worktree_setup tool detail");
}
expect(persistedSetupItem.detail.log).toContain("\nfetch 3/3\nready\n");
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 1/3\n");
expect(persistedSetupItem.detail.log).not.toContain("\nfetch 2/3\n");
expect(persistedSetupItem.detail.commands[0]?.log).toBe("fetch 3/3\nready\n");
});
it("waits for terminal output before sending bootstrap commands", async () => {
@@ -266,7 +335,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-terminal-readiness",
baseBranch: "main",
@@ -280,7 +349,8 @@ describe("runAsyncWorktreeBootstrap", () => {
await runAsyncWorktreeBootstrap({
agentId: "agent-terminal-readiness",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: {
async getTerminals() {
return [];
@@ -357,7 +427,7 @@ describe("runAsyncWorktreeBootstrap", () => {
stdio: "pipe",
});
const worktree = await createAgentWorktree({
const worktreeBootstrap = await createAgentWorktree({
cwd: repoDir,
branchName: "feature-shared-runtime-port",
baseBranch: "main",
@@ -370,7 +440,8 @@ describe("runAsyncWorktreeBootstrap", () => {
const persisted: AgentTimelineItem[] = [];
await runAsyncWorktreeBootstrap({
agentId: "agent-shared-runtime-port",
worktree,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: {
async getTerminals() {
return [];
@@ -416,13 +487,13 @@ describe("runAsyncWorktreeBootstrap", () => {
emitLiveTimelineItem: async () => true,
});
const setupPortPath = join(worktree.worktreePath, "setup-port.txt");
const setupPortPath = join(worktreeBootstrap.worktree.worktreePath, "setup-port.txt");
await waitForPathExists(setupPortPath);
const setupPort = readFileSync(setupPortPath, "utf8").trim();
expect(setupPort.length).toBeGreaterThan(0);
expect(registeredEnvs).toHaveLength(1);
expect(registeredEnvs[0]?.cwd).toBe(worktree.worktreePath);
expect(registeredEnvs[0]?.cwd).toBe(worktreeBootstrap.worktree.worktreePath);
expect(registeredEnvs[0]?.env.PASEO_WORKTREE_PORT).toBe(setupPort);
expect(createTerminalEnvs.length).toBeGreaterThan(0);
expect(createTerminalEnvs[0]?.PASEO_WORKTREE_PORT).toBe(setupPort);
@@ -435,4 +506,85 @@ describe("runAsyncWorktreeBootstrap", () => {
);
expect(terminalToolCall?.status).toBe("completed");
});
it("spawns services without PASEO_SERVICE_URL when the daemon has no TCP port", async () => {
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
services: {
web: {
command: "npm run dev",
},
},
}),
);
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'add service config'", {
cwd: repoDir,
stdio: "pipe",
});
const routeStore = new ServiceRouteStore();
const createTerminalCalls: Array<{ cwd: string; name?: string; env?: Record<string, string> }> = [];
const results = await spawnWorktreeServices({
repoRoot: repoDir,
workspaceId: repoDir,
branchName: "feature-socket-service",
daemonPort: null,
routeStore,
terminalManager: {
async getTerminals() {
return [];
},
async createTerminal(options) {
createTerminalCalls.push(options);
return {
id: "term-service",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit: () => () => {},
getState: () => ({
rows: 1,
cols: 1,
grid: [[{ char: "$" }]],
scrollback: [],
cursor: { row: 0, col: 0 },
}),
kill: () => {},
};
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
},
});
expect(results).toHaveLength(1);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "feature-socket-service.web.localhost",
port: expect.any(Number),
workspaceId: repoDir,
serviceName: "web",
},
]);
expect(createTerminalCalls).toHaveLength(1);
expect(createTerminalCalls[0]?.cwd).toBe(repoDir);
expect(createTerminalCalls[0]?.name).toBe("web");
expect(createTerminalCalls[0]?.env?.PORT).toEqual(expect.any(String));
expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1");
expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_URL).toBeUndefined();
});
});
+302 -87
View File
@@ -7,16 +7,20 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js";
import {
createWorktree,
getServiceConfigs,
getWorktreeTerminalSpecs,
listPaseoWorktrees,
processCarriageReturns,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
WorktreeSetupError,
type WorktreeConfig,
type WorktreeSetupCommandResult,
type WorktreeRuntimeEnv,
} from "../utils/worktree.js";
import type { AgentTimelineItem } from "./agent/agent-sdk-types.js";
import { findFreePort, type ServiceRouteStore } from "./service-proxy.js";
import type { AgentTimelineItem, ToolCallDetail } from "./agent/agent-sdk-types.js";
export interface WorktreeBootstrapTerminalResult {
name: string | null;
@@ -29,7 +33,10 @@ export interface WorktreeBootstrapTerminalResult {
export interface RunAsyncWorktreeBootstrapOptions {
agentId: string;
worktree: WorktreeConfig;
shouldBootstrap?: boolean;
terminalManager: TerminalManager | null;
serviceRouteStore?: ServiceRouteStore;
daemonPort?: number | null;
appendTimelineItem: (item: AgentTimelineItem) => Promise<boolean>;
emitLiveTimelineItem?: (item: AgentTimelineItem) => Promise<boolean>;
logger?: Logger;
@@ -43,6 +50,11 @@ export interface CreateAgentWorktreeOptions {
paseoHome?: string;
}
export interface CreateAgentWorktreeResult {
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500;
@@ -51,8 +63,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
GIT_OPTIONAL_LOCKS: "0",
};
const execAsync = promisify(exec);
const worktreeSetupEligibility = new WeakMap<WorktreeConfig, boolean>();
type MiddleTruncationAccumulator = {
totalBytes: number;
head: string;
@@ -60,6 +70,12 @@ type MiddleTruncationAccumulator = {
truncated: boolean;
};
export type WorktreeSetupOutputAccumulator = MiddleTruncationAccumulator;
export type WorktreeSetupProgressAccumulator = {
resultsByIndex: Map<number, WorktreeSetupCommandResult>;
outputAccumulatorsByIndex: Map<number, WorktreeSetupOutputAccumulator>;
};
function byteLength(text: string): number {
return Buffer.byteLength(text, "utf8");
}
@@ -86,7 +102,7 @@ function sliceLastBytes(text: string, maxBytes: number): string {
return bytes.subarray(bytes.length - maxBytes).toString("utf8");
}
function createMiddleTruncationAccumulator(): MiddleTruncationAccumulator {
export function createWorktreeSetupOutputAccumulator(): WorktreeSetupOutputAccumulator {
return {
totalBytes: 0,
head: "",
@@ -103,8 +119,8 @@ function getHeadTailBudgets(maxBytes: number): { headBytes: number; tailBytes: n
return { headBytes, tailBytes };
}
function appendToMiddleTruncationAccumulator(
accumulator: MiddleTruncationAccumulator,
export function appendWorktreeSetupOutputAccumulator(
accumulator: WorktreeSetupOutputAccumulator,
chunk: string,
): void {
if (!chunk) {
@@ -161,16 +177,17 @@ function renderMiddleTruncationAccumulator(accumulator: MiddleTruncationAccumula
export async function createAgentWorktree(
options: CreateAgentWorktreeOptions,
): Promise<WorktreeConfig> {
): Promise<CreateAgentWorktreeResult> {
const existingWorktree = await findExistingPaseoWorktreeBySlug(options);
if (existingWorktree) {
const branchName = await resolveBranchNameForWorktreePath(existingWorktree.path);
const reusedWorktree = {
branchName,
worktreePath: existingWorktree.path,
return {
worktree: {
branchName,
worktreePath: existingWorktree.path,
},
shouldBootstrap: false,
};
worktreeSetupEligibility.set(reusedWorktree, false);
return reusedWorktree;
}
const createdWorktree = await createWorktree({
@@ -181,8 +198,10 @@ export async function createAgentWorktree(
runSetup: false,
paseoHome: options.paseoHome,
});
worktreeSetupEligibility.set(createdWorktree, true);
return createdWorktree;
return {
worktree: createdWorktree,
shouldBootstrap: true,
};
}
async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptions) {
@@ -221,7 +240,7 @@ function commandStatusFromResult(
function buildWorktreeSetupLog(input: {
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
}): { log: string; truncated: boolean } {
const { results, outputAccumulatorsByIndex } = input;
if (results.length === 0) {
@@ -236,15 +255,13 @@ function buildWorktreeSetupLog(input: {
const total = results.length;
for (const [index, result] of results.entries()) {
lines.push(`==> [${index + 1}/${total}] Running: ${result.command}`);
const accumulator = outputAccumulatorsByIndex?.get(index + 1);
const output = accumulator
? renderMiddleTruncationAccumulator(accumulator)
: truncateTextInMiddle(
`${result.stdout ?? ""}${result.stderr ?? ""}`,
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES,
);
if (output.text.length > 0) {
lines.push(output.text.replace(/\n$/, ""));
const output = buildWorktreeSetupCommandLog({
index: index + 1,
result,
outputAccumulatorsByIndex,
});
if (output.log.length > 0) {
lines.push(output.log.replace(/\n$/, ""));
}
if (output.truncated) {
anyTruncated = true;
@@ -261,34 +278,136 @@ function buildWorktreeSetupLog(input: {
};
}
function buildWorktreeSetupCommandLog(input: {
index: number;
result: WorktreeSetupCommandResult;
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
}): { log: string; truncated: boolean } {
const { index, result, outputAccumulatorsByIndex } = input;
const accumulator = outputAccumulatorsByIndex?.get(index);
const rendered = accumulator
? renderMiddleTruncationAccumulator(accumulator)
: truncateTextInMiddle(
`${result.stdout ?? ""}${result.stderr ?? ""}`,
MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES,
);
return {
log: processCarriageReturns(rendered.text),
truncated: rendered.truncated,
};
}
export function createWorktreeSetupProgressAccumulator(): WorktreeSetupProgressAccumulator {
return {
resultsByIndex: new Map(),
outputAccumulatorsByIndex: new Map(),
};
}
export function applyWorktreeSetupProgressEvent(
accumulator: WorktreeSetupProgressAccumulator,
event: Parameters<NonNullable<Parameters<typeof runWorktreeSetupCommands>[0]["onEvent"]>>[0],
): void {
const existing = accumulator.resultsByIndex.get(event.index);
const baseResult: WorktreeSetupCommandResult = existing ?? {
command: event.command,
cwd: event.cwd,
stdout: "",
stderr: "",
exitCode: null,
durationMs: 0,
};
if (event.type === "output") {
const outputAccumulator =
accumulator.outputAccumulatorsByIndex.get(event.index) ??
createWorktreeSetupOutputAccumulator();
appendWorktreeSetupOutputAccumulator(outputAccumulator, event.chunk);
accumulator.outputAccumulatorsByIndex.set(event.index, outputAccumulator);
accumulator.resultsByIndex.set(event.index, {
...baseResult,
stdout: baseResult.stdout,
stderr: baseResult.stderr,
});
return;
}
if (event.type === "command_completed") {
accumulator.resultsByIndex.set(event.index, {
...baseResult,
stdout: event.stdout,
stderr: event.stderr,
exitCode: event.exitCode,
durationMs: event.durationMs,
});
return;
}
accumulator.resultsByIndex.set(event.index, baseResult);
}
export function getWorktreeSetupProgressResults(
accumulator: WorktreeSetupProgressAccumulator,
): WorktreeSetupCommandResult[] {
return Array.from(accumulator.resultsByIndex.entries())
.sort((a, b) => a[0] - b[0])
.map(([, result]) => result);
}
export function buildWorktreeSetupDetail(input: {
worktree: WorktreeConfig;
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
}): Extract<ToolCallDetail, { type: "worktree_setup" }> {
let anyCommandTruncated = false;
const commands = input.results.map((result, index) => {
const renderedLog = buildWorktreeSetupCommandLog({
index: index + 1,
result,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
if (renderedLog.truncated) {
anyCommandTruncated = true;
}
return {
index: index + 1,
command: result.command,
cwd: result.cwd,
log: renderedLog.log,
status: commandStatusFromResult(result),
exitCode: result.exitCode,
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
};
});
const renderedLog = buildWorktreeSetupLog({
results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
return {
type: "worktree_setup",
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
log: renderedLog.log,
commands,
...(renderedLog.truncated || anyCommandTruncated ? { truncated: true } : {}),
};
}
function buildSetupTimelineItem(input: {
callId: string;
status: "running" | "completed" | "failed";
worktree: WorktreeConfig;
results: WorktreeSetupCommandResult[];
outputAccumulatorsByIndex?: Map<number, MiddleTruncationAccumulator>;
outputAccumulatorsByIndex?: Map<number, WorktreeSetupOutputAccumulator>;
errorMessage: string | null;
}): AgentTimelineItem {
const commands = input.results.map((result, index) => ({
index: index + 1,
command: result.command,
cwd: result.cwd,
status: commandStatusFromResult(result),
exitCode: result.exitCode,
...(result.durationMs > 0 ? { durationMs: result.durationMs } : {}),
}));
const renderedLog = buildWorktreeSetupLog({
const detail = buildWorktreeSetupDetail({
worktree: input.worktree,
results: input.results,
outputAccumulatorsByIndex: input.outputAccumulatorsByIndex,
});
const detail = {
type: "worktree_setup" as const,
worktreePath: input.worktree.worktreePath,
branchName: input.worktree.branchName,
log: renderedLog.log,
commands,
...(renderedLog.truncated ? { truncated: true } : {}),
};
if (input.status === "running") {
return {
@@ -523,7 +642,7 @@ async function runWorktreeTerminalBootstrap(
export async function runAsyncWorktreeBootstrap(
options: RunAsyncWorktreeBootstrapOptions,
): Promise<void> {
if (worktreeSetupEligibility.get(options.worktree) === false) {
if (options.shouldBootstrap === false) {
return;
}
@@ -531,17 +650,14 @@ export async function runAsyncWorktreeBootstrap(
let setupResults: WorktreeSetupCommandResult[] = [];
let runtimeEnv: WorktreeRuntimeEnv | null = null;
const emitLiveTimelineItem = options.emitLiveTimelineItem;
const runningResultsByIndex = new Map<number, WorktreeSetupCommandResult>();
const outputAccumulatorsByIndex = new Map<number, MiddleTruncationAccumulator>();
const progressAccumulator = createWorktreeSetupProgressAccumulator();
let liveEmitQueue = Promise.resolve();
const queueLiveRunningEmit = () => {
if (!emitLiveTimelineItem) {
return;
}
const runningResults = Array.from(runningResultsByIndex.entries())
.sort((a, b) => a[0] - b[0])
.map(([, result]) => result);
const runningResults = getWorktreeSetupProgressResults(progressAccumulator);
liveEmitQueue = liveEmitQueue.then(async () => {
try {
await emitLiveTimelineItem(
@@ -550,7 +666,7 @@ export async function runAsyncWorktreeBootstrap(
status: "running",
worktree: options.worktree,
results: runningResults,
outputAccumulatorsByIndex,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: null,
}),
);
@@ -579,42 +695,7 @@ export async function runAsyncWorktreeBootstrap(
cleanupOnFailure: false,
runtimeEnv,
onEvent: (event) => {
const existing = runningResultsByIndex.get(event.index);
const baseResult: WorktreeSetupCommandResult = existing ?? {
command: event.command,
cwd: event.cwd,
stdout: "",
stderr: "",
exitCode: null,
durationMs: 0,
};
if (event.type === "output") {
const outputAccumulator =
outputAccumulatorsByIndex.get(event.index) ?? createMiddleTruncationAccumulator();
appendToMiddleTruncationAccumulator(outputAccumulator, event.chunk);
outputAccumulatorsByIndex.set(event.index, outputAccumulator);
runningResultsByIndex.set(event.index, {
...baseResult,
// Keep the timeline command model lightweight; output is carried in
// outputAccumulatorsByIndex.
stdout: baseResult.stdout,
stderr: baseResult.stderr,
});
queueLiveRunningEmit();
return;
}
if (event.type === "command_completed") {
runningResultsByIndex.set(event.index, {
...baseResult,
stdout: event.stdout,
stderr: event.stderr,
exitCode: event.exitCode,
durationMs: event.durationMs,
});
queueLiveRunningEmit();
return;
}
runningResultsByIndex.set(event.index, baseResult);
applyWorktreeSetupProgressEvent(progressAccumulator, event);
queueLiveRunningEmit();
},
});
@@ -626,7 +707,7 @@ export async function runAsyncWorktreeBootstrap(
status: "completed",
worktree: options.worktree,
results: setupResults,
outputAccumulatorsByIndex,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: null,
}),
);
@@ -645,7 +726,7 @@ export async function runAsyncWorktreeBootstrap(
status: "failed",
worktree: options.worktree,
results: setupResults,
outputAccumulatorsByIndex,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
errorMessage: message,
}),
);
@@ -653,4 +734,138 @@ export async function runAsyncWorktreeBootstrap(
}
await runWorktreeTerminalBootstrap(options, runtimeEnv);
if (!options.terminalManager || !options.serviceRouteStore) {
return;
}
try {
await spawnWorktreeServices({
repoRoot: options.worktree.worktreePath,
workspaceId: options.worktree.worktreePath,
branchName: options.worktree.branchName,
daemonPort: options.daemonPort,
routeStore: options.serviceRouteStore,
terminalManager: options.terminalManager,
logger: options.logger,
});
} catch (error) {
options.logger?.warn(
{ err: error, agentId: options.agentId, worktreePath: options.worktree.worktreePath },
"Failed to spawn worktree services",
);
}
}
// ---------------------------------------------------------------------------
// Service lifecycle helpers
// ---------------------------------------------------------------------------
export interface WorktreeServiceResult {
serviceName: string;
hostname: string;
port: number;
terminalId: string;
}
export async function spawnWorktreeServices(options: {
repoRoot: string;
workspaceId: string;
branchName: string | null;
daemonPort?: number | null;
routeStore: ServiceRouteStore;
terminalManager: TerminalManager;
logger?: Logger;
}): Promise<WorktreeServiceResult[]> {
const { repoRoot, workspaceId, branchName, daemonPort, routeStore, terminalManager, logger } =
options;
const serviceConfigs = getServiceConfigs(repoRoot);
if (serviceConfigs.size === 0) {
return [];
}
const results: WorktreeServiceResult[] = [];
for (const [serviceName, config] of serviceConfigs) {
let hostname: string | null = null;
let port: number | null = null;
try {
port = config.port ?? (await findFreePort());
const branchHostnameLabel = branchName ? slugify(branchName) : null;
const isDefaultBranch =
branchName === null || branchName === "main" || branchName === "master";
hostname = isDefaultBranch
? `${serviceName}.localhost`
: `${branchHostnameLabel}.${serviceName}.localhost`;
routeStore.registerRoute({
hostname,
port,
workspaceId,
serviceName,
});
const env: Record<string, string> = {
PORT: String(port),
HOST: "127.0.0.1",
};
if (daemonPort !== null && daemonPort !== undefined) {
env.PASEO_SERVICE_URL = `http://${hostname}:${daemonPort}`;
}
const terminal = await terminalManager.createTerminal({
cwd: repoRoot,
name: serviceName,
env,
});
await waitForTerminalBootstrapReadiness(terminal);
terminal.send({ type: "input", data: `${config.command}\r` });
logger?.info(
{ serviceName, hostname, port, terminalId: terminal.id },
`Registered service proxy: ${hostname} -> 127.0.0.1:${port}`,
);
results.push({
serviceName,
hostname,
port,
terminalId: terminal.id,
});
} catch (error) {
if (hostname && port !== null) {
routeStore.removeRoute(hostname);
}
logger?.error(
{
err: error,
serviceName,
repoRoot,
branchName,
hostname,
port,
command: config.command,
},
"Failed to spawn worktree service",
);
throw error;
}
}
return results;
}
export function teardownWorktreeServices(options: {
hostnames: string[];
routeStore: ServiceRouteStore;
logger: Logger;
}): void {
const { hostnames, routeStore, logger } = options;
for (const hostname of hostnames) {
routeStore.removeRoute(hostname);
logger.info({ hostname }, "Removed service proxy route");
}
}
@@ -0,0 +1,795 @@
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { SessionOutboundMessage } from "./messages.js";
import { ServiceRouteStore } from "./service-proxy.js";
import {
createPaseoWorktreeInBackground,
handleCreatePaseoWorktreeRequest,
handleWorkspaceSetupStatusRequest,
} from "./worktree-session.js";
import { createWorktree } from "../utils/worktree.js";
function createLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as any;
}
function createTerminalManagerStub(options?: {
createTerminal?: (input: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) => Promise<any>;
}) {
const terminals: Array<{
id: string;
cwd: string;
name: string | undefined;
env: Record<string, string> | undefined;
sent: string[];
}> = [];
return {
terminals,
manager: {
registerCwdEnv: vi.fn(),
createTerminal: vi.fn(async (input: {
cwd: string;
name?: string;
env?: Record<string, string>;
}) => {
if (options?.createTerminal) {
return options.createTerminal(input);
}
const sent: string[] = [];
const terminal = {
id: `terminal-${terminals.length + 1}`,
getState: () => ({
scrollback: [[{ char: "$" }]],
grid: [],
}),
subscribe: () => () => {},
send: (message: { type: string; data: string }) => {
if (message.type === "input") {
sent.push(message.data);
}
},
};
terminals.push({
id: terminal.id,
cwd: input.cwd,
name: input.name,
env: input.env,
sent,
});
return terminal;
}),
} as any,
};
}
function createGitRepo(options?: { paseoConfig?: Record<string, unknown> }) {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "worktree-session-test-")));
const repoDir = path.join(tempDir, "repo");
execSync(`mkdir -p ${JSON.stringify(repoDir)}`);
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
writeFileSync(path.join(repoDir, "README.md"), "hello\n");
if (options?.paseoConfig) {
writeFileSync(path.join(repoDir, "paseo.json"), JSON.stringify(options.paseoConfig, null, 2));
}
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
}
describe("createPaseoWorktreeInBackground", () => {
const cleanupPaths: string[] = [];
afterEach(() => {
for (const target of cleanupPaths.splice(0)) {
rmSync(target, { recursive: true, force: true });
}
});
test("emits running then completed snapshots for no-setup workspaces and then launches services", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
services: {
web: {
command: "npm run dev",
},
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
branchName: "feature-no-setup",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-no-setup",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
serviceRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
worktree: {
branchName: "feature-no-setup",
worktreePath,
},
shouldBootstrap: true,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload).toMatchObject({
workspaceId: worktreePath,
status: "running",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-no-setup",
log: "",
commands: [],
},
});
expect(progressMessages[1]?.payload).toMatchObject({
workspaceId: worktreePath,
status: "completed",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-no-setup",
log: "",
commands: [],
},
});
expect(snapshots.get(worktreePath)).toMatchObject({
status: "completed",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-no-setup",
log: "",
commands: [],
},
});
expect(routeStore.listRoutes()).toEqual([
{
hostname: "feature-no-setup.web.localhost",
port: expect.any(Number),
workspaceId: worktreePath,
serviceName: "web",
},
]);
expect(terminalManager.terminals).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath);
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined();
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => {
const { tempDir, repoDir } = createGitRepo();
cleanupPaths.push(tempDir);
writeFileSync(path.join(repoDir, "paseo.json"), "{ invalid json\n");
execSync("git add paseo.json", { cwd: repoDir, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'broken config'", {
cwd: repoDir,
stdio: "pipe",
});
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
branchName: "broken-feature",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "broken-feature",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: null,
archiveWorkspaceRecord,
serviceRouteStore: null,
daemonPort: null,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
worktree: {
branchName: "broken-feature",
worktreePath,
},
shouldBootstrap: true,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload.status).toBe("running");
expect(progressMessages[0]?.payload.error).toBeNull();
expect(progressMessages[1]?.payload.status).toBe("failed");
expect(progressMessages[1]?.payload.error).toContain("Failed to parse paseo.json");
expect(progressMessages[1]?.payload.detail.commands).toEqual([]);
expect(snapshots.get(worktreePath)).toMatchObject({
status: "failed",
error: expect.stringContaining("Failed to parse paseo.json"),
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(worktreePath);
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
test("emits running setup snapshots before completed for real setup commands", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
worktree: {
setup: ['sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"'],
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
branchName: "feature-running-setup",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-running-setup",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const logger = createLogger();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: null,
archiveWorkspaceRecord,
serviceRouteStore: null,
daemonPort: null,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
worktree: {
branchName: "feature-running-setup",
worktreePath,
},
shouldBootstrap: true,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages.length).toBeGreaterThan(1);
expect(progressMessages[0]?.payload).toMatchObject({
workspaceId: worktreePath,
status: "running",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-running-setup",
log: "",
commands: [],
},
});
expect(progressMessages.at(-1)?.payload.status).toBe("completed");
const runningMessages = progressMessages.filter((message) => message.payload.status === "running");
expect(runningMessages.length).toBeGreaterThan(0);
expect(progressMessages.findIndex((message) => message.payload.status === "running")).toBeLessThan(
progressMessages.findIndex((message) => message.payload.status === "completed"),
);
const setupOutputMessage = runningMessages.find((message) =>
message.payload.detail.commands[0]?.log.includes("phase-one"),
);
expect(setupOutputMessage?.payload.detail.log).toContain("phase-one");
expect(setupOutputMessage?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
log: expect.stringContaining("phase-one"),
status: "running",
});
expect(progressMessages.at(-1)?.payload).toMatchObject({
workspaceId: worktreePath,
status: "completed",
error: null,
detail: {
type: "worktree_setup",
worktreePath,
branchName: "feature-running-setup",
},
});
expect(progressMessages.at(-1)?.payload.detail.log).toContain("phase-two");
expect(progressMessages.at(-1)?.payload.detail.commands[0]).toMatchObject({
index: 1,
command: 'sh -c "printf \'phase-one\\\\n\'; sleep 0.1; printf \'phase-two\\\\n\'"',
log: expect.stringContaining("phase-two"),
status: "completed",
exitCode: 0,
});
expect(snapshots.get(worktreePath)).toMatchObject({
status: "completed",
error: null,
});
});
test("emits completed when reusing an existing worktree without bootstrapping", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
worktree: {
setup: ["printf 'ran' > setup-ran.txt"],
},
services: {
web: {
command: "npm run dev",
},
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const existingWorktree = await createWorktree({
branchName: "reused-worktree",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "reused-worktree",
runSetup: false,
paseoHome,
});
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
serviceRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
worktree: {
branchName: "reused-worktree",
worktreePath: existingWorktree.worktreePath,
},
shouldBootstrap: false,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload).toMatchObject({
workspaceId: existingWorktree.worktreePath,
status: "running",
error: null,
});
expect(progressMessages[1]?.payload).toMatchObject({
workspaceId: existingWorktree.worktreePath,
status: "completed",
error: null,
detail: {
type: "worktree_setup",
worktreePath: existingWorktree.worktreePath,
branchName: "reused-worktree",
log: "",
commands: [],
},
});
expect(routeStore.listRoutes()).toEqual([
{
hostname: "reused-worktree.web.localhost",
port: expect.any(Number),
workspaceId: existingWorktree.worktreePath,
serviceName: "web",
},
]);
expect(terminalManager.terminals).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(existingWorktree.worktreePath);
expect(terminalManager.terminals[0]?.name).toBe("web");
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeDefined();
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(
readFileSync(path.join(existingWorktree.worktreePath, "README.md"), "utf8"),
).toContain("hello");
expect(() => readFileSync(path.join(existingWorktree.worktreePath, "setup-ran.txt"), "utf8")).toThrow();
expect(snapshots.get(existingWorktree.worktreePath)).toMatchObject({
status: "completed",
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(existingWorktree.worktreePath);
});
test("keeps setup completed when service launch fails afterward", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
services: {
web: {
command: "npm run dev",
},
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
branchName: "feature-service-failure",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-service-failure",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub({
createTerminal: async () => {
throw new Error("terminal spawn failed");
},
});
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
serviceRouteStore: routeStore,
daemonPort: 6767,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
worktree: {
branchName: "feature-service-failure",
worktreePath,
},
shouldBootstrap: true,
},
);
const progressMessages = emitted.filter(
(message): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress",
);
expect(progressMessages).toHaveLength(2);
expect(progressMessages[0]?.payload.status).toBe("running");
expect(progressMessages[0]?.payload.error).toBeNull();
expect(progressMessages[1]?.payload.status).toBe("completed");
expect(progressMessages[1]?.payload.error).toBeNull();
expect(emitted.some((message) => message.type === "workspace_setup_progress" && message.payload.status === "failed")).toBe(false);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
cwd: repoDir,
repoRoot: repoDir,
worktreeSlug: "feature-service-failure",
worktreePath,
}),
"Failed to spawn worktree services after workspace setup completed",
);
expect(snapshots.get(worktreePath)).toMatchObject({
status: "completed",
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
test("launches services in socket mode without requiring a daemon TCP port", async () => {
const { tempDir, repoDir } = createGitRepo({
paseoConfig: {
services: {
web: {
command: "npm run dev",
},
},
},
});
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const createdWorktree = await createWorktree({
branchName: "feature-socket-mode",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "feature-socket-mode",
runSetup: false,
paseoHome,
});
const worktreePath = createdWorktree.worktreePath;
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map<string, unknown>();
const routeStore = new ServiceRouteStore();
const logger = createLogger();
const terminalManager = createTerminalManagerStub();
const emitWorkspaceUpdateForCwd = vi.fn(async () => {});
const archiveWorkspaceRecord = vi.fn(async () => {});
await createPaseoWorktreeInBackground(
{
paseoHome,
emitWorkspaceUpdateForCwd,
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => snapshots.set(workspaceId, snapshot),
emit: (message) => emitted.push(message),
sessionLogger: logger,
terminalManager: terminalManager.manager,
archiveWorkspaceRecord,
serviceRouteStore: routeStore,
daemonPort: null,
},
{
requestCwd: repoDir,
repoRoot: repoDir,
worktree: {
branchName: "feature-socket-mode",
worktreePath,
},
shouldBootstrap: true,
},
);
expect(routeStore.listRoutes()).toEqual([
{
hostname: "feature-socket-mode.web.localhost",
port: expect.any(Number),
workspaceId: worktreePath,
serviceName: "web",
},
]);
expect(terminalManager.terminals).toHaveLength(1);
expect(terminalManager.terminals[0]?.cwd).toBe(worktreePath);
expect(terminalManager.terminals[0]?.env?.PORT).toEqual(expect.any(String));
expect(terminalManager.terminals[0]?.env?.PASEO_SERVICE_URL).toBeUndefined();
expect(terminalManager.terminals[0]?.sent).toEqual(["npm run dev\r"]);
expect(snapshots.get(worktreePath)).toMatchObject({
status: "completed",
error: null,
});
expect(archiveWorkspaceRecord).not.toHaveBeenCalled();
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledWith(worktreePath);
});
test("returns the cached workspace setup snapshot for status requests", async () => {
const emitted: SessionOutboundMessage[] = [];
const snapshots = new Map([
[
"/repo/.paseo/worktrees/feature-a",
{
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "done",
commands: [],
},
error: null,
},
],
]);
await handleWorkspaceSetupStatusRequest(
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: snapshots,
},
{
type: "workspace_setup_status_request",
workspaceId: "/repo/.paseo/worktrees/feature-a",
requestId: "req-status",
},
);
expect(emitted).toContainEqual({
type: "workspace_setup_status_response",
payload: {
requestId: "req-status",
workspaceId: "/repo/.paseo/worktrees/feature-a",
snapshot: {
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "done",
commands: [],
},
error: null,
},
},
});
});
test("returns null when no cached workspace setup snapshot exists", async () => {
const emitted: SessionOutboundMessage[] = [];
await handleWorkspaceSetupStatusRequest(
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: new Map(),
},
{
type: "workspace_setup_status_request",
workspaceId: "/repo/.paseo/worktrees/missing",
requestId: "req-missing",
},
);
expect(emitted).toContainEqual({
type: "workspace_setup_status_response",
payload: {
requestId: "req-missing",
workspaceId: "/repo/.paseo/worktrees/missing",
snapshot: null,
},
});
});
});
describe("handleCreatePaseoWorktreeRequest", () => {
test("creates the worktree before emitting the response", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const emitted: SessionOutboundMessage[] = [];
const backgroundWork = vi.fn(async () => {});
try {
await handleCreatePaseoWorktreeRequest(
{
paseoHome,
sessionLogger: createLogger(),
emit: (message) => emitted.push(message),
registerPendingWorktreeWorkspace: vi.fn(async (options) => {
expect(existsSync(options.worktreePath)).toBe(true);
return {
workspaceId: options.worktreePath,
projectId: options.repoRoot,
} as any;
}),
describeWorkspaceRecord: vi.fn(async (workspace) => ({
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: path.basename(repoDir),
projectRootPath: repoDir,
projectKind: "git",
workspaceKind: "worktree",
name: path.basename(workspace.workspaceId),
status: "done",
activityAt: null,
})),
createPaseoWorktreeInBackground: backgroundWork,
},
{
type: "create_paseo_worktree_request",
cwd: repoDir,
worktreeSlug: "response-after-create",
requestId: "req-1",
},
);
const response = emitted.find(
(message): message is Extract<SessionOutboundMessage, { type: "create_paseo_worktree_response" }> =>
message.type === "create_paseo_worktree_response",
);
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBeTruthy();
expect(existsSync(response!.payload.workspace!.id)).toBe(true);
expect(backgroundWork).toHaveBeenCalledWith(
expect.objectContaining({
requestCwd: repoDir,
repoRoot: repoDir,
worktree: {
branchName: "response-after-create",
worktreePath: response!.payload.workspace!.id,
},
shouldBootstrap: true,
}),
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,831 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
import type { Logger } from "pino";
import { v4 as uuidv4 } from "uuid";
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
type GitSetupOptions,
type ProjectPlacementPayload,
type SessionInboundMessage,
type SessionOutboundMessage,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
} from "./messages.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import {
applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail,
createAgentWorktree,
createWorktreeSetupProgressAccumulator,
getWorktreeSetupProgressResults,
spawnWorktreeServices,
} from "./worktree-bootstrap.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { ServiceRouteStore } from "./service-proxy.js";
import {
getCheckoutStatusLite,
resolveRepositoryDefaultBranch,
} from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
computeWorktreePath,
deletePaseoWorktree,
getWorktreeSetupCommands,
isPaseoOwnedWorktreeCwd,
listPaseoWorktrees,
resolvePaseoWorktreeRootForCwd,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
validateBranchSlug,
type WorktreeConfig,
type WorktreeSetupCommandResult,
WorktreeSetupError,
} from "../utils/worktree.js";
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
const execAsync = promisify(exec);
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
export type NormalizedGitOptions = {
baseBranch?: string;
createNewBranch: boolean;
newBranchName?: string;
createWorktree: boolean;
worktreeSlug?: string;
};
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
type BuildAgentSessionConfigDependencies = {
paseoHome?: string;
sessionLogger: Logger;
checkoutExistingBranch: (cwd: string, branch: string) => Promise<void>;
createBranchFromBase: (params: {
cwd: string;
baseBranch: string;
newBranchName: string;
}) => Promise<void>;
};
type ArchivePaseoWorktreeDependencies = {
paseoHome?: string;
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
agentStorage: Pick<AgentStorage, "list" | "remove">;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
};
type RegisterPendingWorktreeWorkspaceDependencies = {
buildPersistedProjectRecord: (input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}) => PersistedProjectRecord;
buildPersistedWorkspaceRecord: (input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}) => PersistedWorkspaceRecord;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
paseoHome?: string;
emitWorkspaceUpdateForCwd: (
cwd: string,
options?: { dedupeGitState?: boolean },
) => Promise<void>;
cacheWorkspaceSetupSnapshot: (workspaceId: string, snapshot: WorkspaceSetupSnapshot) => void;
emit: EmitSessionMessage;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
serviceRouteStore: ServiceRouteStore | null;
daemonPort?: number | null;
};
type HandleWorkspaceSetupStatusRequestDependencies = {
emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
};
type HandleCreatePaseoWorktreeRequestDependencies = {
paseoHome?: string;
describeWorkspaceRecord: (
workspace: PersistedWorkspaceRecord,
) => Promise<WorkspaceDescriptorPayload>;
emit: EmitSessionMessage;
registerPendingWorktreeWorkspace: (options: {
repoRoot: string;
worktreePath: string;
branchName: string;
}) => Promise<PersistedWorkspaceRecord>;
sessionLogger: Logger;
createPaseoWorktreeInBackground: (options: {
requestCwd: string;
repoRoot: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
}) => Promise<void>;
};
type KillTerminalsUnderPathDependencies = {
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
};
export async function buildAgentSessionConfig(
dependencies: BuildAgentSessionConfigDependencies,
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
_labels?: Record<string, string>,
): Promise<{
sessionConfig: AgentSessionConfig;
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
}> {
let cwd = expandTilde(config.cwd);
const normalized = normalizeGitOptions(gitOptions, legacyWorktreeName);
let worktreeBootstrap: { worktree: WorktreeConfig; shouldBootstrap: boolean } | undefined;
if (!normalized) {
return {
sessionConfig: {
...config,
cwd,
},
};
}
if (normalized.createWorktree) {
let targetBranch: string;
if (normalized.createNewBranch) {
targetBranch = normalized.newBranchName!;
} else {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd,
env: READ_ONLY_GIT_ENV,
});
targetBranch = stdout.trim();
}
if (!targetBranch) {
throw new Error("A branch name is required when creating a worktree.");
}
dependencies.sessionLogger.info(
{ worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch },
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`,
);
const baseBranch =
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
const createdWorktree = await createAgentWorktree({
branchName: targetBranch,
cwd,
baseBranch,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
paseoHome: dependencies.paseoHome,
});
cwd = createdWorktree.worktree.worktreePath;
worktreeBootstrap = createdWorktree;
} else if (normalized.createNewBranch) {
const baseBranch =
normalized.baseBranch ?? (await resolveGitCreateBaseBranch(cwd, dependencies.paseoHome));
await dependencies.createBranchFromBase({
cwd,
baseBranch,
newBranchName: normalized.newBranchName!,
});
} else if (normalized.baseBranch) {
await dependencies.checkoutExistingBranch(cwd, normalized.baseBranch);
}
return {
sessionConfig: {
...config,
cwd,
},
worktreeBootstrap,
};
}
export function normalizeGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
): NormalizedGitOptions | null {
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
? {
createWorktree: true,
createNewBranch: true,
newBranchName: legacyWorktreeName,
worktreeSlug: legacyWorktreeName,
}
: undefined;
const merged = gitOptions ?? fallbackOptions;
if (!merged) {
return null;
}
const baseBranch = merged.baseBranch?.trim() || undefined;
const createWorktree = Boolean(merged.createWorktree);
const createNewBranch = Boolean(merged.createNewBranch);
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined;
const normalizedWorktreeSlug = merged.worktreeSlug
? slugify(merged.worktreeSlug)
: normalizedBranchName;
if (!createWorktree && !createNewBranch && !baseBranch) {
return null;
}
if (baseBranch) {
assertSafeGitRef(baseBranch, "base branch");
}
if (createNewBranch) {
if (!normalizedBranchName) {
throw new Error("New branch name is required");
}
const validation = validateBranchSlug(normalizedBranchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
}
if (normalizedWorktreeSlug) {
const validation = validateBranchSlug(normalizedWorktreeSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
}
return {
baseBranch,
createNewBranch,
newBranchName: normalizedBranchName,
createWorktree,
worktreeSlug: normalizedWorktreeSlug,
};
}
export function assertSafeGitRef(ref: string, label: string): void {
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
throw new Error(`Invalid ${label}: ${ref}`);
}
}
export async function resolveGitCreateBaseBranch(
cwd: string,
paseoHome?: string,
): Promise<string> {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome });
if (!checkout.isGit) {
throw new Error("Cannot create a worktree outside a git repository");
}
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd;
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
return baseBranch;
}
export async function handlePaseoWorktreeListRequest(
dependencies: { emit: EmitSessionMessage; paseoHome?: string },
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
): Promise<void> {
const { requestId } = msg;
const cwd = msg.repoRoot ?? msg.cwd;
if (!cwd) {
dependencies.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: [],
error: { code: "UNKNOWN", message: "cwd or repoRoot is required" },
requestId,
},
});
return;
}
try {
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: dependencies.paseoHome });
dependencies.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: worktrees.map((entry) => ({
worktreePath: entry.path,
createdAt: entry.createdAt,
branchName: entry.branchName ?? null,
head: entry.head ?? null,
})),
error: null,
requestId,
},
});
} catch (error) {
dependencies.emit({
type: "paseo_worktree_list_response",
payload: {
worktrees: [],
error: toCheckoutError(error),
requestId,
},
});
}
}
export async function archivePaseoWorktree(
dependencies: ArchivePaseoWorktreeDependencies,
options: {
targetPath: string;
repoRoot: string;
requestId: string;
},
): Promise<string[]> {
let targetPath = options.targetPath;
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (resolvedWorktree) {
targetPath = resolvedWorktree.worktreePath;
}
const removedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const agents = dependencies.agentManager.listAgents();
for (const agent of agents) {
if (!dependencies.isPathWithinRoot(targetPath, agent.cwd)) {
continue;
}
removedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
try {
await dependencies.agentManager.closeAgent(agent.id);
} catch {
// ignore cleanup errors
}
try {
await dependencies.agentStorage.remove(agent.id);
} catch {
// ignore cleanup errors
}
}
const registryRecords = await dependencies.agentStorage.list();
for (const record of registryRecords) {
if (!dependencies.isPathWithinRoot(targetPath, record.cwd)) {
continue;
}
removedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
try {
await dependencies.agentStorage.remove(record.id);
} catch {
// ignore cleanup errors
}
}
await dependencies.killTerminalsUnderPath(targetPath);
await deletePaseoWorktree({
cwd: options.repoRoot,
worktreePath: targetPath,
paseoHome: dependencies.paseoHome,
});
for (const workspaceId of affectedWorkspaceIds) {
await dependencies.archiveWorkspaceRecord(workspaceId);
}
for (const agentId of removedAgents) {
dependencies.emit({
type: "agent_deleted",
payload: {
agentId,
requestId: options.requestId,
},
});
}
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
return Array.from(removedAgents);
}
export async function handlePaseoWorktreeArchiveRequest(
dependencies: Omit<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & {
emit: EmitSessionMessage;
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
},
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
const { requestId } = msg;
let targetPath = msg.worktreePath;
let repoRoot = msg.repoRoot ?? null;
try {
if (!targetPath) {
if (!repoRoot || !msg.branchName) {
throw new Error("worktreePath or repoRoot+branchName is required");
}
const worktrees = await listPaseoWorktrees({
cwd: repoRoot,
paseoHome: dependencies.paseoHome,
});
const match = worktrees.find((entry) => entry.branchName === msg.branchName);
if (!match) {
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`);
}
targetPath = match.path;
}
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
paseoHome: dependencies.paseoHome,
});
if (!ownership.allowed) {
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: false,
removedAgents: [],
error: {
code: "NOT_ALLOWED",
message: "Worktree is not a Paseo-owned worktree",
},
requestId,
},
});
return;
}
repoRoot = ownership.repoRoot ?? repoRoot ?? null;
if (!repoRoot) {
throw new Error("Unable to resolve repo root for worktree");
}
const removedAgents = await archivePaseoWorktree(dependencies, {
targetPath,
repoRoot,
requestId,
});
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: true,
removedAgents,
error: null,
requestId,
},
});
} catch (error) {
dependencies.emit({
type: "paseo_worktree_archive_response",
payload: {
success: false,
removedAgents: [],
error: toCheckoutError(error),
requestId,
},
});
}
}
export async function registerPendingWorktreeWorkspace(
dependencies: RegisterPendingWorktreeWorkspaceDependencies,
options: {
repoRoot: string;
worktreePath: string;
branchName: string;
},
): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const now = new Date().toISOString();
const existingWorkspace = await dependencies.workspaceRegistry.get(workspaceId);
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await dependencies.projectRegistry.upsert(nextProjectRecord);
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
await dependencies.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
}
export async function handleCreatePaseoWorktreeRequest(
dependencies: HandleCreatePaseoWorktreeRequestDependencies,
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
try {
const checkout = await getCheckoutStatusLite(request.cwd, {
paseoHome: dependencies.paseoHome,
});
if (!checkout.isGit) {
throw new Error("Create worktree requires a git repository");
}
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd;
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!baseBranch) {
throw new Error("Unable to resolve repository default branch");
}
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4();
const validation = validateBranchSlug(normalizedSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
await computeWorktreePath(repoRoot, normalizedSlug, dependencies.paseoHome);
const createdWorktree = await createAgentWorktree({
cwd: repoRoot,
branchName: normalizedSlug,
baseBranch,
worktreeSlug: normalizedSlug,
paseoHome: dependencies.paseoHome,
});
const workspace = await dependencies.registerPendingWorktreeWorkspace({
repoRoot,
worktreePath: createdWorktree.worktree.worktreePath,
branchName: createdWorktree.worktree.branchName,
});
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({
type: "create_paseo_worktree_response",
payload: {
workspace: descriptor,
error: null,
setupTerminalId: null,
requestId: request.requestId,
},
});
void dependencies.createPaseoWorktreeInBackground({
requestCwd: request.cwd,
repoRoot,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.shouldBootstrap,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create worktree";
dependencies.sessionLogger.error(
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
"Failed to create worktree",
);
dependencies.emit({
type: "create_paseo_worktree_response",
payload: {
workspace: null,
error: message,
setupTerminalId: null,
requestId: request.requestId,
},
});
}
}
export async function handleWorkspaceSetupStatusRequest(
dependencies: HandleWorkspaceSetupStatusRequestDependencies,
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
): Promise<void> {
const workspaceId = normalizePersistedWorkspaceId(request.workspaceId);
dependencies.emit({
type: "workspace_setup_status_response",
payload: {
requestId: request.requestId,
workspaceId,
snapshot: dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null,
},
});
}
export async function createPaseoWorktreeInBackground(
dependencies: CreatePaseoWorktreeInBackgroundDependencies,
options: {
requestCwd: string;
repoRoot: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
},
): Promise<void> {
let worktree: WorktreeConfig = options.worktree;
let setupResults: WorktreeSetupCommandResult[] = [];
let setupStarted = false;
const progressAccumulator = createWorktreeSetupProgressAccumulator();
const workspaceId = normalizePersistedWorkspaceId(worktree.worktreePath);
const emitSetupProgress = (status: "running" | "completed" | "failed", error: string | null) => {
const snapshot: WorkspaceSetupSnapshot = {
status,
detail: buildWorktreeSetupDetail({
worktree,
results:
status === "running" ? getWorktreeSetupProgressResults(progressAccumulator) : setupResults,
outputAccumulatorsByIndex: progressAccumulator.outputAccumulatorsByIndex,
}),
error,
};
dependencies.cacheWorkspaceSetupSnapshot(workspaceId, snapshot);
dependencies.emit({
type: "workspace_setup_progress",
payload: {
workspaceId,
...snapshot,
},
});
};
try {
try {
emitSetupProgress("running", null);
if (!options.shouldBootstrap) {
emitSetupProgress("completed", null);
} else {
const setupCommands = getWorktreeSetupCommands(worktree.worktreePath);
if (setupCommands.length === 0) {
setupStarted = true;
emitSetupProgress("completed", null);
} else {
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
repoRootPath: options.repoRoot,
});
dependencies.terminalManager?.registerCwdEnv({
cwd: worktree.worktreePath,
env: runtimeEnv,
});
setupStarted = true;
setupResults = await runWorktreeSetupCommands({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
cleanupOnFailure: false,
repoRootPath: options.repoRoot,
runtimeEnv,
onEvent: (event) => {
applyWorktreeSetupProgressEvent(progressAccumulator, event);
emitSetupProgress("running", null);
},
});
emitSetupProgress("completed", null);
}
}
} catch (error) {
if (error instanceof WorktreeSetupError) {
setupResults = error.results;
}
const message = error instanceof Error ? error.message : String(error);
emitSetupProgress("failed", message);
if (!setupStarted) {
await dependencies.archiveWorkspaceRecord(normalizePersistedWorkspaceId(worktree.worktreePath));
}
dependencies.sessionLogger.error(
{
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: worktree.branchName,
worktreePath: worktree.worktreePath,
setupStarted,
},
"Background worktree creation failed",
);
return;
}
if (!dependencies.terminalManager || !dependencies.serviceRouteStore) {
return;
}
try {
await spawnWorktreeServices({
repoRoot: worktree.worktreePath,
workspaceId: worktree.worktreePath,
branchName: worktree.branchName,
daemonPort: dependencies.daemonPort,
routeStore: dependencies.serviceRouteStore,
terminalManager: dependencies.terminalManager,
logger: dependencies.sessionLogger,
});
} catch (error) {
dependencies.sessionLogger.error(
{
err: error,
cwd: options.requestCwd,
repoRoot: options.repoRoot,
worktreeSlug: worktree.branchName,
worktreePath: worktree.worktreePath,
},
"Failed to spawn worktree services after workspace setup completed",
);
}
} finally {
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
}
}
export async function killTerminalsUnderPath(
dependencies: KillTerminalsUnderPathDependencies,
rootPath: string,
): Promise<void> {
if (!dependencies.terminalManager) {
return;
}
const cleanupErrors: Array<{ cwd: string; message: string }> = [];
const terminalDirectories = [...dependencies.terminalManager.listDirectories()];
for (const terminalCwd of terminalDirectories) {
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
continue;
}
try {
const terminals = await dependencies.terminalManager.getTerminals(terminalCwd);
for (const terminal of [...terminals]) {
dependencies.killTrackedTerminal(terminal.id, { emitExit: true });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
cleanupErrors.push({ cwd: terminalCwd, message });
dependencies.sessionLogger.warn(
{ err: error, cwd: terminalCwd },
"Failed to clean up worktree terminals during archive",
);
}
}
if (cleanupErrors.length > 0) {
const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join("; ");
throw new Error(`Failed to clean up worktree terminals during archive (${details})`);
}
}
+105 -66
View File
@@ -195,7 +195,27 @@ const NonNullUnknownSchema = z.union([
z.object({}).passthrough(),
]);
const WorktreeSetupCommandSnapshotSchema = z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
log: z.string(),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
});
const WorktreeSetupDetailPayloadSchema = z.object({
type: z.literal("worktree_setup"),
worktreePath: z.string(),
branchName: z.string(),
log: z.string(),
commands: z.array(WorktreeSetupCommandSnapshotSchema),
truncated: z.boolean().optional(),
});
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
WorktreeSetupDetailPayloadSchema,
z.object({
type: z.literal("shell"),
command: z.string(),
@@ -254,23 +274,6 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
bytes: z.number().optional(),
durationMs: z.number().optional(),
}),
z.object({
type: z.literal("worktree_setup"),
worktreePath: z.string(),
branchName: z.string(),
log: z.string(),
commands: z.array(
z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
}),
),
truncated: z.boolean().optional(),
}),
z.object({
type: z.literal("sub_agent"),
subAgentType: z.string().optional(),
@@ -726,17 +729,6 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({
requestId: z.string(),
});
export const SpeechModelsListRequestSchema = z.object({
type: z.literal("speech_models_list_request"),
requestId: z.string(),
});
export const SpeechModelsDownloadRequestSchema = z.object({
type: z.literal("speech_models_download_request"),
modelIds: z.array(z.string()).optional(),
requestId: z.string(),
});
export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema,
@@ -994,6 +986,12 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
requestId: z.string(),
});
export const WorkspaceSetupStatusRequestSchema = z.object({
type: z.literal("workspace_setup_status_request"),
workspaceId: z.string(),
requestId: z.string(),
});
export const OpenProjectRequestSchema = z.object({
type: z.literal("open_project_request"),
cwd: z.string(),
@@ -1127,7 +1125,7 @@ export const RegisterPushTokenMessageSchema = z.object({
export const ListTerminalsRequestSchema = z.object({
type: z.literal("list_terminals_request"),
cwd: z.string(),
cwd: z.string().optional(),
requestId: z.string(),
});
@@ -1183,6 +1181,15 @@ export const KillTerminalRequestSchema = z.object({
requestId: z.string(),
});
export const CaptureTerminalRequestSchema = z.object({
type: z.literal("capture_terminal_request"),
terminalId: z.string(),
start: z.number().int().optional(),
end: z.number().int().optional(),
stripAnsi: z.boolean().default(true),
requestId: z.string(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema,
@@ -1203,8 +1210,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CreateAgentRequestMessageSchema,
ListProviderModelsRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema,
SpeechModelsListRequestSchema,
SpeechModelsDownloadRequestSchema,
ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema,
@@ -1230,6 +1235,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema,
WorkspaceSetupStatusRequestSchema,
OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema,
@@ -1248,6 +1254,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UnsubscribeTerminalRequestSchema,
TerminalInputSchema,
KillTerminalRequestSchema,
CaptureTerminalRequestSchema,
ChatCreateRequestSchema,
ChatListRequestSchema,
ChatInspectRequestSchema,
@@ -1574,6 +1581,14 @@ export const ProjectPlacementPayloadSchema = z.object({
checkout: ProjectCheckoutLitePayloadSchema,
});
export const WorkspaceServicePayloadSchema = z.object({
serviceName: z.string(),
hostname: z.string(),
port: z.number().int().positive(),
url: z.string().nullable(),
status: z.enum(["running", "stopped"]),
});
export const WorkspaceDescriptorPayloadSchema = z.object({
id: z.string(),
projectId: z.string(),
@@ -1591,6 +1606,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
})
.nullable()
.optional(),
services: z.array(WorkspaceServicePayloadSchema).default([]),
});
export const AgentUpdateMessageSchema = z.object({
@@ -1683,6 +1699,39 @@ export const WorkspaceUpdateMessageSchema = z.object({
]),
});
export const ServiceStatusUpdateMessageSchema = z.object({
type: z.literal("service_status_update"),
payload: z.object({
workspaceId: z.string(),
services: z.array(WorkspaceServicePayloadSchema),
}),
});
export const WorkspaceSetupProgressMessageSchema = z.object({
type: z.literal("workspace_setup_progress"),
payload: z.object({
workspaceId: z.string(),
status: z.enum(["running", "completed", "failed"]),
detail: WorktreeSetupDetailPayloadSchema,
error: z.string().nullable(),
}),
});
export const WorkspaceSetupSnapshotSchema = z.object({
status: z.enum(["running", "completed", "failed"]),
detail: WorktreeSetupDetailPayloadSchema,
error: z.string().nullable(),
});
export const WorkspaceSetupStatusResponseMessageSchema = z.object({
type: z.literal("workspace_setup_status_response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
snapshot: WorkspaceSetupSnapshotSchema.nullable(),
}),
});
export const OpenProjectResponseMessageSchema = z.object({
type: z.literal("open_project_response"),
payload: z.object({
@@ -2104,34 +2153,6 @@ export const ListAvailableProvidersResponseSchema = z.object({
}),
});
export const SpeechModelsListResponseSchema = z.object({
type: z.literal("speech_models_list_response"),
payload: z.object({
modelsDir: z.string(),
models: z.array(
z.object({
id: z.string(),
kind: z.string(),
description: z.string(),
modelDir: z.string(),
isDownloaded: z.boolean(),
missingFiles: z.array(z.string()).optional(),
}),
),
requestId: z.string(),
}),
});
export const SpeechModelsDownloadResponseSchema = z.object({
type: z.literal("speech_models_download_response"),
payload: z.object({
modelsDir: z.string(),
downloadedModelIds: z.array(z.string()),
error: z.string().nullable(),
requestId: z.string(),
}),
});
const AgentSlashCommandSchema = z.object({
name: z.string(),
description: z.string(),
@@ -2199,7 +2220,7 @@ export const TerminalStateSchema = z
export const ListTerminalsResponseSchema = z.object({
type: z.literal("list_terminals_response"),
payload: z.object({
cwd: z.string(),
cwd: z.string().optional(),
terminals: z.array(TerminalInfoSchema.omit({ cwd: true })),
requestId: z.string(),
}),
@@ -2248,6 +2269,16 @@ export const KillTerminalResponseSchema = z.object({
}),
});
export const CaptureTerminalResponseSchema = z.object({
type: z.literal("capture_terminal_response"),
payload: z.object({
terminalId: z.string(),
lines: z.array(z.string()),
totalLines: z.number().int().nonnegative(),
requestId: z.string(),
}),
});
export const TerminalStreamExitSchema = z.object({
type: z.literal("terminal_stream_exit"),
payload: z.object({
@@ -2272,6 +2303,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArtifactMessageSchema,
AgentUpdateMessageSchema,
WorkspaceUpdateMessageSchema,
ServiceStatusUpdateMessageSchema,
WorkspaceSetupProgressMessageSchema,
WorkspaceSetupStatusResponseMessageSchema,
AgentStreamMessageSchema,
AgentStatusMessageSchema,
FetchAgentsResponseMessageSchema,
@@ -2311,14 +2345,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileDownloadTokenResponseSchema,
ListProviderModelsResponseMessageSchema,
ListAvailableProvidersResponseSchema,
SpeechModelsListResponseSchema,
SpeechModelsDownloadResponseSchema,
ListCommandsResponseSchema,
ListTerminalsResponseSchema,
TerminalsChangedSchema,
CreateTerminalResponseSchema,
SubscribeTerminalResponseSchema,
KillTerminalResponseSchema,
CaptureTerminalResponseSchema,
TerminalStreamExitSchema,
ChatCreateResponseSchema,
ChatListResponseSchema,
@@ -2356,14 +2389,21 @@ export type ServerInfoStatusPayload = z.infer<typeof ServerInfoStatusPayloadSche
export type RpcErrorMessage = z.infer<typeof RpcErrorMessageSchema>;
export type ArtifactMessage = z.infer<typeof ArtifactMessageSchema>;
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
export type WorkspaceSetupProgressMessage = z.infer<typeof WorkspaceSetupProgressMessageSchema>;
export type WorkspaceSetupSnapshot = z.infer<typeof WorkspaceSetupSnapshotSchema>;
export type WorkspaceSetupStatusResponseMessage = z.infer<
typeof WorkspaceSetupStatusResponseMessageSchema
>;
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
export type WorkspaceServicePayload = z.infer<typeof WorkspaceServicePayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
export type ServiceStatusUpdateMessage = z.infer<typeof ServiceStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>;
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>;
@@ -2381,8 +2421,6 @@ export type ListProviderModelsResponseMessage = z.infer<
typeof ListProviderModelsResponseMessageSchema
>;
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type SpeechModelsListResponse = z.infer<typeof SpeechModelsListResponseSchema>;
export type SpeechModelsDownloadResponse = z.infer<typeof SpeechModelsDownloadResponseSchema>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
@@ -2443,8 +2481,6 @@ export type LoopListRequest = z.infer<typeof LoopListRequestSchema>;
export type LoopInspectRequest = z.infer<typeof LoopInspectRequestSchema>;
export type LoopLogsRequest = z.infer<typeof LoopLogsRequestSchema>;
export type LoopStopRequest = z.infer<typeof LoopStopRequestSchema>;
export type SpeechModelsListRequestMessage = z.infer<typeof SpeechModelsListRequestSchema>;
export type SpeechModelsDownloadRequestMessage = z.infer<typeof SpeechModelsDownloadRequestSchema>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type UpdateAgentRequestMessage = z.infer<typeof UpdateAgentRequestMessageSchema>;
@@ -2480,6 +2516,7 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -2515,6 +2552,8 @@ export type TerminalCursor = z.infer<typeof TerminalCursorSchema>;
export type TerminalState = z.infer<typeof TerminalStateSchema>;
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema>;
export type CaptureTerminalResponse = z.infer<typeof CaptureTerminalResponseSchema>;
export type TerminalStreamExit = z.infer<typeof TerminalStreamExitSchema>;
// ============================================================================
@@ -44,10 +44,138 @@ describe("workspace message schemas", () => {
name: "",
status: "not-a-bucket",
activityAt: null,
services: [],
},
},
});
expect(result.success).toBe(false);
});
test("parses workspace descriptors with services", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: {
id: "/repo",
projectId: "/repo",
projectDisplayName: "repo",
projectRootPath: "/repo",
projectKind: "non_git",
workspaceKind: "directory",
name: "repo",
status: "done",
activityAt: null,
services: [
{
serviceName: "web",
hostname: "web.localhost",
port: 3000,
url: "http://web.localhost:6767",
status: "running",
},
],
},
},
});
expect(parsed.type).toBe("workspace_update");
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
throw new Error("Expected workspace_update upsert payload");
}
expect(parsed.payload.workspace.services).toEqual([
{
serviceName: "web",
hostname: "web.localhost",
port: 3000,
url: "http://web.localhost:6767",
status: "running",
},
]);
});
test("parses service_status_update payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "service_status_update",
payload: {
workspaceId: "/repo",
services: [
{
serviceName: "web",
hostname: "web.localhost",
port: 3000,
url: null,
status: "stopped",
},
],
},
});
expect(parsed.type).toBe("service_status_update");
expect(parsed.payload.workspaceId).toBe("/repo");
});
test("parses workspace_setup_progress payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress",
payload: {
workspaceId: "/repo/.paseo/worktrees/feature-a",
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "done",
commands: [
{
index: 1,
command: "npm install",
cwd: "/repo/.paseo/worktrees/feature-a",
log: "done",
status: "completed",
exitCode: 0,
durationMs: 100,
},
],
},
error: null,
},
});
expect(parsed.type).toBe("workspace_setup_progress");
});
test("parses workspace_setup_status_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "workspace_setup_status_request",
workspaceId: "/repo/.paseo/worktrees/feature-a",
requestId: "req-status",
});
expect(parsed.type).toBe("workspace_setup_status_request");
});
test("parses workspace_setup_status_response payload", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "workspace_setup_status_response",
payload: {
requestId: "req-status",
workspaceId: "/repo/.paseo/worktrees/feature-a",
snapshot: {
status: "completed",
detail: {
type: "worktree_setup",
worktreePath: "/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
log: "done",
commands: [],
},
error: null,
},
},
});
expect(parsed.type).toBe("workspace_setup_status_response");
});
});
@@ -79,6 +79,7 @@ describe("shared tool-call display mapping", () => {
index: 1,
command: "npm install",
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
log: "==> [1/1] Running: npm install\n",
status: "running",
exitCode: null,
},
+66
View File
@@ -4,6 +4,7 @@ import { randomUUID } from "crypto";
import { chmodSync, existsSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import stripAnsi from "strip-ansi";
import type { TerminalCell, TerminalState } from "../shared/messages.js";
const { Terminal } = xterm;
@@ -40,6 +41,17 @@ export interface CreateTerminalOptions {
name?: string;
}
export interface CaptureTerminalLinesOptions {
start?: number;
end?: number;
stripAnsi?: boolean;
}
export interface CaptureTerminalLinesResult {
lines: string[];
totalLines: number;
}
type EnsureNodePtySpawnHelperExecutableOptions = {
packageRoot?: string;
platform?: NodeJS.Platform;
@@ -230,6 +242,60 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
};
}
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
const text = cells.map((cell) => cell.char).join("").trimEnd();
return options.stripAnsi ? stripAnsi(text) : text;
}
function resolveCaptureLineIndex(
lineNumber: number | undefined,
totalLines: number,
fallback: "start" | "end",
): number {
if (totalLines === 0) {
return fallback === "start" ? 0 : -1;
}
const defaultIndex = fallback === "start" ? 0 : totalLines - 1;
if (typeof lineNumber !== "number") {
return defaultIndex;
}
const resolvedIndex = lineNumber < 0 ? totalLines + lineNumber : lineNumber;
if (resolvedIndex < 0) {
return 0;
}
if (resolvedIndex >= totalLines) {
return totalLines - 1;
}
return resolvedIndex;
}
export function captureTerminalLines(
terminal: TerminalSession,
options: CaptureTerminalLinesOptions = {},
): CaptureTerminalLinesResult {
const state = terminal.getState();
const allLines = [...state.scrollback, ...state.grid].map((cells) =>
cellsToPlainText(cells, { stripAnsi: options.stripAnsi ?? true }),
);
const totalLines = allLines.length;
const startIndex = resolveCaptureLineIndex(options.start, totalLines, "start");
const endIndex = resolveCaptureLineIndex(options.end, totalLines, "end");
if (totalLines === 0 || startIndex > endIndex) {
return {
lines: [],
totalLines,
};
}
return {
lines: allLines.slice(startIndex, endIndex + 1),
totalLines,
};
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,
+64 -25
View File
@@ -11,7 +11,9 @@ import {
runWorktreeSetupCommands,
slugify,
} from "./worktree";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
import {
getPaseoWorktreeMetadataPath,
} from "./worktree-metadata.js";
import { execSync } from "child_process";
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
import { dirname, join } from "path";
@@ -527,69 +529,106 @@ describe("paseo worktree manager", () => {
expect(remaining.some((worktree) => worktree.path === created.worktreePath)).toBe(false);
});
it("runs destroy commands from paseo.json before deleting a worktree", async () => {
it("runs teardown commands from paseo.json before deleting a worktree", async () => {
const paseoConfig = {
worktree: {
destroy: [
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
teardown: [
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
'echo "port=$PASEO_WORKTREE_PORT" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'", {
cwd: repoDir,
});
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'",
{
cwd: repoDir,
},
);
const created = await createWorktree({
branchName: "destroy-branch",
branchName: "teardown-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "destroy-test",
worktreeSlug: "teardown-test",
paseoHome,
});
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: created.worktreePath,
branchName: created.branchName,
});
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
expect(existsSync(created.worktreePath)).toBe(false);
const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8");
expect(destroyLog).toContain(`source=${repoDir}`);
expect(destroyLog).toContain(`root_alias=${repoDir}`);
expect(destroyLog).toContain(`worktree=${created.worktreePath}`);
expect(destroyLog).toContain("branch=destroy-branch");
const teardownLog = readFileSync(join(repoDir, "teardown.log"), "utf8");
expect(teardownLog).toContain(`source=${repoDir}`);
expect(teardownLog).toContain(`root_alias=${repoDir}`);
expect(teardownLog).toContain(`worktree=${created.worktreePath}`);
expect(teardownLog).toContain("branch=teardown-branch");
expect(teardownLog).toContain(`port=${runtimeEnv.PASEO_WORKTREE_PORT}`);
});
it("does not remove worktree when a destroy command fails", async () => {
it("omits PASEO_WORKTREE_PORT from teardown env when runtime metadata is missing", async () => {
const paseoConfig = {
worktree: {
destroy: [
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"',
teardown: [
'echo "port=${PASEO_WORKTREE_PORT-unset}" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-port.log"',
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown port logging'",
{ cwd: repoDir },
);
const created = await createWorktree({
branchName: "teardown-port-missing-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "teardown-port-missing-test",
paseoHome,
});
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
expect(readFileSync(join(repoDir, "teardown-port.log"), "utf8").trim()).toBe("port=unset");
expect(existsSync(created.worktreePath)).toBe(false);
});
it("does not remove worktree when a teardown command fails", async () => {
const paseoConfig = {
worktree: {
teardown: [
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-start.log"',
"echo boom 1>&2; exit 9",
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing destroy commands'",
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing teardown commands'",
{ cwd: repoDir },
);
const created = await createWorktree({
branchName: "destroy-failure-branch",
branchName: "teardown-failure-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "destroy-failure-test",
worktreeSlug: "teardown-failure-test",
paseoHome,
});
await expect(
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
).rejects.toThrow("Worktree destroy command failed");
).rejects.toThrow("Worktree teardown command failed");
expect(existsSync(created.worktreePath)).toBe(true);
expect(existsSync(join(repoDir, "destroy-start.log"))).toBe(true);
expect(existsSync(join(repoDir, "teardown-start.log"))).toBe(true);
});
});
+178 -58
View File
@@ -4,7 +4,9 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } f
import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import * as pty from "node-pty";
import { createNameId } from "mnemonic-id";
import stripAnsi from "strip-ansi";
import {
normalizeBaseRefName,
readPaseoWorktreeMetadata,
@@ -13,13 +15,15 @@ import {
writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
interface PaseoConfig {
worktree?: {
setup?: string[];
destroy?: string[];
teardown?: string[];
terminals?: WorktreeTerminalConfig[];
};
services?: Record<string, ServiceConfig>;
}
const execAsync = promisify(exec);
@@ -84,6 +88,11 @@ export interface WorktreeTerminalConfig {
command: string;
}
export interface ServiceConfig {
command: string;
port?: number; // explicit port override, otherwise auto-assigned
}
export class WorktreeSetupError extends Error {
readonly results: WorktreeSetupCommandResult[];
@@ -94,14 +103,14 @@ export class WorktreeSetupError extends Error {
}
}
export type WorktreeDestroyCommandResult = WorktreeSetupCommandResult;
export type WorktreeTeardownCommandResult = WorktreeSetupCommandResult;
export class WorktreeDestroyError extends Error {
readonly results: WorktreeDestroyCommandResult[];
export class WorktreeTeardownError extends Error {
readonly results: WorktreeTeardownCommandResult[];
constructor(message: string, results: WorktreeDestroyCommandResult[]) {
constructor(message: string, results: WorktreeTeardownCommandResult[]) {
super(message);
this.name = "WorktreeDestroyError";
this.name = "WorktreeTeardownError";
this.results = results;
}
}
@@ -150,13 +159,13 @@ export function getWorktreeSetupCommands(repoRoot: string): string[] {
return setupCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
}
export function getWorktreeDestroyCommands(repoRoot: string): string[] {
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
const config = readPaseoConfig(repoRoot);
const destroyCommands = config?.worktree?.destroy;
if (!destroyCommands || destroyCommands.length === 0) {
const teardownCommands = config?.worktree?.teardown;
if (!teardownCommands || teardownCommands.length === 0) {
return [];
}
return destroyCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
return teardownCommands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0);
}
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
@@ -194,6 +203,90 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
return specs;
}
export function getServiceConfigs(repoRoot: string): Map<string, ServiceConfig> {
const config = readPaseoConfig(repoRoot);
const services = config?.services;
if (!services || typeof services !== "object") {
return new Map();
}
const result = new Map<string, ServiceConfig>();
for (const [name, entry] of Object.entries(services)) {
if (!entry || typeof entry !== "object") {
continue;
}
const rawCommand = entry.command;
if (typeof rawCommand !== "string") {
continue;
}
const command = rawCommand.trim();
if (!command) {
continue;
}
const serviceConfig: ServiceConfig = { command };
if (typeof entry.port === "number" && Number.isFinite(entry.port)) {
serviceConfig.port = entry.port;
}
result.set(name, serviceConfig);
}
return result;
}
export function processCarriageReturns(text: string): string {
if (!text.includes("\r")) {
return text;
}
const output: string[] = [];
let line: string[] = [];
let cursor = 0;
const flushLine = () => {
output.push(line.join(""));
line = [];
cursor = 0;
};
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === "\r") {
if (text[index + 1] === "\n") {
flushLine();
output.push("\n");
index += 1;
continue;
}
cursor = 0;
continue;
}
if (char === "\n") {
flushLine();
output.push("\n");
continue;
}
if (cursor < line.length) {
line[cursor] = char;
} else {
line.push(char);
}
cursor += 1;
}
if (line.length > 0) {
output.push(line.join(""));
}
return output.join("");
}
async function execSetupCommand(
command: string,
options: { cwd: string; env: NodeJS.ProcessEnv },
@@ -239,6 +332,27 @@ async function execSetupCommandStreamed(options: {
const stderrChunks: string[] = [];
let settled = false;
const emitOutput = (stream: "stdout" | "stderr", chunk: string) => {
const text = stripAnsi(chunk);
if (!text) {
return;
}
if (stream === "stdout") {
stdoutChunks.push(text);
} else {
stderrChunks.push(text);
}
options.onEvent?.({
type: "output",
index: options.index,
total: options.total,
command: options.command,
cwd: options.cwd,
stream,
chunk: text,
});
};
const finish = (exitCode: number | null) => {
if (settled) {
return;
@@ -274,48 +388,52 @@ async function execSetupCommandStreamed(options: {
cwd: options.cwd,
});
const child = spawn("/bin/bash", ["-lc", options.command], {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();
stdoutChunks.push(text);
options.onEvent?.({
type: "output",
index: options.index,
total: options.total,
command: options.command,
const spawnWithPipes = () => {
const child = spawn("/bin/bash", ["-lc", options.command], {
cwd: options.cwd,
stream: "stdout",
chunk: text,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
});
child.stderr?.on("data", (chunk: Buffer | string) => {
const text = chunk.toString();
stderrChunks.push(text);
options.onEvent?.({
type: "output",
index: options.index,
total: options.total,
command: options.command,
child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
emitOutput("stderr", chunk.toString());
});
child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
finish(null);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
};
try {
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
const terminal = pty.spawn("/bin/bash", ["-lc", options.command], {
cwd: options.cwd,
stream: "stderr",
chunk: text,
env: options.env,
name: "xterm-color",
cols: 120,
rows: 30,
});
});
child.on("error", (error) => {
stderrChunks.push(error instanceof Error ? error.message : String(error));
finish(null);
});
terminal.onData((data) => {
emitOutput("stdout", data);
});
child.on("close", (code) => {
finish(typeof code === "number" ? code : null);
});
terminal.onExit(({ exitCode }) => {
finish(typeof exitCode === "number" ? exitCode : null);
});
} catch (error) {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
spawnWithPipes();
}
});
}
@@ -506,14 +624,14 @@ export async function resolveWorktreeRuntimeEnv(options: {
};
}
export async function runWorktreeDestroyCommands(options: {
export async function runWorktreeTeardownCommands(options: {
worktreePath: string;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeDestroyCommandResult[]> {
}): Promise<WorktreeTeardownCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
const destroyCommands = getWorktreeDestroyCommands(options.worktreePath);
if (destroyCommands.length === 0) {
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
if (teardownCommands.length === 0) {
return [];
}
@@ -521,30 +639,32 @@ export async function runWorktreeDestroyCommands(options: {
options.repoRootPath ?? (await inferRepoRootPathFromWorktreePath(options.worktreePath));
const branchName =
options.branchName ?? (await resolveBranchNameForWorktreePath(options.worktreePath));
const worktreePort = readPaseoWorktreeRuntimePort(options.worktreePath);
const destroyEnv = {
const teardownEnv: NodeJS.ProcessEnv = {
...process.env,
// Source checkout path is the original git repo root (shared across worktrees), not the
// worktree itself. This allows destroy scripts to clean resources using paths from the
// source checkout.
// worktree itself. This allows lifecycle scripts to copy or clean resources using paths
// from the source checkout.
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
// Backward-compatible alias.
PASEO_ROOT_PATH: repoRootPath,
PASEO_WORKTREE_PATH: options.worktreePath,
PASEO_BRANCH_NAME: branchName,
...(worktreePort !== null ? { PASEO_WORKTREE_PORT: String(worktreePort) } : {}),
};
const results: WorktreeDestroyCommandResult[] = [];
for (const cmd of destroyCommands) {
const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of teardownCommands) {
const result = await execSetupCommand(cmd, {
cwd: options.worktreePath,
env: destroyEnv,
env: teardownEnv,
});
results.push(result);
if (result.exitCode !== 0) {
throw new WorktreeDestroyError(
`Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(),
throw new WorktreeTeardownError(
`Worktree teardown command failed: ${cmd}\n${result.stderr}`.trim(),
results,
);
}
@@ -884,7 +1004,7 @@ export async function deletePaseoWorktree({
throw new Error("Refusing to delete non-Paseo worktree");
}
await runWorktreeDestroyCommands({
await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree,
});
@@ -59,6 +59,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<div className="space-y-24">
<MultiProviderSection />
<SelfHostedSection />
<ServiceProxySection />
<ShortcutsSection />
<LocalVoiceSection />
<CLISection />
@@ -475,6 +476,52 @@ function SelfHostedSection() {
}
function ServiceProxySection() {
const workspaces = [
{ name: "fix-auth", url: "fix-auth.my-app.localhost" },
{ name: "add-search", url: "add-search.my-app.localhost" },
{ name: "upgrade-deps", url: "upgrade-deps.my-app.localhost" },
];
return (
<FeatureSection
title="Forget about ports"
description="When agents work in parallel, they all run dev servers. Paseo gives each one a URL based on the branch name, no port conflicts, no guessing."
>
<div className="rounded-2xl border border-white/10 bg-white/[0.02] overflow-hidden">
<div className="px-5 py-4 space-y-3">
{/* Project */}
<div className="flex items-center gap-2.5">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40">
<path d="M3 7V17C3 18.1046 3.89543 19 5 19H19C20.1046 19 21 18.1046 21 17V9C21 7.89543 20.1046 7 19 7H13L11 5H5C3.89543 5 3 5.89543 3 7Z" />
</svg>
<span className="text-sm font-medium text-white/60">my-app</span>
</div>
{/* Workspaces indented */}
<div className="pl-6 space-y-2">
{workspaces.map((ws) => (
<div
key={ws.name}
className="flex items-center justify-between"
>
<div className="flex items-center gap-2.5">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
<span className="text-sm text-white/80">{ws.name}</span>
<span className="text-xs text-white/25 font-mono">npm run dev</span>
</div>
<span className="text-xs font-mono text-white/30">
{ws.url}
</span>
</div>
))}
</div>
</div>
</div>
</FeatureSection>
);
}
function ShortcutsSection() {
const shortcuts = [
{ keys: ["⌘", "1-9"], action: "Switch panels" },
+51 -4
View File
@@ -96,7 +96,7 @@ function Worktrees() {
{/* paseo.json */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Setup with paseo.json</h2>
<h2 className="text-xl font-medium">Lifecycle hooks with paseo.json</h2>
<p className="text-white/60 leading-relaxed">
When Paseo creates a worktree, it's a fresh checkout. Dependencies aren't installed,
config files aren't copied. You can automate setup by creating a{" "}
@@ -117,6 +117,20 @@ function Worktrees() {
the worktree is created. Use it to install dependencies, copy local config files, or run
any other initialization.
</p>
<p className="text-white/60 leading-relaxed">
You can also add a <code className="font-mono">teardown</code> array for cleanup commands
that run before Paseo removes the worktree directory during archive:
</p>
<Code>
<pre className="text-white/80">{`{
"worktree": {
"teardown": [
"pkill -f \\"vite --port $PASEO_WORKTREE_PORT\\" || true",
"rm -rf \\"$PASEO_WORKTREE_PATH/.cache\\""
]
}
}`}</pre>
</Code>
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 text-white/80">
<strong>Important:</strong> Setup commands come from{" "}
<code className="font-mono">paseo.json</code> in the selected base branch. If you pick{" "}
@@ -130,7 +144,7 @@ function Worktrees() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Environment variables</h2>
<p className="text-white/60 leading-relaxed">
Setup commands have access to these environment variables:
Setup and teardown commands have access to these environment variables:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
@@ -148,8 +162,8 @@ function Worktrees() {
<code className="font-mono">$PASEO_BRANCH_NAME</code> — the branch name created
</li>
<li>
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — an available local port for
setup scripts
<code className="font-mono">$PASEO_WORKTREE_PORT</code> — the worktree port, when
runtime metadata exists
</li>
</ul>
<p className="text-white/60 leading-relaxed">
@@ -157,6 +171,39 @@ function Worktrees() {
shouldn't be in git (like <code className="font-mono">.env</code>) from your source
checkout to the worktree.
</p>
<p className="text-white/60 leading-relaxed">
<code className="font-mono">$PASEO_WORKTREE_PORT</code> is available when the worktree
was bootstrapped with a port. That makes it useful for both starting services in setup
and stopping them again in teardown.
</p>
</section>
{/* Teardown */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Teardown</h2>
<p className="text-white/60 leading-relaxed">
Teardown runs during archive, before Paseo removes the worktree directory. Use it for
cleanup that needs access to the worktree path or its assigned port.
</p>
<p className="text-white/60 leading-relaxed">
Common uses include stopping dev servers on{" "}
<code className="font-mono">$PASEO_WORKTREE_PORT</code>, deleting generated files, or
deregistering services tied to that worktree.
</p>
<Code>
<pre className="text-white/80">{`{
"worktree": {
"setup": [
"npm ci",
"nohup npm run dev -- --port $PASEO_WORKTREE_PORT > \\"$PASEO_WORKTREE_PATH/dev.log\\" 2>&1 &"
],
"teardown": [
"pkill -f \\"npm run dev -- --port $PASEO_WORKTREE_PORT\\" || true",
"rm -f \\"$PASEO_WORKTREE_PATH/dev.log\\""
]
}
}`}</pre>
</Code>
</section>
{/* Common patterns */}
+53
View File
@@ -154,6 +154,59 @@ paseo chat wait <room> --timeout <duration>
paseo chat delete <name-or-id>
```
## Terminal Commands
Manage workspace terminals: create, inspect, send keystrokes, capture output.
```bash
# List terminals (scoped to current directory by default)
paseo terminal ls # Terminals in current directory
paseo terminal ls --all # All terminals across all workspaces
paseo terminal ls --cwd ~/dev/myapp # Terminals in a specific directory
# Create a terminal
paseo terminal create # In current directory
paseo terminal create --cwd ~/dev/myapp # In a specific directory
paseo terminal create --name "build-runner" # With a custom name
# Kill a terminal (supports short ID prefixes and name matching)
paseo terminal kill <terminal-id>
paseo terminal kill abc123 # Short prefix
paseo terminal kill build-runner # By name
# Capture terminal output as plain text (like tmux capture-pane -p)
paseo terminal capture <terminal-id> # Visible pane only, ANSI stripped
paseo terminal capture <terminal-id> --scrollback # Full scrollback + visible
paseo terminal capture <terminal-id> -S # Short form of --scrollback
paseo terminal capture <terminal-id> --start 0 --end 10 # Line range (tmux-style)
paseo terminal capture <terminal-id> --start -5 # Last 5 lines
paseo terminal capture <terminal-id> --ansi # Preserve ANSI escape codes
paseo terminal capture <terminal-id> --json # JSON output with metadata
# Send keystrokes (like tmux send-keys)
paseo terminal send-keys <terminal-id> "ls -la" Enter
paseo terminal send-keys <terminal-id> "echo hello" Enter
paseo terminal send-keys <terminal-id> C-c # Ctrl+C
paseo terminal send-keys <terminal-id> C-d # Ctrl+D
paseo terminal send-keys <terminal-id> --literal "raw text" # No special token interpretation
```
**Special key tokens** (interpreted by default, use `--literal` to send raw):
`Enter`, `Tab`, `Escape`, `Space`, `BSpace`, `C-c`, `C-d`, `C-z`, `C-l`, `C-a`, `C-e`
**Common pattern — launch a process and interact with it:**
```bash
id=$(paseo terminal create --name "my-shell" -q)
paseo terminal send-keys "$id" "claude" Enter
sleep 5
paseo terminal capture "$id" --scrollback # See what happened
paseo terminal send-keys "$id" "Hello!" Enter
sleep 10
paseo terminal capture "$id" --scrollback # See the response
paseo terminal send-keys "$id" "/exit" Enter
paseo terminal kill "$id"
```
## Available Models
**Claude (default provider)** — use aliases, CLI resolves to latest version: