mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30ebd4b777 | ||
|
|
e95554d782 | ||
|
|
5cf8b7549d | ||
|
|
9c4dee5364 | ||
|
|
c635eabff3 | ||
|
|
51411182fe | ||
|
|
f53c770a71 | ||
|
|
8d55764313 | ||
|
|
4b12ebd5c0 | ||
|
|
27c8cfbd4b | ||
|
|
07b077f1a2 | ||
|
|
ee50d3b8d0 | ||
|
|
4b93f990d2 | ||
|
|
87f297e755 |
113
.github/workflows/desktop-release.yml
vendored
113
.github/workflows/desktop-release.yml
vendored
@@ -149,6 +149,119 @@ jobs:
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-manifest-${{ matrix.electron_arch }}
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
||||
retention-days: 1
|
||||
|
||||
finalize-mac-manifest:
|
||||
needs: [publish-macos]
|
||||
if: ${{ needs.publish-macos.result == 'success' }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_tag="${SOURCE_TAG}"
|
||||
if [[ "$source_tag" =~ ^(desktop-(windows|linux|macos)-|desktop-)?v([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||
release_tag="v${BASH_REMATCH[3]}"
|
||||
else
|
||||
release_tag="$source_tag"
|
||||
fi
|
||||
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
|
||||
|
||||
if [[ "$source_tag" == *gha-smoke* ]]; then
|
||||
echo "IS_SMOKE_TAG=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "IS_SMOKE_TAG=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Download manifest artifacts
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: mac-manifest-*
|
||||
|
||||
- name: Merge manifests
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
// Simple YAML parser for electron-builder's latest-mac.yml format
|
||||
function parseManifest(text) {
|
||||
const lines = text.split('\n');
|
||||
const result = { files: [] };
|
||||
let currentFile = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
|
||||
else if (line.trim().startsWith('- url:')) {
|
||||
currentFile = { url: line.trim().replace('- url: ', '') };
|
||||
result.files.push(currentFile);
|
||||
} else if (line.trim().startsWith('sha512:') && currentFile) {
|
||||
currentFile.sha512 = line.trim().split(': ')[1].trim();
|
||||
} else if (line.trim().startsWith('size:') && currentFile) {
|
||||
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
|
||||
currentFile = null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toYaml(manifest) {
|
||||
let out = `version: ${manifest.version}\n`;
|
||||
out += `files:\n`;
|
||||
for (const f of manifest.files) {
|
||||
out += ` - url: ${f.url}\n`;
|
||||
out += ` sha512: ${f.sha512}\n`;
|
||||
out += ` size: ${f.size}\n`;
|
||||
}
|
||||
out += `path: ${manifest.path}\n`;
|
||||
out += `sha512: ${manifest.sha512}\n`;
|
||||
out += `releaseDate: '${manifest.releaseDate}'\n`;
|
||||
return out;
|
||||
}
|
||||
|
||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
||||
|
||||
const arm64 = parseManifest(arm64Text);
|
||||
const x64 = parseManifest(x64Text);
|
||||
|
||||
// Merge: all files from both, default path points to arm64 zip
|
||||
const merged = {
|
||||
version: arm64.version,
|
||||
files: [...arm64.files, ...x64.files],
|
||||
path: arm64.path,
|
||||
sha512: arm64.sha512,
|
||||
releaseDate: arm64.releaseDate || x64.releaseDate,
|
||||
};
|
||||
|
||||
const output = toYaml(merged);
|
||||
fs.writeFileSync('latest-mac.yml', output);
|
||||
console.log('Merged manifest:\n' + output);
|
||||
NODE
|
||||
|
||||
- name: Upload merged manifest to release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||
|
||||
publish-linux:
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
||||
permissions:
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.40 - 2026-04-01
|
||||
|
||||
### Added
|
||||
- Workspace tabs can now be closed in batches.
|
||||
|
||||
### Improved
|
||||
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
|
||||
|
||||
### Fixed
|
||||
- OpenCode reasoning content no longer appears duplicated as assistant text.
|
||||
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
|
||||
- Archive tab now correctly reconciles agent visibility after archiving.
|
||||
- File diff tracking in workspaces now works correctly on Linux.
|
||||
- iPad layout now renders correctly in desktop mode.
|
||||
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
|
||||
|
||||
## 0.1.39 - 2026-03-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-ohEbz3DFhuYD70GxXfIPxeSpxFfY7AgmJSUbBO+Fhn0=";
|
||||
npmDepsHash = "sha256-wLXSLXXzB1rwuQXPRFFt4EKZsFydN3HTR99ZJqBdXxk=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34962,16 +34962,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.39",
|
||||
"@getpaseo/highlight": "0.1.39",
|
||||
"@getpaseo/server": "0.1.39",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.40",
|
||||
"@getpaseo/highlight": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -35088,11 +35088,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.39",
|
||||
"@getpaseo/server": "0.1.39",
|
||||
"@getpaseo/relay": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35133,11 +35133,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.39",
|
||||
"@getpaseo/server": "0.1.39",
|
||||
"@getpaseo/cli": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35171,7 +35171,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35372,7 +35372,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35398,7 +35398,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35414,13 +35414,13 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.39",
|
||||
"@getpaseo/relay": "0.1.39",
|
||||
"@getpaseo/highlight": "0.1.40",
|
||||
"@getpaseo/relay": "0.1.40",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35818,7 +35818,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
106
packages/app/e2e/archive-tab.spec.ts
Normal file
106
packages/app/e2e/archive-tab.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
reloadWorkspace,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
client = await connectArchiveTabDaemonClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.close();
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `cli-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `cli-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, tempRepo.path);
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `ui-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `ui-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await openSessions(page);
|
||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||
await reloadWorkspace(page, tempRepo.path);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
257
packages/app/e2e/helpers/archive-tab.ts
Normal file
257
packages/app/e2e/helpers/archive-tab.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
export type ArchiveTabAgent = {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
type ArchiveTabDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
};
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
function buildSeededStoragePayload() {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
daemon: buildSeededHost({
|
||||
serverId: getServerId(),
|
||||
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => ArchiveTabDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => ArchiveTabDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: ArchiveTabDaemonClient,
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
initialPrompt: "Reply with exactly READY.",
|
||||
});
|
||||
const finished = await client.waitForFinish(created.id, 120_000);
|
||||
if (finished.status !== "idle") {
|
||||
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
|
||||
}
|
||||
return {
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: ArchiveTabDaemonClient,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
await client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce }) => {
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === seedNonce) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon, preferences, seedNonce },
|
||||
);
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
export async function resetSeededPageState(page: Page): Promise<void> {
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
await page.goto("/");
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
},
|
||||
{ daemon, preferences },
|
||||
);
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
export async function openWorkspaceWithAgents(
|
||||
page: Page,
|
||||
agents: [ArchiveTabAgent, ArchiveTabAgent],
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
for (const agent of agents) {
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceArchiveOutcome(
|
||||
page: Page,
|
||||
input: { archivedAgentId: string; survivingAgentId: string },
|
||||
): Promise<void> {
|
||||
await expectWorkspaceTabHidden(page, input.archivedAgentId);
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
export async function openSessions(page: Page): Promise<void> {
|
||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await sessionsButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionRowByTitle(page: Page, title: string) {
|
||||
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
|
||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function archiveAgentFromSessions(
|
||||
page: Page,
|
||||
input: { agentId: string; title: string },
|
||||
): Promise<void> {
|
||||
const row = getSessionRowByTitle(page, input.title);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const box = await row.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
||||
}
|
||||
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(900);
|
||||
await page.mouse.up();
|
||||
|
||||
const archiveButton = page.getByTestId("agent-action-archive").first();
|
||||
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
|
||||
await archiveButton.click();
|
||||
await expectSessionRowArchived(page, input.title);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.39",
|
||||
"@getpaseo/highlight": "0.1.39",
|
||||
"@getpaseo/server": "0.1.39",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.40",
|
||||
"@getpaseo/highlight": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
@@ -103,7 +103,7 @@ function PushNotificationRouter() {
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
if (getIsDesktop()) {
|
||||
if (getIsElectronRuntime()) {
|
||||
void ensureOsNotificationPermission();
|
||||
|
||||
const unlistenResult = getDesktopHost()?.events?.on?.(
|
||||
@@ -342,12 +342,12 @@ function AppContainer({
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: chromeEnabled,
|
||||
isMobile,
|
||||
isMobile: isCompactLayout,
|
||||
toggleAgentList,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
@@ -363,10 +363,12 @@ function AppContainer({
|
||||
const content = (
|
||||
<View style={containerStyle}>
|
||||
<View style={rowStyle}>
|
||||
{!isMobile && chromeEnabled && !isFocusModeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
{isMobile && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
@@ -375,7 +377,7 @@ function AppContainer({
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
if (!isCompactLayout) {
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type { AttachmentStore } from "@/attachments/types";
|
||||
|
||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isDesktop()) {
|
||||
if (isElectronRuntime()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
|
||||
|
||||
describe("isProviderModelsQueryLoading", () => {
|
||||
it("does not treat a disabled pending query as loading", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const observer = new QueryObserver(queryClient, {
|
||||
queryKey: ["providerModels", "server-1", "__missing_provider__"],
|
||||
enabled: false,
|
||||
queryFn: async () => [],
|
||||
});
|
||||
|
||||
const result = observer.getCurrentResult();
|
||||
|
||||
expect(result.isPending).toBe(true);
|
||||
expect(result.isLoading).toBe(false);
|
||||
expect(result.isFetching).toBe(false);
|
||||
expect(isProviderModelsQueryLoading(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an active fetch as loading", () => {
|
||||
expect(
|
||||
isProviderModelsQueryLoading({
|
||||
isLoading: false,
|
||||
isFetching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
interface ProviderModelsQueryState {
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function isProviderModelsQueryLoading(input: ProviderModelsQueryState): boolean {
|
||||
return input.isLoading || input.isFetching;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -634,12 +635,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
queryKey: [
|
||||
"providerModels",
|
||||
serverId,
|
||||
agent?.provider ?? "__missing_provider__",
|
||||
agent?.cwd ?? "__missing_cwd__",
|
||||
],
|
||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
||||
enabled: Boolean(client && agent?.provider),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
@@ -753,7 +749,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||
});
|
||||
}}
|
||||
isModelLoading={modelsQuery.isPending || modelsQuery.isFetching}
|
||||
isModelLoading={isProviderModelsQueryLoading(modelsQuery)}
|
||||
disabled={!client}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import {
|
||||
usePanelStore,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -40,7 +40,7 @@ export function ExplorerSidebar({
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
|
||||
@@ -119,7 +119,7 @@ function FilePreviewBody({
|
||||
filePath,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
const isDark = theme.colors.surface0 === "#181B1A";
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ interface HighlightedTextProps {
|
||||
|
||||
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
const isDark = theme.colors.surface0 === "#181B1A";
|
||||
|
||||
// Get color for a highlight style
|
||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { HeaderToggleButton } from "./header-toggle-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
@@ -43,7 +44,7 @@ export function SidebarMenuToggle({
|
||||
nativeID = "menu-button",
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
isCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
|
||||
@@ -24,7 +25,7 @@ interface ScreenHeaderProps {
|
||||
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const padding = useWindowControlsPadding("header");
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -13,7 +13,7 @@ export function KeyboardShortcutsDialog() {
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktopApp = getIsDesktop();
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const sections = useMemo(
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }),
|
||||
[isDesktopApp, isMac],
|
||||
|
||||
@@ -20,7 +20,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MessagesSquare, Plus, Settings } from "lucide-react-native";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -39,7 +39,11 @@ import { useDesktopDragHandlers, useWindowControlsPadding } from "@/utils/deskto
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
isCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import {
|
||||
buildHostSessionsRoute,
|
||||
buildHostSettingsRoute,
|
||||
@@ -94,6 +98,7 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
||||
}
|
||||
|
||||
interface DesktopSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
isOpen: boolean;
|
||||
handleViewMore: () => void;
|
||||
}
|
||||
@@ -105,7 +110,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
@@ -175,7 +180,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
|
||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||
serverId: activeServerId,
|
||||
@@ -265,7 +270,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleHostSelect,
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
@@ -283,6 +288,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
return (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
@@ -627,6 +633,7 @@ function DesktopSidebar({
|
||||
handleHostSelect,
|
||||
handleOpenProject,
|
||||
handleSettings,
|
||||
insetsTop,
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
}: DesktopSidebarProps) {
|
||||
@@ -681,7 +688,7 @@ function DesktopSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} {...dragHandlers} /> : null}
|
||||
<View style={styles.sidebarHeader} {...dragHandlers}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "react";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
@@ -42,7 +42,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
@@ -664,8 +664,7 @@ function ProjectHeaderRow({
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobileBreakpoint =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobileBreakpoint = isCompactFormFactor();
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const toast = useToast();
|
||||
|
||||
@@ -836,7 +835,7 @@ function WorkspaceRowInner({
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = Platform.OS !== "web";
|
||||
const isTouchPlatform = Platform.OS !== "web";
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
@@ -901,7 +900,7 @@ function WorkspaceRowInner({
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isMobile) ? (
|
||||
{onArchive && (isHovered || isTouchPlatform) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
@@ -1638,7 +1637,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isNative = Platform.OS !== "web";
|
||||
const pathname = usePathname();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
@@ -1646,7 +1645,7 @@ export function SidebarWorkspaceList({
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const isDesktopApp = getIsDesktop();
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown);
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown);
|
||||
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
@@ -91,7 +92,7 @@ export function TerminalPane({
|
||||
const isAppVisible = useAppVisible();
|
||||
const { theme } = useUnistyles();
|
||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
|
||||
@@ -350,7 +350,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
? HANDLE_OPACITY_VISIBLE
|
||||
: 0;
|
||||
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
const isDark = theme.colors.surface0 === "#181B1A";
|
||||
const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700];
|
||||
const handleCursor = isDragging ? "grabbing" : "grab";
|
||||
const handleTravelDurationMs =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isDesktop, isDesktopMac } from "@/desktop/host";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||
|
||||
export const FOOTER_HEIGHT = 75;
|
||||
|
||||
@@ -23,40 +24,59 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
||||
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
||||
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
||||
|
||||
// Check if running in desktop app (any OS)
|
||||
function isDesktopEnvironment(): boolean {
|
||||
// Check if running in the Electron desktop runtime (any OS)
|
||||
function isElectronDesktopRuntime(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
return isDesktop();
|
||||
return isElectronRuntime();
|
||||
}
|
||||
|
||||
// Check if running in desktop host on macOS
|
||||
function isDesktopEnvironmentMac(): boolean {
|
||||
// Check if running in the Electron desktop runtime on macOS
|
||||
function isElectronDesktopRuntimeMac(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
return isDesktopMac();
|
||||
return isElectronRuntimeMac();
|
||||
}
|
||||
|
||||
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
|
||||
let _isDesktopMacCached: boolean | null = null;
|
||||
let _isDesktopCached: boolean | null = null;
|
||||
let _isElectronRuntimeMacCached: boolean | null = null;
|
||||
let _isElectronRuntimeCached: boolean | null = null;
|
||||
|
||||
export function getIsDesktopMac(): boolean {
|
||||
if (_isDesktopMacCached === true) {
|
||||
export function getIsElectronRuntimeMac(): boolean {
|
||||
if (_isElectronRuntimeMacCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isDesktopEnvironmentMac();
|
||||
const result = isElectronDesktopRuntimeMac();
|
||||
if (result) {
|
||||
_isDesktopMacCached = true;
|
||||
_isElectronRuntimeMacCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getIsDesktop(): boolean {
|
||||
if (_isDesktopCached === true) {
|
||||
export function getIsElectronRuntime(): boolean {
|
||||
if (_isElectronRuntimeCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isDesktopEnvironment();
|
||||
const result = isElectronDesktopRuntime();
|
||||
if (result) {
|
||||
_isDesktopCached = true;
|
||||
_isElectronRuntimeCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isCompactFormFactor(): boolean {
|
||||
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
}
|
||||
|
||||
export function isDesktopFormFactor(): boolean {
|
||||
return !isCompactFormFactor();
|
||||
}
|
||||
|
||||
export function isTouchDesktopFormFactor(): boolean {
|
||||
return Platform.OS !== "web" && isDesktopFormFactor();
|
||||
}
|
||||
|
||||
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
|
||||
// Keep that capability distinct from desktop-width layout so touch tablets
|
||||
// can use the desktop shell without entering web-only code paths.
|
||||
export function supportsDesktopPaneSplits(): boolean {
|
||||
return Platform.OS === "web";
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getRightSidebarAnimationTargets,
|
||||
@@ -27,12 +27,12 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
||||
|
||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const isOpen = isCompactLayout ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
getLeftSidebarAnimationTargets,
|
||||
@@ -34,12 +34,12 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
||||
|
||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
|
||||
// Initialize based on current state
|
||||
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||
|
||||
@@ -9,7 +9,7 @@ import { settingsStyles } from "@/styles/settings";
|
||||
export function DesktopPermissionsSection() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
@@ -20,7 +20,7 @@ export function DesktopPermissionsSection() {
|
||||
sendTestNotification,
|
||||
} = useDesktopPermissions();
|
||||
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDesktopHost, isDesktop } from "@/desktop/host";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored";
|
||||
@@ -123,7 +123,7 @@ function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructio
|
||||
}
|
||||
|
||||
export function shouldUseDesktopDaemon(): boolean {
|
||||
return isDesktop();
|
||||
return isElectronRuntime();
|
||||
}
|
||||
|
||||
export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
||||
|
||||
@@ -97,12 +97,12 @@ export function getDesktopHost(): DesktopHostBridge | null {
|
||||
return getElectronHost();
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
export function isElectronRuntime(): boolean {
|
||||
return getDesktopHost() !== null;
|
||||
}
|
||||
|
||||
export function isDesktopMac(): boolean {
|
||||
if (!isDesktop()) {
|
||||
export function isElectronRuntimeMac(): boolean {
|
||||
if (!isElectronRuntime()) {
|
||||
return false;
|
||||
}
|
||||
if (typeof navigator === "undefined") {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
|
||||
export interface UseDesktopPermissionsReturn {
|
||||
isDesktop: boolean;
|
||||
isDesktopApp: boolean;
|
||||
snapshot: DesktopPermissionSnapshot | null;
|
||||
isRefreshing: boolean;
|
||||
requestingPermission: DesktopPermissionKind | null;
|
||||
@@ -31,7 +31,7 @@ const EMPTY_MICROPHONE_STATUS = {
|
||||
};
|
||||
|
||||
export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
const isDesktop = shouldShowDesktopPermissionSection();
|
||||
const isDesktopApp = shouldShowDesktopPermissionSection();
|
||||
const isMountedRef = useRef(true);
|
||||
const [snapshot, setSnapshot] = useState<DesktopPermissionSnapshot | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -47,7 +47,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
}, []);
|
||||
|
||||
const refreshPermissions = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,11 +65,11 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [isDesktop]);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const requestPermission = useCallback(
|
||||
async (kind: DesktopPermissionKind) => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,13 +110,13 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
await refreshPermissions();
|
||||
}
|
||||
},
|
||||
[isDesktop, refreshPermissions],
|
||||
[isDesktopApp, refreshPermissions],
|
||||
);
|
||||
|
||||
const [testNotificationError, setTestNotificationError] = useState<string | null>(null);
|
||||
|
||||
const sendTestNotification = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,18 +137,18 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
setIsSendingTestNotification(false);
|
||||
}
|
||||
}
|
||||
}, [isDesktop]);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshPermissions();
|
||||
}, [isDesktop, refreshPermissions]);
|
||||
}, [isDesktopApp, refreshPermissions]);
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||
|
||||
export interface DesktopAppUpdateCheckResult {
|
||||
@@ -49,7 +49,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
|
||||
}
|
||||
|
||||
export function shouldShowDesktopUpdateSection(): boolean {
|
||||
return Platform.OS === "web" && isDesktop();
|
||||
return Platform.OS === "web" && isElectronRuntime();
|
||||
}
|
||||
|
||||
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
||||
|
||||
@@ -11,7 +11,7 @@ const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
export function UpdateBanner() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
status,
|
||||
availableUpdate,
|
||||
errorMessage,
|
||||
@@ -23,7 +23,7 @@ export function UpdateBanner() {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return;
|
||||
if (!isDesktopApp) return;
|
||||
|
||||
void checkForUpdates({ silent: true });
|
||||
|
||||
@@ -36,9 +36,9 @@ export function UpdateBanner() {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isDesktop, checkForUpdates]);
|
||||
}, [isDesktopApp, checkForUpdates]);
|
||||
|
||||
if (!isDesktop) return null;
|
||||
if (!isDesktopApp) return null;
|
||||
if (dismissed) return null;
|
||||
if (status !== "available" && status !== "installed" && status !== "installing" && status !== "error")
|
||||
return null;
|
||||
|
||||
@@ -18,7 +18,7 @@ export type DesktopAppUpdateStatus =
|
||||
| "error";
|
||||
|
||||
export interface UseDesktopAppUpdaterReturn {
|
||||
isDesktop: boolean;
|
||||
isDesktopApp: boolean;
|
||||
status: DesktopAppUpdateStatus;
|
||||
statusText: string;
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null;
|
||||
@@ -75,7 +75,7 @@ function formatStatusText(input: {
|
||||
}
|
||||
|
||||
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
const isDesktop = shouldShowDesktopUpdateSection();
|
||||
const isDesktopApp = shouldShowDesktopUpdateSection();
|
||||
const requestVersionRef = useRef(0);
|
||||
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
|
||||
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
|
||||
@@ -85,7 +85,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
|
||||
const checkForUpdates = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -130,11 +130,11 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[isDesktop],
|
||||
[isDesktopApp],
|
||||
);
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -162,10 +162,10 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
setErrorMessage(message);
|
||||
return null;
|
||||
}
|
||||
}, [isDesktop]);
|
||||
}, [isDesktopApp]);
|
||||
|
||||
return {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
status,
|
||||
statusText: formatStatusText({
|
||||
status,
|
||||
|
||||
@@ -418,7 +418,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
}, [formState.workingDir]);
|
||||
|
||||
const providerModelsQuery = useQuery({
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd],
|
||||
queryKey: ["providerModels", formState.serverId, formState.provider],
|
||||
enabled: Boolean(
|
||||
isVisible &&
|
||||
isTargetDaemonReady &&
|
||||
@@ -446,7 +446,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
const allProviderModelQueries = useQueries({
|
||||
queries: providerDefinitions.map((def) => ({
|
||||
queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd],
|
||||
queryKey: ["providerModels", formState.serverId, def.id],
|
||||
enabled: Boolean(
|
||||
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
|
||||
),
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-archive-agent";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
serverId: "server-a",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "running",
|
||||
createdAt: new Date("2026-04-01T03:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T03:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date("2026-04-01T03:00:00.000Z"),
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: "Agent 1",
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
labels: {},
|
||||
archivedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useArchiveAgent", () => {
|
||||
beforeEach(() => {
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
|
||||
it("tracks pending archive state in shared react-query cache", () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -63,4 +101,42 @@ describe("useArchiveAgent", () => {
|
||||
expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]);
|
||||
expect(next.pageInfo).toEqual({ hasMore: false });
|
||||
});
|
||||
|
||||
it("applies archived agent close results to session state and cached lists", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession("server-a", {} as DaemonClient);
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-a",
|
||||
new Map([
|
||||
[
|
||||
"agent-1",
|
||||
makeAgent(),
|
||||
],
|
||||
]),
|
||||
);
|
||||
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
|
||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||
});
|
||||
queryClient.setQueryData(["allAgents", "server-a"], {
|
||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||
});
|
||||
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
serverId: "server-a",
|
||||
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||
});
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions["server-a"]?.agents.get("agent-1")?.archivedAt?.toISOString(),
|
||||
).toBe("2026-04-01T04:00:00.000Z");
|
||||
expect(queryClient.getQueryData(["sidebarAgentsList", "server-a"])).toEqual({
|
||||
entries: [{ agent: { id: "agent-2" } }],
|
||||
});
|
||||
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
|
||||
entries: [{ agent: { id: "agent-2" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,11 @@ export interface ArchiveAgentInput {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export interface ArchivedAgentCloseResult {
|
||||
agentId: string;
|
||||
archivedAt: string;
|
||||
}
|
||||
|
||||
type ArchiveAgentPendingState = Record<string, true>;
|
||||
|
||||
interface SetAgentArchivingInput extends ArchiveAgentInput {
|
||||
@@ -130,6 +135,39 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin
|
||||
});
|
||||
}
|
||||
|
||||
interface ApplyArchivedAgentCloseResultsInput {
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
results: ArchivedAgentCloseResult[];
|
||||
}
|
||||
|
||||
export function applyArchivedAgentCloseResults(
|
||||
input: ApplyArchivedAgentCloseResultsInput,
|
||||
): void {
|
||||
if (input.results.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const result of input.results) {
|
||||
markAgentArchivedInStore({
|
||||
serverId: input.serverId,
|
||||
agentId: result.agentId,
|
||||
archivedAt: result.archivedAt,
|
||||
});
|
||||
removeAgentFromCachedLists(input.queryClient, {
|
||||
serverId: input.serverId,
|
||||
agentId: result.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsList", input.serverId],
|
||||
});
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", input.serverId],
|
||||
});
|
||||
}
|
||||
|
||||
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
|
||||
setAgentArchiving({
|
||||
...input,
|
||||
@@ -165,17 +203,10 @@ export function useArchiveAgent() {
|
||||
});
|
||||
},
|
||||
onSuccess: (result, input) => {
|
||||
markAgentArchivedInStore({
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
archivedAt: result.archivedAt,
|
||||
});
|
||||
removeAgentFromCachedLists(queryClient, input);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsList", input.serverId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", input.serverId],
|
||||
results: [{ agentId: input.agentId, archivedAt: result.archivedAt }],
|
||||
});
|
||||
},
|
||||
onSettled: (_result, _error, input) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
|
||||
export interface AudioCaptureConfig {
|
||||
sampleRate?: number;
|
||||
@@ -157,7 +157,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
|
||||
: true;
|
||||
const currentOrigin =
|
||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
const isDesktopApp = isDesktop();
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
|
||||
@@ -17,7 +17,7 @@ import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
|
||||
@@ -110,8 +110,8 @@ function resolveActionShortcutKeys(
|
||||
): ShortcutKey[][] | undefined {
|
||||
if (!actionId) return undefined;
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
const platform = { isMac, isDesktop };
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const platform = { isMac, isDesktop: isDesktopApp };
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return undefined;
|
||||
const override = overrides[bindingId];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { parsePcm16Wav } from "@/utils/pcm16-wav";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
|
||||
import type {
|
||||
DictationAudioSource,
|
||||
@@ -168,7 +168,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
||||
: true;
|
||||
const currentOrigin =
|
||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
const isDesktopApp = isDesktop();
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { getIsDesktopMac } from "@/constants/layout";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
@@ -96,7 +96,7 @@ function getSystemColorScheme(): ColorScheme {
|
||||
}
|
||||
|
||||
async function updateMacDockBadge(count?: number) {
|
||||
if (Platform.OS !== "web" || !getIsDesktopMac()) return;
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return;
|
||||
|
||||
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
|
||||
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import {
|
||||
normalizePickedImageAssets,
|
||||
openImagePathsWithDesktopDialog,
|
||||
@@ -45,7 +45,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
|
||||
isPickingRef.current = true;
|
||||
|
||||
try {
|
||||
if (Platform.OS === "web" && isDesktop()) {
|
||||
if (Platform.OS === "web" && isElectronRuntime()) {
|
||||
const selectedPaths = await openImagePathsWithDesktopDialog();
|
||||
if (selectedPaths.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -17,12 +17,12 @@ async function loadDesktopDaemonServerId(): Promise<DesktopDaemonServerIdResult>
|
||||
|
||||
export function useIsLocalDaemon(serverId: string): boolean {
|
||||
const normalizedServerId = serverId.trim();
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
const isDesktopApp = shouldUseDesktopDaemon();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY,
|
||||
queryFn: loadDesktopDaemonServerId,
|
||||
enabled: isDesktop,
|
||||
enabled: isDesktopApp,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchInterval: (query) => (query.state.data?.serverId ? false : 1000),
|
||||
@@ -32,7 +32,7 @@ export function useIsLocalDaemon(serverId: string): boolean {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (!isDesktop || normalizedServerId.length === 0) {
|
||||
if (!isDesktopApp || normalizedServerId.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -66,7 +66,7 @@ export function useKeyboardShortcuts({
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isMobile) return;
|
||||
|
||||
const isDesktopApp = getIsDesktop();
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
|
||||
const shouldHandle = () => {
|
||||
|
||||
@@ -4,15 +4,15 @@ import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
|
||||
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
|
||||
return useMemo(() => {
|
||||
const platform = { isMac, isDesktop };
|
||||
const platform = { isMac, isDesktop: isDesktopApp };
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return null;
|
||||
|
||||
@@ -23,5 +23,5 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
|
||||
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : null;
|
||||
}, [actionId, overrides, isMac, isDesktop]);
|
||||
}, [actionId, overrides, isMac, isDesktopApp]);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
FetchAgentsOptions,
|
||||
} from "@server/client/daemon-client";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
HostRuntimeController,
|
||||
HostRuntimeStore,
|
||||
@@ -112,6 +112,7 @@ function makeFetchAgentsEntry(input: {
|
||||
title?: string | null;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: "permission" | "error" | null;
|
||||
archivedAt?: string | null;
|
||||
}): FetchAgentsEntry {
|
||||
return {
|
||||
agent: {
|
||||
@@ -145,7 +146,7 @@ function makeFetchAgentsEntry(input: {
|
||||
requiresAttention: input.requiresAttention ?? false,
|
||||
attentionReason: input.attentionReason ?? null,
|
||||
attentionTimestamp: input.requiresAttention && input.attentionReason ? input.updatedAt : null,
|
||||
archivedAt: null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
labels: {},
|
||||
},
|
||||
project: {
|
||||
@@ -1134,6 +1135,93 @@ describe("HostRuntimeStore", () => {
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("rehydrates archived agents over stale active session state after reconnect bootstrap", async () => {
|
||||
const host = makeHost({
|
||||
serverId: "srv_archived_rehydrate",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
fakeClient.fetchAgentsResponses.push(
|
||||
makeFetchAgentsPayload({
|
||||
entries: [
|
||||
makeFetchAgentsEntry({
|
||||
id: "agent-archived",
|
||||
cwd: "/Users/moboudra/dev/paseo",
|
||||
updatedAt: "2026-03-30T15:30:00.000Z",
|
||||
archivedAt: "2026-03-30T15:31:00.000Z",
|
||||
title: "Archived remotely",
|
||||
}),
|
||||
],
|
||||
subscriptionId: "app:srv_archived_rehydrate",
|
||||
}),
|
||||
);
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
useSessionStore
|
||||
.getState()
|
||||
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(host.serverId, () => {
|
||||
const stale = makeFetchAgentsEntry({
|
||||
id: "agent-archived",
|
||||
cwd: "/Users/moboudra/dev/paseo",
|
||||
updatedAt: "2026-03-30T15:29:00.000Z",
|
||||
archivedAt: null,
|
||||
title: "Stale active copy",
|
||||
}).agent;
|
||||
const staleAgent: Agent = {
|
||||
...stale,
|
||||
serverId: host.serverId,
|
||||
createdAt: new Date(stale.createdAt),
|
||||
updatedAt: new Date(stale.updatedAt),
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: new Date(stale.updatedAt),
|
||||
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
|
||||
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
|
||||
};
|
||||
return new Map([
|
||||
[
|
||||
stale.id,
|
||||
staleAgent,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
store.syncHosts([host]);
|
||||
|
||||
const timeoutAt = Date.now() + 300;
|
||||
let archivedAt =
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
|
||||
?.archivedAt ?? null;
|
||||
while (!archivedAt && Date.now() < timeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
archivedAt =
|
||||
useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-archived")
|
||||
?.archivedAt ?? null;
|
||||
}
|
||||
|
||||
expect(archivedAt?.toISOString()).toBe("2026-03-30T15:31:00.000Z");
|
||||
|
||||
store.syncHosts([]);
|
||||
useSessionStore.getState().clearSession(host.serverId);
|
||||
});
|
||||
|
||||
it("records unavailable startup probes when no connection can be established", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ImageAttachment } from "@/components/message-input";
|
||||
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated from "react-native-reanimated";
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout";
|
||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
@@ -233,7 +233,7 @@ function DraftAgentScreenContent({
|
||||
isCreateFlow: true,
|
||||
onlineServerIds,
|
||||
});
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { FolderOpen } from "lucide-react-native";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,6 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
@@ -16,14 +17,14 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
||||
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) {
|
||||
if (!isCompactLayout) {
|
||||
openAgentList();
|
||||
}
|
||||
}, [isMobile, openAgentList]);
|
||||
}, [isCompactLayout, openAgentList]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Buffer } from "buffer";
|
||||
import {
|
||||
Sun,
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
import { isDesktop as isDesktopHost } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
@@ -59,6 +59,7 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { isCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section definitions
|
||||
@@ -79,7 +80,7 @@ interface SettingsSectionDef {
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
}
|
||||
|
||||
function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDef[] {
|
||||
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
|
||||
const sections: SettingsSectionDef[] = [
|
||||
{ id: "hosts", label: "Hosts", icon: Server },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
@@ -88,7 +89,7 @@ function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDe
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
];
|
||||
|
||||
if (context.isDesktop) {
|
||||
if (context.isDesktopApp) {
|
||||
sections.push(
|
||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||
@@ -174,7 +175,6 @@ interface HostsSectionProps {
|
||||
daemons: HostProfile[];
|
||||
settings: AppSettings;
|
||||
routeServerId: string;
|
||||
isDesktop: boolean;
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
handleEditDaemon: (profile: HostProfile) => void;
|
||||
setAddConnectionTargetServerId: (id: string | null) => void;
|
||||
@@ -464,10 +464,10 @@ function DiagnosticsSection({
|
||||
|
||||
interface AboutSectionProps {
|
||||
appVersionText: string;
|
||||
isDesktop: boolean;
|
||||
isDesktopApp: boolean;
|
||||
}
|
||||
|
||||
function AboutSection({ appVersionText, isDesktop }: AboutSectionProps) {
|
||||
function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>About</Text>
|
||||
@@ -478,7 +478,7 @@ function AboutSection({ appVersionText, isDesktop }: AboutSectionProps) {
|
||||
</View>
|
||||
<Text style={styles.aboutValue}>{appVersionText}</Text>
|
||||
</View>
|
||||
{isDesktop ? <DesktopAppUpdateRow /> : null}
|
||||
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -496,7 +496,7 @@ interface SettingsSectionContentProps {
|
||||
aboutProps: AboutSectionProps;
|
||||
appVersion: string | null;
|
||||
isLocalDaemon: boolean;
|
||||
isDesktop: boolean;
|
||||
isDesktopApp: boolean;
|
||||
}
|
||||
|
||||
function SettingsSectionContent({
|
||||
@@ -507,7 +507,7 @@ function SettingsSectionContent({
|
||||
aboutProps,
|
||||
appVersion,
|
||||
isLocalDaemon,
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
}: SettingsSectionContentProps) {
|
||||
switch (sectionId) {
|
||||
case "hosts":
|
||||
@@ -521,9 +521,9 @@ function SettingsSectionContent({
|
||||
case "about":
|
||||
return <AboutSection {...aboutProps} />;
|
||||
case "permissions":
|
||||
return isDesktop ? <DesktopPermissionsSection /> : null;
|
||||
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
||||
case "daemon":
|
||||
return isDesktop ? (
|
||||
return isDesktopApp ? (
|
||||
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
|
||||
) : null;
|
||||
}
|
||||
@@ -619,7 +619,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
|
||||
|
||||
function DesktopAppUpdateRow() {
|
||||
const {
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
statusText,
|
||||
availableUpdate,
|
||||
errorMessage,
|
||||
@@ -631,23 +631,23 @@ function DesktopAppUpdateRow() {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return undefined;
|
||||
}
|
||||
void checkForUpdates({ silent: true });
|
||||
return undefined;
|
||||
}, [checkForUpdates, isDesktop]),
|
||||
}, [checkForUpdates, isDesktopApp]),
|
||||
);
|
||||
|
||||
const handleCheckForUpdates = useCallback(() => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
void checkForUpdates();
|
||||
}, [checkForUpdates, isDesktop]);
|
||||
}, [checkForUpdates, isDesktopApp]);
|
||||
|
||||
const handleInstallUpdate = useCallback(() => {
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -667,9 +667,9 @@ function DesktopAppUpdateRow() {
|
||||
console.error("[Settings] Failed to open app update confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the update confirmation dialog.");
|
||||
});
|
||||
}, [installUpdate, isDesktop]);
|
||||
}, [installUpdate, isDesktopApp]);
|
||||
|
||||
if (!isDesktop) {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -745,7 +745,7 @@ export default function SettingsScreen() {
|
||||
const isLoading = settingsLoading;
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const isDesktop = isDesktopHost();
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const isLocalDaemon = useIsLocalDaemon(routeServerId);
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
@@ -929,14 +929,13 @@ export default function SettingsScreen() {
|
||||
}
|
||||
}, [isPlaybackTestRunning, voiceAudioEngine]);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const sections = getSettingsSections({ isDesktop });
|
||||
const isCompactLayout = isCompactFormFactor();
|
||||
const sections = getSettingsSections({ isDesktopApp });
|
||||
|
||||
const hostsProps: HostsSectionProps = {
|
||||
daemons,
|
||||
settings,
|
||||
routeServerId,
|
||||
isDesktop,
|
||||
theme,
|
||||
handleEditDaemon,
|
||||
setAddConnectionTargetServerId,
|
||||
@@ -985,7 +984,7 @@ export default function SettingsScreen() {
|
||||
|
||||
const aboutProps: AboutSectionProps = {
|
||||
appVersionText,
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
};
|
||||
|
||||
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
||||
@@ -995,7 +994,7 @@ export default function SettingsScreen() {
|
||||
aboutProps,
|
||||
appVersion,
|
||||
isLocalDaemon,
|
||||
isDesktop,
|
||||
isDesktopApp,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -1009,7 +1008,7 @@ export default function SettingsScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader title="Settings" />
|
||||
{isMobile ? (
|
||||
{isCompactLayout ? (
|
||||
<SettingsMobileLayout sections={sections} sectionContentProps={sectionContentProps} />
|
||||
) : (
|
||||
<SettingsDesktopLayout sections={sections} sectionContentProps={sectionContentProps} />
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "@/keyboard/shortcut-string";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
|
||||
function ShortcutSequence({ chord }: { chord: string[] | null }) {
|
||||
if (!chord || chord.length === 0) {
|
||||
@@ -93,8 +93,8 @@ export function KeyboardShortcutsSection() {
|
||||
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp });
|
||||
|
||||
function cancelCapture() {
|
||||
setCapturedCombos([]);
|
||||
@@ -180,7 +180,10 @@ export function KeyboardShortcutsSection() {
|
||||
<Text style={styles.subsectionTitle}>{section.title}</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
{section.rows.map(function (row, index) {
|
||||
const bindingId = getBindingIdForAction(row.id, { isMac, isDesktop });
|
||||
const bindingId = getBindingIdForAction(row.id, {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
});
|
||||
const overrideCombo = bindingId ? overrides[bindingId] : undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -102,6 +102,17 @@ describe("workspace agent visibility", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("prunes pinned archived agent tabs because archive state is authoritative", () => {
|
||||
expect(
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: "archived-agent",
|
||||
agentsHydrated: true,
|
||||
knownAgentIds: new Set(["archived-agent"]),
|
||||
activeAgentIds: new Set<string>(),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not prune active agent tabs", () => {
|
||||
const knownAgentIds = new Set(["active-agent"]);
|
||||
const activeAgentIds = new Set(["active-agent"]);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildBulkCloseConfirmationMessage,
|
||||
classifyBulkClosableTabs,
|
||||
closeBulkWorkspaceTabs,
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
@@ -71,4 +72,90 @@ describe("workspace bulk close helpers", () => {
|
||||
"This will close 1 terminal(s). Any running process in a closed terminal will be stopped immediately.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
makeTerminalTab("t2"),
|
||||
makeFileTab("/repo/README.md"),
|
||||
]);
|
||||
const closedTabIds: string[] = [];
|
||||
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
||||
const closeItems = vi.fn(async () => ({
|
||||
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||
terminals: [
|
||||
{ terminalId: "t1", success: true },
|
||||
{ terminalId: "t2", success: false },
|
||||
],
|
||||
requestId: "req-1",
|
||||
}));
|
||||
|
||||
const result = await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: { closeItems },
|
||||
closeTab: async (tabId, action) => {
|
||||
closedTabIds.push(tabId);
|
||||
await action();
|
||||
},
|
||||
closeWorkspaceTabWithCleanup: (input) => {
|
||||
cleanupCalls.push(input);
|
||||
},
|
||||
logLabel: "all tabs",
|
||||
});
|
||||
|
||||
expect(closeItems).toHaveBeenCalledTimes(1);
|
||||
expect(closeItems).toHaveBeenCalledWith({
|
||||
agentIds: ["a1"],
|
||||
terminalIds: ["t1", "t2"],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||
terminals: [
|
||||
{ terminalId: "t1", success: true },
|
||||
{ terminalId: "t2", success: false },
|
||||
],
|
||||
requestId: "req-1",
|
||||
});
|
||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||
expect(cleanupCalls).toEqual([
|
||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||
{ tabId: "file_/repo/README.md" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
|
||||
const groups = classifyBulkClosableTabs([
|
||||
makeAgentTab("a1"),
|
||||
makeTerminalTab("t1"),
|
||||
makeFileTab("/repo/README.md"),
|
||||
]);
|
||||
const closedTabIds: string[] = [];
|
||||
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
||||
const warn = vi.fn();
|
||||
|
||||
const result = await closeBulkWorkspaceTabs({
|
||||
groups,
|
||||
client: {
|
||||
closeItems: async () => {
|
||||
throw new Error("rpc failed");
|
||||
},
|
||||
},
|
||||
closeTab: async (tabId, action) => {
|
||||
closedTabIds.push(tabId);
|
||||
await action();
|
||||
},
|
||||
closeWorkspaceTabWithCleanup: (input) => {
|
||||
cleanupCalls.push(input);
|
||||
},
|
||||
warn,
|
||||
logLabel: "others",
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeNull();
|
||||
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
|
||||
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export type BulkClosableTabGroups = {
|
||||
@@ -6,6 +7,22 @@ export type BulkClosableTabGroups = {
|
||||
otherTabs: Array<{ tabId: string }>;
|
||||
};
|
||||
|
||||
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
|
||||
|
||||
interface CloseWorkspaceTabWithCleanupInput {
|
||||
tabId: string;
|
||||
target?: WorkspaceTabDescriptor["target"];
|
||||
}
|
||||
|
||||
interface CloseBulkWorkspaceTabsInput {
|
||||
client: Pick<DaemonClient, "closeItems"> | null;
|
||||
groups: BulkClosableTabGroups;
|
||||
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
|
||||
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
|
||||
logLabel: string;
|
||||
warn?: (message: string, payload: object) => void;
|
||||
}
|
||||
|
||||
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
|
||||
const groups: BulkClosableTabGroups = {
|
||||
agentTabs: [],
|
||||
@@ -50,3 +67,72 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
|
||||
}
|
||||
return `This will archive ${agentTabs.length} agent(s).`;
|
||||
}
|
||||
|
||||
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
|
||||
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
|
||||
}
|
||||
|
||||
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
|
||||
return new Set(
|
||||
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeBulkWorkspaceTabs(
|
||||
input: CloseBulkWorkspaceTabsInput,
|
||||
): Promise<CloseItemsPayload | null> {
|
||||
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
||||
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||
let payload: CloseItemsPayload | null = null;
|
||||
|
||||
if (hasDestructiveTabs && client) {
|
||||
try {
|
||||
payload = await client.closeItems({
|
||||
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||
});
|
||||
} catch (error) {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||
}
|
||||
} else if (hasDestructiveTabs) {
|
||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
|
||||
error: new Error("Daemon client not available"),
|
||||
});
|
||||
}
|
||||
|
||||
const successfulAgentIds = toSuccessfulAgentIds(payload);
|
||||
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
|
||||
|
||||
for (const { tabId, agentId } of groups.agentTabs) {
|
||||
if (!successfulAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||
if (!successfulTerminalIds.has(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId } of groups.otherTabs) {
|
||||
await closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,17 @@ import {
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native";
|
||||
import {
|
||||
CopyX,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
Columns2,
|
||||
Copy,
|
||||
Rows2,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import {
|
||||
@@ -73,6 +83,7 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
externalDndContext?: boolean;
|
||||
activeDragTabId?: string | null;
|
||||
tabDropPreviewIndex?: number | null;
|
||||
showPaneSplitActions?: boolean;
|
||||
};
|
||||
|
||||
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
@@ -258,7 +269,14 @@ function TabChip({
|
||||
</ContextMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
{tab.target.kind === "agent" ? (
|
||||
<View style={styles.tooltipAgentRow}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
<Text style={styles.tooltipAgentId}>{tab.target.agentId.slice(0, 7)}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.newTabTooltipText}>{tooltipLabel}</Text>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -273,6 +291,28 @@ function TabChip({
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
leading={(() => {
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <Copy size={16} color={iconColor} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||
case "arrow-right-to-line":
|
||||
return <ArrowRightToLine size={16} color={iconColor} />;
|
||||
case "copy-x":
|
||||
return <CopyX size={16} color={iconColor} />;
|
||||
case "x":
|
||||
return <X size={16} color={iconColor} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
})()}
|
||||
trailing={
|
||||
entry.hint ? (
|
||||
<Text style={styles.menuItemHint}>{entry.hint}</Text>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</ContextMenuItem>
|
||||
@@ -307,6 +347,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
externalDndContext = false,
|
||||
activeDragTabId = null,
|
||||
tabDropPreviewIndex = null,
|
||||
showPaneSplitActions = true,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
@@ -483,48 +524,52 @@ export function WorkspaceDesktopTabsRow({
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onSplitRight}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Split pane right"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
||||
{splitRightKeys ? (
|
||||
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onSplitDown}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Split pane down"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
||||
{splitDownKeys ? (
|
||||
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{showPaneSplitActions ? (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onSplitRight}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Split pane right"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
||||
{splitRightKeys ? (
|
||||
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={onSplitDown}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Split pane down"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
||||
{splitDownKeys ? (
|
||||
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -792,4 +837,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
tooltipAgentRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipAgentId: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
menuItemHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
CopyX,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Ellipsis,
|
||||
@@ -21,11 +24,12 @@ import {
|
||||
PanelRight,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
@@ -73,7 +77,7 @@ import {
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
@@ -82,6 +86,10 @@ import {
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabOptionRow,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import {
|
||||
WorkspaceDesktopTabsRow,
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
@@ -103,8 +111,10 @@ import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import {
|
||||
buildBulkCloseConfirmationMessage,
|
||||
classifyBulkClosableTabs,
|
||||
closeBulkWorkspaceTabs,
|
||||
} from "@/screens/workspace/workspace-bulk-close";
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
@@ -332,6 +342,28 @@ function MobileWorkspaceTabOption({
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
leading={(() => {
|
||||
const iconColor = theme.colors.foregroundMuted;
|
||||
switch (entry.icon) {
|
||||
case "copy":
|
||||
return <Copy size={16} color={iconColor} />;
|
||||
case "arrow-left-to-line":
|
||||
return <ArrowLeftToLine size={16} color={iconColor} />;
|
||||
case "arrow-right-to-line":
|
||||
return <ArrowRightToLine size={16} color={iconColor} />;
|
||||
case "copy-x":
|
||||
return <CopyX size={16} color={iconColor} />;
|
||||
case "x":
|
||||
return <X size={16} color={iconColor} />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
})()}
|
||||
trailing={
|
||||
entry.hint ? (
|
||||
<Text style={styles.menuItemHint}>{entry.hint}</Text>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
@@ -549,7 +581,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const isDarkMode = useColorScheme() === "dark";
|
||||
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
|
||||
const toast = useToast();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isMobile = isCompactFormFactor();
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
|
||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||
@@ -952,7 +984,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
if (
|
||||
canPruneAgentTabs &&
|
||||
tab.target.kind === "agent" &&
|
||||
!pinnedAgentIds.has(tab.target.agentId) &&
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: tab.target.agentId,
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
@@ -1382,62 +1413,45 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||
await closeTab(tabId, async () => {
|
||||
try {
|
||||
await killTerminalAsync(terminalId);
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||
};
|
||||
});
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[WorkspaceScreen] Failed to close terminal tab ${logLabel}`, {
|
||||
terminalId,
|
||||
error,
|
||||
});
|
||||
const closeItemsPayload = await closeBulkWorkspaceTabs({
|
||||
client,
|
||||
groups,
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup: (cleanupInput) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
closeWorkspaceTabWithCleanup(cleanupInput);
|
||||
},
|
||||
logLabel,
|
||||
warn: (message, payload) => {
|
||||
console.warn(message, payload);
|
||||
},
|
||||
});
|
||||
|
||||
for (const { tabId, agentId } of groups.agentTabs) {
|
||||
if (!normalizedServerId) {
|
||||
continue;
|
||||
if (closeItemsPayload) {
|
||||
for (const terminal of closeItemsPayload.terminals) {
|
||||
if (!terminal.success) {
|
||||
continue;
|
||||
}
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId),
|
||||
};
|
||||
});
|
||||
}
|
||||
await closeTab(tabId, async () => {
|
||||
try {
|
||||
await archiveAgent({ serverId: normalizedServerId, agentId });
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[WorkspaceScreen] Failed to archive agent tab ${logLabel}`, {
|
||||
agentId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId } of groups.otherTabs) {
|
||||
await closeTab(tabId, async () => {
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({ tabId });
|
||||
}
|
||||
});
|
||||
if (normalizedServerId) {
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
serverId: normalizedServerId,
|
||||
results: closeItemsPayload.agents,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
||||
@@ -1445,10 +1459,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||
},
|
||||
[
|
||||
archiveAgent,
|
||||
client,
|
||||
closeTab,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
killTerminalAsync,
|
||||
normalizedServerId,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
@@ -1695,6 +1708,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
});
|
||||
|
||||
const activeTabDescriptor = activeTab?.descriptor ?? null;
|
||||
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
|
||||
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || typeof document === "undefined" || activeTabDescriptor) {
|
||||
return;
|
||||
@@ -1873,6 +1888,17 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
|
||||
const desktopTabRowItems = useMemo<WorkspaceDesktopTabRowItem[]>(
|
||||
() =>
|
||||
tabs.map((tab) => ({
|
||||
tab,
|
||||
isActive: tab.tabId === activeTabDescriptor?.tabId,
|
||||
isCloseHovered: hoveredCloseTabKey === tab.key,
|
||||
isClosingTab: closingTabIds.has(tab.tabId),
|
||||
})),
|
||||
[activeTabDescriptor?.tabId, closingTabIds, hoveredCloseTabKey, tabs],
|
||||
);
|
||||
|
||||
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
|
||||
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
||||
return;
|
||||
@@ -1924,6 +1950,19 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[persistenceKey, reorderWorkspaceTabsInPane],
|
||||
);
|
||||
|
||||
const handleReorderTabsInFocusedPane = useCallback(
|
||||
(nextTabs: WorkspaceTabDescriptor[]) => {
|
||||
if (!focusedPaneId) {
|
||||
return;
|
||||
}
|
||||
handleReorderTabsInPane(
|
||||
focusedPaneId,
|
||||
nextTabs.map((tab) => tab.tabId),
|
||||
);
|
||||
},
|
||||
[focusedPaneId, handleReorderTabsInPane],
|
||||
);
|
||||
|
||||
const renderSplitPaneEmptyState = useCallback(function renderSplitPaneEmptyState() {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
@@ -2179,6 +2218,32 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{shouldRenderDesktopPaneFallback ? (
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={focusedPaneId ?? undefined}
|
||||
isFocused
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={navigateToTabId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCloseTabsToLeft={handleCloseTabsToLeft}
|
||||
onCloseTabsToRight={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||
onReorderTabs={handleReorderTabsInFocusedPane}
|
||||
onNewTerminalTab={handleCreateTerminal}
|
||||
onSplitRight={() => {}}
|
||||
onSplitDown={() => {}}
|
||||
showPaneSplitActions={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
{isMobile ? (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
||||
@@ -2186,7 +2251,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
</GestureDetector>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{workspaceLayout && persistenceKey ? (
|
||||
{canRenderDesktopPaneSplits && workspaceLayout && persistenceKey ? (
|
||||
<SplitContainer
|
||||
layout={workspaceLayout}
|
||||
focusModeEnabled={isFocusModeEnabled && !isMobile}
|
||||
@@ -2422,6 +2487,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
mobileTabMenuTriggerActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuItemHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
tabsContainer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
|
||||
@@ -8,6 +8,8 @@ export type WorkspaceTabMenuEntry =
|
||||
kind: "item";
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: "copy" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x";
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
testID: string;
|
||||
@@ -106,6 +108,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
kind: "item",
|
||||
key: "copy-resume-command",
|
||||
label: "Copy resume command",
|
||||
icon: "copy",
|
||||
testID: `${menuTestIDBase}-copy-resume-command`,
|
||||
onSelect: () => {
|
||||
void onCopyResumeCommand(agentId);
|
||||
@@ -115,6 +118,8 @@ export function buildWorkspaceTabMenuEntries(
|
||||
kind: "item",
|
||||
key: "copy-agent-id",
|
||||
label: "Copy agent id",
|
||||
icon: "copy",
|
||||
hint: agentId.slice(0, 7),
|
||||
testID: `${menuTestIDBase}-copy-agent-id`,
|
||||
onSelect: () => {
|
||||
void onCopyAgentId(agentId);
|
||||
@@ -130,6 +135,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
kind: "item",
|
||||
key: "close-before",
|
||||
label: buildCloseBeforeLabel(surface),
|
||||
icon: "arrow-left-to-line",
|
||||
disabled: isFirstTab,
|
||||
testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`,
|
||||
onSelect: () => {
|
||||
@@ -140,6 +146,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
kind: "item",
|
||||
key: "close-after",
|
||||
label: buildCloseAfterLabel(surface),
|
||||
icon: "arrow-right-to-line",
|
||||
disabled: isLastTab,
|
||||
testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`,
|
||||
onSelect: () => {
|
||||
@@ -150,6 +157,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
kind: "item",
|
||||
key: "close-others",
|
||||
label: "Close other tabs",
|
||||
icon: "copy-x",
|
||||
disabled: isOnlyTab,
|
||||
testID: `${menuTestIDBase}-close-others`,
|
||||
onSelect: () => {
|
||||
@@ -160,6 +168,7 @@ export function buildWorkspaceTabMenuEntries(
|
||||
kind: "item",
|
||||
key: "close",
|
||||
label: "Close",
|
||||
icon: "x",
|
||||
testID: `${menuTestIDBase}-close`,
|
||||
onSelect: () => {
|
||||
void onCloseTab(tab.tabId);
|
||||
|
||||
@@ -175,21 +175,21 @@ const lightSemanticColors = {
|
||||
} as const;
|
||||
|
||||
const darkSemanticColors = {
|
||||
// Surfaces (layers)
|
||||
surface0: "#18181c", // App background
|
||||
surface1: "#1f1f23", // Subtle hover
|
||||
surface2: "#27272a", // Elevated: badges, inputs, sheets
|
||||
surface3: "#3f3f46", // Highest elevation
|
||||
surface4: "#52525b", // Extra emphasis
|
||||
surfaceSidebar: "#121216", // Sidebar background (darker than main)
|
||||
// Surfaces (layers) — subtle teal tint
|
||||
surface0: "#181B1A", // App background
|
||||
surface1: "#1E2120", // Subtle hover
|
||||
surface2: "#272A29", // Elevated: badges, inputs, sheets
|
||||
surface3: "#434645", // Highest elevation
|
||||
surface4: "#595B5B", // Extra emphasis
|
||||
surfaceSidebar: "#141716", // Sidebar background (darker than main)
|
||||
|
||||
// Text
|
||||
foreground: "#fafafa",
|
||||
foregroundMuted: "#a1a1aa",
|
||||
foregroundMuted: "#A1A5A4",
|
||||
|
||||
// Borders
|
||||
border: "#27272a",
|
||||
borderAccent: "#34343a",
|
||||
border: "#252B2A",
|
||||
borderAccent: "#2F3534",
|
||||
|
||||
// Brand
|
||||
accent: "#20744A",
|
||||
@@ -203,28 +203,28 @@ const darkSemanticColors = {
|
||||
successForeground: "#ffffff",
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#18181c",
|
||||
popover: "#27272a",
|
||||
background: "#181B1A",
|
||||
popover: "#272A29",
|
||||
popoverForeground: "#fafafa",
|
||||
primary: "#fafafa",
|
||||
primaryForeground: "#18181b",
|
||||
secondary: "#27272a",
|
||||
primaryForeground: "#181B1A",
|
||||
secondary: "#272A29",
|
||||
secondaryForeground: "#fafafa",
|
||||
muted: "#27272a",
|
||||
mutedForeground: "#a1a1aa",
|
||||
accentBorder: "#34343a",
|
||||
input: "#27272a",
|
||||
muted: "#272A29",
|
||||
mutedForeground: "#A1A5A4",
|
||||
accentBorder: "#2F3534",
|
||||
input: "#272A29",
|
||||
ring: "#d4d4d8",
|
||||
|
||||
terminal: {
|
||||
background: "#18181c",
|
||||
background: "#181B1A",
|
||||
foreground: "#fafafa",
|
||||
cursor: "#fafafa",
|
||||
cursorAccent: "#18181c",
|
||||
cursorAccent: "#181B1A",
|
||||
selectionBackground: "rgba(255, 255, 255, 0.2)",
|
||||
selectionForeground: "#fafafa",
|
||||
|
||||
black: "#121214",
|
||||
black: "#141716",
|
||||
red: "#ef4444",
|
||||
green: "#22c55e",
|
||||
yellow: "#f59e0b",
|
||||
@@ -233,7 +233,7 @@ const darkSemanticColors = {
|
||||
cyan: "#06b6d4",
|
||||
white: "#e4e4e7",
|
||||
|
||||
brightBlack: "#3f3f46",
|
||||
brightBlack: "#434645",
|
||||
brightRed: "#f87171",
|
||||
brightGreen: "#4ade80",
|
||||
brightYellow: "#fbbf24",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, type PointerEvent as RNPointerEvent, type ViewProps } from "react-native";
|
||||
import {
|
||||
getIsDesktopMac,
|
||||
getIsDesktop,
|
||||
getIsElectronRuntimeMac,
|
||||
getIsElectronRuntime,
|
||||
DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
DESKTOP_TRAFFIC_LIGHT_HEIGHT,
|
||||
DESKTOP_WINDOW_CONTROLS_WIDTH,
|
||||
DESKTOP_WINDOW_CONTROLS_HEIGHT,
|
||||
} from "@/constants/layout";
|
||||
import { getDesktopWindow } from "@/desktop/electron/window";
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { readFiniteScreenPoint } from "./desktop-window-drag-coordinates";
|
||||
|
||||
@@ -56,7 +56,7 @@ export function isInteractiveDesktopDragTarget(target: unknown): boolean {
|
||||
export function useDesktopDragHandlers(): DesktopDragViewProps {
|
||||
const isDragging = useRef(false);
|
||||
const lastPointerDownAt = useRef(0);
|
||||
const isActive = Platform.OS === "web" && isDesktop();
|
||||
const isActive = Platform.OS === "web" && isElectronRuntime();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
@@ -136,7 +136,7 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsDesktop()) return;
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
@@ -188,11 +188,11 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
|
||||
}, []);
|
||||
|
||||
return useMemo((): RawWindowControlsPadding => {
|
||||
if (!getIsDesktop() || isFullscreen) {
|
||||
if (!getIsElectronRuntime() || isFullscreen) {
|
||||
return { left: 0, right: 0, top: 0 };
|
||||
}
|
||||
|
||||
if (getIsDesktopMac()) {
|
||||
if (getIsElectronRuntimeMac()) {
|
||||
return {
|
||||
left: DESKTOP_TRAFFIC_LIGHT_WIDTH,
|
||||
right: 0,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsDesktopMac } from "@/constants/layout";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import type { ShortcutOs } from "@/utils/format-shortcut";
|
||||
|
||||
export function getShortcutOs(): ShortcutOs {
|
||||
if (Platform.OS !== "web") {
|
||||
return Platform.OS === "ios" ? "mac" : "non-mac";
|
||||
}
|
||||
if (getIsDesktopMac()) return "mac";
|
||||
if (getIsElectronRuntimeMac()) return "mac";
|
||||
if (typeof navigator === "undefined") return "non-mac";
|
||||
const ua = navigator.userAgent ?? "";
|
||||
const platform = (navigator as any).platform ?? "";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDesktop } from "@/desktop/host";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type {
|
||||
AudioEngine,
|
||||
AudioEngineCallbacks,
|
||||
@@ -289,7 +289,7 @@ export function createAudioEngine(
|
||||
: true;
|
||||
const currentOrigin =
|
||||
typeof window !== "undefined" && window.location ? window.location.origin : "unknown";
|
||||
const isDesktopApp = isDesktop();
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
|
||||
if (missingNavigator) {
|
||||
throw new Error("Microphone capture is not supported in this environment");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.39",
|
||||
"@getpaseo/server": "0.1.39",
|
||||
"@getpaseo/relay": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -12,8 +12,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.39",
|
||||
"@getpaseo/server": "0.1.39",
|
||||
"@getpaseo/cli": "0.1.40",
|
||||
"@getpaseo/server": "0.1.40",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -63,8 +63,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.39",
|
||||
"@getpaseo/relay": "0.1.39",
|
||||
"@getpaseo/highlight": "0.1.40",
|
||||
"@getpaseo/relay": "0.1.40",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
|
||||
@@ -1927,6 +1927,59 @@ describe("DaemonClient", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("sends close_items_request and resolves close_items_response", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.closeItems(
|
||||
{
|
||||
agentIds: ["agent-1"],
|
||||
terminalIds: ["term-1"],
|
||||
},
|
||||
"req-close-items",
|
||||
);
|
||||
|
||||
expect(JSON.parse(String(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "close_items_request",
|
||||
agentIds: ["agent-1"],
|
||||
terminalIds: ["term-1"],
|
||||
requestId: "req-close-items",
|
||||
},
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "close_items_response",
|
||||
payload: {
|
||||
agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }],
|
||||
terminals: [{ terminalId: "term-1", success: true }],
|
||||
requestId: "req-close-items",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(responsePromise).resolves.toEqual({
|
||||
agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }],
|
||||
terminals: [{ terminalId: "term-1", success: true }],
|
||||
requestId: "req-close-items",
|
||||
});
|
||||
});
|
||||
|
||||
test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
CreateTerminalResponse,
|
||||
SubscribeTerminalResponse,
|
||||
TerminalState,
|
||||
CloseItemsResponse,
|
||||
KillTerminalResponse,
|
||||
CaptureTerminalResponse,
|
||||
TerminalInput,
|
||||
@@ -237,6 +238,7 @@ type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type CreateTerminalPayload = CreateTerminalResponse["payload"];
|
||||
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
|
||||
type CloseItemsPayload = CloseItemsResponse["payload"];
|
||||
type KillTerminalPayload = KillTerminalResponse["payload"];
|
||||
type CaptureTerminalPayload = CaptureTerminalResponse["payload"];
|
||||
type ChatCreatePayload = Extract<
|
||||
@@ -2829,6 +2831,26 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async closeItems(
|
||||
input: { agentIds?: string[]; terminalIds?: string[] },
|
||||
requestId?: string,
|
||||
): Promise<CloseItemsPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "close_items_request",
|
||||
agentIds: input.agentIds ?? [],
|
||||
terminalIds: input.terminalIds ?? [],
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
responseType: "close_items_response",
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
});
|
||||
}
|
||||
|
||||
async captureTerminal(
|
||||
terminalId: string,
|
||||
options?: { start?: number; end?: number; stripAnsi?: boolean },
|
||||
|
||||
@@ -2263,6 +2263,50 @@ describe("AgentManager", () => {
|
||||
expect(attentionReasons).toEqual(["error", "error"]);
|
||||
});
|
||||
|
||||
test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000131",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Archive target",
|
||||
});
|
||||
|
||||
const lifecycles: string[] = [];
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === agent.id) {
|
||||
lifecycles.push(event.agent.lifecycle);
|
||||
}
|
||||
},
|
||||
{ agentId: agent.id, replayState: false },
|
||||
);
|
||||
|
||||
const { archivedAt } = await manager.archiveAgent(agent.id);
|
||||
const stored = await storage.get(agent.id);
|
||||
|
||||
expect(stored).toMatchObject({
|
||||
id: agent.id,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
lastStatus: "idle",
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
});
|
||||
expect(lifecycles.slice(-2)).toEqual(["idle", "closed"]);
|
||||
});
|
||||
|
||||
test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -883,11 +883,13 @@ export class AgentManager {
|
||||
await this.registry.upsert({
|
||||
...stored,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
lastStatus: normalizedStatus,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
});
|
||||
this.notifyAgentState(agentId);
|
||||
await this.closeAgent(agentId);
|
||||
|
||||
return { archivedAt };
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
|
||||
describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("listModels rejects gracefully when the codex binary does not exist", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger, {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
|
||||
},
|
||||
});
|
||||
|
||||
const uncaughtErrors: unknown[] = [];
|
||||
const onUncaught = (err: unknown) => {
|
||||
uncaughtErrors.push(err);
|
||||
};
|
||||
process.on("uncaughtException", onUncaught);
|
||||
|
||||
try {
|
||||
await expect(client.listModels()).rejects.toThrow();
|
||||
// Drain microtask queue to ensure no deferred uncaught errors
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(uncaughtErrors).toHaveLength(0);
|
||||
} finally {
|
||||
process.off("uncaughtException", onUncaught);
|
||||
}
|
||||
});
|
||||
|
||||
test("listPersistedAgents rejects gracefully when the codex binary does not exist", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger, {
|
||||
command: {
|
||||
mode: "replace",
|
||||
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
|
||||
},
|
||||
});
|
||||
|
||||
const uncaughtErrors: unknown[] = [];
|
||||
const onUncaught = (err: unknown) => {
|
||||
uncaughtErrors.push(err);
|
||||
};
|
||||
process.on("uncaughtException", onUncaught);
|
||||
|
||||
try {
|
||||
await expect(client.listPersistedAgents()).rejects.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(uncaughtErrors).toHaveLength(0);
|
||||
} finally {
|
||||
process.off("uncaughtException", onUncaught);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -511,6 +511,18 @@ class CodexAppServerClient {
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
this.logger.error({ err }, "Codex app-server child process error");
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.disposed = true;
|
||||
this.resolveExitPromise?.();
|
||||
this.resolveExitPromise = null;
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const message =
|
||||
code === 0 && !signal
|
||||
|
||||
@@ -580,6 +580,8 @@ export type OpenCodeEventTranslationState = {
|
||||
accumulatedUsage: AgentUsage;
|
||||
streamedPartKeys: Set<string>;
|
||||
emittedStructuredMessageIds: Set<string>;
|
||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||
partTypes: Map<string, string>;
|
||||
};
|
||||
|
||||
function stringifyStructuredAssistantMessage(value: unknown): string | null {
|
||||
@@ -705,11 +707,16 @@ export function translateOpenCodeEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
const partId = part.id as string | undefined;
|
||||
const messageId = part.messageID as string | undefined;
|
||||
const messageRole = messageId ? state.messageRoles.get(messageId) : undefined;
|
||||
const partType = part.type as string | undefined;
|
||||
const partTime = part.time as { start?: number; end?: number } | undefined;
|
||||
|
||||
if (partId && partType) {
|
||||
state.partTypes.set(partId, partType);
|
||||
}
|
||||
|
||||
if (partType === "text") {
|
||||
const partKey = resolvePartDedupeKey(part, "text");
|
||||
if (messageRole === "user") {
|
||||
@@ -792,6 +799,54 @@ export function translateOpenCodeEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
case "message.part.delta": {
|
||||
const deltaSessionId = props.sessionID as string | undefined;
|
||||
if (deltaSessionId !== state.sessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const deltaMessageId = props.messageID as string | undefined;
|
||||
const deltaMessageRole = deltaMessageId
|
||||
? state.messageRoles.get(deltaMessageId)
|
||||
: undefined;
|
||||
const deltaField = props.field as string | undefined;
|
||||
const deltaText = props.delta as string | undefined;
|
||||
|
||||
if (!deltaText || !deltaField) {
|
||||
break;
|
||||
}
|
||||
|
||||
const partId = props.partID as string | undefined;
|
||||
const knownPartType = partId ? state.partTypes.get(partId) : undefined;
|
||||
const isReasoning = knownPartType === "reasoning" || deltaField === "reasoning";
|
||||
|
||||
if (isReasoning) {
|
||||
const partKey = partId ? `reasoning:${partId}` : null;
|
||||
if (partKey) {
|
||||
state.streamedPartKeys.add(partKey);
|
||||
}
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: deltaText },
|
||||
});
|
||||
} else if (deltaField === "text") {
|
||||
if (deltaMessageRole === "user") {
|
||||
break;
|
||||
}
|
||||
const partKey = partId ? `text:${partId}` : null;
|
||||
if (partKey) {
|
||||
state.streamedPartKeys.add(partKey);
|
||||
}
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: deltaText },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "permission.asked": {
|
||||
const sessionId = props.sessionID as string | undefined;
|
||||
if (sessionId !== state.sessionId) {
|
||||
@@ -879,6 +934,7 @@ export function translateOpenCodeEvent(
|
||||
const sessionId = props.sessionID as string | undefined;
|
||||
if (sessionId === state.sessionId) {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
events.push({
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
@@ -892,6 +948,7 @@ export function translateOpenCodeEvent(
|
||||
const sessionId = props.sessionID as string | undefined;
|
||||
if (sessionId === state.sessionId) {
|
||||
state.streamedPartKeys.clear();
|
||||
state.partTypes.clear();
|
||||
const error = props.error as string | undefined;
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
@@ -925,6 +982,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private streamedPartKeys = new Set<string>();
|
||||
/** Tracks assistant messages already emitted from structured payloads. */
|
||||
private emittedStructuredMessageIds = new Set<string>();
|
||||
/** Tracks the type of each part by ID, learned from message.part.updated events. */
|
||||
private partTypes = new Map<string, string>();
|
||||
private availableModesCache: AgentMode[] | null = null;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private nextTurnOrdinal = 0;
|
||||
@@ -1095,7 +1154,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
const turnId = this.createTurnId();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
|
||||
void this.consumeEventStream();
|
||||
|
||||
return { turnId };
|
||||
@@ -1440,6 +1498,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
accumulatedUsage: this.accumulatedUsage,
|
||||
streamedPartKeys: this.streamedPartKeys,
|
||||
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
|
||||
partTypes: this.partTypes,
|
||||
});
|
||||
|
||||
for (const translatedEvent of translated) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
describe("OpenCode assistant message", () => {
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"assistant_message appears in live stream with opencode/big-pickle",
|
||||
async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
model: "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
const result = await session.run("Say hello back in one sentence.");
|
||||
|
||||
const assistantItems = result.timeline.filter(
|
||||
(item) => item.type === "assistant_message",
|
||||
);
|
||||
expect(assistantItems.length).toBeGreaterThan(0);
|
||||
expect(result.finalText.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"streamHistory returns assistant_message after a completed turn",
|
||||
async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
});
|
||||
|
||||
const result = await session.run("Say hello back in one sentence.");
|
||||
expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true);
|
||||
|
||||
const historyEvents: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
historyEvents.push(event);
|
||||
}
|
||||
|
||||
const historyAssistant = historyEvents.filter(
|
||||
(e) => e.type === "timeline" && e.item.type === "assistant_message",
|
||||
);
|
||||
expect(historyAssistant.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
describe("OpenCode reasoning dedup", () => {
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"reasoning content is not duplicated as assistant_message",
|
||||
async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = new OpenCodeAgentClient(logger);
|
||||
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
const streamedEvents: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
streamedEvents.push(event);
|
||||
});
|
||||
|
||||
const result = await session.run("What is 2+2? Think step by step.");
|
||||
|
||||
const reasoningTexts: string[] = [];
|
||||
const assistantTexts: string[] = [];
|
||||
|
||||
for (const event of streamedEvents) {
|
||||
if (event.type === "timeline") {
|
||||
if (event.item.type === "reasoning") {
|
||||
reasoningTexts.push(event.item.text);
|
||||
} else if (event.item.type === "assistant_message") {
|
||||
assistantTexts.push(event.item.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullReasoningText = reasoningTexts.join("");
|
||||
const fullAssistantText = assistantTexts.join("");
|
||||
|
||||
// The model should produce reasoning
|
||||
expect(reasoningTexts.length).toBeGreaterThan(0);
|
||||
expect(fullReasoningText.length).toBeGreaterThan(0);
|
||||
|
||||
// The assistant text should be the response, not the reasoning
|
||||
expect(assistantTexts.length).toBeGreaterThan(0);
|
||||
|
||||
// Reasoning text must NOT appear in the assistant text
|
||||
const reasoningPrefix = fullReasoningText.slice(0, 50);
|
||||
if (reasoningPrefix.length > 10) {
|
||||
expect(fullAssistantText).not.toContain(reasoningPrefix);
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
|
||||
accumulatedUsage: {},
|
||||
streamedPartKeys: new Set(),
|
||||
emittedStructuredMessageIds: new Set(),
|
||||
partTypes: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +171,263 @@ describe("translateOpenCodeEvent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits assistant text from message.part.delta events", () => {
|
||||
const state = createState();
|
||||
|
||||
// Register message role
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg-d1", sessionID: "session-1", role: "assistant" },
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
// OpenCode v2 can send streaming text as message.part.delta
|
||||
const delta1 = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-d1",
|
||||
partID: "part-d1",
|
||||
field: "text",
|
||||
delta: "hey! ",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const delta2 = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-d1",
|
||||
partID: "part-d1",
|
||||
field: "text",
|
||||
delta: "what's up?",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect([...delta1, ...delta2]).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: "hey! " },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: "what's up?" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits reasoning from message.part.delta events", () => {
|
||||
const state = createState();
|
||||
|
||||
const delta = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-r1",
|
||||
partID: "rp-1",
|
||||
field: "reasoning",
|
||||
delta: "The user said hello.",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(delta).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: "The user said hello." },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits reasoning (not assistant_message) when delta field is 'text' for a known reasoning part", () => {
|
||||
const state = createState();
|
||||
|
||||
// Part created as reasoning (message.part.updated fires before deltas)
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "rp-2",
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-r2",
|
||||
type: "reasoning",
|
||||
time: { start: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
// Deltas arrive with field="text" (the field name on ReasoningPart)
|
||||
const delta1 = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-r2",
|
||||
partID: "rp-2",
|
||||
field: "text",
|
||||
delta: "Thinking about this...",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
// Completed reasoning part should be deduped
|
||||
const completed = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "rp-2",
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-r2",
|
||||
type: "reasoning",
|
||||
text: "Thinking about this...",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const allEvents = [...delta1, ...completed];
|
||||
|
||||
expect(allEvents).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "reasoning", text: "Thinking about this..." },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates when message.part.delta is followed by completed message.part.updated", () => {
|
||||
const state = createState();
|
||||
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg-dd1", sessionID: "session-1", role: "assistant" },
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
// Stream via delta event
|
||||
const streamed = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-dd1",
|
||||
partID: "part-dd1",
|
||||
field: "text",
|
||||
delta: "hello there",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
// Completed part echoes the same text
|
||||
const completed = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "part-dd1",
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-dd1",
|
||||
type: "text",
|
||||
text: "hello there",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const all = [...streamed, ...completed].filter(
|
||||
(e) => e.type === "timeline" && e.item.type === "assistant_message",
|
||||
);
|
||||
// Only the delta, not the completed echo
|
||||
expect(all).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "assistant_message", text: "hello there" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores message.part.delta for wrong session", () => {
|
||||
const state = createState();
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "other-session",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
field: "text",
|
||||
delta: "should not appear",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores message.part.delta for user messages", () => {
|
||||
const state = createState();
|
||||
|
||||
// Register as user message
|
||||
translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "msg-u1", sessionID: "session-1", role: "user" },
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-u1",
|
||||
partID: "part-u1",
|
||||
field: "text",
|
||||
delta: "user typing",
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits structured assistant output when schema mode completes without text parts", () => {
|
||||
const state = createState();
|
||||
|
||||
|
||||
134
packages/server/src/server/checkout-diff-manager.test.ts
Normal file
134
packages/server/src/server/checkout-diff-manager.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } =
|
||||
vi.hoisted(() => {
|
||||
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
|
||||
return {
|
||||
execMock: vi.fn((_command: string, _options: unknown, callback: (error: null, result: { stdout: string; stderr: string }) => void) => {
|
||||
callback(null, { stdout: "/tmp/repo\n", stderr: "" });
|
||||
}),
|
||||
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
||||
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
|
||||
readdirMock: vi.fn(async (directory: string) => {
|
||||
if (directory === "/tmp/repo") {
|
||||
return [
|
||||
{ name: "packages", isDirectory: () => true },
|
||||
{ name: ".git", isDirectory: () => true },
|
||||
{ name: "README.md", isDirectory: () => false },
|
||||
];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages")) {
|
||||
return [
|
||||
{ name: "server", isDirectory: () => true },
|
||||
{ name: "app", isDirectory: () => true },
|
||||
];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server")) {
|
||||
return [{ name: "src", isDirectory: () => true }];
|
||||
}
|
||||
if (directory === path.join("/tmp/repo", "packages", "server", "src")) {
|
||||
return [{ name: "server", isDirectory: () => true }];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
watchCalls: hoistedWatchCalls,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("child_process", () => ({
|
||||
exec: execMock,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
return {
|
||||
...actual,
|
||||
readdir: readdirMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn((watchPath: string) => {
|
||||
const close = vi.fn();
|
||||
watchCalls.push({ path: watchPath, close });
|
||||
return {
|
||||
close,
|
||||
on: vi.fn().mockReturnThis(),
|
||||
} as any;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/checkout-git.js", () => ({
|
||||
getCheckoutDiff: getCheckoutDiffMock,
|
||||
}));
|
||||
|
||||
vi.mock("./checkout-git-utils.js", () => ({
|
||||
READ_ONLY_GIT_ENV: {},
|
||||
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
||||
toCheckoutError: vi.fn((error: unknown) => ({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
|
||||
describe("CheckoutDiffManager Linux watchers", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
watchCalls.length = 0;
|
||||
execMock.mockClear();
|
||||
getCheckoutDiffMock.mockClear();
|
||||
resolveCheckoutGitDirMock.mockClear();
|
||||
readdirMock.mockClear();
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "linux",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
test("watches nested repository directories on Linux", async () => {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const manager = new CheckoutDiffManager({
|
||||
logger: logger as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
});
|
||||
|
||||
const subscription = await manager.subscribe(
|
||||
{
|
||||
cwd: path.join("/tmp/repo", "packages", "server"),
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(subscription.initial.error).toBeNull();
|
||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||
"/tmp/repo",
|
||||
"/tmp/repo/.git",
|
||||
"/tmp/repo/packages",
|
||||
"/tmp/repo/packages/app",
|
||||
"/tmp/repo/packages/server",
|
||||
"/tmp/repo/packages/server/src",
|
||||
"/tmp/repo/packages/server/src/server",
|
||||
]);
|
||||
|
||||
subscription.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import type pino from "pino";
|
||||
@@ -39,6 +41,10 @@ type CheckoutDiffWatchTarget = {
|
||||
refreshQueued: boolean;
|
||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||
latestFingerprint: string | null;
|
||||
watchedPaths: Set<string>;
|
||||
repoWatchPath: string | null;
|
||||
linuxTreeRefreshPromise: Promise<void> | null;
|
||||
linuxTreeRefreshQueued: boolean;
|
||||
};
|
||||
|
||||
export class CheckoutDiffManager {
|
||||
@@ -145,6 +151,7 @@ export class CheckoutDiffManager {
|
||||
watcher.close();
|
||||
}
|
||||
target.watchers = [];
|
||||
target.watchedPaths.clear();
|
||||
target.listeners.clear();
|
||||
}
|
||||
|
||||
@@ -276,9 +283,14 @@ export class CheckoutDiffManager {
|
||||
refreshQueued: false,
|
||||
latestPayload: null,
|
||||
latestFingerprint: null,
|
||||
watchedPaths: new Set<string>(),
|
||||
repoWatchPath: null,
|
||||
linuxTreeRefreshPromise: null,
|
||||
linuxTreeRefreshQueued: false,
|
||||
};
|
||||
|
||||
const repoWatchPath = watchRoot ?? cwd;
|
||||
target.repoWatchPath = repoWatchPath;
|
||||
const watchPaths = new Set<string>([repoWatchPath]);
|
||||
const gitDir = await resolveCheckoutGitDir(cwd);
|
||||
if (gitDir) {
|
||||
@@ -287,52 +299,15 @@ export class CheckoutDiffManager {
|
||||
|
||||
let hasRecursiveRepoCoverage = false;
|
||||
const allowRecursiveRepoWatch = process.platform !== "linux";
|
||||
if (process.platform === "linux") {
|
||||
hasRecursiveRepoCoverage = await this.ensureLinuxRepoTreeWatchers(target, repoWatchPath);
|
||||
}
|
||||
for (const watchPath of watchPaths) {
|
||||
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
|
||||
const createWatcher = (recursive: boolean): FSWatcher =>
|
||||
watch(watchPath, { recursive }, () => {
|
||||
this.scheduleTargetRefresh(target);
|
||||
});
|
||||
|
||||
let watcher: FSWatcher | null = null;
|
||||
let watcherIsRecursive = false;
|
||||
try {
|
||||
if (shouldTryRecursive) {
|
||||
watcher = createWatcher(true);
|
||||
watcherIsRecursive = true;
|
||||
} else {
|
||||
watcher = createWatcher(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldTryRecursive) {
|
||||
try {
|
||||
watcher = createWatcher(false);
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Checkout diff recursive watch unavailable; using non-recursive fallback",
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
this.logger.warn(
|
||||
{ err: fallbackError, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!watcher) {
|
||||
if (process.platform === "linux" && watchPath === repoWatchPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
|
||||
});
|
||||
target.watchers.push(watcher);
|
||||
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch;
|
||||
const watcherIsRecursive = this.addWatcher(target, watchPath, shouldTryRecursive);
|
||||
if (watchPath === repoWatchPath && watcherIsRecursive) {
|
||||
hasRecursiveRepoCoverage = true;
|
||||
}
|
||||
@@ -358,4 +333,148 @@ export class CheckoutDiffManager {
|
||||
this.targets.set(targetKey, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
private addWatcher(
|
||||
target: CheckoutDiffWatchTarget,
|
||||
watchPath: string,
|
||||
shouldTryRecursive: boolean,
|
||||
): boolean {
|
||||
if (target.watchedPaths.has(watchPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { cwd, compare } = target;
|
||||
const onChange = () => {
|
||||
if (process.platform === "linux" && target.repoWatchPath) {
|
||||
void this.refreshLinuxRepoTreeWatchers(target);
|
||||
}
|
||||
this.scheduleTargetRefresh(target);
|
||||
};
|
||||
const createWatcher = (recursive: boolean): FSWatcher =>
|
||||
watch(watchPath, { recursive }, () => {
|
||||
onChange();
|
||||
});
|
||||
|
||||
let watcher: FSWatcher | null = null;
|
||||
let watcherIsRecursive = false;
|
||||
try {
|
||||
if (shouldTryRecursive) {
|
||||
watcher = createWatcher(true);
|
||||
watcherIsRecursive = true;
|
||||
} else {
|
||||
watcher = createWatcher(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldTryRecursive) {
|
||||
try {
|
||||
watcher = createWatcher(false);
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Checkout diff recursive watch unavailable; using non-recursive fallback",
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
this.logger.warn(
|
||||
{ err: fallbackError, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
{ err: error, watchPath, cwd, compare },
|
||||
"Failed to start checkout diff watcher",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!watcher) {
|
||||
return false;
|
||||
}
|
||||
|
||||
watcher.on("error", (error) => {
|
||||
this.logger.warn({ err: error, watchPath, cwd, compare }, "Checkout diff watcher error");
|
||||
});
|
||||
target.watchers.push(watcher);
|
||||
target.watchedPaths.add(watchPath);
|
||||
return watcherIsRecursive;
|
||||
}
|
||||
|
||||
private async ensureLinuxRepoTreeWatchers(
|
||||
target: CheckoutDiffWatchTarget,
|
||||
rootPath: string,
|
||||
): Promise<boolean> {
|
||||
const directories = await this.listLinuxWatchDirectories(rootPath);
|
||||
let complete = true;
|
||||
for (const directory of directories) {
|
||||
const watcherWasRecursive = this.addWatcher(target, directory, false);
|
||||
if (!watcherWasRecursive && !target.watchedPaths.has(directory)) {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
return complete && target.watchedPaths.has(rootPath);
|
||||
}
|
||||
|
||||
private async refreshLinuxRepoTreeWatchers(target: CheckoutDiffWatchTarget): Promise<void> {
|
||||
if (process.platform !== "linux" || !target.repoWatchPath) {
|
||||
return;
|
||||
}
|
||||
const rootPath = target.repoWatchPath;
|
||||
if (target.linuxTreeRefreshPromise) {
|
||||
target.linuxTreeRefreshQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
target.linuxTreeRefreshPromise = (async () => {
|
||||
do {
|
||||
target.linuxTreeRefreshQueued = false;
|
||||
try {
|
||||
await this.ensureLinuxRepoTreeWatchers(target, rootPath);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{
|
||||
err: error,
|
||||
cwd: target.cwd,
|
||||
compare: target.compare,
|
||||
rootPath,
|
||||
},
|
||||
"Failed to refresh Linux checkout diff tree watchers",
|
||||
);
|
||||
}
|
||||
} while (target.linuxTreeRefreshQueued);
|
||||
})();
|
||||
|
||||
try {
|
||||
await target.linuxTreeRefreshPromise;
|
||||
} finally {
|
||||
target.linuxTreeRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async listLinuxWatchDirectories(rootPath: string): Promise<string[]> {
|
||||
const directories: string[] = [];
|
||||
const pending = [rootPath];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
directories.push(directory);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === ".git") {
|
||||
continue;
|
||||
}
|
||||
pending.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,16 @@ async function main() {
|
||||
|
||||
process.on("SIGTERM", () => beginShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => beginShutdown("SIGINT"));
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
logger.fatal({ err }, "Uncaught exception — daemon crashing");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.fatal({ err: reason }, "Unhandled promise rejection — daemon crashing");
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type SubscribeTerminalRequest,
|
||||
type UnsubscribeTerminalRequest,
|
||||
type TerminalInput,
|
||||
type CloseItemsRequest,
|
||||
type KillTerminalRequest,
|
||||
type CaptureTerminalRequest,
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
@@ -979,6 +980,16 @@ export class Session {
|
||||
const storedRecord = await this.agentStorage.get(agent.id);
|
||||
const title = storedRecord?.title ?? storedRecord?.config?.title ?? null;
|
||||
const payload = toAgentPayload(agent, { title });
|
||||
const storedUpdatedAt = storedRecord
|
||||
? this.resolveStoredAgentPayloadUpdatedAt(storedRecord)
|
||||
: null;
|
||||
if (storedUpdatedAt) {
|
||||
const liveUpdatedAt = Date.parse(payload.updatedAt);
|
||||
const persistedUpdatedAt = Date.parse(storedUpdatedAt);
|
||||
if (!Number.isNaN(persistedUpdatedAt) && (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt)) {
|
||||
payload.updatedAt = storedUpdatedAt;
|
||||
}
|
||||
}
|
||||
payload.archivedAt = storedRecord?.archivedAt ?? null;
|
||||
return payload;
|
||||
}
|
||||
@@ -994,7 +1005,7 @@ export class Session {
|
||||
} as const;
|
||||
|
||||
const createdAt = new Date(record.createdAt);
|
||||
const updatedAt = new Date(record.lastActivityAt ?? record.updatedAt);
|
||||
const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record));
|
||||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
||||
|
||||
const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id);
|
||||
@@ -1045,6 +1056,23 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
|
||||
const timestamps = [record.updatedAt, record.lastActivityAt]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.map((value) => ({
|
||||
raw: value,
|
||||
parsed: Date.parse(value),
|
||||
}))
|
||||
.filter((value) => !Number.isNaN(value.parsed));
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
return record.updatedAt;
|
||||
}
|
||||
|
||||
timestamps.sort((a, b) => b.parsed - a.parsed);
|
||||
return timestamps[0].raw;
|
||||
}
|
||||
|
||||
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
@@ -1447,6 +1475,10 @@ export class Session {
|
||||
await this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
|
||||
case "close_items_request":
|
||||
await this.handleCloseItemsRequest(msg);
|
||||
break;
|
||||
|
||||
case "update_agent_request":
|
||||
await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
|
||||
break;
|
||||
@@ -1959,74 +1991,97 @@ export class Session {
|
||||
}
|
||||
|
||||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`);
|
||||
|
||||
const { archivedAt } = await this.archiveAgentState(agentId);
|
||||
|
||||
const result = await this.archiveAgentForClose(agentId);
|
||||
this.emit({
|
||||
type: "agent_archived",
|
||||
payload: {
|
||||
agentId,
|
||||
archivedAt,
|
||||
agentId: result.agentId,
|
||||
archivedAt: result.archivedAt,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async archiveAgentState(agentId: string): Promise<{
|
||||
archivedAt: string;
|
||||
archivedRecord: StoredAgentRecord;
|
||||
}> {
|
||||
private async archiveAgentForClose(
|
||||
agentId: string,
|
||||
): Promise<{ agentId: string; archivedAt: string }> {
|
||||
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`);
|
||||
|
||||
if (this.agentManager.getAgent(agentId)) {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
await this.agentManager.clearAgentAttention(agentId).catch(() => undefined);
|
||||
}
|
||||
|
||||
const archivedAt = new Date().toISOString();
|
||||
const existing = await this.agentStorage.get(agentId);
|
||||
let archivedRecord: StoredAgentRecord | null = existing;
|
||||
const { archivedAt } = await this.agentManager.archiveAgent(agentId);
|
||||
const archivedRecord = await this.agentStorage.get(agentId);
|
||||
if (!archivedRecord) {
|
||||
const liveAgent = this.agentManager.getAgent(agentId);
|
||||
if (!liveAgent) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
throw new Error(`Agent not found in storage after archive: ${agentId}`);
|
||||
}
|
||||
|
||||
await this.agentStorage.applySnapshot(liveAgent, {
|
||||
internal: liveAgent.internal,
|
||||
if (this.agentUpdatesSubscription) {
|
||||
const payload = this.buildStoredAgentPayload(archivedRecord);
|
||||
const project = await this.buildProjectPlacement(payload.cwd);
|
||||
const matches = this.matchesAgentFilter({
|
||||
agent: payload,
|
||||
project,
|
||||
filter: this.agentUpdatesSubscription.filter,
|
||||
});
|
||||
archivedRecord = await this.agentStorage.get(agentId);
|
||||
if (!archivedRecord) {
|
||||
throw new Error(`Agent not found in storage after snapshot: ${agentId}`);
|
||||
}
|
||||
this.bufferOrEmitAgentUpdate(
|
||||
this.agentUpdatesSubscription,
|
||||
matches
|
||||
? {
|
||||
kind: "upsert",
|
||||
agent: payload,
|
||||
project,
|
||||
}
|
||||
: {
|
||||
kind: "remove",
|
||||
agentId,
|
||||
},
|
||||
);
|
||||
await this.emitWorkspaceUpdateForCwd(payload.cwd);
|
||||
}
|
||||
|
||||
const normalizedStatus =
|
||||
archivedRecord.lastStatus === "running" || archivedRecord.lastStatus === "initializing"
|
||||
? "idle"
|
||||
: archivedRecord.lastStatus;
|
||||
return { agentId, archivedAt };
|
||||
}
|
||||
|
||||
const nextRecord: StoredAgentRecord = {
|
||||
...archivedRecord,
|
||||
archivedAt,
|
||||
lastStatus: normalizedStatus,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
await this.agentStorage.upsert(nextRecord);
|
||||
|
||||
// Unload the agent from memory — the storage record is the source of truth now.
|
||||
// This tears down the provider session and drops the hydrated timeline,
|
||||
// freeing memory. ensureAgentLoaded will re-initialize if needed later.
|
||||
if (this.agentManager.getAgent(agentId)) {
|
||||
private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise<void> {
|
||||
const agents = [];
|
||||
for (const agentId of msg.agentIds) {
|
||||
try {
|
||||
await this.agentManager.closeAgent(agentId);
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn({ err: error, agentId }, "Failed to close agent during archive");
|
||||
agents.push(await this.archiveAgentForClose(agentId));
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, agentId, requestId: msg.requestId },
|
||||
"Failed to archive agent during close_items batch",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { archivedAt, archivedRecord: nextRecord };
|
||||
const terminals = [];
|
||||
for (const terminalId of msg.terminalIds) {
|
||||
try {
|
||||
terminals.push(this.killTerminalForClose(terminalId));
|
||||
} catch (error: any) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, terminalId, requestId: msg.requestId },
|
||||
"Failed to kill terminal during close_items batch",
|
||||
);
|
||||
terminals.push({
|
||||
terminalId,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "close_items_response",
|
||||
payload: {
|
||||
agents,
|
||||
terminals,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async unarchiveAgentState(agentId: string): Promise<boolean> {
|
||||
@@ -2034,9 +2089,11 @@ export class Session {
|
||||
if (!record || !record.archivedAt) {
|
||||
return false;
|
||||
}
|
||||
const updatedAt = new Date().toISOString();
|
||||
await this.agentStorage.upsert({
|
||||
...record,
|
||||
archivedAt: null,
|
||||
updatedAt,
|
||||
});
|
||||
this.agentManager.notifyAgentState(agentId);
|
||||
return true;
|
||||
@@ -7485,29 +7542,32 @@ export class Session {
|
||||
}
|
||||
|
||||
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
|
||||
if (!this.terminalManager) {
|
||||
this.emit({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
success: false,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.killTrackedTerminal(msg.terminalId, { emitExit: true });
|
||||
const result = this.killTerminalForClose(msg.terminalId);
|
||||
this.emit({
|
||||
type: "kill_terminal_response",
|
||||
payload: {
|
||||
terminalId: msg.terminalId,
|
||||
success: true,
|
||||
terminalId: result.terminalId,
|
||||
success: result.success,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private killTerminalForClose(terminalId: string): { terminalId: string; success: boolean } {
|
||||
if (!this.terminalManager) {
|
||||
return {
|
||||
terminalId,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
this.killTrackedTerminal(terminalId, { emitExit: true });
|
||||
return {
|
||||
terminalId,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleCaptureTerminalRequest(msg: CaptureTerminalRequest): Promise<void> {
|
||||
if (!this.terminalManager) {
|
||||
this.emit({
|
||||
|
||||
@@ -82,6 +82,9 @@ function createSessionForWorkspaceTests(): Session {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
@@ -130,6 +133,443 @@ function createSessionForWorkspaceTests(): Session {
|
||||
}
|
||||
|
||||
describe("workspace aggregation", () => {
|
||||
test("archive emits an authoritative agent_update upsert for subscribed clients", async () => {
|
||||
const emitted: Array<{ type: string; payload: any }> = [];
|
||||
const archivedRecord = {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
createdAt: "2026-03-30T15:00:00.000Z",
|
||||
updatedAt: "2026-03-30T15:00:00.000Z",
|
||||
lastActivityAt: "2026-03-30T15:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
runtimeInfo: null,
|
||||
config: {
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
persistence: null,
|
||||
title: "Archive me",
|
||||
labels: {},
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage: (message) => emitted.push(message as any),
|
||||
logger: logger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
archiveAgent: async () => {
|
||||
const archivedAt = new Date().toISOString();
|
||||
Object.assign(archivedRecord, {
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
});
|
||||
return { archivedAt };
|
||||
},
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [archivedRecord],
|
||||
get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null),
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
checkoutDiffManager: {
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}) as any;
|
||||
|
||||
session.agentUpdatesSubscription = {
|
||||
subscriptionId: "sub-agents",
|
||||
filter: { includeArchived: true },
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByAgentId: new Map(),
|
||||
};
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: "repo",
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleArchiveAgentRequest("agent-1", "req-archive");
|
||||
|
||||
const update = emitted.find((message) => message.type === "agent_update");
|
||||
expect(update?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
archivedAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
expect(
|
||||
emitted.find((message) => message.type === "agent_archived")?.payload,
|
||||
).toMatchObject({
|
||||
agentId: "agent-1",
|
||||
archivedAt: expect.any(String),
|
||||
requestId: "req-archive",
|
||||
});
|
||||
});
|
||||
|
||||
test("close_items_request archives agents and kills terminals in one batch", async () => {
|
||||
const emitted: Array<{ type: string; payload: any }> = [];
|
||||
const archivedAt = "2026-04-01T00:00:00.000Z";
|
||||
const sessionLogger = {
|
||||
child: () => sessionLogger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const archivedRecord = {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/repo",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
title: null,
|
||||
labels: {},
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
};
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage: (message) => emitted.push(message as any),
|
||||
logger: sessionLogger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null),
|
||||
archiveAgent: async () => ({ archivedAt }),
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async (agentId: string) => {
|
||||
if (agentId !== "agent-1") {
|
||||
return null;
|
||||
}
|
||||
archivedRecord.archivedAt = archivedAt;
|
||||
archivedRecord.updatedAt = archivedAt;
|
||||
return archivedRecord;
|
||||
},
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
checkoutDiffManager: {
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: "/tmp", files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: {
|
||||
killTerminal: vi.fn(),
|
||||
subscribeTerminalsChanged: () => () => {},
|
||||
} as any,
|
||||
}) as any;
|
||||
|
||||
session.agentUpdatesSubscription = {
|
||||
subscriptionId: "sub-agents",
|
||||
filter: { includeArchived: true },
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByAgentId: new Map(),
|
||||
};
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: "repo",
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
session.interruptAgentIfRunning = vi.fn();
|
||||
|
||||
await session.handleMessage({
|
||||
type: "close_items_request",
|
||||
agentIds: ["agent-1"],
|
||||
terminalIds: ["term-1"],
|
||||
requestId: "req-close-items",
|
||||
});
|
||||
|
||||
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
|
||||
expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1");
|
||||
expect(
|
||||
emitted.find((message) => message.type === "close_items_response")?.payload,
|
||||
).toEqual({
|
||||
agents: [{ agentId: "agent-1", archivedAt }],
|
||||
terminals: [{ terminalId: "term-1", success: true }],
|
||||
requestId: "req-close-items",
|
||||
});
|
||||
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
archivedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("close_items_request continues after an archive failure", async () => {
|
||||
const emitted: Array<{ type: string; payload: any }> = [];
|
||||
const sessionLogger = {
|
||||
child: () => sessionLogger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const archivedAt = "2026-04-01T00:00:00.000Z";
|
||||
const goodRecord = {
|
||||
...makeAgent({
|
||||
id: "agent-good",
|
||||
cwd: "/tmp/repo",
|
||||
status: "idle",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
archivedAt: null as string | null,
|
||||
};
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage: (message) => emitted.push(message as any),
|
||||
logger: sessionLogger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: (agentId: string) =>
|
||||
agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null,
|
||||
archiveAgent: async (agentId: string) => {
|
||||
if (agentId === "agent-bad") {
|
||||
throw new Error("archive failed");
|
||||
}
|
||||
return { archivedAt };
|
||||
},
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async (agentId: string) => {
|
||||
if (agentId !== "agent-good") {
|
||||
return null;
|
||||
}
|
||||
goodRecord.archivedAt = archivedAt;
|
||||
goodRecord.updatedAt = archivedAt;
|
||||
return goodRecord;
|
||||
},
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
checkoutDiffManager: {
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: "/tmp", files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: {
|
||||
killTerminal: vi.fn(),
|
||||
subscribeTerminalsChanged: () => () => {},
|
||||
} as any,
|
||||
}) as any;
|
||||
|
||||
session.agentUpdatesSubscription = {
|
||||
subscriptionId: "sub-agents",
|
||||
filter: { includeArchived: true },
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByAgentId: new Map(),
|
||||
};
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: "repo",
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
session.interruptAgentIfRunning = vi.fn();
|
||||
|
||||
await session.handleMessage({
|
||||
type: "close_items_request",
|
||||
agentIds: ["agent-bad", "agent-good"],
|
||||
terminalIds: ["term-1"],
|
||||
requestId: "req-close-best-effort",
|
||||
});
|
||||
|
||||
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-bad");
|
||||
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-good");
|
||||
expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1");
|
||||
expect(
|
||||
emitted.find((message) => message.type === "close_items_response")?.payload,
|
||||
).toEqual({
|
||||
agents: [{ agentId: "agent-good", archivedAt }],
|
||||
terminals: [{ terminalId: "term-1", success: true }],
|
||||
requestId: "req-close-best-effort",
|
||||
});
|
||||
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
agent: {
|
||||
id: "agent-good",
|
||||
archivedAt,
|
||||
},
|
||||
});
|
||||
expect(sessionLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("non-git workspace uses deterministic directory name and no unknown branch fallback", async () => {
|
||||
const session = createSessionForWorkspaceTests() as any;
|
||||
session.workspaceRegistry.list = async () => [
|
||||
|
||||
@@ -525,6 +525,13 @@ export const ArchiveAgentRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CloseItemsRequestMessageSchema = z.object({
|
||||
type: z.literal("close_items_request"),
|
||||
agentIds: z.array(z.string()).default([]),
|
||||
terminalIds: z.array(z.string()).default([]),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const UpdateAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("update_agent_request"),
|
||||
agentId: z.string(),
|
||||
@@ -1190,6 +1197,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchAgentRequestMessageSchema,
|
||||
DeleteAgentRequestMessageSchema,
|
||||
ArchiveAgentRequestMessageSchema,
|
||||
CloseItemsRequestMessageSchema,
|
||||
UpdateAgentRequestMessageSchema,
|
||||
SetVoiceModeMessageSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
@@ -1805,6 +1813,25 @@ export const AgentArchivedMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const CloseItemsAgentResultSchema = z.object({
|
||||
agentId: z.string(),
|
||||
archivedAt: z.string(),
|
||||
});
|
||||
|
||||
const CloseItemsTerminalResultSchema = z.object({
|
||||
terminalId: z.string(),
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
export const CloseItemsResponseSchema = z.object({
|
||||
type: z.literal("close_items_response"),
|
||||
payload: z.object({
|
||||
agents: z.array(CloseItemsAgentResultSchema),
|
||||
terminals: z.array(CloseItemsTerminalResultSchema),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const AheadBehindSchema = z.object({
|
||||
ahead: z.number(),
|
||||
behind: z.number(),
|
||||
@@ -2270,6 +2297,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
AgentDeletedMessageSchema,
|
||||
AgentArchivedMessageSchema,
|
||||
CloseItemsResponseSchema,
|
||||
CheckoutStatusResponseSchema,
|
||||
SubscribeCheckoutDiffResponseSchema,
|
||||
CheckoutDiffUpdateSchema,
|
||||
@@ -2487,6 +2515,8 @@ export type TerminalCell = z.infer<typeof TerminalCellSchema>;
|
||||
export type TerminalCursorStyle = z.infer<typeof TerminalCursorStyleSchema>;
|
||||
export type TerminalCursor = z.infer<typeof TerminalCursorSchema>;
|
||||
export type TerminalState = z.infer<typeof TerminalStateSchema>;
|
||||
export type CloseItemsRequest = z.infer<typeof CloseItemsRequestMessageSchema>;
|
||||
export type CloseItemsResponse = z.infer<typeof CloseItemsResponseSchema>;
|
||||
export type KillTerminalRequest = z.infer<typeof KillTerminalRequestSchema>;
|
||||
export type KillTerminalResponse = z.infer<typeof KillTerminalResponseSchema>;
|
||||
export type CaptureTerminalRequest = z.infer<typeof CaptureTerminalRequestSchema>;
|
||||
|
||||
@@ -7,6 +7,7 @@ const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".
|
||||
dotenv.config({ path: path.resolve(serverRoot, ".env.test"), override: true });
|
||||
dotenv.config({ path: path.resolve(serverRoot, "../.env") });
|
||||
|
||||
process.env.PASEO_SUPERVISED = "0";
|
||||
process.env.GIT_TERMINAL_PROMPT = "0";
|
||||
process.env.GIT_SSH_COMMAND = "ssh -oBatchMode=yes";
|
||||
process.env.SSH_ASKPASS = "/usr/bin/false";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.39",
|
||||
"version": "0.1.40",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -54,7 +54,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
|
||||
<PhoneShowcase />
|
||||
|
||||
{/* Content section */}
|
||||
<div className="bg-black">
|
||||
<div className="bg-background">
|
||||
<main className="p-6 md:p-20 md:pt-40 max-w-5xl mx-auto">
|
||||
<div className="space-y-24">
|
||||
<MultiProviderSection />
|
||||
|
||||
@@ -6,7 +6,7 @@ export const Route = createRootRoute({
|
||||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ name: "theme-color", content: "#0a0a0a" },
|
||||
{ name: "theme-color", content: "#101615" },
|
||||
{ property: "og:site_name", content: "Paseo" },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:image", content: "https://paseo.sh/og-image.png" },
|
||||
|
||||
@@ -187,27 +187,27 @@
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-background: #0a0a0a;
|
||||
--color-background: #101615;
|
||||
--color-foreground: #fafafa;
|
||||
--color-muted: #262626;
|
||||
--color-muted-foreground: #b6b6b6;
|
||||
--color-card: #171717;
|
||||
--color-border: #27272a;
|
||||
--color-muted: #252B2A;
|
||||
--color-muted-foreground: #A8ADAC;
|
||||
--color-card: #171D1C;
|
||||
--color-border: #252B2A;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #27272a;
|
||||
--color-secondary: #252B2A;
|
||||
--color-secondary-foreground: #fafafa;
|
||||
|
||||
/* Mockup palette (from app unistyles theme) */
|
||||
--color-mock-surface0: #18181c;
|
||||
--color-mock-surface1: #1f1f23;
|
||||
--color-mock-surface2: #27272a;
|
||||
--color-mock-surface3: #3f3f46;
|
||||
--color-mock-sidebar: #121216;
|
||||
--color-mock-surface0: #181B1A;
|
||||
--color-mock-surface1: #1E2120;
|
||||
--color-mock-surface2: #272A29;
|
||||
--color-mock-surface3: #434645;
|
||||
--color-mock-sidebar: #141716;
|
||||
--color-mock-fg: #fafafa;
|
||||
--color-mock-fg-muted: #a1a1aa;
|
||||
--color-mock-border: #27272a;
|
||||
--color-mock-border-accent: #34343a;
|
||||
--color-mock-fg-muted: #A1A5A4;
|
||||
--color-mock-border: #272A29;
|
||||
--color-mock-border-accent: #313433;
|
||||
--color-mock-accent: #20744A;
|
||||
--color-mock-green: #22c55e;
|
||||
--color-mock-green-400: #4ade80;
|
||||
|
||||
Reference in New Issue
Block a user