Compare commits

..

36 Commits

Author SHA1 Message Date
Mohamed Boudra
8d1ba7e3f3 Fix Windows packaged CLI TTY prompts 2026-05-07 14:44:44 +07:00
Mohamed Boudra
faf664d803 Fix desktop CLI passthrough tty handling (#791)
* Fix desktop CLI passthrough tty handling

* Fix desktop daemon manager test drift

* Update daemon stop test after main merge

* test(cli): isolate npx cache per CLI test
2026-05-07 15:14:18 +08:00
Mohamed Boudra
6fb2fe2283 Fix pane shortcuts in editable fields 2026-05-07 13:28:42 +07:00
Mohamed Boudra
cdce9a1235 Add spacing after Codex goal status 2026-05-07 13:08:45 +07:00
Mohamed Boudra
285d4edf23 fix(server): harden findExecutable + remove gratuitous realpath from spawn paths (#787)
* Phase 0: baseline tests for probeExecutable

* Phase 1: invert self node env handling

* Phase 2: refactor probeExecutable to execCommand

* Phase 3: push POSIX executable lookup to system which

* Fix probe test spawn-failure fixture

* Guard system which executable lookup
2026-05-07 14:03:15 +08:00
Mohamed Boudra
5c86956fef Render Codex image thread items as path markdown (#785) 2026-05-07 13:19:31 +08:00
Mohamed Boudra
80bdb4d45b Use tree-kill for daemon shutdown (#788)
* Use tree-kill for daemon shutdown

* fix: update lockfile signatures and Nix hash

* Use tree-kill for daemon shutdown

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-07 13:18:39 +08:00
Mohamed Boudra
7558bd7947 Fix dev override host bootstrap (#789) 2026-05-07 13:08:10 +08:00
Mohamed Boudra
16944da45b Fix MCP terminal capture scrollback 2026-05-07 11:59:13 +07:00
Mohamed Boudra
55ec13f153 fix(cli): honor PASEO_PASSWORD env var (fix #776) (#782)
* fix(cli): honor PASEO_PASSWORD env var (fix #776)

The CLI only extracted passwords from `tcp://host?password=` URIs, so
`PASEO_PASSWORD=xxxx paseo ls` connected without auth and was rejected
by the daemon with "Password required". Fall back to the env var when
the host (TCP or otherwise) carries no password.

* docs: document PASEO_PASSWORD env var as a CLI auth source

Pairs with the CLI fix in this branch — the "Connecting with a password"
section now lists both the tcp URI query and the env-var fallback, with
their precedence. Also clarify the dual role (daemon vs CLI) of
PASEO_PASSWORD in the env var reference.
2026-05-06 23:22:22 +08:00
Mohamed Boudra
c36f4cfee4 Vendor ACP provider catalog 2026-05-06 20:00:40 +08:00
Mohamed Boudra
ac375f152d Wrap desktop IPC in shared mutation/query hooks (fix #761) (#765)
* Wrap desktop IPC calls in shared hooks

* Unslop desktop IPC hook callsites

* Redesign desktop IPC hooks around domains
2026-05-06 19:58:17 +08:00
Zitao Xiong
24bdd3c8ad fix(relay): use TLS for any port-443 relay endpoint (#774)
* fix(relay): use TLS for any port-443 relay endpoint

`shouldUseTlsForDefaultHostedRelay` only returned true for
`relay.paseo.sh:443`. Self-hosted relays on port 443 connected via
`ws://` instead of `wss://`, causing HTTP 400 from the TLS server.

Port-based detection: port 443 → wss, everything else → ws.

* Test port-based hosted relay TLS detection

Cover the new behavior: any port-443 endpoint (hosted Paseo or
self-hosted) and IPv6 hosts return true, non-443 ports and malformed
input return false.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-05-06 19:56:32 +08:00
Mohamed Boudra
7e8187ddc7 fix: never rename worktree branches from create_agent path
Auto-naming should only run during worktree creation (mnemonic on create,
async LLM rename moments after). Creating a new agent inside an existing,
properly-named branch must not trigger a rename.
2026-05-06 18:33:36 +07:00
Mohamed Boudra
2e45650f22 Allow self-hosted relays to opt into wss:// (#767)
* Add relay TLS schema fields

* Wire relay TLS into websocket URLs

* Propagate relay TLS from pairing offers

* Expose relay TLS opt-in

* Mark relay TLS fallback for removal

* Preserve explicit relay TLS false

* Align relay TLS defaults outside config loader
2026-05-06 17:25:16 +08:00
Mohamed Boudra
6c319d05dd feat(codex): wire /goal slash command with mid-turn support (#711)
Adds optional `tryHandleOutOfBand` on AgentSession so providers can
handle slash commands as side effects without allocating a turn or
tripping the activeForegroundTurnId gate. Codex provider implements it
for /goal (set/pause/resume/clear), version-gated to codex >= 0.128.0
with `--enable goals`. Mid-turn /goal pause now works alongside a live
turn against the same thread.

E2E test against real codex 0.128.0 covers set + mid-turn pause + clear.
2026-05-06 16:50:02 +08:00
Mohamed Boudra
9eafab5a50 Fix index startup navigation test mock
The cold workspace URL navigation fix changed the / startup path for a matching persisted workspace. Index no longer renders a workspace Redirect there; it calls navigateToWorkspace so the centralized workspace navigation path owns the transition, then keeps the startup splash visible while that imperative navigation runs.

Update the test mock to expose navigateToWorkspace and assert that behavior directly. This keeps the test aligned with the route-shape fix and prevents CI failures on branches rebased over main.
2026-05-06 15:46:01 +07:00
github-actions[bot]
efff5b8454 fix: update lockfile signatures and Nix hash 2026-05-06 08:23:22 +00:00
Mohamed Boudra
2c2ded7492 Upgrade Electron to 41.2.0 2026-05-06 15:22:12 +07:00
Mohamed Boudra
7932e38d0d Fix browser reload focus and devtools handling
Cmd+R was scoped through a stale Electron-side active browser value. Opening or focusing a browser pane set activeBrowserPaneId, but switching to an agent tab only updated workspace layout state. Electron still remembered the old browser and the app menu reloaded that hidden/previous browser instead of the app.

Make workspace layout the authority. Derive the active browser from focusedPaneId plus the focused pane's focusedTabId, and sync browserId|null to Electron from one WorkspaceScreen effect. BrowserPane no longer marks itself active from URL-bar or webview focus handlers, so call sites do not have to remember to clear Electron state when focus moves to an agent, terminal, or another pane.

Electron now treats the synced value as the workspace-active browser, not an independently observed focus source. Menu reload uses that workspace-derived browser when present; otherwise it reloads the app window. Direct Cmd+R events that originate inside a browser guest still reload that guest through the guest before-input-event path.

Browser webviews now own their guest context menu in the main process. Add copy/paste/select-all and a development Inspect Element action for attached browser guest WebContents.

DevTools opening is routed through the main process by browserId. Docked-right DevTools did not become visible for browser guest contents in practice, even though inspect mode activated, so the final behavior opens detached DevTools for both the toolbar button and Inspect Element. The renderer logs bridge/result failures, and main logs registration/open/inspect outcomes for future debugging.

Regression coverage is at the workspace layout derivation layer: focused browser id follows the focused pane's active browser tab and becomes null when the active tab is not a browser. Verified with targeted layout-store tests, navigation E2E, settings E2E, npm run typecheck, and npm run lint.
2026-05-06 15:15:15 +07:00
Mohamed Boudra
5e5fc97798 Fix cold workspace URL sidebar navigation
Directly opening a workspace URL created a different React Navigation state shape than entering from /. Expo Router parsed the URL as a workspace layout route with a nested index child whose route.path and params pointed at the original workspace. Later sidebar navigation used router.dismissTo for another workspace. React Navigation's POP_TO reused the existing workspace route by name and updated the parent params, but preserved the nested child state. The result was a hybrid state: parent workspace params for B, child index path for A. Expo Router serialized the focused child path, so the address bar stayed on A and sidebar clicks looked like no-ops.

The fix is to stop using workspace/[workspaceId]/_layout.tsx as the actual screen. That layout did not render a Slot, so it only added an extra navigation level for Expo Router to preserve. Move the workspace UI, bootstrap boundary, open-intent consumption, and retained WorkspaceDeck into the leaf index route, making /h/:serverId/workspace/:workspaceId one real rendered route.

Keep workspace switches on navigateToWorkspace/router.dismissTo. This is not another replace workaround: replace previously created duplicate mounted workspace shells. The route-shape fix removes the stale nested child state that made dismissTo fail after cold deep links.

Also route archive/new-agent and notification flows through the centralized workspace navigation path so they do not bypass the same route contract.

Regression coverage: cold-load workspace A, click workspace B in the sidebar, and assert the URL changes to B. Verified with workspace-navigation-regression.spec.ts, settings-navigation.spec.ts, npm run typecheck, and npm run lint.
2026-05-06 15:13:53 +07:00
Mohamed Boudra
868479c493 Fix workspace navigation regression on web (#772)
Centralize workspace navigation behind a small route-derived API so the workspace shell reads active selection from the current Expo Router route instead of a separately synchronized store.

Remove the stale layout-driven active-workspace write that regressed web after 02e5b564.
2026-05-06 13:22:31 +08:00
Mohamed Boudra
0eacc98e17 website: add /cloud design partners page and shared shell
- New /cloud page with signup form (email, name, company, role, message)
  posting to a Discord webhook via a TanStack Start server function on
  the Cloudflare Worker. Webhook URL read from DISCORD_WEBHOOK_URL secret.
- Extract SiteShell, SiteFooter, FAQItem from landing-page.
- Migrate /privacy, /download, /blog, /changelog, /cloud to SiteShell so
  they share header + footer with consistent width.
- Add /cloud to header and footer nav.
- Make /docs sidebar full-width; bump footer text from xs to sm.
- Add Cloudflare Workers types and ignore .dev.vars.
2026-05-06 10:45:14 +07:00
Mohamed Boudra
ea4d6bb7ce Extract cwd resolution into testable function 2026-05-06 08:55:11 +07:00
Mohamed Boudra
40543128f7 Fix Codex import default mode 2026-05-05 22:49:55 +07:00
Mohamed Boudra
eac392e366 docs: tighten skills blurb and add hr before star chart 2026-05-05 22:22:02 +07:00
Mohamed Boudra
5396811c58 docs: tighten skills blurb 2026-05-05 22:19:55 +07:00
Mohamed Boudra
11383fa356 docs: rename to Skills and reframe with capability blurb 2026-05-05 22:19:41 +07:00
Mohamed Boudra
322bb1d962 docs: replace skills code block with bullet list 2026-05-05 22:17:21 +07:00
Mohamed Boudra
2402620d2f docs: add community section with paseo-relay link 2026-05-05 22:15:48 +07:00
Mohamed Boudra
3ac3b71ad3 docs: add Community section with paseo-relay link 2026-05-05 22:14:49 +07:00
Mohamed Boudra
02e5b56408 Fix duplicate workspace shell navigation 2026-05-05 21:55:57 +07:00
Mohamed Boudra
60531b5e1b refactor: long-tail type-aware sweep (T3.c) (#759)
* refactor: long-tail type-aware sweep (T3.c)

Cluster T3.c — 15 files, 24 errors cleared.

Rules fixed:
- no-useless-default-assignment: 6 → 0 (mcp-server.ts — Zod .default() makes destructuring defaults redundant)
- no-unnecessary-type-assertion: 1 → 0 (workspace-registry-model.ts — remove redundant ! on string[])
- no-unsafe-enum-comparison: 3 → 0 (use-push-token-registration.ts, use-image-attachment-picker.ts — use PermissionStatus enum values)
- no-redundant-type-constituents: 6 → 0 (workspace-setup.ts, tool-call-detail-state.ts, workspace-draft-agent-tab.tsx, stream.test.ts, desktop-settings-commands.ts — unknown | T → unknown)
- no-unnecessary-type-parameters: 4 → 0 (indexeddb-attachment-store.test.ts, use-archive-agent.ts, class-mocks.ts, agent-stream-coalescer.ts — narrow interface / remove unused generics)
- no-unnecessary-type-conversion: 4 → 0 (session-context.tsx — remove String() on already-string values; class-mocks.ts — remove String(prop) after symbol guard)
- no-implied-eval: 1 → 0 (agent-stream-coalescer.test.ts — narrow timer interface to function-only, eliminating string overload)

Deferred (need caller updates or extra files beyond 15-file cap):
- no-unnecessary-type-parameters: host.ts:onResized/onDragDropEvent, electron/events.ts (callers pass typed handlers; removing generic breaks contravariance without cast)
- no-base-to-string: ~21 errors, 18 files
- restrict-template-expressions: ~11 errors, 9 files
- await-thenable: ~7 errors, 4 files
- no-unnecessary-type-conversion: ~9 remaining errors, 9 files

* fix(server): restore MCP tool handler defaults that the linter mis-flagged

The `no-useless-default-assignment` rule fires because Zod's `.default()`
chain makes the TypeScript type non-optional, so the linter treats the
destructuring defaults as redundant. But the MCP framework invokes
handlers with raw (untransformed) params — Zod defaults are not applied
before the call. The destructuring defaults are the runtime safety net.

Reverts the three handler changes from T3.c:
- send_prompt: background = false, notifyOnFinish = false
- list_agents: includeArchived = false, sinceHours = 48, limit = 50
- get_terminal_lines: stripAnsi = true

The no-useless-default-assignment errors on these lines remain and are
deferred to a future cluster that addresses the MCP framework integration.
2026-05-05 22:38:55 +08:00
Mohamed Boudra
2415858d8f feat(website): clarify download page with Server section and copy buttons
Splits the old "Web & CLI" block into separate Web and Server sections so
new users can find the headless install flow without reading the FAQ.
Each section gets a one-line subtitle, and command rows reuse the
CodeBlock component (extracted from CommandDialog) with a new compact
size variant. Also adds Homebrew and Nix install rows, and stacks rows
vertically on small screens.
2026-05-05 21:31:10 +07:00
Somasundaram Ayyappan
9f01b4b7a2 fix: include untracked files in checkout shortstat (#608) (#762)
Count lines in untracked files when computing branch diffstat.
Adds file size limit, empty file handling, and CRLF normalization.
2026-05-05 21:10:54 +08:00
github-actions[bot]
425aa20747 fix: update lockfile signatures and Nix hash 2026-05-05 13:03:21 +00:00
236 changed files with 7518 additions and 3245 deletions

View File

@@ -120,11 +120,12 @@ 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/process-tree.test.ts
src/utils/tree-kill.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
View File

@@ -13,6 +13,8 @@ out/
.env.local
.env.test.local
.env*.local
.dev.vars
**/.dev.vars
# Logs
*.log

View File

@@ -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.
## Orchestration skills (Unstable)
## Skills
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.
Skills teach your agent to use Paseo to orchestrate other agents.
```bash
npx skills add getpaseo/paseo
@@ -101,18 +101,10 @@ npx skills add getpaseo/paseo
Then use them in any agent conversation:
```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
```
- `/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.
## Development
@@ -144,6 +136,58 @@ 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>

View File

@@ -95,6 +95,7 @@ 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.

View File

@@ -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 }
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean }
},
app: {
baseUrl: string

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-rjzmIH918poK05edU/f6G3iiP16Ra7g7vM0k5HbfKRs=";
npmDepsHash = "sha256-mGnJDX1LOORj7fDRPcJYIFG0D+rLDyom6LktWhwZasw=";
# 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).

17
package-lock.json generated
View File

@@ -19554,9 +19554,9 @@
}
},
"node_modules/electron": {
"version": "41.0.3",
"resolved": "https://registry.npmjs.org/electron/-/electron-41.0.3.tgz",
"integrity": "sha512-IDjx8liW1q+r7+MOip5W1Eo1eMwJzVObmYrd9yz2dPCkS7XlgLq3qPVMR80TpiROFp73iY30kTzMdpA6fEVs3A==",
"version": "41.2.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-41.2.0.tgz",
"integrity": "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -19842,9 +19842,9 @@
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -36354,7 +36354,6 @@
"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"
@@ -38681,6 +38680,7 @@
"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"
},
@@ -38732,7 +38732,7 @@
"devDependencies": {
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.0.3",
"electron": "41.2.0",
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
@@ -38878,6 +38878,7 @@
"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",

View File

@@ -0,0 +1,33 @@
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);
});
});

View File

@@ -204,6 +204,41 @@ export async function expectHostActionCards(page: Page): Promise<void> {
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);

View File

@@ -45,7 +45,7 @@ interface WorkspaceSetupDaemonClient {
}>;
fetchAgent(agentId: string): Promise<{
agent: { id: string; cwd: string } | null;
project: unknown | null;
project: unknown;
} | null>;
listTerminals(cwd: string): Promise<{
cwd?: string;

View File

@@ -36,6 +36,7 @@ import {
workspaceDeckEntryLocator,
expectWorkspaceDeckEntryCount,
} from "./helpers/workspace-ui";
import { clickSettingsBackToWorkspace } from "./helpers/settings";
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
@@ -75,6 +76,18 @@ 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>();
@@ -133,6 +146,25 @@ 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;
@@ -239,6 +271,48 @@ test.describe("Workspace navigation regression", () => {
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,
}) => {

View File

@@ -3,13 +3,7 @@ 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,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
import {
createContext,
type ReactNode,
@@ -71,15 +65,12 @@ 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";
@@ -336,6 +327,8 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
const waitForConfiguredLocalDaemon =
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
useEffect(() => {
@@ -343,6 +336,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
anyOnlineHostServerId ||
daemonStartError ||
daemonStartIsRunning ||
waitForConfiguredLocalDaemon ||
hasGivenUpWaitingForHost
) {
return;
@@ -353,7 +347,13 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
return () => {
clearTimeout(handle);
};
}, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]);
}, [
anyOnlineHostServerId,
daemonStartError,
daemonStartIsRunning,
waitForConfiguredLocalDaemon,
hasGivenUpWaitingForHost,
]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
@@ -863,28 +863,6 @@ 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>
@@ -927,7 +905,6 @@ export default function RootLayout() {
return (
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<NavigationActiveWorkspaceObserver />
<RootProviders>
<RuntimeProviders>
<AppShell />

View File

@@ -1,12 +1,12 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useLocalSearchParams, usePathname, 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 { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
return (
@@ -18,6 +18,7 @@ export default function HostAgentReadyRoute() {
function HostAgentReadyRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{
serverId?: string;
agentId?: string;
@@ -50,15 +51,14 @@ function HostAgentReadyRouteContent() {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
}) as Href,
);
navigateToPreparedWorkspaceTab({
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
currentPathname: pathname,
});
}
}, [agentId, resolvedWorkspaceId, router, serverId]);
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -102,13 +102,12 @@ function HostAgentReadyRouteContent() {
}
redirectedRef.current = true;
if (workspaceId) {
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
}) as Href,
);
navigateToPreparedWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
currentPathname: pathname,
});
return;
}
router.replace(buildHostRootRoute(serverId));
@@ -125,7 +124,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
}, [agentId, client, hasHydratedWorkspaces, isConnected, pathname, router, serverId]);
return null;
}

