Compare commits
44 Commits
fix-window
...
refactor-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c83dea127 | ||
|
|
96b39d4c56 | ||
|
|
13adfee3a4 | ||
|
|
5387a16bb6 | ||
|
|
6d7848059e | ||
|
|
a324ac5d71 | ||
|
|
6f04d33b18 | ||
|
|
1526532ff4 | ||
|
|
dbff649d88 | ||
|
|
a6ad4ff430 | ||
|
|
165fc11955 | ||
|
|
426b81e5d2 | ||
|
|
2c90498cb8 | ||
|
|
c42d48a8ff | ||
|
|
54b1f524b7 | ||
|
|
85db9711b3 | ||
|
|
3c2a98b09b | ||
|
|
9e3ebd9665 | ||
|
|
a1606ea515 | ||
|
|
fa8fab75aa | ||
|
|
a3f350a7dc | ||
|
|
6a8f507d10 | ||
|
|
ea0bd47c1d | ||
|
|
f4dcb3954a | ||
|
|
7fc4dd1e86 | ||
|
|
78bb56d90d | ||
|
|
a788064e32 | ||
|
|
74f0b9da56 | ||
|
|
aa01f23ab3 | ||
|
|
ae3f1bb5df | ||
|
|
2064bac918 | ||
|
|
fa4294a0e1 | ||
|
|
c993858898 | ||
|
|
6e04153b95 | ||
|
|
a6f0d79212 | ||
|
|
4b2e0357aa | ||
|
|
acf88f4aca | ||
|
|
412c66ce4b | ||
|
|
cb906d4223 | ||
|
|
bf0d3b9786 | ||
|
|
7f37255f88 | ||
|
|
5c03e25232 | ||
|
|
b440f44111 | ||
|
|
d433e1a362 |
3
.github/workflows/ci.yml
vendored
@@ -120,12 +120,11 @@ jobs:
|
||||
working-directory: packages/server
|
||||
run: >
|
||||
npx vitest run
|
||||
src/utils/executable.probe.test.ts
|
||||
src/utils/executable.test.ts
|
||||
src/utils/spawn.launch-regression.test.ts
|
||||
src/utils/spawn.percent-escape.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/tree-kill.test.ts
|
||||
src/utils/process-tree.test.ts
|
||||
src/utils/run-git-command.test.ts
|
||||
src/utils/checkout-git-rev-parse.test.ts
|
||||
src/terminal/worker-terminal-manager.test.ts
|
||||
|
||||
2
.gitignore
vendored
@@ -13,8 +13,6 @@ out/
|
||||
.env.local
|
||||
.env.test.local
|
||||
.env*.local
|
||||
.dev.vars
|
||||
**/.dev.vars
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
13
CHANGELOG.md
@@ -1,18 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.69 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Paseo now recovers automatically when an internal daemon process crashes — your agents stay connected instead of getting stuck and you don't have to restart anything.
|
||||
- Answering an interactive question from a Claude agent now reaches Claude correctly instead of being dropped. ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus))
|
||||
|
||||
## 0.1.68 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- The desktop app no longer fails on first launch after a fresh install.
|
||||
|
||||
## 0.1.67 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
72
README.md
@@ -91,9 +91,9 @@ paseo --host workstation.local:6767 run "run the full test suite"
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
## Skills
|
||||
## Orchestration skills (Unstable)
|
||||
|
||||
Skills teach your agent to use Paseo to orchestrate other agents.
|
||||
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
@@ -101,10 +101,18 @@ npx skills add getpaseo/paseo
|
||||
|
||||
Then use them in any agent conversation:
|
||||
|
||||
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
|
||||
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
|
||||
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
|
||||
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
|
||||
```bash
|
||||
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||
|
||||
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||
|
||||
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||
# Very opinionated and expects both Codex and Claude to work.
|
||||
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
@@ -136,58 +144,6 @@ npm run build:daemon
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Community
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
|
||||
### Self-hosted relay TLS
|
||||
|
||||
Self-hosted relays use `ws://` unless TLS is opted in. For a relay behind nginx on 443, start the daemon with:
|
||||
|
||||
```bash
|
||||
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
|
||||
PASEO_RELAY_USE_TLS=true \
|
||||
paseo daemon start
|
||||
```
|
||||
|
||||
Equivalent config:
|
||||
|
||||
```json
|
||||
{
|
||||
"daemon": {
|
||||
"relay": {
|
||||
"enabled": true,
|
||||
"endpoint": "127.0.0.1:8080",
|
||||
"publicEndpoint": "relay.example.com:443",
|
||||
"useTls": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Minimal nginx WebSocket proxy:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name relay.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# Ad-hoc daemon testing
|
||||
|
||||
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
|
||||
|
||||
This is for test code only. Executable daemon processes must start through
|
||||
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
|
||||
do not use `createPaseoDaemon` as a product launch path.
|
||||
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -89,7 +85,7 @@ await client.close();
|
||||
await daemon.close(); // stops daemon + cleans up temp dirs
|
||||
```
|
||||
|
||||
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
|
||||
## Common client methods
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ Enables remote access when the daemon is behind a firewall.
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
hostnames: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean }
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
},
|
||||
app: {
|
||||
baseUrl: string
|
||||
|
||||
@@ -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-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
|
||||
npmDepsHash = "sha256-14DjNXerk2vLOe7UV0oVo+ifmskwsWpu/jahxrLw4M8=";
|
||||
|
||||
# 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).
|
||||
@@ -123,7 +123,7 @@ buildNpmPackage rec {
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||
--set NODE_ENV production
|
||||
|
||||
# Create wrapper for the CLI
|
||||
|
||||
43
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -19554,9 +19554,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "41.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-41.2.0.tgz",
|
||||
"integrity": "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==",
|
||||
"version": "41.0.3",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-41.0.3.tgz",
|
||||
"integrity": "sha512-IDjx8liW1q+r7+MOip5W1Eo1eMwJzVObmYrd9yz2dPCkS7XlgLq3qPVMR80TpiROFp73iY30kTzMdpA6fEVs3A==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -19842,9 +19842,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -36354,6 +36354,7 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
@@ -38547,7 +38548,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -38673,14 +38674,13 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/server": "0.1.69",
|
||||
"@getpaseo/server": "0.1.67",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"tree-kill": "^1.2.2",
|
||||
"ws": "^8.14.2",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
@@ -38719,7 +38719,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38732,7 +38732,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "41.2.0",
|
||||
"electron": "41.0.3",
|
||||
"electron-builder": "26.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
@@ -38768,7 +38768,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -38804,7 +38804,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -38830,7 +38830,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38845,12 +38845,12 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@getpaseo/highlight": "0.1.69",
|
||||
"@getpaseo/relay": "0.1.69",
|
||||
"@getpaseo/highlight": "0.1.67",
|
||||
"@getpaseo/relay": "0.1.67",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-agent-core": "^0.70.2",
|
||||
"@mariozechner/pi-ai": "^0.70.2",
|
||||
@@ -38878,7 +38878,6 @@
|
||||
"sherpa-onnx": "1.12.28",
|
||||
"sherpa-onnx-node": "1.12.28",
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tree-kill": "^1.2.2",
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
@@ -39304,7 +39303,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// "00-" prefix is intentional: this file must sort before every other spec.
|
||||
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
|
||||
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
connectArchiveTabDaemonClient,
|
||||
expectSessionsEmptyState,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Sessions screen empty state", () => {
|
||||
test.beforeAll(async () => {
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
try {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
|
||||
if (history.entries.length > 0) {
|
||||
throw new Error(
|
||||
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
|
||||
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
|
||||
`or the daemon has stale history from a previous run.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows empty placeholder when there is no session history", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
|
||||
await workspace.navigateTo();
|
||||
await openSessions(page);
|
||||
await expectSessionsEmptyState(page);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderModal,
|
||||
openSettingsHost,
|
||||
} from "./helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
};
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
test.describe("ACP provider catalog", () => {
|
||||
test("adds a catalog provider from settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getSeededServerId());
|
||||
await openAddProviderModal(page);
|
||||
|
||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
awaitAssistantMessage,
|
||||
expectAgentIdle,
|
||||
expectInlineWorkingIndicator,
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-scroll-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for auto-scroll test.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectScrollFollowsNewContent(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-indicator-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stream briefly for indicator transition test.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { expectAgentIdle } from "./helpers/agent-stream";
|
||||
import {
|
||||
openAttachmentMenu,
|
||||
openGithubPickerFromMenu,
|
||||
attachImageFromMenu,
|
||||
expectAttachmentPill,
|
||||
removeAttachmentPill,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
pressInterruptShortcut,
|
||||
expectComposerDraft,
|
||||
expectComposerDisabled,
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
sendDraftToQueue,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
selectGithubOption,
|
||||
expectGithubAttachmentPill,
|
||||
openGithubWorkspace,
|
||||
} from "./helpers/composer";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-plus-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await openAttachmentMenu(page);
|
||||
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
});
|
||||
|
||||
test("GitHub combobox does not render until the picker is opened", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible();
|
||||
|
||||
await openGithubPickerFromMenu(page);
|
||||
|
||||
await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-attach-issue-",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(
|
||||
page,
|
||||
"attach-issue-unique-alpha",
|
||||
`issue:${ghRepo.issues[0].number}`,
|
||||
);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.issues[0].number,
|
||||
title: ghRepo.issues[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-attach-pr-",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.prs[0].number,
|
||||
title: ghRepo.prs[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => {
|
||||
// The review attachment is created via InlineReviewEditor in surface.tsx (addComment action).
|
||||
// Automating this requires: a workspace with staged changes, navigating to the diff panel,
|
||||
// hovering the gutter "+" button, typing a comment, and submitting. A dedicated
|
||||
// helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is
|
||||
// needed before this can be exercised end-to-end.
|
||||
});
|
||||
|
||||
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
|
||||
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
|
||||
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
|
||||
});
|
||||
|
||||
test("image lightbox opens on pill click and closes on Escape", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-lightbox-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await openImageLightbox(page);
|
||||
await closeImageLightbox(page);
|
||||
});
|
||||
|
||||
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-pill-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting while agent is running queues the message and clears the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-queue-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running for queue test.",
|
||||
});
|
||||
try {
|
||||
await fillComposerDraft(page, "queued draft text");
|
||||
await sendDraftToQueue(page);
|
||||
|
||||
await expectQueuedMessageButton(page);
|
||||
await expectComposerDraft(page, "");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape interrupt cancels the running agent and preserves composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-interrupt-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stay running for interrupt test.",
|
||||
});
|
||||
try {
|
||||
await fillComposerDraft(page, "preserve me");
|
||||
await pressInterruptShortcut(page);
|
||||
|
||||
await expectAgentIdle(page, 15_000);
|
||||
await expectComposerDraft(page, "preserve me");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
||||
|
||||
const repo = await createTempGitRepo("attach-lock-");
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const daemonClient = await connectNewWorkspaceDaemonClient();
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await fillComposerDraft(page, "lock test prompt");
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
await expectComposerDisabled(page);
|
||||
await expectAttachButtonDisabled(page);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
|
||||
await expectComposerEditable(page);
|
||||
} finally {
|
||||
agentCreatedDelay.release();
|
||||
await daemonClient.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
toggleDaemonManagement,
|
||||
expectDaemonManagementConfirmDialog,
|
||||
expectDaemonManagementEnabled,
|
||||
expectDaemonManagementDisabled,
|
||||
expectDaemonStatusPid,
|
||||
expectDaemonStatusLogPath,
|
||||
expectDaemonStatusVersion,
|
||||
} from "./helpers/desktop-updates";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
// No Playwright Electron runner exists; we simulate the desktop bridge via
|
||||
// addInitScript so Electron-gated UI activates without a real Electron process.
|
||||
test.describe("Desktop updates", () => {
|
||||
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getSeededServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectUpdateBanner(page, "1.2.3");
|
||||
});
|
||||
|
||||
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getSeededServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
slowInstall: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectUpdateBanner(page, "1.2.3");
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
|
||||
expectDaemonManagementConfirmDialog(dialogArgs);
|
||||
|
||||
await expectDaemonManagementEnabled(page);
|
||||
});
|
||||
|
||||
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonManagementEnabled(page);
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
});
|
||||
|
||||
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
|
||||
await expectDaemonManagementDisabled(page);
|
||||
});
|
||||
|
||||
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: false,
|
||||
daemonPid: realState.pid,
|
||||
daemonVersion: realState.version,
|
||||
daemonLogPath: realState.logPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonStatusVersion(page, realState.version);
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
await expectDaemonStatusLogPath(page, realState.logPath);
|
||||
});
|
||||
|
||||
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
daemonPid: realState.pid,
|
||||
daemonVersion: realState.version,
|
||||
daemonLogPath: realState.logPath,
|
||||
confirmShouldAccept: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
await expectDaemonManagementDisabled(page);
|
||||
await expectDaemonStatusPid(page, null);
|
||||
|
||||
await toggleDaemonManagement(page, "enable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
|
||||
await expectDaemonStatusPid(page, newPid);
|
||||
});
|
||||
});
|
||||
@@ -215,28 +215,8 @@ async function createFakeGhBin(): Promise<string> {
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function findRealGh() {
|
||||
const fakeBinDir = __dirname;
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (dir === fakeBinDir) continue;
|
||||
const candidate = path.join(dir, "gh");
|
||||
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function forwardToRealGh() {
|
||||
const realGh = findRealGh();
|
||||
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
|
||||
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -258,21 +238,8 @@ if (args[0] === "pr" && args[1] === "list") {
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "api" && args[1] === "graphql") {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
console.error("no pull requests found for branch");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
@@ -280,7 +247,8 @@ if (args[0] === "issue" && args[1] === "list") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
forwardToRealGh();
|
||||
console.error("Unsupported fake gh invocation: " + args.join(" "));
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
@@ -605,7 +573,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const { openAiUsable, localModelsDir } = args.dictation;
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
const child = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -737,7 +705,6 @@ export default async function globalSetup() {
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor";
|
||||
|
||||
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
|
||||
const messages = page.getByTestId("assistant-message");
|
||||
@@ -16,20 +15,3 @@ export async function awaitToolCall(page: Page, toolName: string | RegExp): Prom
|
||||
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
|
||||
}
|
||||
|
||||
// The working indicator is an animated spinner View — no semantic ARIA role, testId is correct.
|
||||
export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectTurnCopyButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectScrollFollowsNewContent(page: Page): Promise<void> {
|
||||
const { contentHeight } = await readScrollMetrics(page);
|
||||
await waitForContentGrowth(page, contentHeight);
|
||||
await expectNearBottom(page);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,217 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
if (port === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const needsReset = await page.evaluate(
|
||||
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
|
||||
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||
if (!raw) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||
const entry = parsed[0] as { serverId?: string; connections?: unknown };
|
||||
if (entry?.serverId !== serverId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (
|
||||
connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" &&
|
||||
typeof c?.endpoint === "string" &&
|
||||
/:6767\b/.test(c.endpoint),
|
||||
)
|
||||
)
|
||||
return true;
|
||||
return !connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && c?.endpoint === endpoint,
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{ expectedEndpoint, expectedServerId },
|
||||
);
|
||||
|
||||
if (!needsReset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const daemon = buildSeededHost({
|
||||
serverId: expectedServerId,
|
||||
endpoint: expectedEndpoint,
|
||||
nowIso,
|
||||
});
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
},
|
||||
{ daemon, preferences },
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
|
||||
let registry: unknown;
|
||||
try {
|
||||
registry = JSON.parse(registryRaw);
|
||||
} catch {
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
||||
}
|
||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||
throw new Error(
|
||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
||||
);
|
||||
}
|
||||
const daemon = registry[0] as { serverId?: string; connections?: unknown };
|
||||
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
return { serverId: daemon.serverId, connections: daemon.connections };
|
||||
}
|
||||
|
||||
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
Array.isArray(connections) &&
|
||||
connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
|
||||
try {
|
||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
||||
if (prefs?.serverId !== serverId) {
|
||||
throw new Error(
|
||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
return { registryRaw, prefsRaw };
|
||||
});
|
||||
|
||||
if (!snapshot.registryRaw) {
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||
}
|
||||
|
||||
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
|
||||
if (serverId !== expectedServerId) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
|
||||
);
|
||||
}
|
||||
assertDaemonConnections(connections, expectedEndpoint);
|
||||
|
||||
if (!snapshot.prefsRaw) {
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||
}
|
||||
assertPreferencesMatch(snapshot.prefsRaw, serverId);
|
||||
}
|
||||
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto("/");
|
||||
await ensureE2EStorageSeeded(page);
|
||||
};
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await gotoAppShell(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const entryButton = page
|
||||
.getByText("Add a project", { exact: true })
|
||||
.or(page.getByText("Add project", { exact: true }))
|
||||
.or(page.getByText("New agent", { exact: true }))
|
||||
.first();
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
if (
|
||||
!(await composer
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
|
||||
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
|
||||
const newAgentButton = page.getByText("New agent", { exact: true }).first();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await composer.isVisible().catch(() => false)) ||
|
||||
(await entryButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (!(await composer.isVisible().catch(() => false))) {
|
||||
await entryButton.click();
|
||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||
await addProjectCta.click();
|
||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||
await addProjectSidebar.click();
|
||||
} else {
|
||||
await newAgentButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
|
||||
export const openSettings = async (page: Page) => {
|
||||
@@ -160,6 +341,46 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
};
|
||||
|
||||
export const ensureHostSelected = async (page: Page) => {
|
||||
await ensureE2EStorageSeeded(page);
|
||||
|
||||
// Absolute verification that we're using the per-run e2e daemon (never :6767).
|
||||
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
|
||||
// realigning create-agent-preferences.serverId to the sole seeded daemon.
|
||||
try {
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/create-agent-preferences\.serverId/i.test(message)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fix = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
|
||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
||||
if (!Array.isArray(registry) || registry.length !== 1)
|
||||
return { ok: false, reason: "registry shape" } as const;
|
||||
const serverId = registry[0]?.serverId;
|
||||
if (typeof serverId !== "string" || serverId.length === 0)
|
||||
return { ok: false, reason: "missing serverId" } as const;
|
||||
prefs.serverId = serverId;
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
if (!fix.ok) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await page.reload();
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
}
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
@@ -171,7 +392,7 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
if (await selectHostLabel.isVisible()) {
|
||||
await selectHostLabel.click();
|
||||
|
||||
// We enforce a single seeded daemon, so the option should be unambiguous.
|
||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
||||
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||
const daemonIdOption = page
|
||||
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||
@@ -436,3 +657,40 @@ export const createAgentInRepo = async (
|
||||
await setWorkingDirectory(page, config.directory);
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
// Wait for the stop button to disappear
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||
|
||||
// First, let's debug what's happening - wait a bit to see the state
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check if stop button is visible
|
||||
const isVisible = await stopButton.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
// If stop button is still visible after permission denial,
|
||||
// it might be that the agent is waiting for something.
|
||||
// Let's check if there's a tool call result or other UI indication
|
||||
|
||||
// Look for any indication that the agent has processed the permission denial
|
||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
||||
|
||||
// Wait for the tool call result to appear
|
||||
await expect(toolCallResult)
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
.catch(() => {
|
||||
// If no specific message, just wait for the button to disappear
|
||||
});
|
||||
|
||||
// Now wait for the stop button to disappear
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getToolCallCount(page: Page): Promise<number> {
|
||||
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
|
||||
// They appear as pressable badges in the agent stream
|
||||
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
|
||||
return toolCallBadges.count();
|
||||
}
|
||||
|
||||
@@ -36,9 +36,6 @@ interface ArchiveTabDaemonClient {
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
@@ -268,10 +265,8 @@ export async function openSessions(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
|
||||
|
||||
function getSessionRowByTitle(page: Page, title: string) {
|
||||
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
|
||||
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||
@@ -288,12 +283,6 @@ export async function clickSessionRow(page: Page, title: string): Promise<void>
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectSessionsEmptyState(page: Page): Promise<void> {
|
||||
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
|
||||
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function archiveAgentFromSessions(
|
||||
page: Page,
|
||||
input: { agentId: string; title: string },
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
}
|
||||
|
||||
export function composerLocator(page: Page) {
|
||||
return composerInput(page);
|
||||
}
|
||||
|
||||
export async function expectComposerVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDisabled(page: Page): Promise<void> {
|
||||
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
|
||||
// not <textarea disabled>. Use not.toBeEditable() to match either form.
|
||||
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerEditable(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
@@ -42,154 +11,8 @@ export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
export async function expectQueuedMessageButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelAgent(page: Page): Promise<void> {
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
||||
await stopButton.click();
|
||||
}
|
||||
|
||||
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
|
||||
export async function pressInterruptShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function openAttachmentMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
|
||||
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
|
||||
).toBeDisabled({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function attachImageFromMenu(
|
||||
page: Page,
|
||||
file: { name: string; mimeType: string; buffer: Buffer },
|
||||
): Promise<void> {
|
||||
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-image").click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles([file]);
|
||||
}
|
||||
|
||||
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
||||
export async function removeAttachmentPill(
|
||||
page: Page,
|
||||
pillTestId: string,
|
||||
removeAccessibilityLabel: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(pillTestId).first().hover();
|
||||
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
|
||||
}
|
||||
|
||||
export async function expectGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
): Promise<void> {
|
||||
const pill = page.getByTestId("composer-github-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pill).toContainText(`#${input.number}`);
|
||||
await expect(pill).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function openImageLightbox(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open image attachment" }).first().click();
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function closeImageLightbox(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-github").click();
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
|
||||
export async function selectGithubOption(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
optionId: string,
|
||||
): Promise<void> {
|
||||
await openGithubPickerFromMenu(page);
|
||||
const searchInput = page.getByPlaceholder("Search issues and PRs...");
|
||||
await expect(searchInput).toBeVisible({ timeout: 5_000 });
|
||||
await searchInput.fill(searchTerm);
|
||||
const option = page.getByTestId(`composer-github-option-${optionId}`);
|
||||
await expect(option).toBeVisible({ timeout: 15_000 });
|
||||
await option.click();
|
||||
}
|
||||
|
||||
export interface MockAgentSetup {
|
||||
client: TerminalPerfDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
}
|
||||
|
||||
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
||||
export async function startRunningMockAgent(
|
||||
page: Page,
|
||||
opts: { prefix: string; model: string; prompt: string },
|
||||
): Promise<MockAgentSetup> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
||||
|
||||
const repo = await createTempGitRepo(opts.prefix);
|
||||
const client = await connectTerminalClient();
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
model: opts.model,
|
||||
initialPrompt: opts.prompt,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
return { client, repo };
|
||||
}
|
||||
|
||||
export interface GithubWorkspaceHandle {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
|
||||
export async function openGithubWorkspace(
|
||||
page: Page,
|
||||
repoPath: string,
|
||||
): Promise<GithubWorkspaceHandle> {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const opened = await client.openProject(repoPath);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
|
||||
await openHomeWithProject(page, repoPath);
|
||||
await selectWorkspaceInSidebar(page, opened.workspace.id);
|
||||
await waitForTabBar(page);
|
||||
return { cleanup: () => client.close().catch(() => undefined) };
|
||||
}
|
||||
|
||||
@@ -30,15 +30,10 @@ export function buildSeededHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export const TEST_MOCK_PROVIDER_PREFERENCES = {
|
||||
...TEST_PROVIDER_PREFERENCES,
|
||||
mock: { model: "ten-second-stream" },
|
||||
} as const;
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "mock" as const,
|
||||
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { openSettingsHost } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
serverId: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
interface PidFileContent {
|
||||
pid: number;
|
||||
desktopManaged: boolean;
|
||||
}
|
||||
|
||||
export interface RealDaemonState {
|
||||
version: string;
|
||||
pid: number | null;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads live state from the running E2E test daemon: version from the HTTP
|
||||
* status endpoint, PID from the paseo.pid lock file, log path from the
|
||||
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
||||
*/
|
||||
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||
const data: DaemonApiStatus = await resp.json();
|
||||
|
||||
let pid: number | null = null;
|
||||
try {
|
||||
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
|
||||
const pidContent: PidFileContent = JSON.parse(raw);
|
||||
pid = pidContent.pid ?? null;
|
||||
} catch (err) {
|
||||
// PID file may not be present yet on a very fresh daemon start
|
||||
console.warn("[desktop-updates] paseo.pid not found:", err);
|
||||
}
|
||||
|
||||
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
|
||||
}
|
||||
|
||||
export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
daemonVersion?: string | null;
|
||||
daemonLogPath?: string;
|
||||
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
||||
manageBuiltInDaemon?: boolean;
|
||||
/**
|
||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogCall {
|
||||
message: string;
|
||||
title: string | undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects window.paseoDesktop before app load so all Electron-gated code
|
||||
* activates. The update-check IPC is mocked at the boundary so the real
|
||||
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
|
||||
* tracks running state and assigns a fresh PID on each start, letting tests
|
||||
* observe PID changes without touching the real E2E daemon process.
|
||||
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
|
||||
* can assert dialog copy without depending on window.confirm concatenation.
|
||||
*/
|
||||
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
|
||||
await page.addInitScript((cfg) => {
|
||||
// Mutable state shared across IPC calls within this page
|
||||
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
||||
let daemonRunning = true;
|
||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||
let startCount = 0;
|
||||
|
||||
function buildDaemonStatus() {
|
||||
return {
|
||||
serverId: cfg.serverId,
|
||||
status: daemonRunning ? "running" : "stopped",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: currentPid,
|
||||
home: "",
|
||||
version: cfg.daemonVersion ?? null,
|
||||
desktopManaged: manageDaemon,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "check_app_update") {
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
}
|
||||
: {
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: null,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "install_app_update") {
|
||||
if (cfg.slowInstall) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
version: cfg.latestVersion ?? "1.2.3",
|
||||
message: "App update installed. Restart required.",
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "desktop_daemon_status") {
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
if (command === "desktop_daemon_logs") {
|
||||
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
|
||||
}
|
||||
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "patch_desktop_settings") {
|
||||
const daemon = args?.daemon;
|
||||
if (
|
||||
daemon !== null &&
|
||||
typeof daemon === "object" &&
|
||||
"manageBuiltInDaemon" in daemon &&
|
||||
typeof daemon.manageBuiltInDaemon === "boolean"
|
||||
) {
|
||||
manageDaemon = daemon.manageBuiltInDaemon;
|
||||
}
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "stop_desktop_daemon") {
|
||||
daemonRunning = false;
|
||||
currentPid = null;
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
if (command === "start_desktop_daemon") {
|
||||
startCount += 1;
|
||||
daemonRunning = true;
|
||||
// First start (bootstrap) returns the configured PID; subsequent starts
|
||||
// (after a stop) get a fresh PID so tests can observe the change.
|
||||
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
dialog: {
|
||||
ask: async (message: string, options?: Record<string, unknown>) => {
|
||||
window.__capturedDialogCall = {
|
||||
message,
|
||||
title: typeof options?.title === "string" ? options.title : undefined,
|
||||
};
|
||||
return cfg.confirmShouldAccept ?? false;
|
||||
},
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
export async function expectInstallInProgress(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the daemon management switch and waits for dialog.ask to fire in the
|
||||
* mock, then returns the captured call args (message + title). The mock auto-
|
||||
* dismisses via confirmShouldAccept=false so callers can assert copy without
|
||||
* worrying about state changes.
|
||||
*/
|
||||
export async function interceptDaemonManagementConfirmDialog(
|
||||
page: Page,
|
||||
): Promise<ConfirmDialogCall> {
|
||||
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
|
||||
return page.evaluate(() => window.__capturedDialogCall!);
|
||||
}
|
||||
|
||||
export async function toggleDaemonManagement(
|
||||
page: Page,
|
||||
_action: "enable" | "disable",
|
||||
): Promise<void> {
|
||||
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||
}
|
||||
|
||||
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
|
||||
expect(args.title).toBe("Pause built-in daemon");
|
||||
expect(args.message).toContain("stop the built-in daemon immediately");
|
||||
}
|
||||
|
||||
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
|
||||
}
|
||||
|
||||
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the daemon status card shows the given PID. Pass null to assert
|
||||
* the cleared state (shown as "PID —" when the daemon is stopped).
|
||||
*/
|
||||
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
|
||||
const expected = pid !== null ? `PID ${pid}` : "PID —";
|
||||
await expect(
|
||||
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the host page identity badge shows the given version string.
|
||||
* The badge is populated from the live WebSocket session's serverInfo.version.
|
||||
*/
|
||||
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
try {
|
||||
execSync("gh auth status", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CheckSpec {
|
||||
context: string;
|
||||
state: "success" | "failure" | "pending";
|
||||
}
|
||||
|
||||
export interface PrSpec {
|
||||
title: string;
|
||||
state: "open" | "merged" | "closed" | "draft";
|
||||
checks?: CheckSpec[];
|
||||
commentCount?: number;
|
||||
}
|
||||
|
||||
export interface IssueSpec {
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
state?: "open" | "closed";
|
||||
}
|
||||
|
||||
export interface GhPrFixture {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
branch: string;
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
export interface GhIssueFixture {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GhRepoFixture {
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
prs: GhPrFixture[];
|
||||
issues: GhIssueFixture[];
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function seedPr(args: {
|
||||
spec: PrSpec;
|
||||
branch: string;
|
||||
index: number;
|
||||
basePath: string;
|
||||
authedUrl: string;
|
||||
fullName: string;
|
||||
repoName: string;
|
||||
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
|
||||
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
|
||||
|
||||
const createArgs = [
|
||||
"pr",
|
||||
"create",
|
||||
"--title",
|
||||
spec.title,
|
||||
"--base",
|
||||
"main",
|
||||
"--head",
|
||||
branch,
|
||||
"--body",
|
||||
"",
|
||||
];
|
||||
if (spec.state === "draft") createArgs.push("--draft");
|
||||
|
||||
const prUrl = gh(createArgs, { cwd: basePath });
|
||||
const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10);
|
||||
|
||||
if (spec.checks && spec.checks.length > 0) {
|
||||
const sha = git(["rev-parse", branch], basePath);
|
||||
for (const check of spec.checks) {
|
||||
gh([
|
||||
"api",
|
||||
`repos/${fullName}/statuses/${sha}`,
|
||||
"--method",
|
||||
"POST",
|
||||
"-f",
|
||||
`state=${check.state}`,
|
||||
"-f",
|
||||
`context=${check.context}`,
|
||||
"-f",
|
||||
`target_url=https://example.com/${encodeURIComponent(check.context)}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = 0; j < (spec.commentCount ?? 0); j++) {
|
||||
gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath });
|
||||
}
|
||||
|
||||
if (spec.state === "merged") {
|
||||
gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath });
|
||||
} else if (spec.state === "closed") {
|
||||
gh(["pr", "close", String(prNumber)], { cwd: basePath });
|
||||
}
|
||||
|
||||
const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`));
|
||||
git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath);
|
||||
// Clean remote URL (no embedded token) so gh can parse owner/repo
|
||||
git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], localPath);
|
||||
git(["config", "user.name", "Paseo E2E"], localPath);
|
||||
git(["config", "commit.gpgsign", "false"], localPath);
|
||||
|
||||
return {
|
||||
fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath },
|
||||
localPath,
|
||||
};
|
||||
}
|
||||
|
||||
function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture {
|
||||
const { spec, basePath } = args;
|
||||
const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""];
|
||||
for (const label of spec.labels ?? []) {
|
||||
createArgs.push("--label", label);
|
||||
}
|
||||
const issueUrl = gh(createArgs, { cwd: basePath });
|
||||
const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10);
|
||||
if (spec.state === "closed") {
|
||||
gh(["issue", "close", String(issueNumber)], { cwd: basePath });
|
||||
}
|
||||
return { number: issueNumber, title: spec.title, url: issueUrl };
|
||||
}
|
||||
|
||||
export async function createTempGithubRepo(options: {
|
||||
prefix?: string;
|
||||
prs?: PrSpec[];
|
||||
issues?: IssueSpec[];
|
||||
}): Promise<GhRepoFixture> {
|
||||
const { prefix = "paseo-e2e-", prs = [], issues = [] } = options;
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const repoName = `${prefix}${uniqueSuffix}`;
|
||||
|
||||
// Bootstrap local git repo
|
||||
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n");
|
||||
git(["add", "README.md"], basePath);
|
||||
git(["commit", "-m", "Initial commit"], basePath);
|
||||
|
||||
// Create GitHub repo and push initial commit
|
||||
gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]);
|
||||
|
||||
const owner = gh(["api", "user", "--jq", ".login"]);
|
||||
const fullName = `${owner}/${repoName}`;
|
||||
const token = gh(["auth", "token"]);
|
||||
const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`;
|
||||
|
||||
// Switch remote to authed URL for subsequent pushes
|
||||
git(["remote", "set-url", "origin", authedUrl], basePath);
|
||||
|
||||
// Create a branch + commit for each PR spec
|
||||
const branches: string[] = [];
|
||||
for (let i = 0; i < prs.length; i++) {
|
||||
const branch = `pr-branch-${i + 1}`;
|
||||
branches.push(branch);
|
||||
git(["checkout", "-b", branch], basePath);
|
||||
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
|
||||
git(["add", `pr-${i + 1}.txt`], basePath);
|
||||
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
}
|
||||
|
||||
if (branches.length > 0) {
|
||||
git(["push", "origin", ...branches], basePath);
|
||||
}
|
||||
|
||||
// Create PRs, seed checks/comments, apply state changes, clone workspaces
|
||||
const prFixtures: GhPrFixture[] = [];
|
||||
const localPaths: string[] = [];
|
||||
|
||||
for (let i = 0; i < prs.length; i++) {
|
||||
const { fixture, localPath } = await seedPr({
|
||||
spec: prs[i],
|
||||
branch: branches[i],
|
||||
index: i,
|
||||
basePath,
|
||||
authedUrl,
|
||||
fullName,
|
||||
repoName,
|
||||
});
|
||||
localPaths.push(localPath);
|
||||
prFixtures.push(fixture);
|
||||
}
|
||||
|
||||
// Create issues
|
||||
const issueFixtures: GhIssueFixture[] = [];
|
||||
for (const spec of issues) {
|
||||
issueFixtures.push(seedIssue({ spec, basePath }));
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
name: repoName,
|
||||
fullName,
|
||||
prs: prFixtures,
|
||||
issues: issueFixtures,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
gh(["repo", "delete", fullName, "--yes"]);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
await Promise.all([
|
||||
rm(basePath, { recursive: true, force: true }),
|
||||
...localPaths.map((p) => rm(p, { recursive: true, force: true })),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,20 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
@@ -76,6 +90,11 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
||||
export async function waitForLauncherPanel(_page: Page): Promise<void> {
|
||||
// No-op: the launcher panel no longer exists.
|
||||
}
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
@@ -100,7 +119,7 @@ export async function clickNewChat(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/** Click the new terminal button to create a terminal tab. */
|
||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
@@ -174,19 +193,6 @@ export async function sampleTabsDuringTransition(
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
export function terminalSurfaceLocator(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]').first();
|
||||
}
|
||||
|
||||
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||
const tabTestId = `workspace-tab-agent_${agentId}`;
|
||||
await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId);
|
||||
}
|
||||
|
||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a temp git repo and return its path with a cleanup function. */
|
||||
|
||||
@@ -202,45 +202,12 @@ export async function expectStartingRefPickerTriggerPr(
|
||||
page: Page,
|
||||
input: { number: number; title: string; headRef: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toContainText(`#${input.number}`);
|
||||
await expect(trigger).toContainText(input.title);
|
||||
await expect(trigger).not.toContainText(input.headRef);
|
||||
}
|
||||
|
||||
export async function openBranchPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
export async function selectPickerOptionByKeyboard(page: Page, label: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.type(label);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
export async function closeBranchPicker(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectPickerOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectPickerClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectPickerSelected(page: Page, label: string): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toContainText(label);
|
||||
}
|
||||
|
||||
export async function expectComposerGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
@@ -283,110 +250,3 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
|
||||
return { workspaceId };
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||
const value = input[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export interface AgentCreatedDelayControl {
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
waitForDelayedCreatedStatus(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function delayBrowserAgentCreatedStatus(
|
||||
page: Page,
|
||||
): Promise<AgentCreatedDelayControl> {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||
resolveDelayedCreatedStatus = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||
resolveDelayedCreatedStatus?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/utils/pr-pane-data";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-pr").click();
|
||||
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-title")).toContainText(title, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectPrPaneState(
|
||||
page: Page,
|
||||
state: "open" | "merged" | "closed" | "draft",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
|
||||
const locator = page.getByTestId(testId);
|
||||
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
|
||||
if (count > 0) {
|
||||
await expect(locator).toContainText(String(count));
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
counts: { passed: number; failed: number; pending: number },
|
||||
): Promise<void> {
|
||||
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
|
||||
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
|
||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||
}
|
||||
|
||||
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function navigateToProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
}
|
||||
|
||||
// --- Form interactions ---
|
||||
|
||||
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
export async function clickSaveProjectSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
|
||||
// action-0 is always "Try again"; action-1 is always "Reload".
|
||||
// The write-failed callout renders these two buttons in a fixed order.
|
||||
await page.getByTestId("write-failed-callout-action-0").click();
|
||||
}
|
||||
|
||||
export async function clickReloadProjectSettings(page: Page): Promise<void> {
|
||||
// Scope to the active error callout so the locator is unambiguous.
|
||||
// At most one error callout renders at a time.
|
||||
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
|
||||
}
|
||||
|
||||
// --- Error-state assertions ---
|
||||
|
||||
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
|
||||
|
||||
const errorCalloutTestId: Record<ErrorKind, string> = {
|
||||
stale: "stale-callout",
|
||||
invalid: "invalid-callout",
|
||||
write_failed: "write-failed-callout",
|
||||
transport: "read-transport-callout",
|
||||
read_failed: "read-failed-callout",
|
||||
};
|
||||
|
||||
export async function expectProjectSettingsError(page: Page, kind: ErrorKind): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectNoProjectSettingsError(
|
||||
page: Page,
|
||||
kind: ErrorKind,
|
||||
timeout = 15_000,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function expectWriteFailedCalloutActions(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("write-failed-callout-action-0")).toHaveText("Try again");
|
||||
await expect(page.getByTestId("write-failed-callout-action-1")).toHaveText("Reload");
|
||||
}
|
||||
|
||||
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsFormHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectNoEditableTarget(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// --- Host-section assertions ---
|
||||
|
||||
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-indicator")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPickerHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-picker")).not.toBeVisible();
|
||||
}
|
||||
|
||||
// --- Script-list assertions and interactions ---
|
||||
|
||||
// Counts only row Views, not kebab-trigger elements (which share the "script-row-"
|
||||
// prefix but contain "-menu-").
|
||||
export async function expectScriptRowCount(page: Page, count: number): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])'),
|
||||
).toHaveCount(count);
|
||||
}
|
||||
|
||||
export async function expectEmptyScriptList(page: Page): Promise<void> {
|
||||
await expect(page.getByText("No scripts yet.")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function removeProjectScript(page: Page, scriptName: string): Promise<void> {
|
||||
const row = page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])')
|
||||
.filter({ hasText: scriptName })
|
||||
.first();
|
||||
// DropdownMenuTrigger renders as a Pressable (no role="button"); derive its testID
|
||||
// from the row's testID to avoid scoped locator unreliability.
|
||||
const id = (await row.getAttribute("data-testid"))!.replace("script-row-", "");
|
||||
await page.getByTestId(`script-row-menu-${id}`).click();
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove" }).click();
|
||||
}
|
||||
|
||||
// --- File manipulation ---
|
||||
|
||||
export async function corruptPaseoConfig(repoPath: string): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), "{not valid json}");
|
||||
}
|
||||
|
||||
export async function bumpPaseoConfigOnDisk(repoPath: string): Promise<void> {
|
||||
const configPath = path.join(repoPath, "paseo.json");
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
config._bump = Date.now();
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export async function restorePaseoConfig(
|
||||
repoPath: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// The daemon writes atomically via a temp file + rename, so blocking writes requires
|
||||
// removing write permission from the *directory*, not just the file.
|
||||
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o555);
|
||||
}
|
||||
|
||||
export async function unblockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o755);
|
||||
}
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
function buildDaemonPortPattern(): RegExp {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
}
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
}
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
// server socket already closed
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
// client socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
// and block new ones. Code 1001 (Going Away) without reason triggers "error" state
|
||||
// in DaemonClient due to describeTransportClose returning a non-empty string.
|
||||
export async function installDaemonConnectionGate(
|
||||
page: Page,
|
||||
): Promise<{ drop: () => Promise<void> }> {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(sockets.map((ws) => ws.close({ code: 1001 }).catch(() => undefined)));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { requireServerId } from "./sidebar";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
@@ -55,219 +50,3 @@ export async function selectHostConnectionType(
|
||||
export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
|
||||
export async function openCompactSettings(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarSections(
|
||||
page: Page,
|
||||
sections: Array<Exclude<SettingsSection, "projects">>,
|
||||
): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
for (const section of sections) {
|
||||
await expect(
|
||||
sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function goBackInSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
}
|
||||
|
||||
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||
const serverId = requireServerId();
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expectHostSettingsUrl(page, serverId);
|
||||
}
|
||||
|
||||
export async function openCompactSettingsHost(page: Page): Promise<void> {
|
||||
const serverId = requireServerId();
|
||||
await openSettingsHost(page, serverId);
|
||||
await expectHostSettingsUrl(page, serverId);
|
||||
}
|
||||
|
||||
export async function expectAddHostMethodOptions(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function fillDirectHostUri(page: Page, uri: string): Promise<void> {
|
||||
await page.getByTestId("direct-host-uri-input").fill(uri);
|
||||
}
|
||||
|
||||
export async function expectDirectHostFormValues(
|
||||
page: Page,
|
||||
fields: { host: string; port: string; password: string },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-input")).toHaveValue(fields.host);
|
||||
await expect(page.getByTestId("direct-port-input")).toHaveValue(fields.port);
|
||||
await expect(page.getByTestId("direct-password-input")).toHaveValue(fields.password);
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectDiagnosticsContent(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectAboutContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-label-edit-button").click();
|
||||
}
|
||||
|
||||
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||
const card = page.getByTestId("host-page-connections-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||
const card = page.getByTestId("host-page-inject-mcp-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostActionCards(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
|
||||
await page.route(url, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAddProviderModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add provider", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function findAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search providers" }).fill(providerName);
|
||||
await expect(page.getByText(providerName, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectProviderInstalledInSettings(
|
||||
page: Page,
|
||||
providerName: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${providerName} provider details`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
|
||||
"data-testid",
|
||||
`settings-host-entry-${serverId}`,
|
||||
);
|
||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export function requireServerId(): string {
|
||||
function requireServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
@@ -19,21 +19,3 @@ export async function expectWorkspaceListed(page: Page, name: string): Promise<v
|
||||
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click();
|
||||
}
|
||||
|
||||
// force=true: the overlay covers the button when the mobile sidebar is open.
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByTestId("menu-button").click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -263,34 +263,6 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectTerminalSurfaceVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.locator('[data-testid="terminal-surface"]').first()).toBeVisible({
|
||||
timeout: options?.timeout ?? 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function focusTerminalSurface(page: Page): Promise<void> {
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await page.locator('[data-testid="terminal-surface"]').first().click();
|
||||
}
|
||||
|
||||
export async function typeInTerminal(page: Page, text: string): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid="terminal-surface"]')
|
||||
.first()
|
||||
.pressSequentially(text, { delay: 0 });
|
||||
}
|
||||
|
||||
export async function waitForTerminalAttached(page: Page): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid="terminal-attach-loading"]')
|
||||
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
export function computePercentile(samples: number[], p: number): number {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { clickNewChat, clickNewTerminal } from "./launcher";
|
||||
import { clickNewChat, clickTerminal } from "./launcher";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
function terminalSurface(page: Page) {
|
||||
@@ -20,7 +20,7 @@ export async function expectTerminalCwd(page: Page, expectedPath: string): Promi
|
||||
}
|
||||
|
||||
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
||||
await clickNewTerminal(page);
|
||||
await clickTerminal(page);
|
||||
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
|
||||
interface WorkspaceSetupDaemonClient {
|
||||
@@ -45,7 +44,7 @@ interface WorkspaceSetupDaemonClient {
|
||||
}>;
|
||||
fetchAgent(agentId: string): Promise<{
|
||||
agent: { id: string; cwd: string } | null;
|
||||
project: unknown;
|
||||
project: unknown | null;
|
||||
} | null>;
|
||||
listTerminals(cwd: string): Promise<{
|
||||
cwd?: string;
|
||||
@@ -316,30 +315,6 @@ export async function fetchWorkspaceById(
|
||||
return workspace;
|
||||
}
|
||||
|
||||
export async function navigateToWorkspaceViaSidebar(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
|
||||
}
|
||||
|
||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-scripts-button").click();
|
||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function startWorkspaceScriptFromMenu(page: Page, scriptName: string): Promise<void> {
|
||||
await page.getByTestId(`workspace-scripts-start-${scriptName}`).click();
|
||||
}
|
||||
|
||||
export async function closeWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-scripts-menu-backdrop").click();
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceSetupProgress(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||
|
||||
@@ -70,29 +70,6 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabsAbsent(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectNoTerminalTabs(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function clickFirstTerminalTab(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const tab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||
await expect(tab).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||
await tab.click();
|
||||
}
|
||||
|
||||
export async function expectFirstTerminalTabContains(page: Page, text: string): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]').first()).toContainText(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sampleWorkspaceTabIds(
|
||||
page: Page,
|
||||
options: { durationMs?: number; intervalMs?: number } = {},
|
||||
|
||||
@@ -114,49 +114,6 @@ export async function expectWorkspaceHeader(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastGone(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||
}
|
||||
|
||||
export async function expectMenuButtonVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHeaderAbsent(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function workspaceDeckEntryLocator(page: Page, serverId: string, workspaceId: string) {
|
||||
return page.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceDeckEntryCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(count);
|
||||
}
|
||||
|
||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
@@ -5,18 +5,16 @@ import {
|
||||
assertNewChatTileVisible,
|
||||
assertTerminalTileVisible,
|
||||
assertSingleNewTabButton,
|
||||
clickNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
clickTerminal,
|
||||
countTabsOfKind,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
terminalSurfaceLocator,
|
||||
} from "./helpers/launcher";
|
||||
import { expectComposerVisible, composerLocator } from "./helpers/composer";
|
||||
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
setupDeterministicPrompt,
|
||||
@@ -52,7 +50,9 @@ test.describe("Tab creation", () => {
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
|
||||
await expectComposerVisible(page);
|
||||
// Should show the composer directly (no launcher panel)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||
@@ -77,9 +77,11 @@ test.describe("Tab creation", () => {
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewChat(page);
|
||||
await clickNewTabButton(page);
|
||||
|
||||
await expectComposerVisible(page);
|
||||
// Draft composer should appear (the agent message input)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||
@@ -90,9 +92,11 @@ test.describe("Tab creation", () => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTerminal(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
// Terminal surface should appear
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||
@@ -137,18 +141,19 @@ test.describe("Terminal title propagation", () => {
|
||||
try {
|
||||
// Navigate to workspace and open a terminal
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Send OSC 0 (set window title) escape sequence
|
||||
const testTitle = `E2E-Title-${Date.now()}`;
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
@@ -166,24 +171,24 @@ test.describe("Terminal title propagation", () => {
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Fire many rapid title changes — only the last should stick
|
||||
const finalTitle = `Final-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||
}
|
||||
await terminalSurfaceLocator(page).pressSequentially(
|
||||
`printf '\\033]0;${finalTitle}\\007'\n`,
|
||||
{ delay: 0 },
|
||||
);
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
@@ -223,10 +228,11 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
() => clickTerminal(page),
|
||||
terminal.first(),
|
||||
20_000,
|
||||
);
|
||||
|
||||
@@ -239,12 +245,9 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
|
||||
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
|
||||
|
||||
// Draft creation is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
clickNewWorkspaceButton,
|
||||
closeBranchPicker,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
expectComposerGithubAttachmentPill,
|
||||
expectPickerClosed,
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
openProjectViaDaemon,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
@@ -35,6 +28,108 @@ import {
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||
const value = input[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
async function delayBrowserAgentCreatedStatus(page: Page) {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||
resolveDelayedCreatedStatus = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||
resolveDelayedCreatedStatus?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
@@ -410,55 +505,6 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("picker-escape-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
openPrPane,
|
||||
expectPrPaneTitle,
|
||||
expectPrPaneState,
|
||||
expectPrPaneCheckSummary,
|
||||
expectPrPaneActivityCount,
|
||||
} from "./helpers/pr-pane";
|
||||
import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
const GITHUB_AUTH = hasGithubAuth();
|
||||
|
||||
test.describe("PR pane", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
let seedClient: WorkspaceSetupDaemonClient;
|
||||
let repoFixture: GhRepoFixture;
|
||||
const workspaceByTitle = new Map<string, string>();
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (!GITHUB_AUTH) return;
|
||||
|
||||
seedClient = await connectWorkspaceSetupClient();
|
||||
|
||||
repoFixture = await createTempGithubRepo({
|
||||
prefix: "paseo-e2e-pr-",
|
||||
prs: [
|
||||
{ title: "Review selected start ref", state: "open" },
|
||||
{ title: "Merged feature branch", state: "merged" },
|
||||
{ title: "Closed without merge", state: "closed" },
|
||||
{ title: "Work in progress", state: "draft" },
|
||||
{
|
||||
title: "PR with mixed checks",
|
||||
state: "open",
|
||||
checks: [
|
||||
{ context: "build-1", state: "success" },
|
||||
{ context: "build-2", state: "success" },
|
||||
{ context: "deploy", state: "failure" },
|
||||
{ context: "security", state: "pending" },
|
||||
],
|
||||
},
|
||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||
{ title: "PR with no checks", state: "open" },
|
||||
],
|
||||
});
|
||||
|
||||
for (const pr of repoFixture.prs) {
|
||||
const result = await seedClient.openProject(pr.localPath);
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
|
||||
}
|
||||
workspaceByTitle.set(pr.title, result.workspace.id);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await repoFixture?.cleanup().catch(() => undefined);
|
||||
await seedClient?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
test.skip(!GITHUB_AUTH, "Requires GitHub authentication (gh auth login)");
|
||||
test.setTimeout(60_000);
|
||||
});
|
||||
|
||||
test("renders an open PR with title, state, and repo line", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Review selected start ref")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneTitle(page, "Review selected start ref");
|
||||
await expectPrPaneState(page, "open");
|
||||
});
|
||||
|
||||
test("renders merged state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Merged feature branch")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "merged");
|
||||
await expectPrPaneTitle(page, "Merged feature branch");
|
||||
});
|
||||
|
||||
test("renders closed state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Closed without merge")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "closed");
|
||||
await expectPrPaneTitle(page, "Closed without merge");
|
||||
});
|
||||
|
||||
test("renders draft state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Work in progress")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "draft");
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
|
||||
});
|
||||
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneActivityCount(page, 3);
|
||||
});
|
||||
|
||||
test("renders gracefully with zero checks", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
|
||||
await expectPrPaneTitle(page, "PR with no checks");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,9 @@
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base } from "./fixtures";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
blockPaseoConfigWrites,
|
||||
bumpPaseoConfigOnDisk,
|
||||
clickReloadProjectSettings,
|
||||
clickRetryProjectSettingsSave,
|
||||
clickSaveProjectSettings,
|
||||
corruptPaseoConfig,
|
||||
editWorktreeSetup,
|
||||
expectEmptyScriptList,
|
||||
expectHostIndicatorVisible,
|
||||
expectHostPickerHidden,
|
||||
expectNoEditableTarget,
|
||||
expectNoProjectSettingsError,
|
||||
expectProjectSettingsError,
|
||||
expectProjectSettingsFormHidden,
|
||||
expectProjectSettingsFormVisible,
|
||||
expectSaveButtonDisabled,
|
||||
expectScriptRowCount,
|
||||
expectWriteFailedCalloutActions,
|
||||
installDaemonConnectionGate,
|
||||
installReadTransportFailure,
|
||||
navigateToProjectSettings,
|
||||
openProjectSettings,
|
||||
openProjects,
|
||||
removeProjectScript,
|
||||
restorePaseoConfig,
|
||||
unblockPaseoConfigWrites,
|
||||
} from "./helpers/project-settings";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
@@ -75,9 +48,6 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
||||
});
|
||||
|
||||
await client.close();
|
||||
// Defensive: restore directory write permission in case the test left it blocked
|
||||
// (write_failed test), so that repo.cleanup() can remove files inside.
|
||||
await chmod(repo.path, 0o755).catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
},
|
||||
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
||||
@@ -98,6 +68,30 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
||||
},
|
||||
});
|
||||
|
||||
async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
async function saveProjectConfig(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -139,7 +133,7 @@ test.describe("Projects settings", () => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await clickSaveProjectSettings(page);
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
});
|
||||
|
||||
@@ -151,138 +145,7 @@ test.describe("Projects settings", () => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await clickSaveProjectSettings(page);
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Projects settings — error UX", () => {
|
||||
test("stale-write callout appears on save, disables save, and reload clears it", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
// Bump the file on disk so the daemon detects a revision mismatch on save.
|
||||
await bumpPaseoConfigOnDisk(editableProject.path);
|
||||
|
||||
await clickSaveProjectSettings(page);
|
||||
|
||||
await expectProjectSettingsError(page, "stale");
|
||||
await expectSaveButtonDisabled(page);
|
||||
|
||||
await clickReloadProjectSettings(page);
|
||||
|
||||
await expectNoProjectSettingsError(page, "stale");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("invalid paseo.json shows read-error callout, reload after fix shows form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await corruptPaseoConfig(editableProject.path);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectSettingsError(page, "invalid");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// Restore a valid config so the reload succeeds.
|
||||
await restorePaseoConfig(editableProject.path, initialPaseoConfig);
|
||||
|
||||
await clickReloadProjectSettings(page);
|
||||
|
||||
await expectNoProjectSettingsError(page, "invalid");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("write_failed callout appears on save with blocked directory, retry re-attempts, reload clears it", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await blockPaseoConfigWrites(editableProject.path);
|
||||
|
||||
await clickSaveProjectSettings(page);
|
||||
|
||||
await expectProjectSettingsError(page, "write_failed");
|
||||
await expectWriteFailedCalloutActions(page);
|
||||
|
||||
await clickRetryProjectSettingsSave(page);
|
||||
await expectProjectSettingsError(page, "write_failed");
|
||||
|
||||
await unblockPaseoConfigWrites(editableProject.path);
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "write_failed");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("read-transport failure shows callout, reload recovers", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||
await installReadTransportFailure(page);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectSettingsError(page, "transport");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||
await expect(async () => {
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("project settings shows no-target state when daemon connection drops", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
const gate = await installDaemonConnectionGate(page);
|
||||
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
// Closing with code 1001 (Going Away) transitions DaemonClient to "error" state.
|
||||
// The NoEditableTarget UI renders via isHostGone check regardless of state.
|
||||
await gate.drop();
|
||||
|
||||
await expectNoEditableTarget(page);
|
||||
});
|
||||
|
||||
test("single-host project renders static host indicator, not a picker chip", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectHostIndicatorVisible(page);
|
||||
await expectHostPickerHidden(page);
|
||||
});
|
||||
|
||||
test("script removal via kebab menu removes the row from the form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectScriptRowCount(page, 1);
|
||||
|
||||
await removeProjectScript(page, "dev");
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { test } from "./fixtures";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openSettingsHost,
|
||||
expectHostLabelDisplayed,
|
||||
clickEditHostLabel,
|
||||
expectHostLabelEditMode,
|
||||
expectHostConnectionsCard,
|
||||
expectHostInjectMcpCard,
|
||||
expectHostActionCards,
|
||||
expectHostNoLocalOnlyRows,
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
expectLocalHostEntryFirst,
|
||||
} from "./helpers/settings";
|
||||
import { expectSettingsHeader, openSettingsHost } from "./helpers/settings";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
@@ -32,6 +19,12 @@ function getSeededDaemonPort(): string {
|
||||
return port;
|
||||
}
|
||||
|
||||
async function expectHostLabelHeader(page: Page) {
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||
page,
|
||||
@@ -43,11 +36,26 @@ test.describe("Settings host page", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostConnectionsCard(page, port);
|
||||
await expectHostInjectMcpCard(page);
|
||||
await expectHostActionCards(page);
|
||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||
await expectHostLabelHeader(page);
|
||||
|
||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
||||
await expect(connectionsCard).toBeVisible();
|
||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||
).toBeVisible();
|
||||
|
||||
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
|
||||
await expect(injectMcpCard).toBeVisible();
|
||||
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
@@ -57,9 +65,13 @@ test.describe("Settings host page", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("host-page-label-edit-button").click();
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
});
|
||||
|
||||
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
@@ -72,14 +84,28 @@ test.describe("Settings host page", () => {
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
await expectHostNoLocalOnlyRows(page);
|
||||
// Pair-device now lives behind a row that only the local host sees
|
||||
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
|
||||
// not appear. The daemon-lifecycle card is still local-host only.
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expectRetiredSidebarSectionsAbsent(page);
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||
@@ -90,10 +116,9 @@ test.describe("Settings host page", () => {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||
|
||||
await expectHostPageVisible(page, serverId);
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page);
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expectHostLabelHeader(page);
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
@@ -135,6 +160,18 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await expectLocalHostEntryFirst(page, serverId);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
|
||||
await expect(hostEntries.first()).toHaveAttribute(
|
||||
"data-testid",
|
||||
`settings-host-entry-${serverId}`,
|
||||
);
|
||||
|
||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,62 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
toggleHostAdvanced,
|
||||
openCompactSettings,
|
||||
expectCompactSettingsList,
|
||||
expectSettingsSidebarVisible,
|
||||
expectSettingsSidebarHidden,
|
||||
expectSettingsSidebarSections,
|
||||
goBackInSettings,
|
||||
expectSettingsBackButton,
|
||||
clickSettingsBackToWorkspace,
|
||||
verifyLegacyHostSettingsRedirect,
|
||||
openCompactSettingsHost,
|
||||
expectAddHostMethodOptions,
|
||||
fillDirectHostUri,
|
||||
expectDirectHostFormValues,
|
||||
expectDirectHostSslEnabled,
|
||||
expectDirectHostUriValue,
|
||||
expectDirectHostUriHidden,
|
||||
expectDiagnosticsContent,
|
||||
expectAboutContent,
|
||||
expectGeneralContent,
|
||||
} from "./helpers/settings";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function clickSidebarSection(page: Page, label: string) {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await sidebar.getByRole("button", { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectSettingsHeader(page, "Diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
await clickSidebarSection(page, "Diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectSettingsHeader(page, "About");
|
||||
await expectAboutContent(page);
|
||||
await clickSidebarSection(page, "About");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
|
||||
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHeader(page, "General");
|
||||
await expectGeneralContent(page);
|
||||
await clickSidebarSection(page, "General");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
});
|
||||
|
||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await expectAddHostMethodOptions(page);
|
||||
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("direct connection advanced URI round-trips SSL and password into the form", async ({
|
||||
@@ -62,34 +64,38 @@ test.describe("Settings sidebar navigation", () => {
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||
await toggleHostAdvanced(page);
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await page.getByRole("button", { name: "Direct connection" }).click();
|
||||
|
||||
await expectDirectHostFormValues(page, {
|
||||
host: "example.paseo.test",
|
||||
port: "7443",
|
||||
password: "shared-secret",
|
||||
});
|
||||
await expectDirectHostSslEnabled(page);
|
||||
await expectDirectHostUriHidden(page);
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
await page
|
||||
.getByTestId("direct-host-uri-input")
|
||||
.fill("tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriValue(
|
||||
page,
|
||||
await expect(page.getByTestId("direct-host-input")).toHaveValue("example.paseo.test");
|
||||
await expect(page.getByTestId("direct-port-input")).toHaveValue("7443");
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||
await expect(page.getByTestId("direct-password-input")).toHaveValue("shared-secret");
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(
|
||||
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
|
||||
);
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriHidden(page);
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
|
||||
const backRow = page.getByTestId("settings-back-to-workspace");
|
||||
await expect(backRow).toBeVisible();
|
||||
|
||||
await backRow.click();
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
@@ -97,57 +103,113 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
async function openCompactSettingsRoot(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
// Wait for bootstrap to settle on a host route so storeReady is true before
|
||||
// we navigate into the protected settings stack. A direct page.goto("/settings")
|
||||
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
|
||||
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||
await expectCompactSettingsList(page);
|
||||
// Drive navigation the same way a user would: open the mobile drawer and tap
|
||||
// the Settings footer icon. This preserves the in-app router state instead of
|
||||
// triggering a full reload through Stack.Protected.
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(sidebarSettingsButton).toBeVisible();
|
||||
await sidebarSettingsButton.click();
|
||||
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactSettingsRootList(page: Page) {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
// Sidebar rows are present.
|
||||
await expect(
|
||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Section detail content is NOT rendered at the root.
|
||||
await expectCompactSettingsRootList(page);
|
||||
|
||||
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
||||
await expect(rootBackButton).toBeVisible();
|
||||
await rootBackButton.click();
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
// Sidebar is no longer visible — we are on a detail screen.
|
||||
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
|
||||
// only visible instances.)
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "About", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expectCompactSettingsRootList(page);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await goBackInSettings(page);
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectSettingsSidebarVisible(page);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
|
||||
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function getServerId(): string {
|
||||
@@ -17,15 +17,12 @@ function getServerId(): string {
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
|
||||
async function pressSettingsToggleShortcut(page: Page) {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
async function expectSendBehavior(
|
||||
page: import("@playwright/test").Page,
|
||||
expected: "interrupt" | "queue",
|
||||
) {
|
||||
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||
@@ -38,11 +35,12 @@ async function expectSendBehavior(
|
||||
}
|
||||
|
||||
async function openAgentRouteAndExpectFocused(input: {
|
||||
page: import("@playwright/test").Page;
|
||||
page: Page;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}) {
|
||||
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
||||
await input.page.goto(
|
||||
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
||||
);
|
||||
@@ -51,7 +49,10 @@ async function openAgentRouteAndExpectFocused(input: {
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForTabBar(input.page);
|
||||
await expectAgentTabActive(input.page, input.agentId);
|
||||
await expect(
|
||||
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
||||
}
|
||||
|
||||
test.describe("Settings toggle tab regression", () => {
|
||||
@@ -78,7 +79,13 @@ test.describe("Settings toggle tab regression", () => {
|
||||
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
|
||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
@@ -91,11 +98,19 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForTabBar(page);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
|
||||
await page.reload();
|
||||
await waitForTabBar(page);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
@@ -137,10 +152,14 @@ test.describe("Settings toggle tab regression", () => {
|
||||
agentId: secondAgent.id,
|
||||
});
|
||||
|
||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await page.reload();
|
||||
await waitForTabBar(page);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
await expect(
|
||||
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
}
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
|
||||
@@ -4,12 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||
@@ -186,16 +180,3 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile sidebar panelState transition", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("showMobileAgent open and close transition", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +1,129 @@
|
||||
import { test } from "./fixtures";
|
||||
import { clickNewTerminal } from "./helpers/launcher";
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
} from "./helpers/terminal-perf";
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
const row = page.getByTestId(testId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-cwd-main-");
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||
|
||||
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-cwd-worktree-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
const worktreePath = path.join(
|
||||
resolvedTmp,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `workspace-cwd-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = workspaceResult.workspace.id;
|
||||
|
||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||
// with direct URL navigation to the 2nd+ workspace.
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
@@ -15,28 +15,18 @@ import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
getVisibleWorkspaceAgentTabIds,
|
||||
expectOnlyWorkspaceAgentTabsVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
expectWorkspaceTabsAbsent,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
expectWorkspaceHeaderAbsent,
|
||||
expectMenuButtonVisible,
|
||||
expectHostConnectingOrOffline,
|
||||
expectReconnectingToastVisible,
|
||||
expectReconnectingToastGone,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
workspaceDeckEntryLocator,
|
||||
expectWorkspaceDeckEntryCount,
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
|
||||
@@ -72,22 +62,6 @@ async function expectNoLoadingWorkspacePane(
|
||||
}
|
||||
}
|
||||
|
||||
async function expectNoLoadingPane(page: Page): Promise<void> {
|
||||
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function getVisibleDraftTabCount(page: Page): Promise<number> {
|
||||
return page.locator('[data-testid^="workspace-tab-draft"]').filter({ visible: true }).count();
|
||||
}
|
||||
|
||||
async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
|
||||
const closeButton = page.locator('[data-testid^="workspace-draft-close-"]').filter({
|
||||
visible: true,
|
||||
});
|
||||
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
|
||||
await closeButton.first().click();
|
||||
}
|
||||
|
||||
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
@@ -146,25 +120,6 @@ async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "workspace-settings-back-tab-" });
|
||||
|
||||
await workspace.navigateTo();
|
||||
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
|
||||
|
||||
await closeFirstVisibleDraftTab(page);
|
||||
|
||||
await expect.poll(() => getVisibleDraftTabCount(page), { timeout: 30_000 }).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
@@ -204,31 +159,35 @@ test.describe("Workspace navigation regression", () => {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible({ timeout: 30_000 });
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
|
||||
await daemonGate.drop();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectComposerVisible(page);
|
||||
await expectNoLoadingPane(page);
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
await expect(page.getByText(LOADING_WORKSPACE_TEXT_PATTERN)).toHaveCount(0);
|
||||
|
||||
const monitorReconnect = expectNoLoadingWorkspacePane(page, {
|
||||
label: "host reconnect",
|
||||
});
|
||||
daemonGate.restore();
|
||||
await expectReconnectingToastGone(page);
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await monitorReconnect;
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.workspaceName,
|
||||
subtitle: workspace.projectDisplayName,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
for (const agentId of agentIds) {
|
||||
@@ -263,56 +222,19 @@ test.describe("Workspace navigation regression", () => {
|
||||
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
|
||||
);
|
||||
|
||||
await expectHostConnectingOrOffline(page);
|
||||
await expectMenuButtonVisible(page);
|
||||
await expectWorkspaceHeaderAbsent(page);
|
||||
await expectWorkspaceTabsAbsent(page);
|
||||
await openSettings(page);
|
||||
await expect(
|
||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
|
||||
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible({ timeout: 30_000 });
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
});
|
||||
|
||||
test("cold workspace URL keeps sidebar workspace navigation functional", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const firstRepo = await createTempGitRepo("workspace-cold-url-a-");
|
||||
const secondRepo = await createTempGitRepo("workspace-cold-url-b-");
|
||||
|
||||
try {
|
||||
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
||||
workspaceIds.add(firstWorkspace.workspaceId);
|
||||
workspaceIds.add(secondWorkspace.workspaceId);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const secondRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${secondWorkspace.workspaceId}`,
|
||||
);
|
||||
await expect(secondRow).toBeVisible({ timeout: 30_000 });
|
||||
await secondRow.click();
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -348,13 +270,6 @@ test.describe("Workspace navigation regression", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
|
||||
const firstDeckEntry = workspaceDeckEntryLocator(page, serverId, firstWorkspace.workspaceId);
|
||||
const secondDeckEntry = workspaceDeckEntryLocator(
|
||||
page,
|
||||
serverId,
|
||||
secondWorkspace.workspaceId,
|
||||
);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
@@ -385,6 +300,13 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${firstAgent.id}`,
|
||||
]);
|
||||
|
||||
const firstDeckEntry = page.getByTestId(
|
||||
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
||||
);
|
||||
const secondDeckEntry = page.getByTestId(
|
||||
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
||||
);
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
@@ -420,7 +342,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(firstDeckEntry).toBeAttached();
|
||||
await expect(firstDeckEntry).toBeHidden();
|
||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await page.evaluate(
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
@@ -449,7 +371,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await expect(firstDeckEntry).toBeAttached();
|
||||
await expect(firstDeckEntry).toBeHidden();
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
@@ -463,7 +385,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(secondDeckEntry).toBeAttached();
|
||||
await expect(secondDeckEntry).toBeHidden();
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { clickNewTerminal } from "./helpers/launcher";
|
||||
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
findWorktreeWorkspaceForProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
openHomeWithProject,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
test.describe("Workspace setup runtime authority", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
@@ -32,6 +48,7 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
||||
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
|
||||
|
||||
// Navigate to the workspace via sidebar
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
@@ -69,8 +86,10 @@ test.describe("Workspace setup runtime authority", () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
|
||||
await clickNewTerminal(page);
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Verify terminal is listed under the worktree directory, not the original repo
|
||||
await expect
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
waitForWorkspaceTabsVisible,
|
||||
expectNoTerminalTabs,
|
||||
clickFirstTerminalTab,
|
||||
expectFirstTerminalTabContains,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openFileExplorer, expectExplorerEntryVisible } from "./helpers/file-explorer";
|
||||
import { expectTerminalSurfaceVisible, waitForTerminalAttached } from "./helpers/terminal-perf";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
openWorkspaceScriptsMenu,
|
||||
startWorkspaceScriptFromMenu,
|
||||
closeWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
interface WorkspaceScriptStarter {
|
||||
startWorkspaceScript(
|
||||
workspaceId: string,
|
||||
@@ -35,6 +30,18 @@ interface WorkspaceScriptStarter {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
const row = page.getByTestId(testId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Workspace setup streaming", () => {
|
||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
@@ -93,15 +100,31 @@ test.describe("Workspace setup streaming", () => {
|
||||
});
|
||||
await completed;
|
||||
|
||||
// Navigate to workspace and verify it's usable
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
await openFileExplorer(page);
|
||||
await expectExplorerEntryVisible(page, "README.md");
|
||||
await expectExplorerEntryVisible(page, "src");
|
||||
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
|
||||
await explorerToggle.click();
|
||||
}
|
||||
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
@@ -250,14 +273,33 @@ test.describe("Workspace setup streaming", () => {
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectNoTerminalTabs(page);
|
||||
await openWorkspaceScriptsMenu(page);
|
||||
await startWorkspaceScriptFromMenu(page, "web");
|
||||
await closeWorkspaceScriptsMenu(page);
|
||||
await clickFirstTerminalTab(page);
|
||||
await expectTerminalSurfaceVisible(page, { timeout: 10_000 });
|
||||
await waitForTerminalAttached(page);
|
||||
await expectFirstTerminalTabContains(page, "web");
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
await page.getByTestId("workspace-scripts-button").click();
|
||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("workspace-scripts-start-web").click();
|
||||
await page.getByTestId("workspace-scripts-menu-backdrop").click();
|
||||
|
||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await terminalTab.click();
|
||||
|
||||
// Verify the terminal surface rendered
|
||||
const terminalSurface = page.getByTestId("terminal-surface").first();
|
||||
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for terminal to fully attach (loading overlay gone)
|
||||
await page
|
||||
.locator('[data-testid="terminal-attach-loading"]')
|
||||
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||
.catch(() => {
|
||||
// overlay may never appear if attachment is instant
|
||||
});
|
||||
|
||||
await terminalSurface.click();
|
||||
|
||||
await expect(terminalTab).toContainText("web");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.69",
|
||||
"version": "0.1.67",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,7 +3,13 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -65,12 +71,15 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import {
|
||||
addBrowserActiveWorkspaceLocationListener,
|
||||
syncNavigationActiveWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
@@ -327,8 +336,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const daemonStartError = useDaemonStartLastError();
|
||||
const daemonStartIsRunning = useDaemonStartIsRunning();
|
||||
const waitForConfiguredLocalDaemon =
|
||||
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
|
||||
|
||||
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -336,7 +343,6 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
anyOnlineHostServerId ||
|
||||
daemonStartError ||
|
||||
daemonStartIsRunning ||
|
||||
waitForConfiguredLocalDaemon ||
|
||||
hasGivenUpWaitingForHost
|
||||
) {
|
||||
return;
|
||||
@@ -347,13 +353,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [
|
||||
anyOnlineHostServerId,
|
||||
daemonStartError,
|
||||
daemonStartIsRunning,
|
||||
waitForConfiguredLocalDaemon,
|
||||
hasGivenUpWaitingForHost,
|
||||
]);
|
||||
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
@@ -863,6 +863,28 @@ function RootStack() {
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationActiveWorkspaceObserver() {
|
||||
const navigationRef = useNavigationContainerRef();
|
||||
|
||||
useEffect(() => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
|
||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
const unsubscribeReady = navigationRef.addListener("ready" as never, () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeBrowserLocation();
|
||||
unsubscribeState();
|
||||
unsubscribeReady();
|
||||
};
|
||||
}, [navigationRef]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
return (
|
||||
<SidebarAnimationProvider>
|
||||
@@ -905,6 +927,7 @@ export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={flexStyle}>
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
<NavigationActiveWorkspaceObserver />
|
||||
<RootProviders>
|
||||
<RuntimeProviders>
|
||||
<AppShell />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
return (
|
||||
@@ -18,7 +18,6 @@ export default function HostAgentReadyRoute() {
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -51,14 +50,15 @@ function HostAgentReadyRouteContent() {
|
||||
|
||||
if (resolvedWorkspaceId) {
|
||||
redirectedRef.current = true;
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
currentPathname: pathname,
|
||||
});
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as Href,
|
||||
);
|
||||
}
|
||||
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -102,12 +102,13 @@ function HostAgentReadyRouteContent() {
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
if (workspaceId) {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
currentPathname: pathname,
|
||||
});
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as Href,
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
@@ -124,7 +125,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, pathname, router, serverId]);
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import {
|
||||
useGlobalSearchParams,
|
||||
useLocalSearchParams,
|
||||
useRouter,
|
||||
useRootNavigationState,
|
||||
} from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
useNavigationActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const firstValue = value[0];
|
||||
return typeof firstValue === "string" ? firstValue.trim() : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
||||
if (openIntent.kind === "agent") {
|
||||
return { kind: "agent", agentId: openIntent.agentId };
|
||||
}
|
||||
if (openIntent.kind === "terminal") {
|
||||
return { kind: "terminal", terminalId: openIntent.terminalId };
|
||||
}
|
||||
if (openIntent.kind === "file") {
|
||||
return { kind: "file", path: openIntent.path };
|
||||
}
|
||||
if (openIntent.kind === "setup") {
|
||||
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
||||
}
|
||||
return { kind: "draft", draftId: openIntent.draftId };
|
||||
}
|
||||
|
||||
function stripOpenSearchParamFromBrowserUrl() {
|
||||
if (!isWeb || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has("open")) {
|
||||
return;
|
||||
}
|
||||
url.searchParams.delete("open");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
function clearConsumedOpenIntent(input: {
|
||||
navigation: { setParams: (params: { open?: string | undefined }) => void };
|
||||
router: ReturnType<typeof useRouter>;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
input.navigation.setParams({ open: undefined });
|
||||
if (isWeb) {
|
||||
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
||||
stripOpenSearchParamFromBrowserUrl();
|
||||
}
|
||||
}
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostWorkspaceLayoutContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostWorkspaceLayoutContent() {
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[];
|
||||
}>();
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const routeWorkspaceSelection = useMemo(
|
||||
() =>
|
||||
serverId && workspaceId
|
||||
? {
|
||||
serverId,
|
||||
workspaceId,
|
||||
}
|
||||
: null,
|
||||
[serverId, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
|
||||
}, [routeWorkspaceSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
return;
|
||||
}
|
||||
if (!rootNavigationState?.key) {
|
||||
return;
|
||||
}
|
||||
if (!hasHydratedWorkspaceLayoutStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
clearConsumedOpenIntent({
|
||||
navigation: navigation as unknown as {
|
||||
setParams: (params: { open?: string | undefined }) => void;
|
||||
},
|
||||
router,
|
||||
serverId,
|
||||
workspaceId,
|
||||
});
|
||||
setIntentConsumed(true);
|
||||
return;
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
});
|
||||
}
|
||||
|
||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||
// skips search params). Strip ?open from the browser URL directly so the
|
||||
// address bar reflects the clean workspace route.
|
||||
clearConsumedOpenIntent({
|
||||
navigation: navigation as unknown as {
|
||||
setParams: (params: { open?: string | undefined }) => void;
|
||||
},
|
||||
router,
|
||||
serverId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
setIntentConsumed(true);
|
||||
}, [
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
navigation,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
router,
|
||||
serverId,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
|
||||
}
|
||||
|
||||
function areWorkspaceSelectionsEqual(
|
||||
left: ActiveWorkspaceSelection | null,
|
||||
right: ActiveWorkspaceSelection | null,
|
||||
): boolean {
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({
|
||||
fallbackSelection,
|
||||
}: {
|
||||
fallbackSelection: ActiveWorkspaceSelection | null;
|
||||
}) {
|
||||
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
||||
return current;
|
||||
}
|
||||
return [...current, activeSelection];
|
||||
});
|
||||
}, [activeSelection]);
|
||||
|
||||
if (!activeSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{mountedSelections.map((selection) => {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
return (
|
||||
<View
|
||||
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
},
|
||||
activeDeckEntry: {
|
||||
flex: 1,
|
||||
},
|
||||
inactiveDeckEntry: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,217 +1,3 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const firstValue = value[0];
|
||||
return typeof firstValue === "string" ? firstValue.trim() : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
||||
if (openIntent.kind === "agent") {
|
||||
return { kind: "agent", agentId: openIntent.agentId };
|
||||
}
|
||||
if (openIntent.kind === "terminal") {
|
||||
return { kind: "terminal", terminalId: openIntent.terminalId };
|
||||
}
|
||||
if (openIntent.kind === "file") {
|
||||
return { kind: "file", path: openIntent.path };
|
||||
}
|
||||
if (openIntent.kind === "setup") {
|
||||
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
||||
}
|
||||
return { kind: "draft", draftId: openIntent.draftId };
|
||||
}
|
||||
|
||||
function stripOpenSearchParamFromBrowserUrl() {
|
||||
if (!isWeb || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.searchParams.has("open")) {
|
||||
return;
|
||||
}
|
||||
url.searchParams.delete("open");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
function clearConsumedOpenIntent(input: {
|
||||
navigation: { setParams: (params: { open?: string | undefined }) => void };
|
||||
}) {
|
||||
input.navigation.setParams({ open: undefined });
|
||||
if (isWeb) {
|
||||
stripOpenSearchParamFromBrowserUrl();
|
||||
}
|
||||
}
|
||||
|
||||
export default function HostWorkspaceIndexRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostWorkspaceRouteContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function HostWorkspaceRouteContent() {
|
||||
const navigation = useNavigation();
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[];
|
||||
}>();
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
return;
|
||||
}
|
||||
if (!rootNavigationState?.key) {
|
||||
return;
|
||||
}
|
||||
if (!hasHydratedWorkspaceLayoutStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
clearConsumedOpenIntent({
|
||||
navigation: navigation as unknown as {
|
||||
setParams: (params: { open?: string | undefined }) => void;
|
||||
},
|
||||
});
|
||||
setIntentConsumed(true);
|
||||
return;
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
});
|
||||
}
|
||||
|
||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||
// skips search params). Strip ?open from the browser URL directly so the
|
||||
// address bar reflects the clean workspace route.
|
||||
clearConsumedOpenIntent({
|
||||
navigation: navigation as unknown as {
|
||||
setParams: (params: { open?: string | undefined }) => void;
|
||||
},
|
||||
});
|
||||
|
||||
setIntentConsumed(true);
|
||||
}, [
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
navigation,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
serverId,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck />;
|
||||
}
|
||||
|
||||
function areWorkspaceSelectionsEqual(
|
||||
left: ActiveWorkspaceSelection | null,
|
||||
right: ActiveWorkspaceSelection | null,
|
||||
): boolean {
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
function WorkspaceDeck() {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
||||
return current;
|
||||
}
|
||||
return [...current, activeSelection];
|
||||
});
|
||||
}, [activeSelection]);
|
||||
|
||||
if (!activeSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{mountedSelections.map((selection) => {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
return (
|
||||
<View
|
||||
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
},
|
||||
activeDeckEntry: {
|
||||
flex: 1,
|
||||
},
|
||||
inactiveDeckEntry: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveStartupRedirectRoute,
|
||||
resolveStartupWorkspaceSelection,
|
||||
startHostRuntimeBootstrap,
|
||||
WELCOME_ROUTE,
|
||||
} from "./host-runtime-bootstrap";
|
||||
@@ -151,24 +150,14 @@ describe("resolveStartupRedirectRoute", () => {
|
||||
});
|
||||
|
||||
describe("scenario: saved-host-online", () => {
|
||||
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
||||
it("redirects to the workspace route when the online host matches the persisted workspace", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-1",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when the online host matches it", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-1",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
expect(route).toBe("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("redirects to the host root when the persisted workspace targets a different server", () => {
|
||||
@@ -203,14 +192,14 @@ describe("resolveStartupRedirectRoute", () => {
|
||||
});
|
||||
|
||||
describe("scenario: both-succeed", () => {
|
||||
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
||||
it("redirects to the host that the host runtime selects as earliest online", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
expect(route).toBe("/h/server-saved/workspace/workspace-a");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
||||
import type { Href } from "expo-router";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
export interface HostRuntimeBootstrapStore {
|
||||
boot: () => void;
|
||||
@@ -66,31 +66,8 @@ export interface ResolveStartupRedirectInput {
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
}
|
||||
|
||||
function isIndexPathname(pathname: string) {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
|
||||
export function resolveStartupWorkspaceSelection(
|
||||
input: ResolveStartupRedirectInput,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!isIndexPathname(input.pathname)) {
|
||||
return null;
|
||||
}
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!input.anyOnlineHostServerId ||
|
||||
!input.workspaceSelection ||
|
||||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaceSelection;
|
||||
}
|
||||
|
||||
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
|
||||
if (!isIndexPathname(input.pathname)) {
|
||||
if (input.pathname !== "/" && input.pathname !== "") {
|
||||
return null;
|
||||
}
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
@@ -98,8 +75,14 @@ export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput):
|
||||
}
|
||||
|
||||
if (input.anyOnlineHostServerId) {
|
||||
if (resolveStartupWorkspaceSelection(input)) {
|
||||
return null;
|
||||
if (
|
||||
input.workspaceSelection &&
|
||||
input.workspaceSelection.serverId === input.anyOnlineHostServerId
|
||||
) {
|
||||
return buildHostWorkspaceRoute(
|
||||
input.workspaceSelection.serverId,
|
||||
input.workspaceSelection.workspaceId,
|
||||
);
|
||||
}
|
||||
return buildHostRootRoute(input.anyOnlineHostServerId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
||||
const { redirectMock, state } = vi.hoisted(() => {
|
||||
const hoistedState = {
|
||||
pathname: "/",
|
||||
bootstrapState: {
|
||||
@@ -18,11 +18,11 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
||||
storeReady: false,
|
||||
} as HostRuntimeBootstrapState,
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||
};
|
||||
|
||||
return {
|
||||
navigateToWorkspaceMock: vi.fn(),
|
||||
redirectMock: vi.fn(),
|
||||
state: hoistedState,
|
||||
};
|
||||
@@ -50,8 +50,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
useActiveWorkspaceSelection: () => state.workspaceSelection,
|
||||
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection,
|
||||
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
|
||||
}));
|
||||
|
||||
describe("Index route startup navigation", () => {
|
||||
@@ -68,8 +68,8 @@ describe("Index route startup navigation", () => {
|
||||
storeReady: false,
|
||||
};
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
navigateToWorkspaceMock.mockReset();
|
||||
redirectMock.mockReset();
|
||||
|
||||
container = document.createElement("div");
|
||||
@@ -98,17 +98,23 @@ describe("Index route startup navigation", () => {
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the startup splash while the workspace selection has not loaded", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.isWorkspaceSelectionLoaded = false;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
|
||||
currentPathname: "/",
|
||||
});
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("navigates to the host root when the persisted workspace targets a different server", async () => {
|
||||
|
||||
@@ -2,13 +2,10 @@ import React from "react";
|
||||
import { Redirect, usePathname } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
resolveStartupRedirectRoute,
|
||||
resolveStartupWorkspaceSelection,
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
getLastNavigationWorkspaceRouteSelection,
|
||||
useIsLastNavigationWorkspaceRouteSelectionLoaded,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
@@ -18,35 +15,17 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const workspaceSelection = useActiveWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded();
|
||||
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: isWorkspaceSelectionLoaded
|
||||
? getLastNavigationWorkspaceRouteSelection()
|
||||
: null,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!startupWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, {
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}, [pathname, startupWorkspaceSelection]);
|
||||
|
||||
if (startupWorkspaceSelection) {
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
}
|
||||
|
||||
if (redirectRoute) {
|
||||
return <Redirect href={redirectRoute} />;
|
||||
|
||||
@@ -178,7 +178,6 @@ export default function PairScanScreen() {
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
|
||||
useTls: offer.relay.useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
},
|
||||
{ serverId: offer.serverId },
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M5.5 10.5L8 4L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="6.25" y1="8.75" x2="9.75" y2="8.75" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 416 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="currentColor"/>
|
||||
<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="currentColor"/>
|
||||
<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="currentColor"/>
|
||||
<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 650 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M9.972 13.193h2.577q.187 0 .277-.09t.091-.294V10.47q0-.324.133-.59.135-.27.36-.424a1 1 0 0 1 .099-.06 1 1 0 0 1-.1-.058 1.1 1.1 0 0 1-.359-.424 1.3 1.3 0 0 1-.133-.59v-2.33q0-.202-.091-.293t-.277-.089H9.972a.38.38 0 0 1-.294-.124.4.4 0 0 1-.108-.281q.002-.176.131-.293a.46.46 0 0 1 .313-.111h2.775c.36 0 .642.097.836.288q.293.29.294.832V8.24q0 .39.152.562.148.168.555.178a.4.4 0 0 1 .27.128.41.41 0 0 1 .104.287.44.44 0 0 1-.1.282.34.34 0 0 1-.277.132c-.266.006-.452.066-.55.177q-.151.174-.152.573v2.318c0 .235-.043.44-.128.607a.85.85 0 0 1-.389.387 1.3 1.3 0 0 1-.534.124V14h-2.854a.45.45 0 0 1-.315-.118.38.38 0 0 1-.129-.286q0-.164.109-.282a.38.38 0 0 1 .293-.123zM1.103 9.108a.4.4 0 0 1 .276-.128q.4-.008.55-.177.15-.173.151-.562V5.923q.002-.543.294-.832c.195-.191.477-.288.836-.288h2.775q.188 0 .313.111a.37.37 0 0 1 .131.293.4.4 0 0 1-.108.281.38.38 0 0 1-.293.123H3.45q-.186 0-.277.09t-.092.292v2.33c0 .213-.044.413-.133.59a1.1 1.1 0 0 1-.359.424 1 1 0 0 1-.1.059 1 1 0 0 1 .1.059q.225.156.359.423t.133.59v2.34q0 .203.092.293.088.09.277.089h2.577a.38.38 0 0 1 .294.123q.108.12.108.281a.38.38 0 0 1-.13.286.45.45 0 0 1-.314.118l-2.775-.003a1.4 1.4 0 0 1-.613-.126.87.87 0 0 1-.388-.387 1.34 1.34 0 0 1-.129-.608v-2.318q0-.4-.151-.572-.151-.168-.55-.177a.35.35 0 0 1-.278-.132.42.42 0 0 1-.102-.28q0-.173.103-.287"/>
|
||||
<path fill="currentColor" d="M5.437 10.36a.986.986 0 0 1-.994-.975c0-.538.446-.976.994-.976s.994.438.994.976a.986.986 0 0 1-.994.976M10.562 8.41c.548 0 .994.437.994.975a.986.986 0 0 1-.994.976.986.986 0 0 1-.994-.976c0-.538.446-.976.994-.976M8.74 2c.37 0 .446.15.446.275v.034q0 .034-.009.201-.01.162-.017.549-.018.378-.044 1.06c0 .106-.065.233-.375.233-.311 0-.376-.126-.376-.23l-.07-1.835v-.003c0-.13.078-.284.446-.284M7.256 2c.369 0 .446.15.446.275v.034q0 .034-.01.201-.007.162-.017.549-.017.378-.043 1.06c0 .106-.065.233-.376.233-.31 0-.375-.126-.375-.23l-.07-1.835v-.003C6.81 2.154 6.887 2 7.256 2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,26 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.66949 9.56637C4.66949 8.92122 4.13288 8.39823 3.47094 8.39823C2.809 8.39823 2.2724 8.92122 2.2724 9.56637C2.2724 10.2115 2.809 10.7345 3.47094 10.7345V11C2.65856 11 2 10.3581 2 9.56637C2 8.7746 2.65856 8.13274 3.47094 8.13274C4.28332 8.13274 4.94189 8.7746 4.94189 9.56637C4.94189 10.3581 4.28332 11 3.47094 11V10.7345C4.13288 10.7345 4.66949 10.2115 4.66949 9.56637Z" fill="currentColor"/>
|
||||
<path d="M3.90678 9.59292C3.90678 9.81286 3.72385 9.99115 3.49818 9.99115C3.27252 9.99115 3.08959 9.81286 3.08959 9.59292C3.08959 9.37298 3.27252 9.19469 3.49818 9.19469C3.72385 9.19469 3.90678 9.37298 3.90678 9.59292Z" fill="currentColor"/>
|
||||
<path d="M3.8523 9.59292C3.8523 9.40231 3.69376 9.24779 3.49818 9.24779C3.30261 9.24779 3.14407 9.40231 3.14407 9.59292C3.14407 9.78353 3.30261 9.93805 3.49818 9.93805V9.99115C3.27252 9.99115 3.08959 9.81286 3.08959 9.59292C3.08959 9.37298 3.27252 9.19469 3.49818 9.19469C3.72385 9.19469 3.90678 9.37298 3.90678 9.59292C3.90678 9.81286 3.72385 9.99115 3.49818 9.99115V9.93805C3.69376 9.93805 3.8523 9.78353 3.8523 9.59292Z" fill="currentColor"/>
|
||||
<path d="M4.66949 6.43363C4.66949 5.78848 4.13288 5.26549 3.47094 5.26549C2.809 5.26549 2.2724 5.78848 2.2724 6.43363C2.2724 7.07878 2.809 7.60177 3.47094 7.60177V7.86726C2.65856 7.86726 2 7.2254 2 6.43363C2 5.64186 2.65856 5 3.47094 5C4.28332 5 4.94189 5.64186 4.94189 6.43363C4.94189 7.2254 4.28332 7.86726 3.47094 7.86726V7.60177C4.13288 7.60177 4.66949 7.07878 4.66949 6.43363Z" fill="currentColor"/>
|
||||
<path d="M3.90678 6.46018C3.90678 6.68011 3.72385 6.85841 3.49818 6.85841C3.27252 6.85841 3.08959 6.68011 3.08959 6.46018C3.08959 6.24024 3.27252 6.06195 3.49818 6.06195C3.72385 6.06195 3.90678 6.24024 3.90678 6.46018Z" fill="currentColor"/>
|
||||
<path d="M3.8523 6.46018C3.8523 6.26957 3.69376 6.11504 3.49818 6.11504C3.30261 6.11504 3.14407 6.26957 3.14407 6.46018C3.14407 6.65079 3.30261 6.80531 3.49818 6.80531V6.85841C3.27252 6.85841 3.08959 6.68011 3.08959 6.46018C3.08959 6.24024 3.27252 6.06195 3.49818 6.06195C3.72385 6.06195 3.90678 6.24024 3.90678 6.46018C3.90678 6.68011 3.72385 6.85841 3.49818 6.85841V6.80531C3.69376 6.80531 3.8523 6.65079 3.8523 6.46018Z" fill="currentColor"/>
|
||||
<path d="M8.04722 9.56637C8.04722 8.92122 7.51061 8.39823 6.84867 8.39823C6.18673 8.39823 5.65012 8.92122 5.65012 9.56637C5.65012 10.2115 6.18673 10.7345 6.84867 10.7345V11C6.03629 11 5.37772 10.3581 5.37772 9.56637C5.37772 8.7746 6.03629 8.13274 6.84867 8.13274C7.66105 8.13274 8.31961 8.7746 8.31961 9.56637C8.31961 10.3581 7.66105 11 6.84867 11V10.7345C7.51061 10.7345 8.04722 10.2115 8.04722 9.56637Z" fill="currentColor"/>
|
||||
<path d="M7.2845 9.59292C7.2845 9.81286 7.10157 9.99115 6.87591 9.99115C6.65025 9.99115 6.46731 9.81286 6.46731 9.59292C6.46731 9.37298 6.65025 9.19469 6.87591 9.19469C7.10157 9.19469 7.2845 9.37298 7.2845 9.59292Z" fill="currentColor"/>
|
||||
<path d="M7.23002 9.59292C7.23002 9.40231 7.07148 9.24779 6.87591 9.24779C6.68033 9.24779 6.52179 9.40231 6.52179 9.59292C6.52179 9.78353 6.68033 9.93805 6.87591 9.93805V9.99115C6.65025 9.99115 6.46731 9.81286 6.46731 9.59292C6.46731 9.37298 6.65025 9.19469 6.87591 9.19469C7.10157 9.19469 7.2845 9.37298 7.2845 9.59292C7.2845 9.81286 7.10157 9.99115 6.87591 9.99115V9.93805C7.07148 9.93805 7.23002 9.78353 7.23002 9.59292Z" fill="currentColor"/>
|
||||
<path d="M8.04722 6.43363C8.04722 5.78848 7.51061 5.26549 6.84867 5.26549C6.18673 5.26549 5.65012 5.78848 5.65012 6.43363C5.65012 7.07878 6.18673 7.60177 6.84867 7.60177V7.86726C6.03629 7.86726 5.37772 7.2254 5.37772 6.43363C5.37772 5.64186 6.03629 5 6.84867 5C7.66105 5 8.31961 5.64186 8.31961 6.43363C8.31961 7.2254 7.66105 7.86726 6.84867 7.86726V7.60177C7.51061 7.60177 8.04722 7.07878 8.04722 6.43363Z" fill="currentColor"/>
|
||||
<path d="M7.2845 6.46018C7.2845 6.68011 7.10157 6.85841 6.87591 6.85841C6.65025 6.85841 6.46731 6.68011 6.46731 6.46018C6.46731 6.24024 6.65025 6.06195 6.87591 6.06195C7.10157 6.06195 7.2845 6.24024 7.2845 6.46018Z" fill="currentColor"/>
|
||||
<path d="M7.23002 6.46018C7.23002 6.26957 7.07148 6.11504 6.87591 6.11504C6.68033 6.11504 6.52179 6.26957 6.52179 6.46018C6.52179 6.65079 6.68033 6.80531 6.87591 6.80531V6.85841C6.65025 6.85841 6.46731 6.68011 6.46731 6.46018C6.46731 6.24024 6.65025 6.06195 6.87591 6.06195C7.10157 6.06195 7.2845 6.24024 7.2845 6.46018C7.2845 6.68011 7.10157 6.85841 6.87591 6.85841V6.80531C7.07148 6.80531 7.23002 6.65079 7.23002 6.46018Z" fill="currentColor"/>
|
||||
<path d="M11.207 9.56637C11.207 8.92122 10.6704 8.39823 10.0085 8.39823C9.34653 8.39823 8.80993 8.92122 8.80993 9.56637C8.80993 10.2115 9.34653 10.7345 10.0085 10.7345V11C9.19609 11 8.53753 10.3581 8.53753 9.56637C8.53753 8.7746 9.19609 8.13274 10.0085 8.13274C10.8209 8.13274 11.4794 8.7746 11.4794 9.56637C11.4794 10.3581 10.8209 11 10.0085 11V10.7345C10.6704 10.7345 11.207 10.2115 11.207 9.56637Z" fill="currentColor"/>
|
||||
<path d="M10.4443 9.59292C10.4443 9.81286 10.2614 9.99115 10.0357 9.99115C9.81005 9.99115 9.62712 9.81286 9.62712 9.59292C9.62712 9.37298 9.81005 9.19469 10.0357 9.19469C10.2614 9.19469 10.4443 9.37298 10.4443 9.59292Z" fill="currentColor"/>
|
||||
<path d="M10.3898 9.59292C10.3898 9.40231 10.2313 9.24779 10.0357 9.24779C9.84014 9.24779 9.6816 9.40231 9.6816 9.59292C9.6816 9.78353 9.84014 9.93805 10.0357 9.93805V9.99115C9.81005 9.99115 9.62712 9.81286 9.62712 9.59292C9.62712 9.37298 9.81005 9.19469 10.0357 9.19469C10.2614 9.19469 10.4443 9.37298 10.4443 9.59292C10.4443 9.81286 10.2614 9.99115 10.0357 9.99115V9.93805C10.2313 9.93805 10.3898 9.78353 10.3898 9.59292Z" fill="currentColor"/>
|
||||
<path d="M11.207 6.43363C11.207 5.78848 10.6704 5.26549 10.0085 5.26549C9.34653 5.26549 8.80993 5.78848 8.80993 6.43363C8.80993 7.07878 9.34653 7.60177 10.0085 7.60177V7.86726C9.19609 7.86726 8.53753 7.2254 8.53753 6.43363C8.53753 5.64186 9.19609 5 10.0085 5C10.8209 5 11.4794 5.64186 11.4794 6.43363C11.4794 7.2254 10.8209 7.86726 10.0085 7.86726V7.60177C10.6704 7.60177 11.207 7.07878 11.207 6.43363Z" fill="currentColor"/>
|
||||
<path d="M10.4443 6.46018C10.4443 6.68011 10.2614 6.85841 10.0357 6.85841C9.81005 6.85841 9.62712 6.68011 9.62712 6.46018C9.62712 6.24024 9.81005 6.06195 10.0357 6.06195C10.2614 6.06195 10.4443 6.24024 10.4443 6.46018Z" fill="currentColor"/>
|
||||
<path d="M10.3898 6.46018C10.3898 6.26957 10.2313 6.11504 10.0357 6.11504C9.84014 6.11504 9.6816 6.26957 9.6816 6.46018C9.6816 6.65079 9.84014 6.80531 10.0357 6.80531V6.85841C9.81005 6.85841 9.62712 6.68011 9.62712 6.46018C9.62712 6.24024 9.81005 6.06195 10.0357 6.06195C10.2614 6.06195 10.4443 6.24024 10.4443 6.46018C10.4443 6.68011 10.2614 6.85841 10.0357 6.85841V6.80531C10.2313 6.80531 10.3898 6.65079 10.3898 6.46018Z" fill="currentColor"/>
|
||||
<path d="M14.5847 9.56637C14.5847 8.92122 14.0481 8.39823 13.3862 8.39823C12.7243 8.39823 12.1877 8.92122 12.1877 9.56637C12.1877 10.2115 12.7243 10.7345 13.3862 10.7345V11C12.5738 11 11.9153 10.3581 11.9153 9.56637C11.9153 8.7746 12.5738 8.13274 13.3862 8.13274C14.1986 8.13274 14.8571 8.7746 14.8571 9.56637C14.8571 10.3581 14.1986 11 13.3862 11V10.7345C14.0481 10.7345 14.5847 10.2115 14.5847 9.56637Z" fill="currentColor"/>
|
||||
<path d="M13.822 9.59292C13.822 9.81286 13.6391 9.99115 13.4134 9.99115C13.1878 9.99115 13.0048 9.81286 13.0048 9.59292C13.0048 9.37298 13.1878 9.19469 13.4134 9.19469C13.6391 9.19469 13.822 9.37298 13.822 9.59292Z" fill="currentColor"/>
|
||||
<path d="M13.7676 9.59292C13.7676 9.40231 13.609 9.24779 13.4134 9.24779C13.2179 9.24779 13.0593 9.40231 13.0593 9.59292C13.0593 9.78353 13.2179 9.93805 13.4134 9.93805V9.99115C13.1878 9.99115 13.0048 9.81286 13.0048 9.59292C13.0048 9.37298 13.1878 9.19469 13.4134 9.19469C13.6391 9.19469 13.822 9.37298 13.822 9.59292C13.822 9.81286 13.6391 9.99115 13.4134 9.99115V9.93805C13.609 9.93805 13.7676 9.78353 13.7676 9.59292Z" fill="currentColor"/>
|
||||
<path d="M14.5847 6.43363C14.5847 5.78848 14.0481 5.26549 13.3862 5.26549C12.7243 5.26549 12.1877 5.78848 12.1877 6.43363C12.1877 7.07878 12.7243 7.60177 13.3862 7.60177V7.86726C12.5738 7.86726 11.9153 7.2254 11.9153 6.43363C11.9153 5.64186 12.5738 5 13.3862 5C14.1986 5 14.8571 5.64186 14.8571 6.43363C14.8571 7.2254 14.1986 7.86726 13.3862 7.86726V7.60177C14.0481 7.60177 14.5847 7.07878 14.5847 6.43363Z" fill="currentColor"/>
|
||||
<path d="M13.822 6.46018C13.822 6.68011 13.6391 6.85841 13.4134 6.85841C13.1878 6.85841 13.0048 6.68011 13.0048 6.46018C13.0048 6.24024 13.1878 6.06195 13.4134 6.06195C13.6391 6.06195 13.822 6.24024 13.822 6.46018Z" fill="currentColor"/>
|
||||
<path d="M13.7676 6.46018C13.7676 6.26957 13.609 6.11504 13.4134 6.11504C13.2179 6.11504 13.0593 6.26957 13.0593 6.46018C13.0593 6.65079 13.2179 6.80531 13.4134 6.80531V6.85841C13.1878 6.85841 13.0048 6.68011 13.0048 6.46018C13.0048 6.24024 13.1878 6.06195 13.4134 6.06195C13.6391 6.06195 13.822 6.24024 13.822 6.46018C13.822 6.68011 13.6391 6.85841 13.4134 6.85841V6.80531C13.609 6.80531 13.7676 6.65079 13.7676 6.46018Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1200 1200">
|
||||
<path fill="currentColor" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 92 96" fill="currentColor">
|
||||
<path d="M65.45 16.3c10.89 0 19.71 8.86 19.71 19.8v6.6l5.74 11.46a4.48 4.48 0 0 1-.01 3.6L85.16 69.1v6.6c0 10.94-8.83 19.8-19.71 19.8H26.02c-10.89 0-19.71-8.86-19.71-19.8v-6.6L.45 57.8a4.48 4.48 0 0 1-.01-3.67L6.31 42.7v-6.6c0-10.94 8.83-19.8 19.71-19.8h39.43zm-2.52 5.7H29.19c-9.32 0-16.87 7.56-16.87 16.88v5.62l-4.88 9.46a4.48 4.48 0 0 0 .01 3.68l4.87 9.36v5.62c0 9.32 7.55 16.88 16.87 16.88h33.74c9.32 0 16.87-7.55 16.87-16.88V67l4.77-9.39a4.48 4.48 0 0 0 0-3.61L79.8 44.5v-5.62C79.8 29.56 72.25 22 62.93 22z" fill-rule="nonzero"/>
|
||||
<circle cx="45.73" cy="11" r="11"/>
|
||||
<rect stroke="currentColor" stroke-width="8" x="31" y="44.5" width="5" height="22" rx="2.5"/>
|
||||
<rect stroke="currentColor" stroke-width="8" x="55" y="44.5" width="5" height="22" rx="2.5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 878 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M12.13 1.25c.15-.14.16-.14.28-.15.18 0 .36.08.64.34.68.62 1.61 1.89 2.2 2.97l.22.42.32.16c.3.16.81.48 1.02.65.09.08.11.08.21.04.45-.18 1.09.05 1.65.6.51.49 1 1.33 1.18 2.03.03.11.06.36.07.54.04.64-.16 1.14-.55 1.37-.08.04-.08.06-.08.26.02.95-.23 1.89-.74 2.82-.58 1.04-1.6 2.11-2.98 3.12-.74.55-2.5 1.58-3.3 1.95-1.9.86-3.43 1.2-4.76 1.03-.79-.09-1.69-.41-2.22-.77-.14-.1-.16-.1-.27-.07-.57.16-1.31-.18-1.94-.88-.25-.28-.66-.98-.79-1.35-.3-.87-.25-1.65.16-2.12.11-.12.09-.33.09-.33-.04-.33-.05-.82-.04-1.14v-.3l-.43-.79c-.68-1.23-1.12-2.25-1.28-3.04-.09-.43-.09-.62.02-.77.07-.09.28-.18.54-.22.66-.12 2.09-.01 3.69.27l.16.03.36-.32c.61-.54 1.01-.84 1.75-1.31.77-.48 1.64-.89 2.62-1.2l.32-.1.17-.46c.62-1.65 1.25-2.86 1.71-3.27h0Zm-5.19 8.45c-.7.41-1.05.61-1.3.84-1.04.93-1.43 2.39-.99 3.71.11.33.31.68.71 1.39.41.71.61 1.06.83 1.32.92 1.05 2.37 1.45 3.68 1 .32-.11.68-.31 1.38-.72l4.02-2.34c.7-.41 1.05-.61 1.3-.84 1.04-.93 1.43-2.39.99-3.71-.11-.33-.31-.68-.71-1.39s-.61-1.06-.83-1.32c-.92-1.05-2.37-1.45-3.68-1-.32.11-.67.31-1.38.72l-4.02 2.34Z"/>
|
||||
<path fill="currentColor" fill-opacity="0.4" d="M8.02 12.13c.38-.22.87-.09 1.09.29l.86 1.5c.22.38.09.88-.29 1.09-.38.22-.86.09-1.09-.29l-.86-1.5c-.22-.38-.09-.87.29-1.09Z"/>
|
||||
<path fill="currentColor" fill-opacity="0.4" d="M12.31 9.64c.38-.22.87-.09 1.09.29l.86 1.5c.22.38.09.87-.29 1.09s-.87.09-1.09-.29l-.86-1.5c-.22-.38-.09-.88.29-1.09Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 362 362" overflow="hidden">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M1562.63,107.07h-4.18v5.15h4.18c1.94,0,3.21-.87,3.21-2.53,0-1.75-1.17-2.62-3.21-2.62ZM1553.3,102.31h9.42c5.15,0,8.55,2.82,8.55,7.19,0,2.72-1.36,4.76-3.4,6.02l3.69,5.34v1.07h-5.34l-3.59-5.15h-4.18v5.15h-5.15v-19.62ZM1579.63,112.51c0-10.49-6.99-18.46-17.68-18.46s-17.49,7.58-17.49,18.46,6.99,18.46,17.49,18.46,17.68-7.97,17.68-18.46ZM1584,112.51c0,12.34-8.26,22.63-22.05,22.63s-21.86-10.39-21.86-22.63,8.16-22.63,21.86-22.63,22.05,10.3,22.05,22.63ZM348.21,156.4l-42.55,24.61,42.55,24.54c10.75,6.22,14.42,19.9,8.21,30.67-6.2,10.7-19.92,14.37-30.63,8.18l-76.21-44c-5.08-2.96-8.61-7.59-10.18-12.8-.74-2.4-1.09-4.87-1-7.31.04-1.76.3-3.53.79-5.29,1.52-5.48,5.1-10.38,10.39-13.46l76.21-43.97c10.71-6.19,24.43-2.51,30.63,8.22,6.22,10.72,2.54,24.41-8.21,30.61ZM307.93,275.68l-76.19-43.94c-4.09-2.38-8.63-3.3-12.99-2.93-11.62.83-20.75,10.53-20.75,22.34v87.98c0,12.4,10,22.45,22.42,22.45s22.45-10.05,22.45-22.45v-49.21l42.66,24.63c10.72,6.23,24.43,2.54,30.61-8.17,6.17-10.74,2.53-24.5-8.21-30.7ZM220.01,189.61l-31.67,31.63c-.91.93-2.65,1.69-3.98,1.69h-9.31c-1.28,0-3.06-.76-3.98-1.69l-31.66-31.63c-.91-.89-1.64-2.68-1.64-3.94v-9.33c0-1.29.73-3.09,1.64-3.99l31.66-31.63c.92-.93,2.7-1.66,3.98-1.66h9.31c1.29,0,3.07.73,3.98,1.66l31.67,31.63c.9.9,1.63,2.7,1.63,3.99v9.33c0,1.26-.73,3.05-1.63,3.94ZM194.7,180.83c0-1.28-.77-3.06-1.68-4l-9.17-9.13c-.9-.9-2.68-1.65-3.96-1.65h-.36c-1.28,0-3.05.75-3.94,1.65l-9.17,9.13c-.92.95-1.62,2.73-1.62,4v.36c0,1.26.7,3.03,1.62,3.95l9.17,9.16c.9.9,2.67,1.65,3.94,1.65h.36c1.28,0,3.06-.75,3.96-1.65l9.17-9.16c.9-.92,1.68-2.68,1.68-3.95v-.36ZM51.48,86.28l76.21,43.99c4.09,2.35,8.64,3.29,13.01,2.93,11.6-.86,20.75-10.58,20.75-22.37V22.84c0-12.37-10.06-22.41-22.42-22.41s-22.45,10.05-22.45,22.41v49.23l-42.7-24.66c-10.71-6.2-24.4-2.53-30.61,8.2-6.19,10.76-2.53,24.47,8.2,30.67ZM218.75,133.19c4.36.36,8.9-.57,12.99-2.93l76.19-43.99c10.74-6.2,14.38-19.91,8.21-30.67-6.18-10.72-19.89-14.4-30.61-8.2l-42.66,24.66V22.84c0-12.37-10.03-22.41-22.45-22.41s-22.42,10.05-22.42,22.41v87.98c0,11.8,9.13,21.51,20.75,22.37ZM140.71,228.8c-4.38-.37-8.92.54-13.01,2.93l-76.21,43.94c-10.72,6.2-14.38,19.96-8.2,30.7,6.22,10.71,19.91,14.4,30.61,8.17l42.7-24.63v49.21c0,12.4,10.03,22.45,22.45,22.45s22.42-10.05,22.42-22.45v-87.98c0-11.81-9.16-21.51-20.75-22.34ZM120.04,187.6c.76-2.4,1.08-4.87,1.02-7.31-.09-1.76-.32-3.53-.82-5.29-1.51-5.48-5.08-10.38-10.44-13.46l-76.15-43.97c-10.75-6.19-24.45-2.51-30.63,8.22-6.23,10.72-2.55,24.41,8.2,30.61l42.55,24.61-42.55,24.54c-10.75,6.22-14.41,19.9-8.2,30.67,6.17,10.7,19.88,14.37,30.63,8.18l76.15-44c5.14-2.96,8.63-7.59,10.23-12.8Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 12 12">
|
||||
<g transform="matrix(0.00160555,0,0,-0.0014599,-0.26812129,11.08482)" fill="currentColor">
|
||||
<path d="m 297,6493 c -4,-3 -7,-1357 -7,-3008 0,-2390 3,-3005 12,-3012 13,-10 1199,-9 1224,1 11,4 13,61 10,295 -2,159 -7,294 -10,299 -4,7 -112,11 -312,11 -168,0 -309,3 -314,6 -12,7 -13,4775 -2,4792 6,10 85,13 317,15 l 310,3 v 600 l -611,3 c -336,1 -614,-1 -617,-5 z"/>
|
||||
<path d="m 6287,6493 c -4,-3 -7,-136 -7,-294 0,-219 3,-290 13,-296 6,-4 152,-10 322,-13 l 310,-5 3,-2394 c 1,-1545 -1,-2398 -8,-2405 -6,-6 -127,-9 -316,-7 -246,2 -309,0 -315,-11 -5,-7 -9,-145 -9,-305 V 470 l 23,-1 c 158,-4 1192,-2 1200,3 14,9 22,5964 7,6002 l -10,26 h -603 c -332,0 -607,-3 -610,-7 z"/>
|
||||
<g transform="matrix(1.0249,0,0,1.0566,-429.39,51.322)">
|
||||
<path d="m 2476.7,5111.6 c -64.95,-6.2305 -71.077,-28.66 -73.528,-325.23 -3.6765,-261.68 4.9018,-351.4 36.764,-371.34 7.3528,-4.9844 583.32,-12.461 1276.9,-16.199 l 1262.2,-6.2306 73.528,-34.891 c 191.17,-90.966 300.24,-292.84 284.31,-527.1 -15.931,-223.05 -134.8,-392.52 -314.95,-449.85 -61.274,-19.938 -133.58,-21.184 -727.93,-18.692 -607.83,3.7384 -661.75,2.4922 -682.59,-17.446 -22.058,-19.938 -23.284,-43.614 -23.284,-331.47 0,-190.65 4.9019,-320.25 12.255,-335.2 11.029,-21.184 26.96,-23.676 123.77,-23.676 h 110.29 l 66.176,-63.552 c 36.764,-34.891 125,-125.86 198.53,-201.87 72.303,-76.013 322.3,-333.96 555.14,-571.96 l 424.01,-434.89 519.6,3.7383 c 390.93,2.4922 520.83,7.4767 520.83,18.692 0,7.4766 -223.04,249.22 -496.32,534.58 -562.49,589.41 -666.66,701.56 -660.53,707.79 2.451,2.4922 52.695,8.7227 111.52,13.707 322.3,24.922 593.13,194.39 790.43,495.95 129.9,199.38 187.5,391.28 196.08,660.44 8.5783,245.48 -14.706,368.85 -116.42,600.63 -123.77,286.61 -328.43,492.21 -596.8,601.87 -232.84,95.95 -167.89,92.212 -1574.7,93.458 -691.17,1.2462 -1274.5,1.2462 -1295.3,-1.2461 z"/>
|
||||
<path d="m 2430.1,3942.7 c -24.509,-18.692 -24.509,-33.645 -24.509,-1254.8 0,-1163.9 1.2255,-1238.6 22.058,-1271 l 20.833,-33.645 h 337 c 289.21,0 338.23,2.4922 354.16,19.938 18.382,17.446 19.608,145.79 18.382,1267.3 -1.2255,1115.3 -2.4509,1251.1 -19.608,1271 -18.382,18.692 -50.244,21.184 -351.71,21.184 -276.96,-1.2461 -335.78,-3.7383 -356.61,-19.938 z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 366 368"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m 26,275.93574 c 0.65,-9.32 8.46,-15.43 15,-20.96 15.43,-13.04 35.67,-21.92 53,-32.71 20.67,-12.88 61.58,-40.31 80,-55.03 0,0 21,-18.39 21,-18.39 0,0 28,-23.91 28,-23.91 10,-8.33 14.1,-11.65 25,-18.92 7.42,-4.94 19.07,-12.550001 24.52,-19.330001 7.17,-8.93 4.16,-18.82 -4.53,-25.45 -5.67,-4.32 -23.5,-8.94 -30.99,-10.66 -3.1,-0.72 -11.45,-1.57 -13.38,-3.62 -3.22,-3.43 1.67,-6.99 4.38,-8.45 6.61,-3.54 17.51,-5.73 25,-5.82 0,0 4,0 4,0 16.51,-0.2 18.85,-12.47 40,-11.99 9.26,0.21 21.16,2.48 29,7.54 7.87,5.07 12.2,11.73 17.05,19.45 6.31,10.05 12.35,21.16 14.49,33 1.42,7.88 -0.45,13.62 -0.54,21.000001 -0.28,24.26 6.68,37.92 -2.44,63 -5.9,16.24 -14.87,35.36 -23.98,50 -9.34,15 -20.81,30.64 -35.58,40.64 0,0 -16,9.36 -16,9.36 -3.3,1.9 -6.18,2.94 -6.64,7.04 -0.68,2.83 -0.16,7.11 0,9.96 0,0 1.29,8 1.29,8 1.62,5.16 8.51,12.15 12.35,16 3.36,3.37 6.84,7.82 12,7.89 4.2,0.06 8.14,-2.87 12,-4.44 5.24,-2.13 11.35,-3.55 17,-3.94 6.33,-0.43 17.05,1.85 14,10.49 -2.84,-3.54 -6.09,-6.36 -11,-5.57 -2.47,0.4 -10.06,3.6 -8.09,7.08 1.19,2.08 7.67,3.01 10.09,3.88 6.26,2.25 10.83,5.76 14,11.61 0,0 -17,-6.33 -17,-6.33 -9.74,-2.08 -18.73,0.62 -28,3.33 5.49,3.71 8.06,5.51 12,11 -8.23,-1.96 -13.6,-7.46 -26,-6.96 -9.64,0.39 -11.89,4.13 -27,3.96 -9.42,-0.11 -17.78,-4.36 -27,-3.96 -9.45,0.41 -13.16,5.64 -21,8.96 3.16,-14.88 19.73,-16 32,-16 -7.31,-10.21 -20.46,-18.11 -23.28,-26 -0.7,-1.97 -0.69,-3.94 -0.71,-6 0,0 0,-14 0,-14 0.23,-2.35 1.13,-5.62 0,-7.73 -1.66,-2.77 -8.94,-4.64 -12.01,-5.55 -20.1,-5.96 -24.58,-0.84 -43,2.01 0,0 -14,2.11 -14,2.11 -9.78,1.06 -18.83,0.92 -28,5.02 0,0 -15,7.88 -15,7.88 -16.76,8.6 -42.02,22.27 -59,27.93 -9.14,3.04 -16.2,6.24 -26,6.33 0,0 5,-10 5,-10 -3.92,0.18 -15.76,3.08 -17.92,-0.51 -2.44,-4.07 5.28,-9.01 7.92,-10.91 0,0 36,-24.58 36,-24.58 0,0 -19,3.25 -19,3.25 z m 227,1.75 c -2.98,0.84 -11.45,3.61 -12.96,6.27 -0.95,1.89 -0.18,5.54 0,7.73 0.04,2.47 -0.13,4.56 0.61,7 1.52,5.07 11.84,17.33 16.35,19.88 3.38,1.91 15.93,0.11 20,-0.88 -4.12,-8.71 -16.66,-15.05 -21.99,-24 -3.43,-5.75 -0.8,-10.06 -2.01,-16 z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">
|
||||
<path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 573 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 128 128">
|
||||
<path fill="currentColor" d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/>
|
||||
<path fill="currentColor" d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/>
|
||||
<path fill="currentColor" d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/>
|
||||
<path fill="currentColor" d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 973 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.12109 11.0078H1.99902V5.49316H3.12109V11.0078ZM3.7041 5.49316C4.91979 5.49316 5.31142 5.57449 5.80762 5.7373C6.50208 5.97365 6.85299 6.40958 6.86133 7.04492V9.45605C6.86125 10.207 6.37767 10.6797 5.41016 10.874C4.95546 10.9633 4.69632 11.0078 3.7041 11.0078V10.6064C4.72131 10.6064 4.95994 10.5507 5.34863 10.4404C5.91072 10.2671 6.19576 9.93907 6.2041 9.45605V7.04492C6.20402 6.4883 5.83211 6.13886 5.08789 5.99707C4.74057 5.93405 4.58897 5.89978 3.7041 5.89453V5.49316ZM9.16797 5.49316V11.0078H8.0459V5.49316H9.16797ZM14 6.79297V11.0078H12.8779V8.0791L13.8877 6.79297H14ZM14 5.49316V5.7373L11.3594 8.97852H10.7852L9.74219 6.94531V6.07812H9.86719L11.0723 8.33203L13.4258 5.49316H14Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 822 B |
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<text x="8" y="10" text-anchor="middle" font-size="10.5" font-family="Georgia, serif" font-style="italic" fill="currentColor">δ</text>
|
||||
<line x1="1" y1="14" x2="15" y2="14" stroke="currentColor" stroke-opacity="0.4" stroke-width="1" stroke-linecap="round"/>
|
||||
<line x1="8" y1="14" x2="8" y2="12" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="8" cy="11.5" r="0.7" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 538 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 900 900" fill="none"><path fill="currentColor" d="M622.037 192.524a10.58 10.58 0 0 1-4.056-2.001 10.573 10.573 0 0 1-3.98-7.89 10.595 10.595 0 0 1 .804-4.452c20.214-49.194 29.134-88.555 14.74-105.033-38.123-43.716-191.004 43.219-239.75 72.663a10.596 10.596 0 0 1-10.841.041 10.576 10.576 0 0 1-4.396-5.07c-20.491-49.089-42.031-83.233-63.865-84.714-57.871-3.96-104.516 165.624-118.17 220.895a10.574 10.574 0 0 1-1.993 4.057 10.572 10.572 0 0 1-7.884 3.977 10.586 10.586 0 0 1-4.447-.802c-49.194-20.215-88.572-29.134-105.033-14.74-43.717 38.123 43.202 191.005 72.644 239.75a10.553 10.553 0 0 1 1.454 4.279 10.573 10.573 0 0 1-6.481 10.958c-49.072 20.491-83.216 42.031-84.715 63.864-3.944 57.871 165.624 104.516 220.913 118.171a10.566 10.566 0 0 1 5.658 3.601 10.57 10.57 0 0 1 2.355 6.281 10.58 10.58 0 0 1-.799 4.442c-20.214 49.194-29.134 88.572-14.739 105.033 38.121 43.717 191.021-43.202 239.767-72.644a10.566 10.566 0 0 1 15.238 5.027c20.489 49.072 42.013 83.216 63.862 84.715 57.871 3.944 104.516-165.624 118.153-220.913a10.603 10.603 0 0 1 9.895-8.021 10.566 10.566 0 0 1 4.449.808c49.193 20.214 88.553 29.116 105.032 14.738 43.718-38.121-43.219-191.022-72.663-239.767a10.602 10.602 0 0 1 1.326-12.655 10.604 10.604 0 0 1 3.703-2.582c49.089-20.49 83.233-42.031 84.714-63.863 3.96-57.871-165.624-104.516-220.895-118.153Zm-66.394-55.478c11.123 19.938-46.196 152.797-88.83 245.724a8.36 8.36 0 0 1-8.239 4.855 8.374 8.374 0 0 1-7.412-6.043c-17.219-60.42-36.899-131.411-57.957-191.675a10.557 10.557 0 0 1 4.924-12.759c52.586-28.721 142.568-66.86 157.514-40.102ZM303.635 153.49c21.953 6.233 75.365 140.709 110.92 236.564a8.364 8.364 0 0 1-2.394 9.249 8.364 8.364 0 0 1-9.504.978c-54.943-30.493-119.013-66.824-176.522-94.546a10.565 10.565 0 0 1-5.528-12.501c16.926-57.44 53.532-148.095 83.028-139.744ZM137.064 343.322c19.921-11.123 152.795 46.197 245.707 88.83a8.369 8.369 0 0 1-1.189 15.652c-60.401 17.219-131.411 36.899-191.675 57.957a10.552 10.552 0 0 1-12.742-4.925c-28.668-52.584-66.876-142.568-40.101-157.514Zm16.443 252.009c6.217-21.953 140.709-75.365 236.564-110.921a8.368 8.368 0 0 1 10.227 11.898c-30.511 54.945-66.842 119.014-94.563 176.507a10.548 10.548 0 0 1-5.229 5.075 10.544 10.544 0 0 1-7.271.468c-57.441-16.822-148.095-53.531-139.728-83.027ZM343.34 761.902c-11.14-19.922 46.197-152.796 88.829-245.707a8.383 8.383 0 0 1 5.713-4.66 8.38 8.38 0 0 1 7.182 1.664 8.371 8.371 0 0 1 2.758 4.184c17.217 60.403 36.898 131.412 57.957 191.675a10.558 10.558 0 0 1-4.942 12.743c-52.568 28.668-142.568 66.875-157.445 40.101h-.052Zm252.009-16.443c-21.971-6.216-75.383-140.709-110.939-236.564a8.37 8.37 0 0 1 11.916-10.228c54.926 30.494 119.014 66.842 176.506 94.563a10.538 10.538 0 0 1 5.527 12.502c-16.909 57.526-53.515 148.094-83.01 139.727Zm166.57-189.834c-19.938 11.141-152.796-46.197-245.724-88.83a8.367 8.367 0 0 1-2.993-12.892 8.378 8.378 0 0 1 4.182-2.759c60.419-17.218 131.41-36.899 191.675-57.958a10.577 10.577 0 0 1 12.758 4.943c28.652 52.568 66.86 142.569 40.102 157.496Zm-16.444-252.009c-6.232 21.971-140.709 75.383-236.562 110.94a8.371 8.371 0 0 1-10.229-11.917c30.495-54.926 66.825-119.012 94.547-176.505a10.555 10.555 0 0 1 12.5-5.528c57.441 16.909 148.096 53.516 139.744 83.01Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,293 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 360" width="16" height="16">
|
||||
<g fill="currentColor">
|
||||
<circle cx="100.50" cy="40.50" r="4.50"/>
|
||||
<circle cx="112.50" cy="40.50" r="4.50"/>
|
||||
<circle cx="124.50" cy="40.50" r="4.50"/>
|
||||
<circle cx="136.50" cy="40.50" r="4.50"/>
|
||||
<circle cx="148.50" cy="40.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="52.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="52.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="52.50" r="4.50"/>
|
||||
<circle cx="136.50" cy="52.50" r="4.50"/>
|
||||
<circle cx="148.50" cy="52.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="52.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="64.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="64.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="64.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="64.50" r="4.50"/>
|
||||
<circle cx="136.50" cy="64.50" r="4.50"/>
|
||||
<circle cx="148.50" cy="64.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="64.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="64.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="76.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="76.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="76.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="76.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="76.50" r="4.50"/>
|
||||
<circle cx="136.50" cy="76.50" r="4.50"/>
|
||||
<circle cx="148.50" cy="76.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="76.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="88.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="88.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="88.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="88.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="88.50" r="4.50"/>
|
||||
<circle cx="148.50" cy="88.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="100.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="100.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="100.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="100.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="100.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="112.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="112.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="112.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="112.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="112.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="124.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="124.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="124.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="124.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="124.50" r="4.50"/>
|
||||
<circle cx="232.50" cy="124.50" r="4.50"/>
|
||||
<circle cx="244.50" cy="124.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="124.50" r="4.50"/>
|
||||
<circle cx="28.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="40.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="112.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="124.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="220.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="232.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="244.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="136.50" r="4.50"/>
|
||||
<circle cx="28.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="40.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="112.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="124.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="148.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="160.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="172.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="184.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="196.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="244.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="208.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="220.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="232.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="220.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="220.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="232.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="244.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="256.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="268.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="280.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="220.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="232.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="244.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="292.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="160.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="220.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="232.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="256.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="316.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="328.50" cy="304.50" r="4.50"/>
|
||||
<circle cx="28.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="40.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="52.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="64.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="76.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="88.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="100.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="112.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="124.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="172.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="184.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="196.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="208.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="220.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="268.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="280.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="292.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="304.50" cy="316.50" r="4.50"/>
|
||||
<circle cx="316.50" cy="316.50" r="4.50"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 410 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99816 14.2779C12.0678 14.2779 14.9997 11.6274 14.9997 10.9574V9.3189C14.939 8.77306 14.4165 7.82349 13.6074 7.48636C13.5959 7.42498 13.5859 7.36153 13.5756 7.29542C13.5506 7.13548 13.5231 6.95977 13.4649 6.75954C13.6409 6.31508 13.6873 5.81187 13.6873 5.31135C13.6873 4.54947 13.5757 3.76321 13.0811 3.13712C12.5745 2.4959 11.7737 2.15372 10.6972 2.03411C9.64215 1.91688 8.71828 2.06409 8.12157 2.70342C8.07827 2.74981 8.0345 2.79812 7.99655 2.84746C7.95861 2.79812 7.91805 2.74981 7.87475 2.70342C7.27804 2.06409 6.35416 1.91688 5.29908 2.03411C4.22263 2.15372 3.42181 2.4959 2.91521 3.13712C2.42057 3.76321 2.30905 4.54947 2.30905 5.31135C2.30905 5.81187 2.35539 6.31508 2.53142 6.75954C2.47323 6.95977 2.44574 7.13542 2.42072 7.29536C2.41037 7.3615 2.40044 7.42496 2.38893 7.48636C1.57983 7.82349 1.05728 8.77306 0.996582 9.3189V10.9574C0.996582 11.6274 3.92849 14.2779 7.99816 14.2779ZM10.5523 3.33859C9.63863 3.23707 9.25001 3.41798 9.0811 3.59896C8.90273 3.79007 8.76738 4.19254 8.86971 5.0112C8.94909 5.64627 9.13423 6.08757 9.41018 6.36929C9.67188 6.63645 10.0966 6.8426 10.8435 6.8426C11.6506 6.8426 11.9631 6.66867 12.1051 6.51046C12.2619 6.33577 12.3748 6.00462 12.3748 5.31135C12.3748 4.64141 12.2675 4.22455 12.0513 3.95077C11.8469 3.69214 11.4446 3.43773 10.5523 3.33859ZM6.91639 3.59896C6.74748 3.41798 6.35886 3.23707 5.44518 3.33859C4.55289 3.43773 4.15058 3.69214 3.94624 3.95077C3.72994 4.22455 3.62271 4.64141 3.62271 5.31135C3.62271 6.00462 3.73563 6.33577 3.89239 6.51046C4.03436 6.66867 4.34693 6.8426 5.15396 6.8426C5.90092 6.8426 6.32561 6.63645 6.58731 6.36929C6.86326 6.08757 7.0484 5.64627 7.12778 5.0112C7.23011 4.19254 7.09476 3.79007 6.91639 3.59896ZM7.99818 12.9788C9.99307 12.9788 12.0088 12.0076 12.3748 11.7248V8.00133L12.3546 7.90026C11.9257 8.08369 11.4142 8.1551 10.8435 8.1551C9.84049 8.1551 9.04148 7.86851 8.47257 7.28773C8.28092 7.09208 8.12536 6.87308 7.99978 6.6378C7.8742 6.87308 7.71544 7.09208 7.52378 7.28773C6.95488 7.86851 6.15587 8.1551 5.15283 8.1551C4.58212 8.1551 4.07068 8.08369 3.6418 7.90026L3.62158 8.00133V11.7248C3.98755 12.0076 6.00329 12.9788 7.99818 12.9788Z" fill="currentColor"/>
|
||||
<path d="M6.46854 9.0301C6.83097 9.0301 7.12479 9.32391 7.12479 9.68635V10.9988C7.12479 11.3613 6.83097 11.6551 6.46854 11.6551C6.1061 11.6551 5.81229 11.3613 5.81229 10.9988V9.68635C5.81229 9.32391 6.1061 9.0301 6.46854 9.0301Z" fill="currentColor"/>
|
||||
<path d="M10.1873 9.68635C10.1873 9.32391 9.89347 9.0301 9.53104 9.0301C9.1686 9.0301 8.87479 9.32391 8.87479 9.68635V10.9988C8.87479 11.3613 9.1686 11.6551 9.53104 11.6551C9.89347 11.6551 10.1873 11.3613 10.1873 10.9988V9.68635Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>
|
||||
|
Before Width: | Height: | Size: 302 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20.9093 19.3861L19.5185 18.2413C18.7624 17.619 18.1189 16.8713 17.6157 16.0313C16.9205 14.8706 15.9599 13.8912 14.8133 13.1735L14.2533 12.8475C14.0614 12.7141 13.9276 12.5062 13.9086 12.2716C13.8963 12.1204 13.9326 11.9852 14.0171 11.8662C14.3087 11.4553 15.896 9.74698 16.1722 9.51845C16.528 9.22442 16.9243 8.97987 17.2921 8.69986C17.3443 8.66 17.3968 8.62035 17.4485 8.57989C17.4503 8.57808 17.4529 8.57668 17.4545 8.57508C17.5725 8.48195 17.6838 8.383 17.7724 8.26563C18.2036 7.76631 18.195 7.3443 18.195 7.3443C18.195 7.3443 18.1954 7.3439 18.1956 7.3437C18.1497 7.23133 17.9847 6.88163 17.6492 6.71759C17.9458 6.71178 18.2805 6.82294 18.4323 6.97156C18.6148 6.68534 18.7328 6.49967 18.9162 6.18762C18.9599 6.11352 18.9831 5.97652 18.8996 5.89981C18.8996 5.89981 18.8992 5.89981 18.8988 5.89981C18.8988 5.89981 18.8988 5.8994 18.8988 5.899C18.8972 5.8974 18.8952 5.8962 18.8936 5.8946C18.892 5.893 18.891 5.89119 18.8892 5.88939C18.8892 5.88939 18.8888 5.88939 18.8884 5.88939C18.8884 5.88939 18.8884 5.88899 18.8884 5.88859C18.885 5.88518 18.8812 5.88258 18.8776 5.87938C18.8754 5.87717 18.8736 5.87457 18.8716 5.87217C18.8692 5.87016 18.8665 5.86836 18.8643 5.86616C18.8609 5.86275 18.8587 5.85855 18.8551 5.85534C18.8551 5.85534 18.8545 5.85514 18.8543 5.85534C18.8543 5.85534 18.8543 5.85494 18.8543 5.85454C18.8527 5.85294 18.8507 5.85174 18.8491 5.85013C18.8475 5.84853 18.8463 5.84653 18.8447 5.84493C18.8447 5.84493 18.8441 5.84473 18.8439 5.84493C18.8439 5.84493 18.8439 5.84453 18.8439 5.84413C18.7672 5.7606 18.6302 5.78384 18.5561 5.8275C18.1503 6.06625 17.7555 6.32322 17.3996 6.54855C17.3996 6.54855 16.9778 6.53973 16.4783 6.97116C16.3607 7.05989 16.2618 7.17125 16.1688 7.28902C16.167 7.29082 16.1654 7.29322 16.164 7.29503C16.1234 7.3465 16.0837 7.39898 16.0441 7.45145C15.7639 7.81939 15.5195 8.21556 15.2255 8.57128C14.9971 8.84768 13.2887 10.4348 12.8777 10.7264C12.7587 10.8109 12.6237 10.8474 12.4723 10.835C12.2379 10.8161 12.0298 10.6821 11.8965 10.4903L11.5704 9.93024C10.8527 8.78318 9.87332 7.82299 8.71264 7.12778C7.87262 6.62466 7.12514 5.98092 6.50264 5.22503L5.35778 3.83421C5.3013 3.76571 5.19314 3.77693 5.15268 3.85585C5.02249 4.10941 4.77393 4.64479 4.58346 5.36483C4.57885 5.38186 4.58286 5.39988 4.59407 5.4135C4.83082 5.69952 5.37901 6.32983 6.03196 6.863C6.07742 6.90005 6.04017 6.97336 5.98369 6.95774C5.42047 6.80432 4.87288 6.55796 4.46308 6.34805C4.42964 6.33103 4.38918 6.35226 4.38437 6.38951C4.32068 6.89985 4.30425 7.46027 4.37155 8.05112C4.37355 8.07035 4.38577 8.08697 4.4036 8.09479C4.87088 8.29808 5.61816 8.59311 6.40269 8.78078C6.45958 8.7944 6.45777 8.87632 6.40029 8.88733C5.78941 9.0023 5.14968 9.02794 4.62973 9.02113C4.59327 9.02073 4.56643 9.05518 4.57625 9.09023C4.6806 9.45896 4.822 9.8339 5.00847 10.2115C5.08559 10.3811 5.16951 10.5475 5.25944 10.7104C5.27486 10.7382 5.3047 10.7548 5.33655 10.7534C5.76577 10.7324 6.28452 10.6871 6.80608 10.595C6.89501 10.5794 6.94268 10.6964 6.86757 10.7466C6.51345 10.9834 6.13571 11.1873 5.7844 11.3551C5.73733 11.3777 5.72211 11.4378 5.75315 11.4797C5.96186 11.7625 6.19139 12.0301 6.44075 12.2794C6.44075 12.2794 7.66853 13.5441 7.70198 13.6432C8.41841 12.9096 9.59612 12.0964 10.8966 11.3864C9.15488 12.8036 8.18387 13.8499 7.69517 14.4444L7.35447 14.9225C7.17742 15.1708 7.02379 15.4346 6.89541 15.7112C6.46579 16.6356 5.75756 18.5051 5.75756 18.5051C5.70328 18.6515 5.74754 18.7959 5.84168 18.89C5.84388 18.8922 5.84609 18.8944 5.84849 18.8964C5.85069 18.8986 5.8527 18.901 5.8549 18.9032C5.94924 18.9976 6.09345 19.0416 6.23986 18.9874C6.23986 18.9874 8.10897 18.2791 9.03371 17.8495C9.31031 17.7211 9.57429 17.5673 9.82245 17.3905L10.349 17.0153C10.6278 16.8166 11.0096 16.8483 11.2517 17.0904L12.4655 18.3042C12.7148 18.5535 12.9824 18.7831 13.2652 18.9918C13.3073 19.0226 13.3672 19.0076 13.3898 18.9605C13.5579 18.6094 13.7618 18.2313 13.9983 17.8774C14.0486 17.8022 14.1657 17.8501 14.1499 17.9388C14.0576 18.4606 14.0127 18.9794 13.9915 19.4084C13.9899 19.44 14.0067 19.4701 14.0345 19.4855C14.1972 19.5756 14.3636 19.6595 14.5335 19.7364C14.911 19.9229 15.2862 20.0645 15.6547 20.1687C15.6897 20.1785 15.7242 20.1516 15.7238 20.1152C15.7168 19.595 15.7424 18.9553 15.8576 18.3446C15.8684 18.2869 15.9503 18.2851 15.9641 18.3422C16.1516 19.127 16.4466 19.8742 16.6501 20.3413C16.6579 20.3591 16.6744 20.3712 16.6938 20.3734C17.2847 20.4407 17.8451 20.4242 18.3554 20.3606C18.3929 20.3559 18.4141 20.3155 18.3969 20.2818C18.187 19.872 17.9406 19.3241 17.7872 18.7612C17.7718 18.7046 17.8449 18.6675 17.8819 18.713C18.4151 19.3659 19.0454 19.9141 19.3314 20.1508C19.345 20.1621 19.3633 20.1659 19.3801 20.1615C20.1003 19.9712 20.6357 19.7226 20.8891 19.5922C20.968 19.5518 20.9792 19.4436 20.9107 19.3871L20.9093 19.3861Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40" fill="none">
|
||||
<path d="M25 15H35V16.75C35 29 30.5001 35 16.5001 35H15V25H16.5001C22.6251 25 25 22.875 25 16.75V15Z" fill="currentColor"/>
|
||||
<rect x="5" y="15" width="10" height="10" fill="currentColor"/>
|
||||
<rect x="15" y="5" width="10" height="10" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 361 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40">
|
||||
<path fill="currentColor" d="M27.9998324,34.6666921h6.6666279v5.3333023h-8.3809037l-3.6190266-3.6190266v-8.3809037h5.3333023v6.6666279ZM39.9997627,26.2857884l-3.6190266-3.6190266h-8.3809037v5.3333023h6.6666279v6.6666279h5.3333023v-8.3809037ZM17.3332326,22.6667578h-5.3333023v5.3333023h5.3333023v-5.3333023ZM0,36.3809638l3.6190266,3.6190266h13.714206v-5.3333023H5.3333023v-11.9999302H0v13.714206ZM33.9600959,11.9999247V3.619021L30.3410693-.0000056h-7.6745392v5.3333023h5.9602634v6.6666279h-5.9602634v5.3333023h17.3332326v-5.3333023h-6.0396668ZM5.3333023,0H0v17.3332326h5.3333023v-5.9999651h6.6666279v5.9999651h5.3333023v-5.9999651l-5.3333023-5.3333023h-6.6666279V0ZM17.3332326,0h-5.3333023v5.9999651h5.3333023V0Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 808 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M19.514 7.342C19.711 7.862 19.814 8.413 19.816 8.969C19.816 12.494 15.998 14.697 12.946 12.934C11.529 12.116 10.657 10.605 10.657 8.969C10.657 5.646 14.085 3.431 17.115 4.793C15.26 3.373 12.99 2.607 10.655 2.612C4.772 2.614 0.005 7.381 0 13.265C0.002 19.148 4.772 23.918 10.655 23.92C16.538 23.916 21.306 19.147 21.31 13.265C21.312 11.155 20.687 9.095 19.514 7.342ZM14.841 4.666C14.841 8.191 18.657 10.395 21.709 8.632C23.127 7.814 24 6.302 24 4.666C24 1.14 20.184-1.061 17.13 0.699C15.714 1.519 14.841 3.03 14.841 4.666"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 646 B |
@@ -1,19 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<rect fill="none" stroke="currentColor" stroke-width="1.1" x="4.5" y="2.5" width="7" height="10" rx="3.5"/>
|
||||
<line stroke="currentColor" stroke-width="1" x1="3" y1="5.5" x2="13" y2="5.5"/>
|
||||
<circle fill="none" stroke="currentColor" stroke-width="1" cx="6.5" cy="5.5" r="1.6"/>
|
||||
<circle fill="none" stroke="currentColor" stroke-width="1" cx="9.5" cy="5.5" r="1.6"/>
|
||||
<circle fill="currentColor" cx="6.8" cy="5.6" r="0.55"/>
|
||||
<circle fill="currentColor" cx="9.8" cy="5.6" r="0.55"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" d="M7 8.3 Q8 9 9 8.3"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" d="M4.5 8 L3 9.5"/>
|
||||
<path fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" d="M11.5 8 L13 9.5"/>
|
||||
<line stroke="currentColor" stroke-width="0.7" x1="5" y1="9.5" x2="11" y2="9.5"/>
|
||||
<line stroke="currentColor" stroke-width="0.9" x1="6.5" y1="12.5" x2="6.5" y2="14"/>
|
||||
<line stroke="currentColor" stroke-width="0.9" x1="9.5" y1="12.5" x2="9.5" y2="14"/>
|
||||
<line stroke="currentColor" stroke-width="0.9" stroke-linecap="round" x1="6.5" y1="14" x2="5.5" y2="14"/>
|
||||
<line stroke="currentColor" stroke-width="0.9" stroke-linecap="round" x1="9.5" y1="14" x2="10.5" y2="14"/>
|
||||
<line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="7.3" y2="0.8"/>
|
||||
<line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="8" y2="0.5"/>
|
||||
<line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="8.7" y2="0.8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.8147 5.35803H5.35791V8.46914H8.8147V5.35803Z" fill="currentColor"/>
|
||||
<path d="M22.6419 5.35803H19.1851V8.46914H22.6419V5.35803Z" fill="currentColor"/>
|
||||
<path d="M15.7283 15.7284H12.2715V18.8395H15.7283V15.7284Z" fill="currentColor"/>
|
||||
<path d="M8.8147 15.7284H5.35791V18.8395H8.8147V15.7284Z" fill="currentColor"/>
|
||||
<path d="M22.6419 15.7284H19.1851V18.8395H22.6419V15.7284Z" fill="currentColor"/>
|
||||
<path d="M12.2715 8.81482H5.35791V11.9259H12.2715V8.81482Z" fill="currentColor"/>
|
||||
<path d="M12.2718 19.1852H1.90137V22.2963H12.2718V19.1852Z" fill="currentColor"/>
|
||||
<path d="M26.0989 19.1852H15.7285V22.2963H26.0989V19.1852Z" fill="currentColor"/>
|
||||
<path d="M22.6419 12.2716H5.35791V15.3827H22.6419V12.2716Z" fill="currentColor"/>
|
||||
<path d="M22.6421 8.81482H15.7285V11.9259H22.6421V8.81482Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 919 B |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1"/>
|
||||
<circle cx="8" cy="8" r="4.5" fill="none" stroke="currentColor" stroke-width="0.3" opacity="0.5"/>
|
||||
<line x1="8" y1="8" x2="13.5" y2="13.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<polygon points="14.5,14.5 12.3,13.5 13.5,12.3" fill="currentColor"/>
|
||||
<line x1="8" y1="8" x2="2.5" y2="2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<polygon points="1.5,1.5 3.7,2.5 2.5,3.7" fill="currentColor"/>
|
||||
<circle cx="8" cy="8" r="1.3" fill="currentColor"/>
|
||||
<circle cx="8" cy="8" r="0.6" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 782 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 14H3V2H13V14ZM10.5 4.4H5.5V11.6H10.5V4.4Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 221 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 1H11.7692V7.9999H8.17942V11.4999H4.58982V15H1V1ZM4.58982 4.50005V7.9999H8.17942V4.50005H4.58982Z" fill="currentColor"/>
|
||||
<path d="M11.7692 7.46154H15V15H11.7692V7.46154Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 307 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.63653 5.50522L2.18735 5.28559C2.09852 5.46726 2.12787 5.68456 2.26172 5.83616C2.39558 5.98775 2.60757 6.04379 2.79884 5.97814L2.63653 5.50522ZM5.83578 6.94285L5.3732 7.13265L5.5645 7.59886L6.02919 7.40392L5.83578 6.94285ZM10.7441 9.44034L10.2614 9.57083L10.393 10.0573L10.8784 9.92198L10.7441 9.44034ZM2.5 8.24963C2.4998 7.97349 2.27577 7.74979 1.99963 7.75C1.72349 7.75021 1.4998 7.97423 1.5 8.25037L2.5 8.24963ZM13.4143 10.7751C12.0197 13.6273 8.57701 14.8089 5.72485 13.4143L5.28559 14.3126C8.63389 15.9498 12.6754 14.5627 14.3126 11.2144L13.4143 10.7751ZM3.08571 5.72485C4.48031 2.87269 7.92299 1.69111 10.7751 3.08571L11.2144 2.18735C7.86611 0.550151 3.82455 1.93728 2.18735 5.28559L3.08571 5.72485ZM10.7751 3.08571C13.6273 4.48031 14.8089 7.92299 13.4143 10.7751L14.3126 11.2144C15.9498 7.86611 14.5627 3.82455 11.2144 2.18735L10.7751 3.08571ZM2.79884 5.97814C3.02201 5.90155 3.54835 5.84926 4.09141 6.00587C4.61256 6.15616 5.10736 6.48476 5.3732 7.13265L6.29835 6.75304C5.89213 5.76303 5.11508 5.26032 4.3685 5.04503C3.64385 4.83605 2.9066 4.8839 2.47422 5.0323L2.79884 5.97814ZM6.02919 7.40392C6.25314 7.30998 6.55771 7.25841 6.85281 7.36034C7.13062 7.45631 7.49214 7.72014 7.78743 8.4398L8.71257 8.0602C8.3358 7.14195 7.79046 6.62626 7.17931 6.41515C6.58544 6.21001 6.01893 6.32381 5.64236 6.48177L6.02919 7.40392ZM6.26827 7.19375C6.80493 6.26869 8.18817 4.18684 11.1653 3.10654L10.8242 2.16652C7.53166 3.36127 5.99463 5.67263 5.40329 6.69195L6.26827 7.19375ZM14.3364 10.8325C14.1845 10.39 13.7564 9.79897 13.14 9.37995C12.504 8.94759 11.6302 8.67426 10.6099 8.9587L10.8784 9.92198C11.5634 9.73102 12.1368 9.90713 12.5779 10.207C13.0385 10.5201 13.3174 10.9441 13.3905 11.1571L14.3364 10.8325ZM11.2268 9.30984C11.1236 8.92833 10.8783 8.39265 10.3638 8.02453C9.82769 7.6409 9.08331 7.49864 8.11574 7.76836L8.38426 8.73164C9.1221 8.52595 9.53979 8.66454 9.78189 8.83777C10.0457 9.02651 10.1946 9.32371 10.2614 9.57083L11.2268 9.30984ZM11.216 9.60556C11.6225 8.44463 12.4244 5.47936 11.4712 2.48487L10.5183 2.78819C11.3726 5.47172 10.6545 8.18331 10.2722 9.27512L11.216 9.60556ZM7.80082 8.03037L5.05604 13.6438L5.9544 14.0831L8.69918 8.46963L7.80082 8.03037ZM5.72485 13.4143C3.68742 12.4181 2.50158 10.3763 2.5 8.24963L1.5 8.25037C1.50185 10.7441 2.89229 13.1424 5.28559 14.3126L5.72485 13.4143Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8 1C4.134 1 1 4.134 1 8s3.134 7 7 7c1.5 0 2.9-.47 4.05-1.28l1.12 1.12a.75.75 0 1 0 1.06-1.06l-1.12-1.12A6.97 6.97 0 0 0 15 8c0-3.866-3.134-7-7-7Zm0 2c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 334 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 141.38 140" width="16" height="16"><path fill="currentColor" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.323 3.323 0 0 0 0-3.34l-9.62-16.67c-.3-.51-.72-.93-1.22-1.22s-1.07-.45-1.67-.45H82.23l-8.66-15a3.33 3.33 0 0 0-2.89-1.67H51.43c-.59 0-1.17.16-1.66.45-.5.29-.92.71-1.22 1.22L32.19 29.98l-1.92 3.33H12.96c-.59 0-1.17.16-1.66.45-.5.29-.93.71-1.22 1.22L.45 51.66a3.323 3.323 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67c.3.51.72.93 1.22 1.22s1.07.45 1.67.45h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32c.6 0 1.17-.16 1.67-.45s.92-.71 1.22-1.22l9.62-16.67a3.323 3.323 0 0 0 0-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/></svg>
|
||||
|
Before Width: | Height: | Size: 946 B |
@@ -1,98 +0,0 @@
|
||||
<?xml version="1.0" standalone="no" ?>
|
||||
<svg
|
||||
version="1.0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 1024 1024"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
|
||||
<g
|
||||
transform="translate(0,1024) scale(0.1,-0.1)"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
d="M6428 6358 c-104 -8 -227 -38 -314 -78 -73 -33 -196 -115 -239 -160
|
||||
-18 -19 -39 -30 -56 -30 -26 0 -27 1 -18 32 5 18 9 52 9 76 0 34 -6 49 -34 80
|
||||
l-34 37 -319 3 c-412 3 -383 18 -447 -236 -20 -78 -36 -154 -36 -168 0 -14 -5
|
||||
-35 -12 -47 -6 -12 -21 -60 -33 -107 l-22 -85 -22 43 c-47 91 -179 184 -310
|
||||
218 -146 37 -375 34 -536 -7 -203 -51 -365 -175 -443 -339 -68 -143 -109 -304
|
||||
-114 -445 l-2 -70 -127 5 c-70 3 -125 2 -123 -1 5 -8 -27 -21 -34 -14 -3 3 0
|
||||
5 7 5 7 0 9 5 6 10 -4 6 -11 8 -16 5 -5 -4 -15 -3 -21 2 -7 4 -17 8 -23 8 -5
|
||||
0 -10 7 -10 15 0 13 -142 15 -1205 18 -663 1 -1212 -1 -1220 -5 -8 -3 -19 -5
|
||||
-25 -4 -5 1 1 -8 15 -21 13 -13 29 -22 34 -20 5 2 3 -4 -4 -14 -13 -15 -15
|
||||
-14 -24 5 -5 13 -15 20 -24 16 -7 -3 -157 -5 -333 -5 l-319 0 0 -2540 0 -2540
|
||||
5120 0 5120 0 0 2540 0 2540 -685 1 c-377 1 -689 2 -694 3 -16 2 -25 16 -10
|
||||
16 11 0 11 2 -1 10 -8 5 -10 10 -4 10 5 0 16 -6 23 -12 11 -10 13 -10 7 0 -5
|
||||
8 1 12 18 12 14 0 27 -1 29 -2 1 -2 13 7 27 20 13 12 30 22 37 22 8 0 11 5 8
|
||||
10 -3 6 -2 10 2 10 5 0 9 8 9 18 1 9 18 82 38 161 20 79 36 158 36 176 0 42
|
||||
-38 97 -75 109 -17 6 -31 11 -32 11 -1 1 5 23 13 50 20 68 20 137 -2 169 -27
|
||||
43 -64 56 -155 56 -73 0 -80 2 -74 18 4 9 9 42 12 72 3 44 0 62 -17 88 -38 61
|
||||
-57 63 -396 60 l-304 -3 0 38 c0 29 -8 46 -34 75 l-34 37 -313 3 c-215 2 -321
|
||||
-1 -340 -8 -50 -21 -68 -52 -100 -177 l-31 -121 -28 16 c-16 9 -35 30 -43 47
|
||||
-23 48 -102 130 -168 174 -132 88 -315 126 -531 109z m296 -148 c43 -11 101
|
||||
-32 129 -47 67 -37 145 -115 170 -172 41 -92 33 -253 -25 -493 l-21 -88 -275
|
||||
0 -274 0 4 23 c3 12 22 91 42 175 25 102 36 170 34 205 -3 51 -4 52 -39 61
|
||||
-57 14 -98 -15 -128 -89 -36 -89 -321 -1238 -328 -1321 -5 -66 -4 -72 18 -88
|
||||
54 -38 118 -7 159 78 28 58 122 403 113 419 -2 4 -27 7 -54 7 -43 0 -50 3 -46
|
||||
18 3 9 20 79 38 154 l33 137 75 7 c79 7 558 1 566 -8 3 -3 -58 -260 -136 -572
|
||||
l-141 -566 -174 0 -174 0 0 29 c0 15 -3 49 -6 74 l-7 46 -61 -55 c-33 -30 -94
|
||||
-72 -135 -92 -74 -37 -76 -37 -201 -37 -126 0 -126 0 -202 38 -203 100 -254
|
||||
266 -179 592 66 287 235 944 271 1050 72 215 132 298 289 403 144 97 262 130
|
||||
462 131 95 1 145 -4 203 -19z m-1046 -37 c-3 -10 -18 -71 -33 -135 l-29 -118
|
||||
-273 0 c-213 0 -271 3 -268 13 2 6 13 50 25 97 11 46 25 101 31 122 l10 38
|
||||
271 0 c253 0 270 -1 266 -17z m2240 0 c-3 -10 -18 -71 -33 -135 l-29 -118
|
||||
-273 0 c-213 0 -271 3 -268 13 2 6 13 50 25 97 11 46 25 101 31 122 l10 38
|
||||
271 0 c253 0 270 -1 266 -17z m722 -139 c0 -3 -13 -59 -30 -124 -16 -65 -30
|
||||
-126 -30 -134 0 -13 13 -16 65 -16 45 0 65 -4 65 -12 0 -13 -31 -146 -51 -220
|
||||
l-10 -38 -72 0 -73 0 -132 -532 c-73 -293 -132 -552 -132 -574 0 -47 11 -54
|
||||
93 -54 26 0 47 -3 47 -7 0 -5 -15 -66 -33 -138 l-33 -130 -165 -3 c-326 -6
|
||||
-421 26 -459 155 -17 55 2 146 155 761 69 277 125 508 125 513 0 5 -25 9 -55
|
||||
9 l-55 0 7 28 c3 15 18 74 33 132 l26 105 59 5 59 5 34 138 34 137 264 0 c145
|
||||
0 264 -3 264 -6z m-4147 -223 c137 -36 218 -93 257 -183 27 -63 24 -128 -12
|
||||
-278 l-24 -95 -232 -3 c-143 -1 -232 1 -232 7 0 5 9 47 20 93 35 145 26 178
|
||||
-49 178 -65 0 -117 -79 -128 -195 -9 -104 10 -126 222 -252 211 -127 273 -191
|
||||
290 -305 14 -94 -44 -332 -112 -458 -105 -195 -319 -301 -607 -300 -253 1
|
||||
-438 89 -476 225 -17 61 -8 193 21 318 l22 97 234 0 233 0 -5 -22 c-3 -13 -14
|
||||
-61 -25 -108 -11 -47 -20 -106 -20 -132 0 -40 4 -50 25 -64 31 -20 85 -12 120
|
||||
19 32 28 64 120 72 203 8 82 7 82 -202 224 -178 120 -243 177 -277 242 -72
|
||||
138 33 532 175 650 70 59 97 75 175 105 101 38 184 51 328 52 99 1 149 -4 207
|
||||
-18z m1087 -20 c0 -7 -408 -1652 -426 -1718 l-6 -23 -269 0 c-173 0 -269 4
|
||||
-269 10 0 6 97 399 216 875 l216 865 269 0 c148 0 269 -4 269 -9z m2240 -1 c0
|
||||
-5 -97 -397 -215 -871 -118 -474 -215 -863 -215 -865 0 -2 -121 -4 -270 -4
|
||||
-174 0 -270 4 -270 10 0 6 97 399 216 875 l216 865 269 0 c166 0 269 -4 269
|
||||
-10z m1040 -742 c0 -5 -5 -6 -11 -2 -8 4 -9 -1 -4 -17 4 -13 4 -21 0 -17 -4 4
|
||||
-9 21 -11 39 -3 27 -2 30 11 19 8 -7 15 -17 15 -22z m-8192 4 c2 -7 -5 -12
|
||||
-15 -11 -12 0 -14 2 -4 6 11 5 11 7 0 15 -12 8 -12 9 0 6 8 -3 17 -10 19 -16z
|
||||
m1840 -1329 c-311 -2 -824 -2 -1140 0 -315 1 -60 2 567 2 627 0 885 -1 573 -2z
|
||||
m619 1 c-3 -3 -12 -4 -19 -1 -8 3 -5 6 6 6 11 1 17 -2 13 -5z"
|
||||
/>
|
||||
<path
|
||||
d="M900 6270 c0 -11 6 -20 14 -20 7 0 16 -9 19 -20 5 -20 12 -20 1803
|
||||
-18 1731 3 1799 4 1802 21 3 12 -4 20 -17 24 -12 3 -21 12 -21 19 0 12 -274
|
||||
14 -1800 14 -1793 0 -1800 0 -1800 -20z"
|
||||
/>
|
||||
<path
|
||||
d="M900 6005 c0 -15 6 -25 14 -25 7 0 16 -9 19 -20 5 -20 12 -20 1334
|
||||
-18 l1328 3 3 23 c3 17 -2 22 -17 22 -14 0 -21 6 -21 20 0 20 -7 20 -1330 20
|
||||
l-1330 0 0 -25z"
|
||||
/>
|
||||
<path
|
||||
d="M737 5713 c-12 -12 -7 -43 7 -43 7 0 16 -9 19 -20 5 -20 12 -20 1323
|
||||
-18 1248 3 1319 4 1322 21 2 9 -4 20 -14 24 -10 3 -26 15 -35 24 -17 17 -83
|
||||
18 -1317 19 -714 0 -1302 -3 -1305 -7z"
|
||||
/>
|
||||
<path
|
||||
d="M1953 5412 c-1265 -2 -1273 -2 -1273 -22 0 -11 6 -20 14 -20 7 0 16
|
||||
-9 19 -20 5 -20 12 -20 1298 -18 1242 3 1294 4 1297 21 3 12 -4 20 -17 24 -12
|
||||
3 -21 11 -21 18 0 7 -10 14 -22 16 -13 2 -596 3 -1295 1z"
|
||||
/>
|
||||
<path d="M9010 5130 c-12 -7 -10 -9 7 -7 12 0 19 5 17 9 -6 10 -6 10 -24 -2z" />
|
||||
<path
|
||||
d="M3180 5109 c0 -5 5 -7 10 -4 6 3 10 8 10 11 0 2 -4 4 -10 4 -5 0 -10
|
||||
-5 -10 -11z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M6.53 4.412h5.883v3.587H9.471v3.588H3.588V7.999H6.53Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 179 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.5 5.5L2.5 8 0.5 10.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="3.2" y="11.5" font-family="'SF Mono', 'Fira Code', 'Consolas', monospace" font-size="10" font-weight="600" letter-spacing="-0.5" fill="currentColor">VT</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 399 B |
@@ -9,12 +9,8 @@ import type {
|
||||
} from "@/attachments/types";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { useClearReviewDraft } from "@/review/store";
|
||||
|
||||
interface WorkspaceAttachmentBindingInput {
|
||||
@@ -73,6 +69,31 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
});
|
||||
}
|
||||
|
||||
function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||
return (
|
||||
<WorkspaceAttachmentPill
|
||||
@@ -282,7 +303,7 @@ function WorkspaceAttachmentPill({
|
||||
export const composerWorkspaceAttachment = {
|
||||
is: isWorkspaceAttachment,
|
||||
renderPill,
|
||||
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
|
||||
toSubmitAttachment,
|
||||
userAttachmentsOnly,
|
||||
useBinding: useWorkspaceAttachmentBinding,
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createIndexedDbAttachmentStore } from "./indexeddb-attachment-store";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
class FakeRequest {
|
||||
result!: unknown;
|
||||
class FakeRequest<T = unknown> {
|
||||
result!: T;
|
||||
error: Error | null = null;
|
||||
private listeners = new Map<string, Listener[]>();
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("indexeddb attachment store", () => {
|
||||
const store = new FakeObjectStore((record) => {
|
||||
storedRecord = record;
|
||||
});
|
||||
const request = new FakeRequest();
|
||||
const request = new FakeRequest<FakeDatabase>();
|
||||
request.result = new FakeDatabase(store);
|
||||
queueMicrotask(() => request.emit("success"));
|
||||
return request;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
export function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
export function userAttachmentsOnly(
|
||||
attachments: readonly ComposerAttachment[],
|
||||
): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
export function workspaceAttachmentToSubmitAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
): AgentAttachment | null {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -267,7 +267,7 @@ export interface AddHostModalProps {
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection } = useHostMutations();
|
||||
const { upsertDirectConnection } = useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -336,12 +336,22 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
|
||||
const { profile, serverId, hostname } = await probeAndUpsertDirectConnection({
|
||||
const { client, serverId, hostname } = await connectToDaemon({
|
||||
id: "probe",
|
||||
type: "directTcp",
|
||||
endpoint: connection.endpoint,
|
||||
useTls: connection.useTls,
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
});
|
||||
await client.close().catch(() => undefined);
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
const profile = await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint: connection.endpoint,
|
||||
useTls: connection.useTls,
|
||||
...(connection.password ? { password: connection.password } : {}),
|
||||
label: hostname ?? undefined,
|
||||
});
|
||||
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
@@ -371,7 +381,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onSaved,
|
||||
password,
|
||||
port,
|
||||
probeAndUpsertDirectConnection,
|
||||
upsertDirectConnection,
|
||||
useTls,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
buildAcpProviderConfigPatch,
|
||||
useAcpProviderCatalog,
|
||||
type AcpProviderCatalogItem,
|
||||
} from "@/hooks/use-acp-provider-catalog";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
interface AddProviderModalProps {
|
||||
serverId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type InstallState = "installed" | "available";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ACTION_BUTTON_STYLE = { width: 92 } as const;
|
||||
const MODAL_SNAP_POINTS = ["78%", "92%"];
|
||||
const SEARCH_ICON_SIZE = 16;
|
||||
const PROVIDER_FALLBACK_ICON_SIZE = 20;
|
||||
const PROVIDER_REMOTE_ICON_SIZE = 24;
|
||||
|
||||
const ThemedPackagePlus = withUnistyles(PackagePlus);
|
||||
const ThemedSvgXml = withUnistyles(SvgXml);
|
||||
const ThemedSearch = withUnistyles(Search);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
function getInstallState(
|
||||
entry: AcpProviderCatalogItem,
|
||||
installedProviderIds: Set<string>,
|
||||
): InstallState {
|
||||
if (installedProviderIds.has(entry.id)) return "installed";
|
||||
return "available";
|
||||
}
|
||||
|
||||
function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
return [entry.title, entry.id, entry.description].some((value) =>
|
||||
value.toLowerCase().includes(normalized),
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderCatalogRowProps {
|
||||
entry: AcpProviderCatalogItem;
|
||||
state: InstallState;
|
||||
installing: boolean;
|
||||
onInstall: (entry: AcpProviderCatalogItem) => void;
|
||||
}
|
||||
|
||||
function ProviderCatalogRow({ entry, state, installing, onInstall }: ProviderCatalogRowProps) {
|
||||
const isAvailable = state === "available";
|
||||
let actionLabel = "Add";
|
||||
if (installing) {
|
||||
actionLabel = "Adding";
|
||||
} else if (state === "installed") {
|
||||
actionLabel = "Installed";
|
||||
}
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
onInstall(entry);
|
||||
}, [entry, onInstall]);
|
||||
|
||||
const handleOpenInstallLink = useCallback(() => {
|
||||
void openExternalUrl(entry.installLink);
|
||||
}, [entry.installLink]);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<View style={styles.iconFrame}>
|
||||
{entry.iconSvg ? (
|
||||
<ThemedSvgXml
|
||||
xml={entry.iconSvg}
|
||||
width={PROVIDER_REMOTE_ICON_SIZE}
|
||||
height={PROVIDER_REMOTE_ICON_SIZE}
|
||||
uniProps={foregroundColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedPackagePlus size={PROVIDER_FALLBACK_ICON_SIZE} uniProps={foregroundColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.textColumn}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.title}
|
||||
</Text>
|
||||
<Text style={styles.version} numberOfLines={1}>
|
||||
{entry.version}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.description} numberOfLines={1}>
|
||||
{entry.description || entry.id}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${entry.title} install instructions`}
|
||||
onPress={handleOpenInstallLink}
|
||||
style={styles.installLink}
|
||||
>
|
||||
<Text style={styles.installLinkText} numberOfLines={1}>
|
||||
Install instructions
|
||||
</Text>
|
||||
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isAvailable ? "default" : "secondary"}
|
||||
disabled={!isAvailable || installing}
|
||||
loading={installing}
|
||||
onPress={handleInstall}
|
||||
style={ACTION_BUTTON_STYLE}
|
||||
testID={`install-provider-${entry.id}`}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddProviderModal({ serverId, visible, onClose }: AddProviderModalProps) {
|
||||
const { entries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
||||
|
||||
const installedProviderIds = useMemo(
|
||||
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
|
||||
[providerEntries],
|
||||
);
|
||||
const filteredEntries = useMemo(
|
||||
() => entries.filter((entry) => matchesSearch(entry, search)),
|
||||
[entries, search],
|
||||
);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (entry: AcpProviderCatalogItem) => {
|
||||
if (installingProviderId) return;
|
||||
|
||||
setInstallingProviderId(entry.id);
|
||||
try {
|
||||
await patchConfig(buildAcpProviderConfigPatch(entry));
|
||||
await refresh([entry.id]);
|
||||
onClose();
|
||||
} catch (installError) {
|
||||
Alert.alert(
|
||||
"Unable to install provider",
|
||||
installError instanceof Error ? installError.message : String(installError),
|
||||
);
|
||||
} finally {
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, onClose, patchConfig, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Add provider"
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
desktopMaxWidth={680}
|
||||
snapPoints={MODAL_SNAP_POINTS}
|
||||
testID="add-provider-modal"
|
||||
>
|
||||
<View style={styles.searchField}>
|
||||
<View style={styles.searchIcon}>
|
||||
<ThemedSearch size={SEARCH_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
<AdaptiveTextInput
|
||||
testID="provider-catalog-search"
|
||||
accessibilityLabel="Search providers"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search providers"
|
||||
style={styles.searchInput}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{filteredEntries.length === 0 ? (
|
||||
<View style={styles.stateBox}>
|
||||
<Text style={styles.stateText}>No providers found</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{filteredEntries.length > 0 ? (
|
||||
<View style={styles.list}>
|
||||
{filteredEntries.map((entry) => (
|
||||
<ProviderCatalogRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
state={getInstallState(entry, installedProviderIds)}
|
||||
installing={installingProviderId === entry.id}
|
||||
onInstall={handleInstall}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchField: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
searchIcon: {
|
||||
width: 18,
|
||||
alignItems: "center",
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
list: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
iconFrame: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
textColumn: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
name: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
version: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
installLink: {
|
||||
alignSelf: "flex-start",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
maxWidth: "100%",
|
||||
},
|
||||
installLinkText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
stateBox: {
|
||||
minHeight: 96,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
stateText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
}));
|
||||
@@ -21,7 +21,7 @@ import { Archive } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
|
||||
@@ -324,12 +324,13 @@ export function AgentList({
|
||||
}
|
||||
|
||||
rememberArchivedAgentDetail(agent);
|
||||
navigateToPreparedWorkspaceTab({
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
router.navigate(route);
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
);
|
||||
|
||||
@@ -11,8 +11,6 @@ export function getStatusSelectorHint(selector: ExplainedStatusSelector): string
|
||||
return "Change model";
|
||||
case "mode":
|
||||
return "Change permission mode";
|
||||
default:
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { NeighborResolver } from "./agent-stream-view-data";
|
||||
import {
|
||||
getAssistantBlockSpacing,
|
||||
isSameAssistantBlockGroup,
|
||||
resolveInlineWorkingIndicatorItemId,
|
||||
} from "./agent-stream-view-data";
|
||||
|
||||
// Minimal forward-order resolver: "below" = next item in array.
|
||||
// Matches web strategy rendering order (chronological, top-to-bottom).
|
||||
const forwardStrategy: NeighborResolver = {
|
||||
getNeighborItem(items, index, relation) {
|
||||
const neighborIndex = relation === "below" ? index + 1 : index - 1;
|
||||
return items[neighborIndex];
|
||||
},
|
||||
};
|
||||
|
||||
function assistantBlock(params: {
|
||||
id: string;
|
||||
blockGroupId: string;
|
||||
blockIndex: number;
|
||||
text?: string;
|
||||
}): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id: params.id,
|
||||
blockGroupId: params.blockGroupId,
|
||||
blockIndex: params.blockIndex,
|
||||
text: params.text ?? "",
|
||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallBlock(id: string): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "bash",
|
||||
arguments: "cmd",
|
||||
result: null,
|
||||
status: "executing",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("isSameAssistantBlockGroup", () => {
|
||||
it("returns true for two assistant blocks with the same blockGroupId", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for blocks from different groups", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when one item is not an assistant_message", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const tc = toolCallBlock("tc-1");
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null neighbors", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantBlockSpacing", () => {
|
||||
it("returns default for non-assistant items", () => {
|
||||
const tc = toolCallBlock("tc-1");
|
||||
expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns default when no same-group neighbors exist", () => {
|
||||
const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default");
|
||||
});
|
||||
|
||||
it("returns compactTop when the item above is in the same block group", () => {
|
||||
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe(
|
||||
"compactTop",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compactBottom when the item below is in the same block group", () => {
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe(
|
||||
"compactBottom",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compactBoth when both neighbors are in the same block group", () => {
|
||||
const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 });
|
||||
const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 });
|
||||
const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 });
|
||||
expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe(
|
||||
"compactBoth",
|
||||
);
|
||||
});
|
||||
|
||||
it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => {
|
||||
const tailBlock = assistantBlock({
|
||||
id: "group-1:block:0",
|
||||
blockGroupId: "group-1",
|
||||
blockIndex: 0,
|
||||
text: "First paragraph",
|
||||
});
|
||||
const headBlock = assistantBlock({
|
||||
id: "group-1:head",
|
||||
blockGroupId: "group-1",
|
||||
blockIndex: 1,
|
||||
text: "Second paragraph",
|
||||
});
|
||||
|
||||
expect(
|
||||
getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }),
|
||||
).toBe("compactBottom");
|
||||
expect(
|
||||
getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }),
|
||||
).toBe("compactTop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInlineWorkingIndicatorItemId", () => {
|
||||
it("returns null when the agent is not running", () => {
|
||||
const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the last assistant block id when running with a single head block", () => {
|
||||
const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe(
|
||||
"group-1:head",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => {
|
||||
const tc = toolCallBlock("tool-1");
|
||||
expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the footer assistant block when history and streaming head coexist", () => {
|
||||
const historyBlock = assistantBlock({
|
||||
id: "group-1:block:0",
|
||||
blockGroupId: "group-1",
|
||||
blockIndex: 0,
|
||||
});
|
||||
const streamingBlock = assistantBlock({
|
||||
id: "group-2:head",
|
||||
blockGroupId: "group-2",
|
||||
blockIndex: 0,
|
||||
});
|
||||
// historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block
|
||||
expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe(
|
||||
"group-2:head",
|
||||
);
|
||||
expect(
|
||||
resolveInlineWorkingIndicatorItemId(
|
||||
"running",
|
||||
[historyBlock, streamingBlock],
|
||||
forwardStrategy,
|
||||
),
|
||||
).toBe("group-2:head");
|
||||
});
|
||||
});
|
||||