View File

@@ -1,258 +0,0 @@
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,
},
});

View File

@@ -1,3 +1,217 @@
export default function HostWorkspaceIndexRoute() {
return null;
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>
);
}
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,
},
});

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection,
startHostRuntimeBootstrap,
WELCOME_ROUTE,
} from "./host-runtime-bootstrap";
@@ -150,14 +151,24 @@ describe("resolveStartupRedirectRoute", () => {
});
describe("scenario: saved-host-online", () => {
it("redirects to the workspace route when the online host matches the persisted workspace", () => {
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-1",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(route).toBe("/h/server-1/workspace/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" });
});
it("redirects to the host root when the persisted workspace targets a different server", () => {
@@ -192,14 +203,14 @@ describe("resolveStartupRedirectRoute", () => {
});
describe("scenario: both-succeed", () => {
it("redirects to the host that the host runtime selects as earliest online", () => {
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-saved",
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
});
expect(route).toBe("/h/server-saved/workspace/workspace-a");
expect(route).toBeNull();
});
});

View File

@@ -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, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { buildHostRootRoute } from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
boot: () => void;
@@ -66,8 +66,31 @@ 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 (input.pathname !== "/" && input.pathname !== "") {
if (!isIndexPathname(input.pathname)) {
return null;
}
if (!input.isWorkspaceSelectionLoaded) {
@@ -75,14 +98,8 @@ export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput):
}
if (input.anyOnlineHostServerId) {
if (
input.workspaceSelection &&
input.workspaceSelection.serverId === input.anyOnlineHostServerId
) {
return buildHostWorkspaceRoute(
input.workspaceSelection.serverId,
input.workspaceSelection.workspaceId,
);
if (resolveStartupWorkspaceSelection(input)) {
return null;
}
return buildHostRootRoute(input.anyOnlineHostServerId);
}

View File

@@ -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 { redirectMock, state } = vi.hoisted(() => {
const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
const hoistedState = {
pathname: "/",
bootstrapState: {
@@ -18,11 +18,11 @@ const { 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", () => ({
getLastNavigationWorkspaceRouteSelection: () => state.workspaceSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded: () => state.isWorkspaceSelectionLoaded,
navigateToWorkspace: navigateToWorkspaceMock,
useActiveWorkspaceSelection: () => state.workspaceSelection,
}));
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,23 +98,17 @@ 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(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
currentPathname: "/",
});
expect(redirectMock).not.toHaveBeenCalled();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
});
it("navigates to the host root when the persisted workspace targets a different server", async () => {

View File

@@ -2,10 +2,13 @@ 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 {
getLastNavigationWorkspaceRouteSelection,
useIsLastNavigationWorkspaceRouteSelectionLoaded,
resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection,
} from "@/app/host-runtime-bootstrap";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
@@ -15,17 +18,35 @@ export default function Index() {
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const isWorkspaceSelectionLoaded = useIsLastNavigationWorkspaceRouteSelectionLoaded();
const workspaceSelection = useActiveWorkspaceSelection();
const redirectRoute = resolveStartupRedirectRoute({
pathname,
anyOnlineHostServerId,
workspaceSelection: isWorkspaceSelectionLoaded
? getLastNavigationWorkspaceRouteSelection()
: null,
isWorkspaceSelectionLoaded,
workspaceSelection,
isWorkspaceSelectionLoaded: true,
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} />;

View File

@@ -178,6 +178,7 @@ export default function PairScanScreen() {
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
},
{ serverId: offer.serverId },

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 416 B

View File

@@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 650 B

View File

@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,26 @@
<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>

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 878 B

View File

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 573 B

View File

@@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 973 B

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 822 B

View File

@@ -0,0 +1,6 @@
<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">&#x03b4;</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>

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,293 @@
<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>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 361 B

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 808 B

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 646 B

View File

@@ -0,0 +1,19 @@
<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>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,12 @@
<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>

After

Width:  |  Height:  |  Size: 919 B

View File

@@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 782 B

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 307 B

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 946 B

View File

@@ -0,0 +1,98 @@
<?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>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 179 B

View File

@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 399 B

View File

@@ -3,8 +3,8 @@ import { createIndexedDbAttachmentStore } from "./indexeddb-attachment-store";
type Listener = () => void;
class FakeRequest<T = unknown> {
result!: T;
class FakeRequest {
result!: unknown;
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<FakeDatabase>();
const request = new FakeRequest();
request.result = new FakeDatabase(store);
queueMicrotask(() => request.emit("success"));
return request;

View File

@@ -10,7 +10,7 @@ import {
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-connection";
import { DaemonConnectionTestError } 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 { upsertDirectConnection } = useHostMutations();
const { probeAndUpsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const [isSaving, setIsSaving] = useState(false);
@@ -336,22 +336,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsSaving(true);
setErrorMessage("");
const { client, serverId, hostname } = await connectToDaemon({
id: "probe",
type: "directTcp",
const { profile, serverId, hostname } = await probeAndUpsertDirectConnection({
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();
@@ -381,7 +371,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onSaved,
password,
port,
upsertDirectConnection,
probeAndUpsertDirectConnection,
useTls,
]);

View File

@@ -0,0 +1,327 @@
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",
},
}));

View File

@@ -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 { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { Agent } from "@/stores/session-store";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
@@ -324,13 +324,12 @@ export function AgentList({
}
rememberArchivedAgentDetail(agent);
const route = prepareWorkspaceTab({
navigateToPreparedWorkspaceTab({
serverId,
workspaceId,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
});
router.navigate(route);
},
[isActionSheetVisible, onAgentSelect],
);

View File

@@ -21,7 +21,6 @@ import {
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import Animated, {
FadeIn,
FadeOut,
@@ -83,7 +82,7 @@ import {
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
getWorkingIndicatorDotStrength,
@@ -135,7 +134,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
ref,
) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const router = useRouter();
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
() =>
@@ -213,12 +211,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
if (workspaceId) {
const route = prepareWorkspaceTab({
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route);
}
return;
}
@@ -247,7 +244,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
onOpenWorkspaceFile,
requestDirectoryListing,
resolvedServerId,
router,
setExplorerTabForCheckout,
openWorkspaceFile,
workspaceId,

View File

@@ -8,7 +8,7 @@ import {
createElement,
} from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { ArrowLeft, ArrowRight, MousePointer2, RotateCw } from "lucide-react-native";
import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
buildWorkspaceAttachmentScopeKey,
@@ -34,7 +34,6 @@ type ElectronWebview = HTMLElement & {
stop?: () => void;
loadURL?: (url: string) => Promise<void>;
getURL?: () => string;
openDevTools?: () => void;
executeJavaScript?: (code: string) => Promise<unknown>;
focus?: () => void;
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => void;
@@ -343,15 +342,9 @@ export function BrowserPane({
}, 0);
}, []);
const markActivePane = useCallback(() => {
if (!isElectronRuntime()) return;
void getDesktopHost()?.browser?.setActivePane?.(browserIdRef.current);
}, []);
const handleUrlBarFocus = useCallback(() => {
markActivePane();
selectUrlBar();
}, [markActivePane, selectUrlBar]);
}, [selectUrlBar]);
const focusUrlBar = useCallback(() => {
urlInputRef.current?.focus();
@@ -481,7 +474,6 @@ export function BrowserPane({
};
const handleWebviewFocus = () => {
onFocusPane?.();
markActivePane();
};
webview.addEventListener("did-start-loading", handleStartLoading);
@@ -870,6 +862,26 @@ export function BrowserPane({
startElementSelector();
}, [cancelElementSelector, selectorActive, startElementSelector]);
const handleOpenDevTools = useCallback(() => {
const currentBrowserId = browserIdRef.current;
const openDevTools = getDesktopHost()?.browser?.openDevTools;
if (typeof openDevTools !== "function") {
console.warn("[browser-pane] openDevTools bridge missing", { browserId: currentBrowserId });
return;
}
void openDevTools(currentBrowserId)
.then((result) => {
console.info("[browser-pane] openDevTools result", {
browserId: currentBrowserId,
result,
});
return undefined;
})
.catch((error: unknown) => {
console.warn("[browser-pane] openDevTools failed", { browserId: currentBrowserId, error });
});
}, []);
const baseIconButtonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed?: boolean }) => [
styles.iconButton,
@@ -973,17 +985,27 @@ export function BrowserPane({
</View>
<View style={styles.chromeRight}>
{isDev ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
>
<MousePointer2
size={16}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
/>
</Pressable>
<>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open browser dev tools"
onPress={handleOpenDevTools}
style={baseIconButtonStyle}
>
<PencilRuler size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
onPress={handleToggleElementSelector}
style={selectorIconButtonStyle}
>
<MousePointer2
size={16}
color={selectorActive ? theme.colors.accent : theme.colors.foregroundMuted}
/>
</Pressable>
</>
) : null}
</View>
</View>

View File

@@ -9,7 +9,6 @@ import {
type ReactNode,
type RefObject,
} from "react";
import { useRouter } from "expo-router";
import { DiffStat } from "@/components/diff-stat";
import {
View,
@@ -87,7 +86,8 @@ import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store";
@@ -1874,7 +1874,6 @@ export function GitDiffPane({
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -2360,9 +2359,9 @@ export function GitDiffPane({
const handleArchiveSuccess = useCallback(
(targetWorkingDir: string) => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
navigateToWorkspace(serverId, targetWorkingDir);
},
[router, serverId],
[serverId],
);
const toastError = useCallback(

View File

@@ -137,6 +137,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
useTls: parsedOffer.relay.useTls,
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
},
{ serverId: parsedOffer.serverId },

View File

@@ -13,6 +13,18 @@ vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
const pathnameState = vi.hoisted(() => ({
value: "/",
}));
vi.mock("expo-router", () => ({
router: {
dismissTo: vi.fn(),
},
useLocalSearchParams: () => ({}),
usePathname: () => pathnameState.value,
}));
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
@@ -28,12 +40,7 @@ import type { HostProfile } from "@/types/host-connection";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import {
activateNavigationWorkspaceSelection,
syncNavigationActiveWorkspace,
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
@@ -222,10 +229,11 @@ function ProjectActiveProbe({
project: SidebarProjectEntry;
counts: RenderCounts;
}): null {
useIsNavigationProjectActive({
serverId,
workspaceIds: project.workspaces.map((entry) => entry.workspaceId),
});
const activeSelection = useActiveWorkspaceSelection();
const isActive =
activeSelection?.serverId === serverId &&
project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId);
void isActive;
incrementRecord(counts.projectSelection, project.projectKey);
return null;
}
@@ -239,7 +247,10 @@ function WorkspaceSelectionProbe({
workspaceId: string;
counts: RenderCounts;
}): null {
useIsNavigationWorkspaceSelected({ serverId, workspaceId });
const activeSelection = useActiveWorkspaceSelection();
const selected =
activeSelection?.serverId === serverId && activeSelection.workspaceId === workspaceId;
void selected;
incrementRecord(counts.rowSelection, workspaceId);
return null;
}
@@ -304,12 +315,16 @@ async function renderProbe(counts: RenderCounts): Promise<{ root: Root; containe
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(<SidebarFrameProbe counts={counts} />);
renderSidebarFrame(root, counts);
});
resetCounts(counts);
return { root, container };
}
function renderSidebarFrame(root: Root, counts: RenderCounts) {
root.render(<SidebarFrameProbe counts={counts} />);
}
describe("sidebar workspace render isolation", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -328,7 +343,7 @@ describe("sidebar workspace render isolation", () => {
container?.remove();
container = null;
act(() => {
syncNavigationActiveWorkspace({ current: null });
pathnameState.value = "/";
getHostRuntimeStore().syncHosts([]);
useSessionStore.getState().clearSession(SERVER_ID);
useSidebarOrderStore.setState({
@@ -417,7 +432,7 @@ describe("sidebar workspace render isolation", () => {
});
});
it("isolates active selection updates to affected row and project boolean probes", async () => {
it("updates active selection probes from the active workspace route", async () => {
const counts: RenderCounts = {
frame: 0,
headers: {},
@@ -427,29 +442,28 @@ describe("sidebar workspace render isolation", () => {
};
act(() => {
activateNavigationWorkspaceSelection({
serverId: SERVER_ID,
workspaceId: "a-one",
});
pathnameState.value = `/h/${SERVER_ID}/workspace/a-one`;
});
({ root, container } = await renderProbe(counts));
act(() => {
activateNavigationWorkspaceSelection({
serverId: SERVER_ID,
workspaceId: "b-two",
});
pathnameState.value = `/h/${SERVER_ID}/workspace/b-two`;
if (root) {
renderSidebarFrame(root, counts);
}
});
expect(counts.frame).toBe(0);
expect(counts.headers).toEqual({});
expect(counts.rows).toEqual({});
expect(counts.frame).toBe(1);
expect(counts.projectSelection).toEqual({
"project-a": 1,
"project-b": 1,
});
expect(counts.rowSelection).toEqual({
"a-main": 1,
"a-one": 1,
"a-two": 1,
"b-main": 1,
"b-one": 1,
"b-two": 1,
});
});

View File

@@ -97,10 +97,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import {
useIsNavigationProjectActive,
useIsNavigationWorkspaceSelected,
} from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -1482,6 +1479,7 @@ function WorkspaceRowWithMenu({
isCreating?: boolean;
}) {
const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
@@ -1502,8 +1500,9 @@ function WorkspaceRowWithMenu({
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection,
});
}, [workspace.serverId, workspace.workspaceId]);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => {
if (isArchiving) {
@@ -1686,13 +1685,15 @@ function NonGitProjectRowWithMenuContent({
}) {
const toast = useToast();
const contextMenu = useContextMenu();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection,
});
}, [workspace.serverId, workspace.workspaceId]);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1839,11 +1840,11 @@ function FlattenedProjectRow({
selectionEnabled: boolean;
}) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId,
workspaceId: rowModel.workspace.workspaceId,
enabled: selectionEnabled,
});
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
selectionEnabled &&
activeWorkspaceSelection?.serverId === serverId &&
activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId;
if (!workspace) {
return null;
@@ -1970,11 +1971,11 @@ function WorkspaceRow({
selectionEnabled: boolean;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const selected = useIsNavigationWorkspaceSelected({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
enabled: selectionEnabled,
});
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
selectionEnabled &&
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection.workspaceId === workspace.workspaceId;
if (!hydratedWorkspace) {
return null;
@@ -2050,11 +2051,11 @@ function ProjectBlock({
() => project.workspaces.map((workspace) => workspace.workspaceId),
[project.workspaces],
);
const isProjectActive = useIsNavigationProjectActive({
serverId,
workspaceIds: projectWorkspaceIds,
enabled: selectionEnabled,
});
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const isProjectActive =
selectionEnabled &&
activeWorkspaceSelection?.serverId === serverId &&
projectWorkspaceIds.includes(activeWorkspaceSelection.workspaceId);
const renderWorkspaceRow = useCallback(
(

View File

@@ -195,7 +195,6 @@ export function WorkspaceSetupDialog() {
serverId: pendingWorkspaceSetup.serverId,
workspaceId,
target,
navigationMethod: pendingWorkspaceSetup.navigationMethod,
});
},
[clearWorkspaceSetup, pendingWorkspaceSetup],

View File

@@ -67,7 +67,7 @@ vi.mock("expo-router", () => ({
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
useNavigationActiveWorkspaceSelection: () => activeSelection.value,
useActiveWorkspaceSelection: () => activeSelection.value,
}));
vi.mock("@/stores/session-store-hooks", () => ({

View File

@@ -5,7 +5,7 @@ import { useEffect } from "react";
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
@@ -45,7 +45,7 @@ function hasSetupCommands(config: PaseoConfigRaw): boolean {
}
export function WorktreeSetupCalloutSource() {
const selection = useNavigationActiveWorkspaceSelection();
const selection = useActiveWorkspaceSelection();
const activeProject = useWorkspaceFields(
selection?.serverId ?? null,
selection?.workspaceId ?? null,

View File

@@ -0,0 +1,369 @@
import { ACP_PROVIDER_ICON_SVGS } from "@/assets/acp-provider-icons";
export interface AcpProviderCatalogEntry {
id: string;
title: string;
description: string;
version: string;
iconSvg: string | null;
installLink: string;
command: readonly [string, ...string[]];
env?: Readonly<Record<string, string>>;
}
const CATALOG_DATA = [
{
id: "agoragentic-acp",
title: "Agoragentic",
description:
"Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.",
version: "1.3.0",
iconId: "agoragentic-acp",
installLink: "https://agoragentic.com",
command: ["npx", "-y", "agoragentic-mcp@1.3.0", "--acp"],
},
{
id: "amp-acp",
title: "Amp",
description: "ACP wrapper for Amp - the frontier coding agent",
version: "0.7.0",
iconId: "amp-acp",
installLink: "https://github.com/tao12345666333/amp-acp",
command: ["amp-acp"],
},
{
id: "auggie",
title: "Auggie CLI",
description:
"Augment Code's powerful software agent, backed by industry-leading context engine",
version: "0.26.0",
iconId: "auggie",
installLink: "https://www.augmentcode.com/",
command: ["npx", "-y", "@augmentcode/auggie@0.26.0", "--acp"],
env: {
AUGMENT_DISABLE_AUTO_UPDATE: "1",
},
},
{
id: "autohand",
title: "Autohand Code",
description: "Autohand Code - AI coding agent powered by Autohand AI",
version: "0.2.1",
iconId: "autohand",
installLink: "https://www.autohand.ai/cli/",
command: ["npx", "-y", "@autohandai/autohand-acp@0.2.1"],
},
{
id: "claude-acp",
title: "Claude Agent",
description: "ACP wrapper for Anthropic's Claude",
version: "0.32.0",
iconId: "claude-acp",
installLink: "https://github.com/agentclientprotocol/claude-agent-acp",
command: ["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.32.0"],
},
{
id: "cline",
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "2.18.0",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@2.18.0", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.95.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.95.0", "--acp"],
},
{
id: "codex-acp",
title: "Codex CLI",
description: "ACP adapter for OpenAI's coding assistant",
version: "0.13.0",
iconId: "codex-acp",
installLink: "https://github.com/zed-industries/codex-acp",
command: ["codex-acp"],
},
{
id: "cortex-code",
title: "Cortex Code",
description: "Snowflake's Cortex Code coding agent",
version: "1.0.73",
iconId: "cortex-code",
installLink: "https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code-cli",
command: ["cortex", "acp", "serve"],
},
{
id: "corust-agent",
title: "Corust Agent",
description: "Co-building with a seasoned Rust partner.",
version: "0.5.1",
iconId: "corust-agent",
installLink: "https://github.com/Corust-ai/corust-agent-release/releases",
command: ["corust-agent-acp"],
},
{
id: "crow-cli",
title: "crow-cli",
description: "Minimal ACP Native Coding Agent",
version: "0.1.23",
iconId: "crow-cli",
installLink: "https://crow-ai.dev/",
command: ["crow-cli", "acp"],
},
{
id: "cursor",
title: "Cursor",
description: "Cursor's coding agent",
version: "2026.03.30",
iconId: "cursor",
installLink: "https://docs.cursor.com/en/cli/overview",
command: ["cursor-agent", "acp"],
},
{
id: "deepagents",
title: "DeepAgents",
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
version: "0.1.7",
iconId: "deepagents",
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
command: ["npx", "-y", "deepagents-acp@0.1.7"],
},
{
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.0.66",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.0.66", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.3.36",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.3.36", "--acp"],
},
{
id: "factory-droid",
title: "Factory Droid",
description: "Factory Droid - AI coding agent powered by Factory AI",
version: "0.119.0",
iconId: "factory-droid",
installLink: "https://factory.ai/product/cli",
command: ["npx", "-y", "droid@0.119.0", "exec", "--output-format", "acp-daemon"],
env: {
DROID_DISABLE_AUTO_UPDATE: "true",
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
},
},
{
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.7.0",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "fast-agent-acp@latest", "-x"],
},
{
id: "gemini",
title: "Gemini CLI",
description: "Google's official CLI for Gemini",
version: "0.41.1",
iconId: "gemini",
installLink: "https://geminicli.com",
command: ["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"],
},
{
id: "github-copilot-cli",
title: "GitHub Copilot",
description: "GitHub's AI pair programmer",
version: "1.0.41",
iconId: "github-copilot-cli",
installLink: "https://github.com/features/copilot/cli/",
command: ["npx", "-y", "@github/copilot@1.0.41", "--acp"],
},
{
id: "glm-acp-agent",
title: "GLM Agent",
description:
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
version: "1.1.2",
iconId: "glm-acp-agent",
installLink: "https://github.com/stefandevo/glm-acp-agent",
command: ["npx", "-y", "glm-acp-agent@1.1.2"],
},
{
id: "goose",
title: "goose",
description: "A local, extensible, open source AI agent that automates engineering tasks",
version: "1.33.1",
iconId: "goose",
installLink: "https://block.github.io/goose/",
command: ["goose", "acp"],
},
{
id: "hermes",
title: "Hermes",
description: "Nous Research self-improving AI agent",
version: "manual",
iconId: null,
installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
command: ["hermes", "acp"],
},
{
id: "junie",
title: "Junie",
description: "AI Coding Agent by JetBrains",
version: "1468.30.0",
iconId: "junie",
installLink: "https://junie.jetbrains.com/docs/junie-cli-acp.html",
command: ["junie", "--acp", "true"],
},
{
id: "kilo",
title: "Kilo",
description: "The open source coding agent",
version: "7.2.40",
iconId: "kilo",
installLink: "https://kilo.ai/docs/code-with-ai/platforms/cli",
command: ["kilo", "acp"],
},
{
id: "kimi",
title: "Kimi CLI",
description: "Moonshot AI's coding assistant",
version: "1.41.0",
iconId: "kimi",
installLink: "https://github.com/MoonshotAI/kimi-cli",
command: ["kimi", "acp"],
},
{
id: "minion-code",
title: "Minion Code",
description:
"An enhanced AI code assistant built on the Minion framework with rich development tools",
version: "0.1.44",
iconId: "minion-code",
installLink: "https://github.com/femto/minion-code",
command: ["uvx", "minion-code@latest", "acp"],
},
{
id: "mistral-vibe",
title: "Mistral Vibe",
description: "Mistral's open-source coding assistant",
version: "2.9.3",
iconId: "mistral-vibe",
installLink: "https://github.com/mistralai/mistral-vibe",
command: ["vibe-acp"],
},
{
id: "nova",
title: "Nova",
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
version: "1.1.4",
iconId: "nova",
installLink: "https://www.compassap.ai/portfolio/nova.html",
command: ["npx", "-y", "@compass-ai/nova@1.1.4", "acp"],
},
{
id: "opencode",
title: "OpenCode",
description: "The open source coding agent",
version: "1.14.39",
iconId: "opencode",
installLink: "https://opencode.ai/docs/acp/",
command: ["opencode", "acp"],
},
{
id: "pi-acp",
title: "pi ACP",
description: "ACP adapter for pi coding agent",
version: "0.0.26",
iconId: "pi-acp",
installLink: "https://github.com/svkozak/pi-acp",
command: ["npx", "-y", "pi-acp@0.0.26"],
},
{
id: "poolside",
title: "Poolside",
description: "Poolside's coding agent",
version: "1.0.0",
iconId: "poolside",
installLink: "https://docs.poolside.ai/cli/pool",
command: ["pool", "acp"],
},
{
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "0.2.6",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@0.2.6", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.15.6",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.15.6", "--acp", "--experimental-skills"],
},
{
id: "sigit",
title: "siGit Code",
description:
"Local-first coding agent. Runs entirely on your machine with optional on-device LLM inference via Onde.",
version: "1.0.3",
iconId: "sigit",
installLink: "https://github.com/getsigit/sigit",
command: ["sigit"],
},
{
id: "stakpak",
title: "Stakpak",
description: "Open-source DevOps agent in Rust with enterprise-grade security",
version: "0.3.80",
iconId: "stakpak",
installLink: "https://stakpak.dev/",
command: ["stakpak", "acp"],
},
{
id: "vtcode",
title: "VT Code",
description:
"An open-source coding agent with LLM-native code understanding and robust shell safety. Supports multiple LLM providers with automatic failover and efficient context management.",
version: "0.96.14",
iconId: "vtcode",
installLink: "https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md",
command: ["vtcode", "acp"],
env: {
VT_ACP_ENABLED: "1",
VT_ACP_ZED_ENABLED: "1",
},
},
] as const;
export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map((entry) => ({
id: entry.id,
title: entry.title,
description: entry.description,
version: entry.version,
installLink: entry.installLink,
command: entry.command,
env: "env" in entry ? entry.env : undefined,
iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null,
}));

View File

@@ -8,87 +8,16 @@ import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { confirmDialog } from "@/utils/confirm-dialog";
import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import { executeDaemonManagementToggle } from "@/desktop/daemon/daemon-management-toggle";
import { getCliDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
import { resolveAppVersion } from "@/utils/app-version";
type DesktopDaemonSettings = DesktopSettings["daemon"];
function useDaemonManagementToggle(args: {
daemonStatus: DesktopDaemonStatus | null;
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
setStatus: (status: DesktopDaemonStatus) => void;
refetch: () => void;
}) {
const { daemonStatus, settings, updateSettings, setStatus, refetch } = args;
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
return;
}
setIsUpdatingDaemonManagement(true);
void executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
startDaemon: startDesktopDaemon,
stopDaemon: stopDesktopDaemon,
})
.then((result) => {
if (result.kind === "cancelled") {
return;
}
if (result.newStatus) {
setStatus(result.newStatus);
}
refetch();
return;
})
.catch((error) => {
console.error("[Settings] Failed to update built-in daemon management", error);
Alert.alert(
"Error",
settings.manageBuiltInDaemon
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
: "Unable to update built-in daemon management.",
);
})
.finally(() => {
setIsUpdatingDaemonManagement(false);
});
}, [
daemonStatus,
isUpdatingDaemonManagement,
refetch,
setStatus,
settings.manageBuiltInDaemon,
updateSettings,
]);
return { isUpdatingDaemonManagement, handleToggleDaemonManagement };
}
function useKeepRunningAfterQuitToggle(args: {
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
@@ -99,9 +28,8 @@ function useKeepRunningAfterQuitToggle(args: {
const handleToggleKeepRunningAfterQuit = useCallback(() => {
setIsUpdatingKeepRunningAfterQuit(true);
void updateSettings({ keepRunningAfterQuit: !settings.keepRunningAfterQuit })
.catch((error) => {
console.error("[Settings] Failed to update desktop quit daemon behavior", error);
Alert.alert("Error", "Unable to update the desktop quit daemon behavior.");
.catch(() => {
// useDesktopSettings owns the user-visible IPC error.
})
.finally(() => {
setIsUpdatingKeepRunningAfterQuit(false);
@@ -393,13 +321,14 @@ export function LocalDaemonSection() {
const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`;
const isDaemonManagementPaused = !daemonSettings.manageBuiltInDaemon;
const { isUpdatingDaemonManagement, handleToggleDaemonManagement } = useDaemonManagementToggle({
daemonStatus,
settings: daemonSettings,
updateSettings: updateDaemonSettings,
setStatus,
refetch,
});
const { isUpdating: isUpdatingDaemonManagement, toggle: handleToggleDaemonManagement } =
useBuiltInDaemonManagement({
daemonStatus,
settings: daemonSettings,
updateSettings: updateDaemonSettings,
setStatus,
refreshStatus: refetch,
});
const { isUpdatingKeepRunningAfterQuit, handleToggleKeepRunningAfterQuit } =
useKeepRunningAfterQuitToggle({
settings: daemonSettings,

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { Text, View } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -7,14 +7,8 @@ import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
import {
shouldUseDesktopDaemon,
getCliInstallStatus,
installCli,
getSkillsInstallStatus,
installSkills,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useCliInstall, useSkillsInstall } from "@/desktop/hooks/use-install-status";
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
@@ -23,59 +17,37 @@ const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
export function IntegrationsSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const [cliStatus, setCliStatus] = useState<InstallStatus | null>(null);
const [skillsStatus, setSkillsStatus] = useState<InstallStatus | null>(null);
const [isInstallingCli, setIsInstallingCli] = useState(false);
const [isInstallingSkills, setIsInstallingSkills] = useState(false);
const loadStatus = useCallback(() => {
if (!showSection) return;
void getCliInstallStatus()
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to load CLI status", error);
});
void getSkillsInstallStatus()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to load skills status", error);
});
}, [showSection]);
const {
status: cliStatus,
isInstalling: isInstallingCli,
install: installCli,
refresh: refreshCliStatus,
} = useCliInstall();
const {
status: skillsStatus,
isInstalling: isInstallingSkills,
install: installSkills,
refresh: refreshSkillsStatus,
} = useSkillsInstall();
useFocusEffect(
useCallback(() => {
if (!showSection) return undefined;
loadStatus();
refreshCliStatus();
refreshSkillsStatus();
return undefined;
}, [loadStatus, showSection]),
}, [refreshCliStatus, refreshSkillsStatus, showSection]),
);
const handleInstallCli = useCallback(() => {
if (isInstallingCli) return;
setIsInstallingCli(true);
void installCli()
.then(setCliStatus)
.catch((error) => {
console.error("[Integrations] Failed to install CLI", error);
})
.finally(() => {
setIsInstallingCli(false);
});
}, [isInstallingCli]);
installCli();
}, [installCli, isInstallingCli]);
const handleInstallSkills = useCallback(() => {
if (isInstallingSkills) return;
setIsInstallingSkills(true);
void installSkills()
.then(setSkillsStatus)
.catch((error) => {
console.error("[Integrations] Failed to install skills", error);
})
.finally(() => {
setIsInstallingSkills(false);
});
}, [isInstallingSkills]);
installSkills();
}, [installSkills, isInstallingSkills]);
const handleOpenCliDocs = useCallback(() => {
void openExternalUrl(CLI_DOCS_URL);

View File

@@ -0,0 +1,56 @@
import { useCallback, useEffect, useRef } from "react";
import type { ToastApi } from "@/components/toast-host";
import { useToast } from "@/contexts/toast-context";
interface DesktopIpcErrorReport {
toast: ToastApi;
logLabel: string;
message: string;
error: unknown;
}
interface DesktopIpcQueryErrorToastOptions {
error: Error | null;
logLabel: string;
message: string;
}
interface DesktopIpcErrorReporterInput {
logLabel: string;
message: string;
error: unknown;
}
export function reportDesktopIpcError(input: DesktopIpcErrorReport): void {
console.error(input.logLabel, input.error);
input.toast.error(input.message);
}
export function useDesktopIpcErrorReporter(): (input: DesktopIpcErrorReporterInput) => void {
const toast = useToast();
return useCallback(
(input: DesktopIpcErrorReporterInput) => {
reportDesktopIpcError({ ...input, toast });
},
[toast],
);
}
export function useDesktopIpcQueryErrorToast(options: DesktopIpcQueryErrorToastOptions): void {
const toast = useToast();
const lastReportedErrorRef = useRef<Error | null>(null);
useEffect(() => {
if (!options.error || options.error === lastReportedErrorRef.current) {
return;
}
lastReportedErrorRef.current = options.error;
reportDesktopIpcError({
toast,
logLabel: options.logLabel,
message: options.message,
error: options.error,
});
}, [options.error, options.logLabel, options.message, toast]);
}

View File

@@ -0,0 +1,84 @@
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import {
type DesktopDaemonStatus,
startDesktopDaemon,
stopDesktopDaemon,
} from "@/desktop/daemon/desktop-daemon";
import {
executeDaemonManagementToggle,
type DaemonManagementToggleResult,
} from "@/desktop/daemon/daemon-management-toggle";
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { confirmDialog } from "@/utils/confirm-dialog";
type DesktopDaemonSettings = DesktopSettings["daemon"];
interface UseBuiltInDaemonManagementInput {
daemonStatus: DesktopDaemonStatus | null;
settings: DesktopDaemonSettings;
updateSettings: (next: Partial<DesktopDaemonSettings>) => Promise<unknown>;
setStatus: (status: DesktopDaemonStatus) => void;
refreshStatus: () => void;
}
interface UseBuiltInDaemonManagementResult {
isUpdating: boolean;
toggle: () => void;
}
export function useBuiltInDaemonManagement(
input: UseBuiltInDaemonManagementInput,
): UseBuiltInDaemonManagementResult {
const { daemonStatus, settings, updateSettings, setStatus, refreshStatus } = input;
const reportError = useDesktopIpcErrorReporter();
const { mutate: toggleDaemonManagement, isPending: isUpdating } = useMutation<
DaemonManagementToggleResult,
Error
>({
mutationFn: () =>
executeDaemonManagementToggle(settings.manageBuiltInDaemon, daemonStatus, {
confirm: () =>
confirmDialog({
title: "Pause built-in daemon",
message:
"This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.",
confirmLabel: "Pause and stop",
cancelLabel: "Cancel",
destructive: true,
}),
persistSettings: (next) => updateSettings(next) as Promise<void>,
startDaemon: startDesktopDaemon,
stopDaemon: stopDesktopDaemon,
}),
onError: (error) => {
reportError({
error,
message: settings.manageBuiltInDaemon
? "Built-in daemon management was paused, but Paseo could not stop the daemon."
: "Unable to update built-in daemon management.",
logLabel: "[Settings] Failed to update built-in daemon management",
});
},
onSuccess: (result) => {
if (result.kind === "cancelled") {
return;
}
if (result.newStatus) {
setStatus(result.newStatus);
}
refreshStatus();
},
});
const toggle = useCallback(() => {
if (isUpdating) {
return;
}
toggleDaemonManagement();
}, [isUpdating, toggleDaemonManagement]);
return { isUpdating, toggle };
}

View File

@@ -7,6 +7,7 @@ import {
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useDesktopIpcQueryErrorToast } from "@/desktop/hooks/desktop-ipc-error";
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
@@ -19,16 +20,22 @@ export function useDaemonStatus() {
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
const query = useQuery<DaemonStatusData>({
const query = useQuery<DaemonStatusData, Error>({
queryKey: DAEMON_STATUS_QUERY_KEY,
enabled,
staleTime: 30_000,
refetchOnMount: "always",
retry: false,
queryFn: async () => {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return { status, logs };
},
});
useDesktopIpcQueryErrorToast({
error: query.error,
message: "Unable to load desktop daemon status.",
logLabel: "[DesktopDaemon] Failed to load daemon status",
});
const setStatus = useCallback(
(status: DesktopDaemonStatus) => {

View File

@@ -0,0 +1,135 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCliInstall, useSkillsInstall } from "./use-install-status";
const toast = vi.hoisted(() => ({
error: vi.fn(),
show: vi.fn(),
copied: vi.fn(),
}));
const desktopDaemon = vi.hoisted(() => ({
getCliInstallStatus: vi.fn(),
getSkillsInstallStatus: vi.fn(),
installCli: vi.fn(),
installSkills: vi.fn(),
shouldUseDesktopDaemon: vi.fn(() => true),
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => toast,
}));
vi.mock("@/desktop/daemon/desktop-daemon", () => desktopDaemon);
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function renderDesktopHook<TResult>(callback: () => TResult) {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(callback, { wrapper });
}
describe("useCliInstall", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: true });
desktopDaemon.installCli.mockResolvedValue({ installed: true });
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("loads CLI install status", async () => {
const { result } = renderDesktopHook(() => useCliInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: true });
});
expect(toast.error).not.toHaveBeenCalled();
});
it("toasts and exposes CLI install errors", async () => {
const error = new Error("Missing IPC handler");
desktopDaemon.getCliInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installCli.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useCliInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
});
act(() => {
result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("Unable to install the Paseo CLI.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install CLI", error);
});
});
describe("useSkillsInstall", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: true });
desktopDaemon.installSkills.mockResolvedValue({ installed: true });
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("loads skills install status", async () => {
const { result } = renderDesktopHook(() => useSkillsInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: true });
});
expect(toast.error).not.toHaveBeenCalled();
});
it("toasts and exposes skills install errors", async () => {
const error = new Error("Missing IPC handler");
desktopDaemon.getSkillsInstallStatus.mockResolvedValue({ installed: false });
desktopDaemon.installSkills.mockRejectedValue(error);
const { result } = renderDesktopHook(() => useSkillsInstall());
await waitFor(() => {
expect(result.current.status).toEqual({ installed: false });
});
act(() => {
result.current.install();
});
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(toast.error).toHaveBeenCalledWith("Unable to install orchestration skills.");
expect(console.error).toHaveBeenCalledWith("[Integrations] Failed to install skills", error);
});
});

View File

@@ -0,0 +1,126 @@
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getCliInstallStatus,
getSkillsInstallStatus,
installCli,
installSkills,
shouldUseDesktopDaemon,
type InstallStatus,
} from "@/desktop/daemon/desktop-daemon";
import {
useDesktopIpcErrorReporter,
useDesktopIpcQueryErrorToast,
} from "@/desktop/hooks/desktop-ipc-error";
const CLI_INSTALL_STATUS_QUERY_KEY = ["desktop", "integrations", "cli-install-status"] as const;
const SKILLS_INSTALL_STATUS_QUERY_KEY = [
"desktop",
"integrations",
"skills-install-status",
] as const;
interface DesktopInstallHookResult {
status: InstallStatus | null;
isLoading: boolean;
isInstalling: boolean;
error: Error | null;
install: () => void;
refresh: () => void;
}
export function useCliInstall(): DesktopInstallHookResult {
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<InstallStatus, Error>({
queryKey: CLI_INSTALL_STATUS_QUERY_KEY,
queryFn: getCliInstallStatus,
enabled,
retry: false,
});
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check CLI install status.",
logLabel: "[Integrations] Failed to load CLI status",
});
const installMutation = useMutation<InstallStatus, Error>({
mutationFn: installCli,
onError: (error) => {
reportError({
error,
message: "Unable to install the Paseo CLI.",
logLabel: "[Integrations] Failed to install CLI",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(CLI_INSTALL_STATUS_QUERY_KEY, nextStatus);
void queryClient.invalidateQueries({ queryKey: CLI_INSTALL_STATUS_QUERY_KEY });
},
});
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
const refresh = useCallback(() => {
void refetch();
}, [refetch]);
return {
status: installStatus ?? null,
isLoading,
isInstalling,
error: statusError ?? installError ?? null,
install,
refresh,
};
}
export function useSkillsInstall(): DesktopInstallHookResult {
const queryClient = useQueryClient();
const reportError = useDesktopIpcErrorReporter();
const enabled = shouldUseDesktopDaemon();
const statusQuery = useQuery<InstallStatus, Error>({
queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY,
queryFn: getSkillsInstallStatus,
enabled,
retry: false,
});
const { data: installStatus, error: statusError, isLoading, refetch } = statusQuery;
useDesktopIpcQueryErrorToast({
error: statusQuery.error,
message: "Unable to check orchestration skills install status.",
logLabel: "[Integrations] Failed to load skills status",
});
const installMutation = useMutation<InstallStatus, Error>({
mutationFn: installSkills,
onError: (error) => {
reportError({
error,
message: "Unable to install orchestration skills.",
logLabel: "[Integrations] Failed to install skills",
});
},
onSuccess: (nextStatus) => {
queryClient.setQueryData<InstallStatus>(SKILLS_INSTALL_STATUS_QUERY_KEY, nextStatus);
void queryClient.invalidateQueries({ queryKey: SKILLS_INSTALL_STATUS_QUERY_KEY });
},
});
const { error: installError, isPending: isInstalling, mutate: install } = installMutation;
const refresh = useCallback(() => {
void refetch();
}, [refetch]);
return {
status: installStatus ?? null,
isLoading,
isInstalling,
error: statusError ?? installError ?? null,
install,
refresh,
};
}

View File

@@ -89,7 +89,8 @@ export interface DesktopBrowserShortcutEvent {
}
export interface DesktopBrowserBridge {
setActivePane?: (browserId: string | null) => Promise<void>;
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
}

View File

@@ -1,10 +1,14 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getIsElectron } from "@/constants/platform";
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
import {
useDesktopIpcErrorReporter,
useDesktopIpcQueryErrorToast,
} from "@/desktop/hooks/desktop-ipc-error";
import type { ReleaseChannel } from "@/hooks/use-settings";
export const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
const DESKTOP_SETTINGS_QUERY_KEY = ["desktop-settings"] as const;
export interface DesktopSettings {
releaseChannel: ReleaseChannel;
@@ -30,15 +34,59 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
export function useDesktopSettings(): {
settings: DesktopSettings;
isLoading: boolean;
isSaving: boolean;
error: unknown;
updateSettings: (updates: DesktopSettingsPatch) => Promise<void>;
} {
const queryClient = useQueryClient();
const { data, isPending, error } = useQuery({
const reportError = useDesktopIpcErrorReporter();
const {
data,
isPending,
error: loadError,
} = useQuery<DesktopSettings, Error>({
queryKey: DESKTOP_SETTINGS_QUERY_KEY,
queryFn: loadDesktopSettings,
staleTime: Infinity,
gcTime: Infinity,
retry: false,
});
useDesktopIpcQueryErrorToast({
error: loadError,
message: "Unable to load desktop settings.",
logLabel: "[DesktopSettings] Failed to load settings",
});
const { mutateAsync: saveDesktopSettings, isPending: isSaving } = useMutation<
DesktopSettings,
Error,
DesktopSettingsPatch,
DesktopSettingsMutationContext
>({
mutationFn: updatePersistedDesktopSettings,
onMutate: (updates) => {
const previous =
queryClient.getQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY) ??
DEFAULT_DESKTOP_SETTINGS;
queryClient.setQueryData<DesktopSettings>(
DESKTOP_SETTINGS_QUERY_KEY,
mergeDesktopSettings(previous, updates),
);
return { previous };
},
onSuccess: (persisted) => {
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, persisted);
},
onError: (saveError, _updates, context) => {
if (context) {
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, context.previous);
}
reportError({
error: saveError,
message: "Unable to save desktop settings.",
logLabel: "[DesktopSettings] Failed to save settings",
});
},
});
const updateSettings = useCallback(
@@ -47,25 +95,24 @@ export function useDesktopSettings(): {
return;
}
const previous =
queryClient.getQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY) ??
DEFAULT_DESKTOP_SETTINGS;
const next = mergeDesktopSettings(previous, updates);
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, next);
const persisted = await updatePersistedDesktopSettings(updates);
queryClient.setQueryData<DesktopSettings>(DESKTOP_SETTINGS_QUERY_KEY, persisted);
await saveDesktopSettings(updates);
},
[queryClient],
[saveDesktopSettings],
);
return {
settings: data ?? DEFAULT_DESKTOP_SETTINGS,
isLoading: isPending,
error: error ?? null,
isSaving,
error: loadError ?? null,
updateSettings,
};
}
interface DesktopSettingsMutationContext {
previous: DesktopSettings;
}
export async function loadDesktopSettings(): Promise<DesktopSettings> {
if (!getIsElectron()) {
return DEFAULT_DESKTOP_SETTINGS;

View File

@@ -1,7 +1,9 @@
/**
* @vitest-environment jsdom
*/
import React from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useDesktopAppUpdater } from "./use-desktop-app-updater";
@@ -37,6 +39,27 @@ vi.mock("@/desktop/settings/desktop-settings", () => ({
}),
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn(), show: vi.fn(), copied: vi.fn() }),
}));
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function renderUpdaterHook() {
const queryClient = createQueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
return renderHook(() => useDesktopAppUpdater(), { wrapper });
}
describe("useDesktopAppUpdater", () => {
beforeEach(() => {
settingsState.releaseChannel = "stable";
@@ -47,7 +70,7 @@ describe("useDesktopAppUpdater", () => {
it("uses the effective desktop release channel when checking for updates", async () => {
settingsState.releaseChannel = "beta";
renderHook(() => useDesktopAppUpdater());
renderUpdaterHook();
await waitFor(() => {
expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({
@@ -58,7 +81,7 @@ describe("useDesktopAppUpdater", () => {
it("uses the effective desktop release channel when installing updates", async () => {
settingsState.releaseChannel = "beta";
const { result } = renderHook(() => useDesktopAppUpdater());
const { result } = renderUpdaterHook();
await act(async () => {
await result.current.installUpdate();

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import {
checkDesktopAppUpdate,
formatVersionWithPrefix,
@@ -8,6 +9,7 @@ import {
type DesktopAppUpdateInstallResult,
} from "@/desktop/updates/desktop-updates";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
export type DesktopAppUpdateStatus =
| "idle"
@@ -86,12 +88,26 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
const isDesktopApp = shouldShowDesktopUpdateSection();
const { settings: desktopSettings } = useDesktopSettings();
const releaseChannel = desktopSettings.releaseChannel;
const reportError = useDesktopIpcErrorReporter();
const requestVersionRef = useRef(0);
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [installMessage, setInstallMessage] = useState<string | null>(null);
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null);
const { mutateAsync: installAppUpdate, isPending: isInstallingAppUpdate } = useMutation<
DesktopAppUpdateInstallResult,
Error
>({
mutationFn: () => installDesktopAppUpdate({ releaseChannel }),
onError: (error) => {
reportError({
error,
message: "Unable to install the desktop app update.",
logLabel: "[DesktopUpdater] Failed to install app update",
});
},
});
const checkForUpdates = useCallback(
async (options: { silent?: boolean } = {}) => {
@@ -177,7 +193,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(null);
try {
const result = await installDesktopAppUpdate({ releaseChannel });
const result = await installAppUpdate();
setLastCheckedAt(Date.now());
if (result.installed) {
@@ -197,7 +213,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
setErrorMessage(message);
return null;
}
}, [isDesktopApp, releaseChannel]);
}, [installAppUpdate, isDesktopApp]);
return {
isDesktopApp,
@@ -211,7 +227,7 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
errorMessage,
lastCheckedAt,
isChecking: status === "checking",
isInstalling: status === "installing",
isInstalling: status === "installing" || isInstallingAppUpdate,
checkForUpdates,
installUpdate,
};

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
import { buildAcpProviderConfigPatch, getAcpProviderCatalog } from "./use-acp-provider-catalog";
function findProvider(id: string) {
const entry = getAcpProviderCatalog().find((provider) => provider.id === id);
if (!entry) {
throw new Error(`Missing ACP provider catalog entry: ${id}`);
}
return entry;
}
describe("ACP provider catalog", () => {
it("vendors provider entries with unique ids and concrete commands", () => {
const ids = new Set<string>();
for (const entry of ACP_PROVIDER_CATALOG) {
expect(ids.has(entry.id)).toBe(false);
ids.add(entry.id);
expect(entry.title).not.toBe("");
expect(entry.description).not.toBe("");
expect(entry.installLink).toMatch(/^https:\/\//);
expect(entry.command.length).toBeGreaterThan(0);
expect(entry.command[0]).not.toBe("");
}
});
it("bundles SVG icons for catalog entries that declare an icon", () => {
const entriesWithIcons = ACP_PROVIDER_CATALOG.filter((entry) => entry.id !== "hermes");
expect(entriesWithIcons.length).toBeGreaterThan(0);
for (const entry of entriesWithIcons) {
expect(entry.iconSvg).toContain("<svg");
}
});
it("uses PATH commands for entries that were binary distributions upstream", () => {
expect(findProvider("amp-acp").command).toEqual(["amp-acp"]);
expect(findProvider("cursor").command).toEqual(["cursor-agent", "acp"]);
expect(findProvider("goose").command).toEqual(["goose", "acp"]);
expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]);
expect(findProvider("poolside").command).toEqual(["pool", "acp"]);
});
it("maps a catalog entry to the daemon provider config patch", () => {
expect(buildAcpProviderConfigPatch(findProvider("amp-acp"))).toEqual({
providers: {
"amp-acp": {
extends: "acp",
label: "Amp",
description: "ACP wrapper for Amp - the frontier coding agent",
command: ["amp-acp"],
env: {},
},
},
});
});
it("preserves provider env in the daemon config patch", () => {
const patch = buildAcpProviderConfigPatch(findProvider("auggie"));
expect(patch.providers?.auggie?.env).toEqual({
AUGMENT_DISABLE_AUTO_UPDATE: "1",
});
});
});

View File

@@ -0,0 +1,33 @@
import { useCallback, useState } from "react";
import type { MutableDaemonConfigPatch } from "@server/shared/messages";
import { ACP_PROVIDER_CATALOG, type AcpProviderCatalogEntry } from "@/data/acp-provider-catalog";
export type AcpProviderCatalogItem = AcpProviderCatalogEntry;
export function getAcpProviderCatalog(): AcpProviderCatalogItem[] {
return ACP_PROVIDER_CATALOG;
}
export function buildAcpProviderConfigPatch(
entry: AcpProviderCatalogItem,
): MutableDaemonConfigPatch {
return {
providers: {
[entry.id]: {
extends: "acp",
label: entry.title,
description: entry.description,
command: [...entry.command],
env: entry.env ? { ...entry.env } : {},
},
},
};
}
export function useAcpProviderCatalog() {
const [entries] = useState<AcpProviderCatalogItem[]>(ACP_PROVIDER_CATALOG);
const refetch = useCallback(async () => entries, [entries]);
return { entries, loading: false, error: null, refetch };
}

View File

@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { router } from "expo-router";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
@@ -10,7 +10,7 @@ import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"];
export function useActiveWorktreeNewAction() {
const selection = useNavigationActiveWorkspaceSelection();
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;

View File

@@ -230,10 +230,10 @@ function getArchivedAgentListCacheSnapshot(
};
}
function restoreCachedQuerySnapshot<T>(
function restoreCachedQuerySnapshot(
queryClient: QueryClient,
queryKey: readonly unknown[],
snapshot: T | undefined,
snapshot: unknown,
): void {
if (snapshot === undefined) {
queryClient.removeQueries({ queryKey, exact: true });

View File

@@ -19,7 +19,7 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
import { useActiveServerId } from "@/hooks/use-active-server-id";
@@ -123,7 +123,7 @@ function resolveActionShortcutKeys(
}
export function useCommandCenter() {
const _pathname = usePathname();
const pathname = usePathname();
const routeActiveServerId = useActiveServerId();
const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
@@ -212,14 +212,14 @@ export function useCommandCenter() {
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as Href);
return;
}
const route = prepareWorkspaceTab({
navigateToPreparedWorkspaceTab({
serverId: agent.serverId,
workspaceId,
target: { kind: "agent", agentId: agent.id },
currentPathname: pathname,
});
router.navigate(route);
},
[setOpen],
[pathname, setOpen],
);
const openProjectPicker = useOpenProjectPicker(activeServerId);

View File

@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { useRouter } from "expo-router";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
type CheckoutGitActionStatus,
@@ -14,9 +13,10 @@ import {
useCheckoutPrStatusQuery,
} from "@/hooks/use-checkout-pr-status-query";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
@@ -225,7 +225,6 @@ interface UseGitActionsResult {
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const router = useRouter();
const toast = useToast();
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -432,13 +431,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
navigateToWorkspace(serverId, targetWorkingDir);
return;
})
.catch((err) => {
toastActionError(err, "Failed to archive worktree");
});
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
const derived = deriveGitActionsState({

View File

@@ -20,7 +20,10 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
const ensurePermission = useCallback(async () => {
let currentPermission = mediaPermission;
if (!currentPermission || currentPermission.status === "undetermined") {
if (
!currentPermission ||
currentPermission.status === ImagePicker.PermissionStatus.UNDETERMINED
) {
currentPermission = await requestMediaPermission();
} else if (!currentPermission.granted) {
currentPermission = await requestMediaPermission();

View File

@@ -24,8 +24,8 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
getLastNavigationWorkspaceRouteSelection,
getNavigationActiveWorkspaceSelection,
navigateToLastWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
export function useKeyboardShortcuts({
@@ -55,6 +55,7 @@ export function useKeyboardShortcuts({
});
const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
useEffect(() => {
if (!enabled) return;
@@ -96,6 +97,8 @@ export function useKeyboardShortcuts({
case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
case "navigate-last-workspace":
return navigateToLastWorkspace();
case "router-replace":
router.replace(action.route as Parameters<typeof router.replace>[0]);
return true;
@@ -194,8 +197,7 @@ export function useKeyboardShortcuts({
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace: getNavigationActiveWorkspaceSelection(),
lastNavigationWorkspaceRoute: getLastNavigationWorkspaceRouteSelection(),
navigationActiveWorkspace: activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
@@ -275,6 +277,7 @@ export function useKeyboardShortcuts({
bindings,
cycleTheme,
enabled,
activeWorkspaceSelection,
isMobile,
openProjectPickerAction,
pathname,

View File

@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.hoisted(() => {
(globalThis as typeof globalThis & { __DEV__?: boolean }).__DEV__ = false;
});
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
@@ -19,10 +23,8 @@ const { replaceRoute } = vi.hoisted(() => ({
replaceRoute: vi.fn(),
}));
vi.mock("expo-router", () => ({
router: {
replace: replaceRoute,
},
vi.mock("@/hooks/use-workspace-navigation", () => ({
navigateToWorkspace: replaceRoute,
}));
import { openProjectDirectly } from "@/hooks/use-open-project";
@@ -89,7 +91,7 @@ describe("openProjectDirectly", () => {
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
openDraftTab: createOpenDraftTab(),
replaceRoute,
navigateToWorkspace: replaceRoute,
});
expect(result).toBe(true);
@@ -115,7 +117,7 @@ describe("openProjectDirectly", () => {
const tabs = collectAllTabs(layout.root);
expect(tabs).toHaveLength(1);
expect(tabs[0]?.target.kind).toBe("draft");
expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/1");
expect(replaceRoute).toHaveBeenCalledWith("server-1", "1");
});
it("does not navigate or seed tabs when openProject fails", async () => {
@@ -133,7 +135,7 @@ describe("openProjectDirectly", () => {
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
openDraftTab: createOpenDraftTab(),
replaceRoute,
navigateToWorkspace: replaceRoute,
});
expect(result).toBe(false);

View File

@@ -1,5 +1,4 @@
import { useCallback } from "react";
import { router, type Href } from "expo-router";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
@@ -12,7 +11,7 @@ import {
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
interface OpenProjectDirectlyInput {
serverId: string;
@@ -22,7 +21,7 @@ interface OpenProjectDirectlyInput {
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
openDraftTab: (workspaceKey: string) => string | null;
replaceRoute: (route: string) => void;
navigateToWorkspace: (serverId: string, workspaceId: string) => void;
}
export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise<boolean> {
@@ -50,7 +49,7 @@ export async function openProjectDirectly(input: OpenProjectDirectlyInput): Prom
}
input.openDraftTab(workspaceKey);
input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id));
input.navigateToWorkspace(normalizedServerId, workspace.id);
return true;
}
@@ -75,9 +74,7 @@ export function useOpenProject(serverId: string | null): (path: string) => Promi
kind: "draft",
draftId: generateDraftId(),
}),
replaceRoute: (route) => {
router.replace(route as Href);
},
navigateToWorkspace,
});
},
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],

View File

@@ -24,10 +24,10 @@ function getExpoProjectId(): string | null {
async function ensurePushPermission(): Promise<boolean> {
const existing = await Notifications.getPermissionsAsync();
if (existing.status === "granted") return true;
if (existing.status === Notifications.PermissionStatus.GRANTED) return true;
if (!existing.canAskAgain) return false;
const requested = await Notifications.requestPermissionsAsync();
return requested.status === "granted";
return requested.status === Notifications.PermissionStatus.GRANTED;
}
export function usePushTokenRegistration(params: { client: DaemonClient; serverId: string }): void {

View File

@@ -1,59 +1,27 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* @vitest-environment jsdom
*/
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { navigateMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
const navigateToWorkspaceMock = vi.hoisted(() => vi.fn());
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
}));
vi.mock("expo-router", () => ({
router: {
navigate: navigateMock,
},
}));
import { navigateToWorkspace, useWorkspaceNavigation } from "@/hooks/use-workspace-navigation";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import {
activateNavigationWorkspaceSelection,
getNavigationActiveWorkspaceSelection,
syncNavigationActiveWorkspace,
} from "@/stores/navigation-active-workspace-store";
describe("navigateToWorkspace", () => {
beforeEach(() => {
navigateMock.mockReset();
syncNavigationActiveWorkspace({ current: null });
describe("useWorkspaceNavigation", () => {
it("re-exports the workspace navigation action", () => {
expect(navigateToWorkspace).toBe(navigateToWorkspaceMock);
});
it("uses router navigation from a non-workspace route even when active selection is stale", () => {
activateNavigationWorkspaceSelection({
serverId: "server-1",
workspaceId: "workspace-a",
});
it("returns a stable callback wrapper", () => {
const { result } = renderHook(() => useWorkspaceNavigation());
navigateToWorkspace("server-1", "workspace-b", {
currentPathname: "/h/server-1/sessions",
});
result.current.navigateToWorkspace("server-1", "workspace-a");
expect(navigateMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b");
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-a",
});
});
it("keeps retained workspace switching on a workspace route", () => {
activateNavigationWorkspaceSelection({
serverId: "server-1",
workspaceId: "workspace-a",
});
navigateToWorkspace("server-1", "workspace-b", {
currentPathname: "/h/server-1/workspace/workspace-a",
});
expect(navigateMock).not.toHaveBeenCalled();
expect(getNavigationActiveWorkspaceSelection()).toEqual({
serverId: "server-1",
workspaceId: "workspace-b",
});
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", undefined);
});
});

View File

@@ -1,61 +1,16 @@
import { useCallback } from "react";
import { router } from "expo-router";
import {
activateNavigationWorkspaceSelection,
getNavigationActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
interface NavigateToWorkspaceOptions {
currentPathname?: string | null;
}
function shouldUseRetainedWorkspaceSwitch(input: {
hasActiveWorkspace: boolean;
currentPathname?: string | null;
}): boolean {
if (!input.hasActiveWorkspace) {
return false;
}
if (input.currentPathname == null) {
return true;
}
return parseHostWorkspaceRouteFromPathname(input.currentPathname) !== null;
}
/**
* Open a workspace. Once the workspace shell is mounted, switching workspaces
* is app-level state so native-stack does not rebuild every retained screen.
*/
export function navigateToWorkspace(
serverId: string,
workspaceId: string,
options: NavigateToWorkspaceOptions = {},
) {
const activeWorkspace = getNavigationActiveWorkspaceSelection();
if (
shouldUseRetainedWorkspaceSwitch({
hasActiveWorkspace: activeWorkspace !== null,
currentPathname: options.currentPathname,
})
) {
activateNavigationWorkspaceSelection(
{ serverId, workspaceId },
{ updateBrowserHistory: true, historyMode: "push" },
);
return;
}
const href = buildHostWorkspaceRoute(serverId, workspaceId);
router.navigate(href);
}
export { navigateToWorkspace };
export function useWorkspaceNavigation() {
return {
navigateToWorkspace: useCallback(
(serverId: string, workspaceId: string, options?: NavigateToWorkspaceOptions) => {
(
serverId: string,
workspaceId: string,
options?: Parameters<typeof navigateToWorkspace>[2],
) => {
navigateToWorkspace(serverId, workspaceId, options);
},
[],

View File

@@ -363,6 +363,16 @@ describe("keyboard-shortcuts", () => {
event: { key: "\\", code: "Backslash", ctrlKey: true },
context: { isMac: false },
},
{
name: "keeps Cmd+Shift+ArrowRight available for message input selection",
event: { key: "ArrowRight", code: "ArrowRight", metaKey: true, shiftKey: true },
context: { isMac: true, focusScope: "message-input" },
},
{
name: "keeps Cmd+Shift+ArrowLeft available for generic editable selection",
event: { key: "ArrowLeft", code: "ArrowLeft", metaKey: true, shiftKey: true },
context: { isMac: true, focusScope: "editable" },
},
{
name: "keeps space typing available in message input",
event: { key: " ", code: "Space" },

View File

@@ -52,6 +52,8 @@ interface ShortcutWhen {
mac?: boolean;
/** true = desktop only, false = web only */
desktop?: boolean;
/** false = disabled when a text-editing surface is focused */
editable?: false;
/** false = disabled when terminal is focused */
terminal?: false;
/** false = disabled when command center is open */
@@ -454,7 +456,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-left-cmd-shift-left",
action: "workspace.pane.focus.left",
combo: "Cmd+Shift+ArrowLeft",
when: { mac: true, commandCenter: false },
when: { mac: true, commandCenter: false, editable: false },
help: {
id: "workspace-pane-focus-left",
section: "tabs-panes",
@@ -466,7 +468,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-right-cmd-shift-right",
action: "workspace.pane.focus.right",
combo: "Cmd+Shift+ArrowRight",
when: { mac: true, commandCenter: false },
when: { mac: true, commandCenter: false, editable: false },
help: {
id: "workspace-pane-focus-right",
section: "tabs-panes",
@@ -478,7 +480,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-up-cmd-shift-up",
action: "workspace.pane.focus.up",
combo: "Cmd+Shift+ArrowUp",
when: { mac: true, commandCenter: false },
when: { mac: true, commandCenter: false, editable: false },
help: {
id: "workspace-pane-focus-up",
section: "tabs-panes",
@@ -490,7 +492,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "workspace-pane-focus-down-cmd-shift-down",
action: "workspace.pane.focus.down",
combo: "Cmd+Shift+ArrowDown",
when: { mac: true, commandCenter: false },
when: { mac: true, commandCenter: false, editable: false },
help: {
id: "workspace-pane-focus-down",
section: "tabs-panes",
@@ -1035,6 +1037,12 @@ function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutCo
if (!when) return true;
if (when.mac !== undefined && when.mac !== context.isMac) return false;
if (when.desktop !== undefined && when.desktop !== context.isDesktop) return false;
if (
when.editable === false &&
(context.focusScope === "message-input" || context.focusScope === "editable")
) {
return false;
}
if (when.terminal === false && context.focusScope === "terminal") return false;
if (when.commandCenter === false && context.commandCenterOpen) return false;
if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false;

View File

@@ -19,7 +19,6 @@ function makeCtx(overrides: Partial<ShortcutRoutingContext> = {}): ShortcutRouti
isMobile: false,
sidebarShortcutTargets: SIDEBAR_TARGETS,
navigationActiveWorkspace: null,
lastNavigationWorkspaceRoute: null,
commandCenterOpen: false,
shortcutsDialogOpen: false,
...overrides,
@@ -253,39 +252,25 @@ describe("routeKeyboardShortcut — settings.toggle", () => {
).toEqual<ShortcutAction>({ kind: "router-push", route: "/settings" });
});
it("replaces to the retained workspace route when leaving settings on desktop", () => {
it("navigates to the last workspace when leaving settings on desktop", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({
pathname: "/settings/general",
isMobile: false,
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
}),
),
).toEqual<ShortcutAction>({
kind: "router-replace",
route: "/h/srv/workspace/ws-2",
});
).toEqual<ShortcutAction>({ kind: "navigate-last-workspace" });
});
it("falls back to router.back() when no retained workspace exists", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({ pathname: "/settings/general", lastNavigationWorkspaceRoute: null }),
),
).toEqual<ShortcutAction>({ kind: "router-back" });
});
it("falls back to router.back() on mobile even if a retained workspace exists", () => {
it("falls back to router.back() on mobile", () => {
expect(
routeKeyboardShortcut(
{ action: "settings.toggle", payload: null },
makeCtx({
pathname: "/settings/general",
isMobile: true,
lastNavigationWorkspaceRoute: { serverId: "srv", workspaceId: "ws-2" },
}),
),
).toEqual<ShortcutAction>({ kind: "router-back" });

View File

@@ -1,10 +1,6 @@
import type { KeyboardShortcutPayload, MessageInputKeyboardActionKind } from "@/keyboard/actions";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import {
buildHostWorkspaceRoute,
buildSettingsRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { buildSettingsRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import {
getRelativeSidebarShortcutTarget,
type SidebarShortcutWorkspaceTarget,
@@ -15,7 +11,6 @@ export interface ShortcutRoutingContext {
isMobile: boolean;
sidebarShortcutTargets: ReadonlyArray<SidebarShortcutWorkspaceTarget>;
navigationActiveWorkspace: SidebarShortcutWorkspaceTarget | null;
lastNavigationWorkspaceRoute: SidebarShortcutWorkspaceTarget | null;
commandCenterOpen: boolean;
shortcutsDialogOpen: boolean;
}
@@ -35,6 +30,7 @@ export type ShortcutAction =
| { kind: "none" }
| { kind: "dispatch"; action: KeyboardActionDefinition }
| { kind: "navigate-workspace"; serverId: string; workspaceId: string }
| { kind: "navigate-last-workspace" }
| { kind: "router-replace"; route: string }
| { kind: "router-back" }
| { kind: "router-push"; route: string }
@@ -166,14 +162,8 @@ function routeSettingsToggle(ctx: ShortcutRoutingContext): ShortcutAction {
if (!ctx.pathname.startsWith("/settings")) {
return { kind: "router-push", route: buildSettingsRoute() };
}
if (!ctx.isMobile && ctx.lastNavigationWorkspaceRoute) {
return {
kind: "router-replace",
route: buildHostWorkspaceRoute(
ctx.lastNavigationWorkspaceRoute.serverId,
ctx.lastNavigationWorkspaceRoute.workspaceId,
),
};
if (!ctx.isMobile) {
return { kind: "navigate-last-workspace" };
}
return { kind: "router-back" };
}

View File

@@ -198,6 +198,7 @@ function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
relay: {
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
useTls: input?.relay?.useTls ?? false,
},
};
}
@@ -1595,6 +1596,85 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("probeAndUpsertConnection learns the real server id before storing a direct host", async () => {
const connection: HostConnection = {
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
};
const probeClient = makeConnectedProbeClient(5);
const seenProbeHosts: string[] = [];
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host, connection: probedConnection }) => {
seenProbeHosts.push(host.serverId);
expect(probedConnection).toEqual(connection);
return {
client: probeClient as unknown as DaemonClient,
serverId: "srv_real_direct",
hostname: "mbp",
};
},
getClientId: async () => "cid_test_runtime",
},
});
const result = await store.probeAndUpsertConnection({ connection });
expect(result.serverId).toBe("srv_real_direct");
expect(result.hostname).toBe("mbp");
expect(seenProbeHosts).toEqual([""]);
expect(probeClient.closeCalls).toBe(0);
expect(store.getHosts()).toMatchObject([
{
serverId: "srv_real_direct",
label: "mbp",
connections: [connection],
},
]);
store.syncHosts([]);
});
it("probeAndUpsertConnection replaces a matching placeholder host with the real server id", async () => {
const connection: HostConnection = {
id: "direct:lan:6767",
type: "directTcp",
endpoint: "lan:6767",
};
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async () => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: "srv_real_direct",
hostname: "mbp",
}),
getClientId: async () => "cid_test_runtime",
},
});
(
store as unknown as {
hosts: HostProfile[];
}
).hosts = [
makeHost({
serverId: "local:lan:6767",
label: "local:lan:6767",
connections: [connection],
preferredConnectionId: connection.id,
}),
];
await store.probeAndUpsertConnection({ connection });
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_real_direct"]);
expect(store.getHosts()[0]?.label).toBe("mbp");
store.syncHosts([]);
});
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
@@ -1616,6 +1696,43 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("stores relay TLS from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertConnectionFromOffer(
makeOffer({
relay: {
endpoint: "relay.example.com:443",
useTls: true,
},
}),
"tls relay",
);
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.connections).toEqual([
{
id: "relay:wss:relay.example.com:443",
type: "relay",
relayEndpoint: "relay.example.com:443",
useTls: true,
daemonPublicKeyB64: "pk_test_offer",
},
]);
store.syncHosts([]);
});
it("uses the latest advertised hostname when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {

View File

@@ -9,7 +9,6 @@ import {
} from "@server/client/daemon-client";
import {
connectionFromListen,
hostHasConnection,
normalizeStoredHostProfile,
upsertHostConnectionInProfiles,
registryHasConnection,
@@ -120,7 +119,11 @@ export interface HostRuntimeControllerDeps {
clientId: string;
runtimeGeneration: number;
}) => DaemonClient;
connectToDaemon: (input: { host: HostProfile; connection: HostConnection }) => Promise<{
connectToDaemon: (input: {
host: HostProfile;
connection: HostConnection;
timeoutMs?: number;
}) => Promise<{
client: DaemonClient;
serverId: string;
hostname: string | null;
@@ -142,6 +145,7 @@ const PROBE_MAX_BACKOFF_MS = 30_000;
const ADAPTIVE_SWITCH_THRESHOLD_MS = 40;
const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3;
const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200;
const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000;
const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" },
@@ -476,7 +480,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
...base,
url: buildRelayWebSocketUrl({
endpoint: connection.relayEndpoint,
useTls: shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint),
serverId: host.serverId,
}),
e2ee: {
@@ -485,8 +489,11 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
},
});
},
connectToDaemon: ({ host, connection }) =>
connectToDaemon(connection, { serverId: host.serverId }),
connectToDaemon: ({ host, connection, timeoutMs }) =>
connectToDaemon(connection, {
...(host.serverId ? { serverId: host.serverId } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
}),
getClientId: () => getOrCreateClientId(),
};
}
@@ -1179,14 +1186,18 @@ function readConfiguredLocalDaemonOverride(): string | null {
return value && value.length > 0 ? value : null;
}
function placeholderServerIdForEndpoint(endpoint: string): string {
return `local:${normalizeHostPort(endpoint)}`;
export function hasConfiguredLocalDaemonOverride(): boolean {
return readConfiguredLocalDaemonOverride() !== null;
}
function isPlaceholderServerId(serverId: string): boolean {
return serverId.startsWith("local:");
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function rekeyMap<V>(map: Map<string, V>, oldKey: string, newKey: string): void {
const value = map.get(oldKey);
if (value === undefined) {
@@ -1207,6 +1218,7 @@ export class HostRuntimeStore {
private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
private agentDirectoryBootstrapInFlight = new Map<string, Promise<void>>();
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
private bootStarted = false;
constructor(input?: { deps?: HostRuntimeControllerDeps }) {
@@ -1239,12 +1251,8 @@ export class HostRuntimeStore {
}
private async runBoot(): Promise<void> {
await this.loadFromStorage();
const override = readConfiguredLocalDaemonOverride();
if (override) {
this.prunePlaceholderHostsForOtherEndpoints(override);
}
await this.loadFromStorage();
let isE2E: string | null = null;
try {
@@ -1277,12 +1285,16 @@ export class HostRuntimeStore {
if (!Array.isArray(parsed)) {
return;
}
const profiles = parsed
const normalizedProfiles = parsed
.map((entry) => normalizeStoredHostProfile(entry))
.filter((entry): entry is HostProfile => entry !== null);
const profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
this.hosts = profiles;
this.syncHosts(profiles);
this.emitHostList();
if (profiles.length !== normalizedProfiles.length) {
void this.persistHosts();
}
} catch (error) {
console.error("[HostRuntime] Failed to load host registry from storage", error);
}
@@ -1295,15 +1307,9 @@ export class HostRuntimeStore {
}
try {
const { client, serverId, hostname } = await connectToDaemon(connection, {
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
await this.upsertHostConnection({
serverId,
label: hostname ?? undefined,
await this.probeAndUpsertConnection({
connection,
existingClient: client,
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
} catch (error) {
console.warn("[HostRuntime] bootstrap probe failed", {
@@ -1318,35 +1324,45 @@ export class HostRuntimeStore {
if (!connection) {
return;
}
const placeholderId = placeholderServerIdForEndpoint(endpoint);
const staleHosts = this.hosts.filter(
(host) => host.serverId !== placeholderId && hostHasConnection(host, connection),
);
for (const staleHost of staleHosts) {
void this.removeConnection(staleHost.serverId, connection.id);
}
if (registryHasConnection(this.hosts, connection)) {
return;
}
void this.upsertHostConnection({
serverId: placeholderId,
connection,
});
}
private prunePlaceholderHostsForOtherEndpoints(currentEndpoint: string): void {
const expectedServerId = placeholderServerIdForEndpoint(currentEndpoint);
const remaining = this.hosts.filter((host) => {
if (!isPlaceholderServerId(host.serverId)) {
return true;
}
return host.serverId === expectedServerId;
});
if (remaining.length === this.hosts.length) {
if (this.configuredOverrideBootstrapInFlight) {
return;
}
this.setHostsAndSync(remaining);
void this.persistHosts();
const bootstrap = this.runConfiguredOverrideBootstrap(endpoint, connection).finally(() => {
if (this.configuredOverrideBootstrapInFlight === bootstrap) {
this.configuredOverrideBootstrapInFlight = null;
}
});
this.configuredOverrideBootstrapInFlight = bootstrap;
}
private async runConfiguredOverrideBootstrap(
endpoint: string,
connection: HostConnection,
): Promise<void> {
let attempt = 0;
while (!registryHasConnection(this.hosts, connection)) {
attempt += 1;
try {
await this.probeAndUpsertConnection({
connection,
timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS,
});
return;
} catch (error) {
if (attempt === 1 || attempt % 10 === 0) {
console.warn("[HostRuntime] configured bootstrap probe failed", {
endpoint,
attempt,
error,
});
}
await delay(CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS);
}
}
}
reconcileServerId(oldServerId: string, newServerId: string): void {
@@ -1411,24 +1427,79 @@ export class HostRuntimeStore {
});
}
async probeAndUpsertConnection(input: {
connection: HostConnection;
label?: string;
timeoutMs?: number;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
if (input.connection.type === "relay") {
throw new Error("Cannot probe a relay connection without a server id.");
}
const probeHost: HostProfile = {
serverId: "",
label: input.label ?? input.connection.id,
lifecycle: {},
connections: [input.connection],
preferredConnectionId: input.connection.id,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
};
const { client, serverId, hostname } = await this.deps.connectToDaemon({
host: probeHost,
connection: input.connection,
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
});
const profile = await this.upsertHostConnection({
serverId,
label: input.label ?? hostname ?? undefined,
connection: input.connection,
existingClient: client,
});
return { profile, serverId, hostname };
}
async probeAndUpsertDirectConnection(input: {
endpoint: string;
useTls?: boolean;
password?: string;
label?: string;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const endpoint = normalizeHostPort(input.endpoint);
const password = input.password?.trim();
return this.probeAndUpsertConnection({
label: input.label,
connection: {
id: `direct:${endpoint}`,
type: "directTcp",
endpoint,
useTls: input.useTls ?? false,
...(password ? { password } : {}),
},
});
}
async upsertRelayConnection(input: {
serverId: string;
relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
}): Promise<HostProfile> {
const relayEndpoint = normalizeHostPort(input.relayEndpoint);
const useTls = input.useTls ?? false;
const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim();
if (!daemonPublicKeyB64) {
throw new Error("daemonPublicKeyB64 is required");
}
const explicitUseTls = input.useTls !== undefined;
return this.upsertHostConnection({
serverId: input.serverId,
label: input.label,
connection: {
id: `relay:${relayEndpoint}`,
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
...(explicitUseTls ? { useTls } : {}),
daemonPublicKeyB64,
},
});
@@ -1438,6 +1509,7 @@ export class HostRuntimeStore {
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
});
@@ -1980,9 +2052,16 @@ export interface HostMutations {
password?: string;
label?: string;
}) => Promise<HostProfile>;
probeAndUpsertDirectConnection: (input: {
endpoint: string;
useTls?: boolean;
password?: string;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
upsertRelayConnection: (input: {
serverId: string;
relayEndpoint: string;
useTls?: boolean;
daemonPublicKeyB64: string;
label?: string;
}) => Promise<HostProfile>;
@@ -2001,6 +2080,7 @@ export function useHostMutations(): HostMutations {
return useMemo(
() => ({
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),

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