mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41fd45588f | ||
|
|
54b8126deb | ||
|
|
7c50b7a080 | ||
|
|
11b407c804 | ||
|
|
814098852c | ||
|
|
5c6f175db5 | ||
|
|
278acf5fe1 | ||
|
|
165ae58f75 | ||
|
|
a1f3256923 | ||
|
|
86bb5cc827 | ||
|
|
483312e1dc | ||
|
|
0b3c29b2ec | ||
|
|
1885d8602f | ||
|
|
57312d4f75 | ||
|
|
ffbb2ffd06 | ||
|
|
45a5ba8637 | ||
|
|
b8ade39f73 | ||
|
|
deca82653e | ||
|
|
4e9d80d118 | ||
|
|
918949c7fa | ||
|
|
c1df523a77 | ||
|
|
7a8f65805d | ||
|
|
89b87c0235 | ||
|
|
fc96b260d1 | ||
|
|
3fe79535cc | ||
|
|
d40e4b6f0d | ||
|
|
8f04e65cf1 | ||
|
|
c5295a4ab3 | ||
|
|
f59af4c810 | ||
|
|
e012c8f52e | ||
|
|
312a6e22fe | ||
|
|
7f92f7f51d | ||
|
|
200453f032 | ||
|
|
d4a4015804 | ||
|
|
32b68cbcf4 | ||
|
|
c8cafbabff | ||
|
|
f0ac632732 | ||
|
|
25fd93d7e2 | ||
|
|
2090d685ed | ||
|
|
c70277f445 | ||
|
|
649eddeea8 | ||
|
|
30c7729a7a | ||
|
|
8651c334c7 | ||
|
|
d1702e8a40 | ||
|
|
80eb5dbe83 | ||
|
|
380c9927d7 | ||
|
|
345729c588 | ||
|
|
3cdadd362c | ||
|
|
5a836eb170 | ||
|
|
61c36b4e22 | ||
|
|
df0ce60a35 | ||
|
|
f21f861653 | ||
|
|
dbd1645dc9 | ||
|
|
dd99144587 | ||
|
|
dd6a396dbf | ||
|
|
202fa3e8f3 | ||
|
|
8d854a6342 | ||
|
|
5329a12222 | ||
|
|
9c0b5616fe | ||
|
|
c59a979446 | ||
|
|
6ed166641f | ||
|
|
be3d5ed78d | ||
|
|
8d1def4615 | ||
|
|
7ad4482096 | ||
|
|
7ef47e9193 | ||
|
|
bdb300b919 | ||
|
|
0d0a8a791a | ||
|
|
4806cd9a51 | ||
|
|
61201d68a6 | ||
|
|
6dfcb4daa9 | ||
|
|
95e112c185 | ||
|
|
6f28027687 | ||
|
|
8122d501b0 | ||
|
|
8023e1f0c3 | ||
|
|
a2d652d8d8 | ||
|
|
bf76915884 | ||
|
|
f0c59ec534 | ||
|
|
8aa1833e2b | ||
|
|
b3d5401295 | ||
|
|
8008ea69dd | ||
|
|
42aaa4e418 | ||
|
|
0f7fa55a0f | ||
|
|
964bf3e13f | ||
|
|
e6ff15a1e0 | ||
|
|
80a7fd4f3e | ||
|
|
4547d597ef | ||
|
|
ec445e606c | ||
|
|
0095f61121 | ||
|
|
609d11ee8d | ||
|
|
a161a12a42 | ||
|
|
39b56af47b | ||
|
|
64b9b61223 | ||
|
|
7c18170d6a | ||
|
|
ca20945b93 | ||
|
|
cd4db5d81f | ||
|
|
d1fc271ad4 | ||
|
|
266eb375ff | ||
|
|
a9dcef80ed | ||
|
|
ae03e8283a | ||
|
|
5aa98f13f2 | ||
|
|
698a18e88f | ||
|
|
44cc7a3771 | ||
|
|
acb294b9eb | ||
|
|
cde10485d4 | ||
|
|
40fc81bbbc | ||
|
|
0d340959d7 | ||
|
|
b9269df3e4 | ||
|
|
dbc525c2ad | ||
|
|
56512d852b | ||
|
|
40e27f5a00 | ||
|
|
f35b0b8b7d | ||
|
|
fb5e2608e0 | ||
|
|
6d4cc53a0a | ||
|
|
66bb8c6f04 | ||
|
|
fa552b0faa | ||
|
|
51e96fa303 | ||
|
|
9fd31bfa5a | ||
|
|
fdc8d3c8db | ||
|
|
6897a400e5 | ||
|
|
cff41d78ab | ||
|
|
4de2766114 | ||
|
|
30ffda6cae | ||
|
|
7a1e12f0c0 | ||
|
|
70985088f6 | ||
|
|
a525b0e22e | ||
|
|
497159ada8 | ||
|
|
e22fb51975 | ||
|
|
a315021c75 | ||
|
|
ba1ecedca3 | ||
|
|
01af138939 | ||
|
|
faf85e038c | ||
|
|
d3cd46a810 |
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
format:
|
||||
@@ -75,6 +76,44 @@ jobs:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
server-tests-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run Windows-critical server tests
|
||||
working-directory: packages/server
|
||||
run: >
|
||||
npx vitest run
|
||||
src/utils/executable.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/run-git-command.test.ts
|
||||
src/utils/checkout-git-rev-parse.test.ts
|
||||
src/server/agent/provider-registry.test.ts
|
||||
src/server/agent/provider-launch-config.test.ts
|
||||
src/server/agent/provider-snapshot-manager.test.ts
|
||||
src/server/agent/providers/claude-agent.spawn.test.ts
|
||||
src/server/agent/providers/provider-availability.test.ts
|
||||
src/server/workspace-registry-model.test.ts
|
||||
src/server/persisted-config.test.ts
|
||||
src/server/bootstrap-provider-availability.test.ts
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -88,6 +127,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
@@ -113,6 +155,12 @@ jobs:
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
env:
|
||||
@@ -160,6 +208,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
|
||||
89
CHANGELOG.md
89
CHANGELOG.md
@@ -1,5 +1,94 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.57 - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Markdown files render as formatted markdown in the file pane instead of raw source. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input, with a hint shown when the agent panel is active.
|
||||
- Provider model lists refresh on a freshness TTL, and Settings surfaces when models were last updated along with any fetch errors inline. ([#426](https://github.com/getpaseo/paseo/pull/426))
|
||||
- `disallowedTools` option in provider config to block specific tools from an agent.
|
||||
|
||||
### Improved
|
||||
- Windows: Codex startup and provider resolution are significantly more resilient — handles quoted paths, `.cmd` shims, and missing binaries without crashing. ([#454](https://github.com/getpaseo/paseo/pull/454))
|
||||
- OpenCode reliability — permission prompts now include the requesting tool's context, todo and compaction events render in the timeline, sessions archive cleanly on close, and slash commands recover from SSE timeouts. ([#398](https://github.com/getpaseo/paseo/pull/398), [#407](https://github.com/getpaseo/paseo/pull/407), [#408](https://github.com/getpaseo/paseo/pull/408), [#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- Paseo MCP tools work against archived agents, matching the CLI's behaviour. ([#423](https://github.com/getpaseo/paseo/pull/423))
|
||||
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
|
||||
|
||||
### Fixed
|
||||
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
|
||||
- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen))
|
||||
- Workspace headers no longer show a branch icon for non-git workspaces.
|
||||
- Branch switcher layout is stable on mobile.
|
||||
- Model names no longer truncate mid-word in the picker rows.
|
||||
- Messages appear in the correct order after reconnecting on mobile.
|
||||
- Clearing agent attention no longer throws on timeout.
|
||||
|
||||
## 0.1.56 - 2026-04-14
|
||||
|
||||
### Fixed
|
||||
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
|
||||
- A single problematic project can no longer prevent the rest of your workspaces from loading.
|
||||
|
||||
## 0.1.55 - 2026-04-14
|
||||
|
||||
### Added
|
||||
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
|
||||
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
|
||||
- Choose provider and model when creating scheduled agents.
|
||||
- Max reasoning effort option for Opus 4.6 models.
|
||||
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
|
||||
|
||||
### Improved
|
||||
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
|
||||
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
|
||||
- iPad and tablet layouts work correctly across all screen sizes.
|
||||
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
|
||||
|
||||
### Fixed
|
||||
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
|
||||
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
|
||||
- Workspace navigation updates correctly when switching between workspaces in the same project.
|
||||
- Desktop workspace header alignment and model selector no longer overflow on narrow windows.
|
||||
- Loading indicators are visible in light mode.
|
||||
|
||||
## 0.1.54 - 2026-04-12
|
||||
|
||||
### Added
|
||||
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
|
||||
|
||||
### Improved
|
||||
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
|
||||
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
|
||||
|
||||
### Fixed
|
||||
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
|
||||
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
|
||||
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
|
||||
|
||||
## 0.1.53 - 2026-04-12
|
||||
|
||||
### Added
|
||||
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
|
||||
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
|
||||
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
|
||||
- Agent reload — `paseo agent reload` restarts an agent's underlying process from the CLI.
|
||||
- Middle-click to close tabs on desktop.
|
||||
- Keyboard shortcut to cycle themes.
|
||||
|
||||
### Improved
|
||||
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
|
||||
- Streaming markdown on mobile renders significantly faster.
|
||||
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
|
||||
- Paseo tool calls in agent timelines show the Paseo logo and human-readable names.
|
||||
- Relay and pairing URLs are stripped from daemon logs.
|
||||
|
||||
### Fixed
|
||||
- Closed agent tabs no longer reappear after reconnecting.
|
||||
- Desktop notification badge counts match across all workspaces.
|
||||
- Host switcher status syncs correctly when switching between hosts.
|
||||
|
||||
## 0.1.52 - 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
50
CLAUDE.md
50
CLAUDE.md
@@ -24,6 +24,7 @@ This is an npm workspace monorepo:
|
||||
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
@@ -35,6 +36,8 @@ npm run dev # Start daemon + Expo in Tmux
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
npm run format # Auto-format with Biome
|
||||
npm run format:check # Check formatting without writing
|
||||
```
|
||||
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
@@ -44,7 +47,14 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||
- **Always run typecheck after every change.**
|
||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never change a field from optional to required.
|
||||
@@ -52,6 +62,46 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
|
||||
## Platform gating
|
||||
|
||||
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
|
||||
|
||||
### The four gates
|
||||
|
||||
| Gate | Type | When to use |
|
||||
|---|---|---|
|
||||
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
|
||||
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
|
||||
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
|
||||
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
|
||||
|
||||
### Decision matrix
|
||||
|
||||
| I need to... | Use |
|
||||
|---|---|
|
||||
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
|
||||
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
|
||||
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
|
||||
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
|
||||
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
|
||||
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
|
||||
|
||||
### Rules
|
||||
|
||||
- **Default is cross-platform.** Don't gate unless you have a specific reason.
|
||||
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
|
||||
```
|
||||
hooks/
|
||||
use-audio-recorder.web.ts ← uses Web Audio API
|
||||
use-audio-recorder.native.ts ← uses expo-audio
|
||||
```
|
||||
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
||||
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
|
||||
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
|
||||
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
|
||||
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
|
||||
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
|
||||
15
README.md
15
README.md
@@ -4,6 +4,21 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/getpaseo/paseo/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
|
||||
</a>
|
||||
<a href="https://github.com/getpaseo/paseo/releases">
|
||||
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
|
||||
</a>
|
||||
<a href="https://x.com/moboudra">
|
||||
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
|
||||
</a>
|
||||
<a href="https://discord.gg/jz8T2uahpH">
|
||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
|
||||
|
||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||
|
||||
Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
|
||||
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
|
||||
## Agent authentication
|
||||
|
||||
|
||||
159
docs/AD-HOC-DAEMON-TESTING.md
Normal file
159
docs/AD-HOC-DAEMON-TESTING.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Ad-hoc daemon testing
|
||||
|
||||
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||
|
||||
## Quick start
|
||||
|
||||
```typescript
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import pino from "pino";
|
||||
import { createPaseoDaemon } from "./bootstrap.js";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
|
||||
const logger = pino({ level: "warn" });
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
|
||||
const daemon = await createPaseoDaemon(
|
||||
{
|
||||
listen: "127.0.0.1:0", // OS picks a free port
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: {},
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: false,
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
// Add custom config here, e.g.:
|
||||
// providerOverrides: { ... },
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
await daemon.start();
|
||||
const target = daemon.getListenTarget();
|
||||
const port = target!.type === "tcp" ? target!.port : null;
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54", // see gotcha #1
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// ... do your testing ...
|
||||
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
npx tsx packages/server/src/server/your-script.ts
|
||||
```
|
||||
|
||||
## Using the test helper
|
||||
|
||||
For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection:
|
||||
|
||||
```typescript
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// ... test ...
|
||||
|
||||
await client.close();
|
||||
await daemon.close(); // stops daemon + cleans up temp dirs
|
||||
```
|
||||
|
||||
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
|
||||
## Common client methods
|
||||
|
||||
```typescript
|
||||
// Provider discovery
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
const models = await client.listProviderModels("claude");
|
||||
const modes = await client.listProviderModes("claude");
|
||||
|
||||
// Agent lifecycle
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
|
||||
await client.sendMessage(agent.id, "Hello");
|
||||
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### 1. appVersion gates provider visibility
|
||||
|
||||
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
|
||||
|
||||
Always pass `appVersion`:
|
||||
```typescript
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Provider snapshots are async
|
||||
|
||||
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
|
||||
|
||||
```typescript
|
||||
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const entry = snapshot.entries.find((e) => e.provider === "gemini");
|
||||
if (entry && entry.status !== "loading") break;
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
}
|
||||
```
|
||||
|
||||
### 3. fetchAgents is required before most operations
|
||||
|
||||
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
|
||||
|
||||
### 4. listen: "127.0.0.1:0" for port allocation
|
||||
|
||||
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
|
||||
|
||||
### 5. Script must live inside packages/server
|
||||
|
||||
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
|
||||
|
||||
### 6. Cleanup on failure
|
||||
|
||||
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// ... test logic ...
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
}
|
||||
```
|
||||
|
||||
### 7. ACP providers spawn real processes
|
||||
|
||||
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
|
||||
548
docs/CUSTOM-PROVIDERS.md
Normal file
548
docs/CUSTOM-PROVIDERS.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# Custom Provider Configuration
|
||||
|
||||
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
||||
|
||||
All provider configuration lives under `agents.providers` in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"providers": {
|
||||
"provider-id": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
|
||||
- [Custom binary for a provider](#custom-binary-for-a-provider)
|
||||
- [Disabling a provider](#disabling-a-provider)
|
||||
- [ACP providers](#acp-providers)
|
||||
- [Provider override reference](#provider-override-reference)
|
||||
|
||||
---
|
||||
|
||||
## Extending a built-in provider
|
||||
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-claude": {
|
||||
"extends": "claude",
|
||||
"label": "My Claude",
|
||||
"description": "Claude with custom API endpoint",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-...",
|
||||
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Register at [z.ai](https://z.ai) and subscribe to a coding plan
|
||||
2. Create an API key from the Z.AI dashboard
|
||||
3. Add a provider entry in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"zai": {
|
||||
"extends": "claude",
|
||||
"label": "ZAI",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
{ "id": "glm-5.1", "label": "GLM 5.1" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available models
|
||||
|
||||
| Model | Tier |
|
||||
|---|---|
|
||||
| `glm-5.1` | Advanced (flagship) |
|
||||
| `glm-5-turbo` | Advanced |
|
||||
| `glm-4.7` | Standard |
|
||||
| `glm-4.5-air` | Lightweight |
|
||||
|
||||
### Notes
|
||||
|
||||
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
|
||||
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
|
||||
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
|
||||
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||
- Automated setup is also available: `npx @z_ai/coding-helper`
|
||||
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
|
||||
|
||||
---
|
||||
|
||||
## Alibaba Cloud (Qwen) coding plan
|
||||
|
||||
[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region)
|
||||
2. Subscribe to the Pro plan ($50/month)
|
||||
3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key
|
||||
4. Add a provider entry in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"qwen": {
|
||||
"extends": "claude",
|
||||
"label": "Qwen (Alibaba)",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
|
||||
{ "id": "kimi-k2.5", "label": "Kimi K2.5" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API endpoints
|
||||
|
||||
| Mode | Base URL |
|
||||
|---|---|
|
||||
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
|
||||
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
|
||||
|
||||
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
### Available models
|
||||
|
||||
**Recommended for coding plan:**
|
||||
|
||||
| Model | Notes |
|
||||
|---|---|
|
||||
| `qwen3.5-plus` | Vision capable, recommended |
|
||||
| `qwen3-coder-next` | Optimized for coding |
|
||||
| `kimi-k2.5` | Vision capable |
|
||||
| `glm-5` | Zhipu GLM |
|
||||
| `MiniMax-M2.5` | MiniMax |
|
||||
|
||||
**Additional models (pay-as-you-go):**
|
||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||
|
||||
### Notes
|
||||
|
||||
- API keys must be created in the **Singapore region**
|
||||
- The coding plan is for personal use only in interactive coding tools
|
||||
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
|
||||
|
||||
---
|
||||
|
||||
## Multiple profiles for the same provider
|
||||
|
||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||
|
||||
Example: two different Anthropic accounts as separate profiles:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude-work": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Work)",
|
||||
"description": "Work Anthropic account",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-work-..."
|
||||
}
|
||||
},
|
||||
"claude-personal": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Personal)",
|
||||
"description": "Personal Anthropic account",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-personal-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
|
||||
|
||||
You can also combine profiles with model overrides to pin specific models per profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude-fast": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Fast)",
|
||||
"models": [
|
||||
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
|
||||
]
|
||||
},
|
||||
"claude-smart": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Smart)",
|
||||
"models": [
|
||||
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom binary for a provider
|
||||
|
||||
Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments.
|
||||
|
||||
### Override a built-in provider's binary
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude": {
|
||||
"command": ["/opt/claude-nightly/claude"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use a custom wrapper script
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude": {
|
||||
"command": ["/usr/local/bin/my-claude-wrapper", "--verbose"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom binary on a derived provider
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "Codex (Custom Build)",
|
||||
"command": ["/home/user/codex-dev/target/release/codex"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
---
|
||||
|
||||
## Disabling a provider
|
||||
|
||||
Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"copilot": { "enabled": false },
|
||||
"codex": { "enabled": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
|
||||
|
||||
---
|
||||
|
||||
## ACP providers
|
||||
|
||||
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
Set `extends: "acp"` and provide a `command`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent-binary", "--acp"],
|
||||
"env": {
|
||||
"MY_API_KEY": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for ACP providers:
|
||||
- `extends: "acp"`
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
### Example: Google Gemini CLI
|
||||
|
||||
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
|
||||
|
||||
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
2. Authenticate with Google (Gemini CLI handles its own auth)
|
||||
3. Add to config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"gemini": {
|
||||
"extends": "acp",
|
||||
"label": "Google Gemini",
|
||||
"command": ["gemini", "--acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md)
|
||||
|
||||
### Example: Hermes (Nous Research)
|
||||
|
||||
[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand.
|
||||
|
||||
1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`
|
||||
2. Install ACP support: `pip install -e '.[acp]'`
|
||||
3. Configure Hermes credentials in `~/.hermes/`
|
||||
4. Add to config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"hermes": {
|
||||
"extends": "acp",
|
||||
"label": "Hermes",
|
||||
"description": "Nous Research self-improving AI agent",
|
||||
"command": ["hermes", "acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp)
|
||||
|
||||
### How ACP providers work in Paseo
|
||||
|
||||
When you launch an agent with an ACP provider:
|
||||
|
||||
1. Paseo spawns the process using the configured `command`
|
||||
2. Sends an `initialize` JSON-RPC request over stdin
|
||||
3. The agent responds with its capabilities, available modes, and models
|
||||
4. Paseo creates a session and sends prompts through the ACP protocol
|
||||
5. The agent streams responses, tool calls, and permission requests back over stdout
|
||||
|
||||
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "--acp"],
|
||||
"models": [
|
||||
{ "id": "fast-model", "label": "Fast", "isDefault": true },
|
||||
{ "id": "smart-model", "label": "Smart" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Profile models (defined in config.json) completely replace runtime-discovered models when present.
|
||||
|
||||
---
|
||||
|
||||
## Provider override reference
|
||||
|
||||
Every entry under `agents.providers` accepts these fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
|
||||
### Model definition
|
||||
|
||||
Each entry in the `models` array:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | Yes | Model identifier sent to the provider |
|
||||
| `label` | `string` | Yes | Display name in the UI |
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default model selection |
|
||||
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
|
||||
|
||||
### Thinking option
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | Yes | Thinking option identifier |
|
||||
| `label` | `string` | Yes | Display name |
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
|
||||
Use `disallowedTools` to disable unsupported tools:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-proxy": {
|
||||
"extends": "claude",
|
||||
"label": "My Proxy",
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Valid `extends` values
|
||||
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||
|
||||
Special value: `acp` — creates a generic ACP provider (requires `command`)
|
||||
|
||||
### Full example
|
||||
|
||||
A config.json with multiple custom providers:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"providers": {
|
||||
"copilot": { "enabled": false },
|
||||
|
||||
"zai": {
|
||||
"extends": "claude",
|
||||
"label": "ZAI",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "<zai-api-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
{ "id": "glm-5.1", "label": "GLM 5.1" }
|
||||
]
|
||||
},
|
||||
|
||||
"qwen": {
|
||||
"extends": "claude",
|
||||
"label": "Qwen (Alibaba)",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
|
||||
]
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"extends": "acp",
|
||||
"label": "Google Gemini",
|
||||
"command": ["gemini", "--acp"]
|
||||
},
|
||||
|
||||
"hermes": {
|
||||
"extends": "acp",
|
||||
"label": "Hermes",
|
||||
"command": ["hermes", "acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
allowedHosts: true | string[],
|
||||
hostnames: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
|
||||
@@ -139,6 +139,33 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog attribution
|
||||
|
||||
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
|
||||
|
||||
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
|
||||
|
||||
```markdown
|
||||
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
|
||||
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
|
||||
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
|
||||
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
|
||||
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
|
||||
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
|
||||
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
|
||||
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
|
||||
@@ -9,6 +9,10 @@ let
|
||||
cfg = config.services.paseo;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
|
||||
];
|
||||
|
||||
options.services.paseo = {
|
||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||
|
||||
@@ -58,12 +62,12 @@ in
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
allowedHosts = lib.mkOption {
|
||||
hostnames = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
|
||||
Localhost and IP addresses are always allowed by default.
|
||||
|
||||
Use a leading dot to match a domain and all its subdomains
|
||||
@@ -141,10 +145,10 @@ in
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||
PASEO_ALLOWED_HOSTS = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
|
||||
@@ -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-gyCcVTlgLk8+G/OtwZT/GomN2lYe7uEcDoLp1ZQoc5M=";
|
||||
npmDepsHash = "sha256-Wn7g+VXuUe0LI733MUj+WRX/fJQHOO/HFEkqyHrWTf0=";
|
||||
|
||||
# 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).
|
||||
|
||||
118
package-lock.json
generated
118
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -34906,16 +34906,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.52",
|
||||
"@getpaseo/highlight": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.57",
|
||||
"@getpaseo/highlight": "0.1.57",
|
||||
"@getpaseo/server": "0.1.57",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -34990,6 +34990,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
@@ -34997,7 +34998,8 @@
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.59.1"
|
||||
"wrangler": "^4.59.1",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/expo-clipboard": {
|
||||
@@ -35021,6 +35023,28 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
@@ -35032,11 +35056,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"@getpaseo/relay": "0.1.57",
|
||||
"@getpaseo/server": "0.1.57",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35077,11 +35101,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"@getpaseo/cli": "0.1.57",
|
||||
"@getpaseo/server": "0.1.57",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -35115,7 +35139,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -35316,7 +35340,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -35342,7 +35366,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -35358,14 +35382,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.52",
|
||||
"@getpaseo/relay": "0.1.52",
|
||||
"@getpaseo/highlight": "0.1.57",
|
||||
"@getpaseo/relay": "0.1.57",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
@@ -35376,11 +35400,13 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.1.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"node-pty": "1.2.0-beta.11",
|
||||
"onnxruntime-node": "^1.23.0",
|
||||
"openai": "^4.20.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"pi-acp": "^0.0.24",
|
||||
"pino": "^10.2.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
@@ -35391,6 +35417,7 @@
|
||||
"strip-ansi": "^7.1.2",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"uuid": "^9.0.1",
|
||||
"which": "^5.0.0",
|
||||
"ws": "^8.14.2",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
@@ -35614,6 +35641,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/isexe": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
@@ -35656,6 +35692,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/p-limit": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
|
||||
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/raw-body": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||
@@ -35753,6 +35804,33 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/which": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/which.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/yocto-queue": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
@@ -35764,7 +35842,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -14,6 +14,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
@@ -36,6 +37,7 @@
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
|
||||
@@ -19,13 +19,15 @@ test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
client = await connectArchiveTabDaemonClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.close();
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -224,6 +224,51 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHomePath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(payload.url);
|
||||
}
|
||||
|
||||
async function waitForPairingOfferFromCli(args: {
|
||||
repoRoot: string;
|
||||
paseoHome: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OfferPayload> {
|
||||
const timeoutMs = args.timeoutMs ?? 15000;
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
@@ -433,12 +478,6 @@ export default async function globalSetup() {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
@@ -473,26 +512,6 @@ export default async function globalSetup() {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes("pairing_offer")) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
@@ -523,17 +542,10 @@ export default async function globalSetup() {
|
||||
}),
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error("pairing_offer was not parsed from daemon logs");
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
@@ -83,33 +84,36 @@ export function createReplyTurn(label: string): {
|
||||
};
|
||||
}
|
||||
|
||||
type DaemonClientConfig = {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
};
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance
|
||||
new (
|
||||
config: DaemonClientConfig,
|
||||
) => DaemonClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance;
|
||||
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
@@ -127,7 +131,7 @@ export async function seedBottomAnchorAgent(input: {
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
|
||||
@@ -3,8 +3,13 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostSessionsRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export type ArchiveTabAgent = {
|
||||
id: string;
|
||||
@@ -18,7 +23,7 @@ type ArchiveTabDaemonClient = {
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
@@ -63,33 +68,36 @@ function buildSeededStoragePayload() {
|
||||
};
|
||||
}
|
||||
|
||||
type ArchiveTabDaemonClientConfig = {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
};
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => ArchiveTabDaemonClient
|
||||
new (
|
||||
config: ArchiveTabDaemonClientConfig,
|
||||
) => ArchiveTabDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => ArchiveTabDaemonClient;
|
||||
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
@@ -100,17 +108,18 @@ export async function createIdleAgent(
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "default",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
initialPrompt: "Reply with exactly READY.",
|
||||
});
|
||||
const finished = await client.waitForFinish(created.id, 120_000);
|
||||
if (finished.status !== "idle") {
|
||||
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
|
||||
throw new Error(
|
||||
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: created.id,
|
||||
|
||||
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
|
||||
231
packages/app/e2e/helpers/new-workspace.ts
Normal file
231
packages/app/e2e/helpers/new-workspace.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
|
||||
type NewWorkspaceDaemonClient = Pick<
|
||||
ServerDaemonClient,
|
||||
| "archivePaseoWorktree"
|
||||
| "archiveWorkspace"
|
||||
| "close"
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "openProject"
|
||||
>;
|
||||
|
||||
type NewWorkspaceDaemonClientConfig = {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
};
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
|
||||
export type OpenedProject = {
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
workspaceName: string;
|
||||
};
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (
|
||||
config: NewWorkspaceDaemonClientConfig,
|
||||
) => NewWorkspaceDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
function requireWorkspace(payload: OpenProjectPayload) {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.workspace) {
|
||||
throw new Error("openProject returned no workspace.");
|
||||
}
|
||||
return payload.workspace;
|
||||
}
|
||||
|
||||
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
||||
const pathname = new URL(page.url()).pathname;
|
||||
const match = pathname.match(
|
||||
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
|
||||
);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||
}
|
||||
|
||||
function parseWorkspaceIdFromSidebarRowTestId(
|
||||
testId: string,
|
||||
input: { serverId: string; previousWorkspaceId: string },
|
||||
): string | null {
|
||||
const prefix = `sidebar-workspace-row-${input.serverId}:`;
|
||||
if (!testId.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const workspaceId = testId.slice(prefix.length).trim();
|
||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||
return null;
|
||||
}
|
||||
return workspaceId;
|
||||
}
|
||||
|
||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-new-workspace-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function openProjectViaDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<OpenedProject> {
|
||||
const workspace = requireWorkspace(await client.openProject(repoPath));
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
if (!payload.success) {
|
||||
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveLocalWorkspaceFromDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const payload = await client.archiveWorkspace(workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.archivedAt) {
|
||||
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorktreeViaDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
input: { cwd: string; slug: string },
|
||||
): Promise<OpenedProject> {
|
||||
const payload = await client.createPaseoWorktree({
|
||||
cwd: input.cwd,
|
||||
worktreeSlug: input.slug,
|
||||
});
|
||||
const workspace = requireWorkspace(payload);
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
|
||||
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||
): Promise<{ workspaceId: string }> {
|
||||
let workspaceId: string | null = null;
|
||||
const sidebarWorkspaceRows = page.locator(
|
||||
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
|
||||
);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid") ?? ""),
|
||||
);
|
||||
workspaceId =
|
||||
sidebarRowTestIds
|
||||
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
|
||||
.find((id) => id !== null) ?? null;
|
||||
if (workspaceId) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
|
||||
);
|
||||
throw new Error(
|
||||
[
|
||||
"Expected a newly created workspace to load.",
|
||||
`Current URL: ${page.url()}`,
|
||||
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(workspaceId),
|
||||
subtitle: input.projectDisplayName,
|
||||
});
|
||||
|
||||
return { workspaceId };
|
||||
}
|
||||
27
packages/app/e2e/helpers/node-ws-factory.ts
Normal file
27
packages/app/e2e/helpers/node-ws-factory.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import WebSocket from "ws";
|
||||
|
||||
type WebSocketLike = {
|
||||
readyState: number;
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
binaryType?: string;
|
||||
on?: (event: string, listener: (...args: any[]) => void) => void;
|
||||
off?: (event: string, listener: (...args: any[]) => void) => void;
|
||||
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
|
||||
addEventListener?: (event: string, listener: (event: any) => void) => void;
|
||||
removeEventListener?: (event: string, listener: (event: any) => void) => void;
|
||||
onopen?: ((event: any) => void) | null;
|
||||
onclose?: ((event: any) => void) | null;
|
||||
onerror?: ((event: any) => void) | null;
|
||||
onmessage?: ((event: any) => void) | null;
|
||||
};
|
||||
|
||||
export type NodeWebSocketFactory = (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string> },
|
||||
) => WebSocketLike;
|
||||
|
||||
export function createNodeWebSocketFactory(): NodeWebSocketFactory {
|
||||
return (url: string, options?: { headers?: Record<string, string> }) =>
|
||||
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export type TerminalPerfDaemonClient = {
|
||||
@@ -43,29 +44,36 @@ function getServerId(): string {
|
||||
return serverId;
|
||||
}
|
||||
|
||||
type TerminalPerfDaemonClientConfig = {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
};
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
|
||||
new (
|
||||
config: TerminalPerfDaemonClientConfig,
|
||||
) => TerminalPerfDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => TerminalPerfDaemonClient;
|
||||
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `terminal-perf-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
@@ -77,6 +85,10 @@ export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): stri
|
||||
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||
}
|
||||
|
||||
function buildWorkspaceUrl(cwd: string): string {
|
||||
return buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
}
|
||||
|
||||
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const term = (window as any).__paseoTerminal;
|
||||
@@ -118,33 +130,29 @@ export async function navigateToTerminal(
|
||||
// Boot the app at the workspace route directly.
|
||||
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||
// so the daemon registry is already configured when the app starts.
|
||||
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), input.cwd);
|
||||
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
|
||||
await page.goto(workspaceRoute);
|
||||
|
||||
// The workspace layout consumes `?open=...`, returns null during the effect,
|
||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// Wait for daemon connection (sidebar shows host label)
|
||||
await page.getByText("localhost", { exact: true }).first().waitFor({ state: "visible", timeout: 15_000 });
|
||||
await page
|
||||
.getByText("localhost", { exact: true })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// The open intent should have prepared and focused the exact pre-created terminal tab.
|
||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
|
||||
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
|
||||
await terminalTab.click();
|
||||
|
||||
// The workspace should now query listTerminals and discover our terminal.
|
||||
// Click the terminal tab if it auto-appeared, or wait for it.
|
||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
|
||||
|
||||
if (!surfaceVisible) {
|
||||
// Terminal tab might not be focused — look for it in the tab row and click it
|
||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
|
||||
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
|
||||
if (tabExists) {
|
||||
await terminalTab.click();
|
||||
} else {
|
||||
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
|
||||
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
|
||||
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await newTerminalBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for terminal surface to be visible
|
||||
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// Wait for loading overlay to disappear (terminal attached)
|
||||
@@ -155,6 +163,7 @@ export async function navigateToTerminal(
|
||||
// overlay may never appear if attachment is instant
|
||||
});
|
||||
|
||||
await terminalSurface.scrollIntoViewIfNeeded();
|
||||
await terminalSurface.click();
|
||||
}
|
||||
|
||||
|
||||
153
packages/app/e2e/new-workspace.spec.ts
Normal file
153
packages/app/e2e/new-workspace.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
clickNewWorkspaceButton,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
expectWorkspaceHeader,
|
||||
switchWorkspaceViaSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeIds = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceId of createdWorktreeIds) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeIds.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const firstRepo = await createTempGitRepo("workspace-nav-a-");
|
||||
const secondRepo = await createTempGitRepo("workspace-nav-b-");
|
||||
|
||||
try {
|
||||
const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path);
|
||||
localWorkspaceIds.add(firstWorkspace.workspaceId);
|
||||
localWorkspaceIds.add(secondWorkspace.workspaceId);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: workspaceLabelFromPath(firstRepo.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: workspaceLabelFromPath(secondRepo.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: workspaceLabelFromPath(firstRepo.path),
|
||||
});
|
||||
} finally {
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: workspaceLabelFromPath(tempRepo.path),
|
||||
});
|
||||
|
||||
await clickNewWorkspaceButton(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
|
||||
await expect(page).toHaveURL(
|
||||
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
|
||||
has: page.getByText("New Agent", { exact: true }),
|
||||
});
|
||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,11 @@ test.describe("Terminal wire performance", () => {
|
||||
|
||||
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||
|
||||
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
|
||||
await waitForTerminalContent(
|
||||
page,
|
||||
(text) => text.includes(sentinel),
|
||||
THROUGHPUT_BUDGET_MS + 15_000,
|
||||
);
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
|
||||
@@ -81,9 +85,10 @@ test.describe("Terminal wire performance", () => {
|
||||
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
||||
);
|
||||
|
||||
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
|
||||
THROUGHPUT_BUDGET_MS,
|
||||
);
|
||||
expect(
|
||||
elapsedMs,
|
||||
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
|
||||
).toBeLessThan(THROUGHPUT_BUDGET_MS);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.52",
|
||||
"version": "0.1.57",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.52",
|
||||
"@getpaseo/highlight": "0.1.52",
|
||||
"@getpaseo/server": "0.1.52",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.57",
|
||||
"@getpaseo/highlight": "0.1.57",
|
||||
"@getpaseo/server": "0.1.57",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -108,6 +108,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eas-cli": "^16.24.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
@@ -115,6 +116,7 @@
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
"wrangler": "^4.59.1"
|
||||
"wrangler": "^4.59.1",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,17 @@
|
||||
[contenteditable='true'] {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
|
||||
/* Suppress the browser's default focus outline — it appears on mouse
|
||||
clicks and looks out of place against our themed surfaces. Keep a
|
||||
themed ring for keyboard users via :focus-visible. */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
*:focus-visible {
|
||||
outline: 2px solid #20744A;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { THEME_TO_UNISTYLES } from "@/styles/theme";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { View, Text } from "react-native";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
parseWorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
polyfillCrypto();
|
||||
|
||||
@@ -90,6 +91,22 @@ export type HostRuntimeBootstrapState = {
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const firstValue = value[0];
|
||||
if (typeof firstValue !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = firstValue.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
phase: "starting-daemon",
|
||||
error: null,
|
||||
@@ -101,7 +118,7 @@ function PushNotificationRouter() {
|
||||
const lastHandledIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -358,6 +375,8 @@ interface AppContainerProps {
|
||||
chromeEnabled?: boolean;
|
||||
}
|
||||
|
||||
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||
|
||||
function AppContainer({
|
||||
children,
|
||||
selectedAgentId,
|
||||
@@ -365,6 +384,7 @@ function AppContainer({
|
||||
}: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||
@@ -373,6 +393,12 @@ function AppContainer({
|
||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
|
||||
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
|
||||
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex]! });
|
||||
}, [settings.theme, updateSettings]);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
|
||||
@@ -381,10 +407,10 @@ function AppContainer({
|
||||
const screenW = UnistylesRuntime.screen.width;
|
||||
const screenH = UnistylesRuntime.screen.height;
|
||||
const isElectron = getIsElectronRuntime();
|
||||
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
|
||||
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
|
||||
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
|
||||
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
|
||||
const windowW = isWeb ? window.innerWidth : undefined;
|
||||
const windowH = isWeb ? window.innerHeight : undefined;
|
||||
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
||||
const ua = isWeb ? navigator.userAgent : undefined;
|
||||
|
||||
console.log(
|
||||
"[layout-debug]",
|
||||
@@ -413,6 +439,7 @@ function AppContainer({
|
||||
toggleFileExplorer,
|
||||
toggleBothSidebars,
|
||||
toggleFocusMode,
|
||||
cycleTheme,
|
||||
});
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -567,7 +594,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoading || Platform.OS !== "web") {
|
||||
if (settingsLoading || isNative) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -775,17 +802,21 @@ function RootStack() {
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]"
|
||||
getId={({ params }) => {
|
||||
const serverId = getRouteParamValue(params?.serverId);
|
||||
const workspaceId = getRouteParamValue(params?.workspaceId);
|
||||
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="index" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostAgentReadyRouteContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostAgentReadyRouteContent() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
@@ -12,6 +13,14 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostIndexRouteContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostIndexRouteContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
@@ -48,11 +57,6 @@ export default function HostIndexRoute() {
|
||||
);
|
||||
|
||||
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
||||
visibleWorkspaces.sort((left, right) => {
|
||||
const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||
const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
|
||||
const primaryAgent = visibleAgents[0];
|
||||
if (primaryAgent?.cwd?.trim()) {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||
|
||||
export default function HostOpenProjectRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostOpenProjectRouteContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostOpenProjectRouteContent() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||
|
||||
export default function HostAgentsRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostAgentsRouteContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostAgentsRouteContent() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default SettingsScreen;
|
||||
export default function HostSettingsRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
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") {
|
||||
@@ -35,8 +36,17 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
||||
}
|
||||
|
||||
export default function HostWorkspaceLayout() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostWorkspaceLayoutContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostWorkspaceLayoutContent() {
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
@@ -55,6 +65,9 @@ export default function HostWorkspaceLayout() {
|
||||
if (!openValue) {
|
||||
return;
|
||||
}
|
||||
if (!rootNavigationState?.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
@@ -63,19 +76,30 @@ export default function HostWorkspaceLayout() {
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
const route = openIntent
|
||||
? prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
})
|
||||
: buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: getOpenIntentTarget(openIntent),
|
||||
pin: openIntent.kind === "agent",
|
||||
});
|
||||
}
|
||||
|
||||
router.replace(route as any);
|
||||
}, [openValue, router, serverId, workspaceId]);
|
||||
// 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.
|
||||
if (isWeb && typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("open")) {
|
||||
url.searchParams.delete("open");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (openValue) {
|
||||
setIntentConsumed(true);
|
||||
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
|
||||
|
||||
if (openValue && !intentConsumed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import {
|
||||
useHostRuntimeBootstrapState,
|
||||
useStoreReady,
|
||||
} from "@/app/_layout";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
@@ -55,9 +48,7 @@ export default function Index() {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRoute = anyOnlineServerId
|
||||
? buildHostRootRoute(anyOnlineServerId)
|
||||
: WELCOME_ROUTE;
|
||||
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
|
||||
router.replace(targetRoute);
|
||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Platform, Pressable, Text, View } from "react-native";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -10,6 +10,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -198,7 +199,7 @@ export default function PairScanScreen() {
|
||||
}, [router, source, sourceServerId, targetServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") return;
|
||||
if (isWeb) return;
|
||||
if (permission && permission.granted) return;
|
||||
void requestPermission().catch(() => undefined);
|
||||
}, [permission, requestPermission]);
|
||||
@@ -253,7 +254,7 @@ export default function PairScanScreen() {
|
||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
import type { AttachmentStore } from "@/attachments/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||
|
||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
if (isElectronRuntime()) {
|
||||
const { createDesktopAttachmentStore } = await import(
|
||||
"../desktop/attachments/desktop-attachment-store"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -14,6 +14,36 @@ import {
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type EscHandler = () => void;
|
||||
const escStack: EscHandler[] = [];
|
||||
let escListenerAttached = false;
|
||||
|
||||
function handleEscKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
const top = escStack[escStack.length - 1];
|
||||
if (!top) return;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
top();
|
||||
}
|
||||
|
||||
function pushEscHandler(handler: EscHandler): () => void {
|
||||
escStack.push(handler);
|
||||
if (!escListenerAttached && typeof window !== "undefined") {
|
||||
window.addEventListener("keydown", handleEscKeyDown, true);
|
||||
escListenerAttached = true;
|
||||
}
|
||||
return () => {
|
||||
const index = escStack.lastIndexOf(handler);
|
||||
if (index !== -1) escStack.splice(index, 1);
|
||||
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
|
||||
window.removeEventListener("keydown", handleEscKeyDown, true);
|
||||
escListenerAttached = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopOverlay: {
|
||||
@@ -47,10 +77,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginLeft: theme.spacing[3],
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
closeButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -82,6 +120,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
bottomSheetStaticContent: {
|
||||
flex: 1,
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopStaticContent: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
padding: theme.spacing[6],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
@@ -105,9 +155,11 @@ export interface AdaptiveModalSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
snapPoints?: string[];
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
testID?: string;
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -115,9 +167,11 @@ export function AdaptiveModalSheet({
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
headerActions,
|
||||
snapPoints,
|
||||
stackBehavior,
|
||||
testID,
|
||||
scrollable = true,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -156,6 +210,11 @@ export function AdaptiveModalSheet({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile || !visible) return;
|
||||
return pushEscHandler(onClose);
|
||||
}, [visible, isMobile, onClose]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheetModal
|
||||
@@ -173,18 +232,25 @@ export function AdaptiveModalSheet({
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
)}
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -198,25 +264,32 @@ export function AdaptiveModalSheet({
|
||||
/>
|
||||
<View style={styles.desktopCard}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// On web, use portal to overlay root for consistent stacking with toasts
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
if (!visible) return null;
|
||||
return createPortal(desktopContent, getOverlayRoot());
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import { Pressable, Text, View, Platform } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{Platform.OS !== "web" ? (
|
||||
{isNative ? (
|
||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native";
|
||||
import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-native";
|
||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -32,6 +32,7 @@ import type { AgentProviderDefinition } from "@server/server/agent/provider-mani
|
||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||
import { baseColors } from "@/styles/theme";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||
ShieldCheck,
|
||||
@@ -168,7 +169,7 @@ export function SelectField({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: unknown) => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
const key = getWebKey(event);
|
||||
if (key === "Enter" || key === " ") {
|
||||
preventWebDefault(event);
|
||||
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: unknown) => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
const key = getWebKey(event);
|
||||
if (key === "Enter" || key === " ") {
|
||||
preventWebDefault(event);
|
||||
@@ -556,7 +557,11 @@ export function AgentConfigRow({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||
|
||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
||||
const selectedModeVisuals = getModeVisuals(
|
||||
selectedProvider,
|
||||
effectiveSelectedMode,
|
||||
providerDefinitions,
|
||||
);
|
||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||
|
||||
@@ -773,7 +778,8 @@ export function ModelDropdown({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
|
||||
const selectedLabel = models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
||||
const selectedLabel =
|
||||
models.find((model) => model.id === selectedModel)?.label ?? selectedModel ?? "Select model";
|
||||
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
||||
const helperText = error
|
||||
? undefined
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { View, Pressable, Text, ActivityIndicator } from "react-native";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -50,6 +50,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -62,7 +63,7 @@ type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageA
|
||||
interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isInputActive: boolean;
|
||||
isPaneFocused: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
@@ -97,7 +98,7 @@ const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
export function AgentInputArea({
|
||||
agentId,
|
||||
serverId,
|
||||
isInputActive,
|
||||
isPaneFocused,
|
||||
onSubmitMessage,
|
||||
isSubmitLoading = false,
|
||||
blurOnSubmit = false,
|
||||
@@ -118,7 +119,7 @@ export function AgentInputArea({
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
||||
const { theme } = useUnistyles();
|
||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -156,7 +157,7 @@ export function AgentInputArea({
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile;
|
||||
const isDesktopWebBreakpoint = isWeb && !isMobile;
|
||||
const messagePlaceholder = isDesktopWebBreakpoint
|
||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||
@@ -217,7 +218,7 @@ export function AgentInputArea({
|
||||
}, [addImages, onAddImages]);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (isNative) return;
|
||||
focusWithRetries({
|
||||
focus: () => messageInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
@@ -420,7 +421,7 @@ export function AgentInputArea({
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (!isInputActive) {
|
||||
if (!isPaneFocused) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -430,7 +431,7 @@ export function AgentInputArea({
|
||||
case "message-input.dictation-confirm":
|
||||
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
||||
case "message-input.focus":
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
messageInputRef.current?.focus();
|
||||
return true;
|
||||
}
|
||||
@@ -460,7 +461,7 @@ export function AgentInputArea({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[isInputActive],
|
||||
[isPaneFocused],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -474,9 +475,9 @@ export function AgentInputArea({
|
||||
"message-input.voice-toggle",
|
||||
"message-input.voice-mute-toggle",
|
||||
],
|
||||
enabled: isInputActive,
|
||||
enabled: isPaneFocused,
|
||||
priority: isMessageInputFocused ? 200 : 100,
|
||||
isActive: () => isInputActive,
|
||||
isActive: () => isPaneFocused,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
@@ -596,14 +597,14 @@ export function AgentInputArea({
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
{dictationCancelKeys ? (
|
||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
{dictationCancelKeys ? (
|
||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
|
||||
const rightContent = (
|
||||
@@ -645,9 +646,7 @@ export function AgentInputArea({
|
||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||
typeof agentState.contextWindowUsedTokens === "number";
|
||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||
const contextWindowUsedTokens = hasContextWindowMeter
|
||||
? agentState.contextWindowUsedTokens
|
||||
: null;
|
||||
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
||||
|
||||
const beforeVoiceContent = (
|
||||
<View style={styles.contextWindowMeterSlot}>
|
||||
@@ -737,7 +736,7 @@ export function AgentInputArea({
|
||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isInputActive={isInputActive}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
|
||||
@@ -258,7 +258,8 @@ export function AgentList({
|
||||
if (!actionAgent || !actionClient) {
|
||||
return;
|
||||
}
|
||||
void actionClient.archiveAgent(actionAgent.id);
|
||||
// Timeout errors are swallowed — the daemon will still process the archive
|
||||
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
|
||||
setActionAgent(null);
|
||||
}, [actionAgent, actionClient]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
||||
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, Text, Pressable, Keyboard } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import {
|
||||
buildFavoriteModelKey,
|
||||
mergeProviderPreferences,
|
||||
@@ -40,7 +41,6 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
getModeVisuals,
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -59,10 +60,6 @@ type StatusOption = {
|
||||
|
||||
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string;
|
||||
providerOptions?: StatusOption[];
|
||||
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
disabled?: boolean;
|
||||
isModelLoading?: boolean;
|
||||
providerDefinitions?: AgentProviderDefinition[];
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||
canSelectModelProvider?: (providerId: string) => boolean;
|
||||
favoriteKeys?: Set<string>;
|
||||
@@ -222,7 +219,6 @@ function ControlledStatusBar({
|
||||
onModelSelectorOpen,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
||||
|
||||
@@ -252,7 +248,9 @@ function ControlledStatusBar({
|
||||
thinkingOptions?.[0]?.label ?? "Unknown",
|
||||
);
|
||||
|
||||
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined;
|
||||
const modeVisuals = selectedModeId
|
||||
? getModeVisuals(provider, selectedModeId, providerDefinitions)
|
||||
: undefined;
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
@@ -300,8 +298,7 @@ function ControlledStatusBar({
|
||||
);
|
||||
return map;
|
||||
}, [modelOptions, provider]);
|
||||
const effectiveProviderDefinitions = providerDefinitions ??
|
||||
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
|
||||
const effectiveProviderDefinitions = providerDefinitions;
|
||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||
@@ -321,7 +318,7 @@ function ControlledStatusBar({
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => {
|
||||
const visuals = getModeVisuals(provider, option.id);
|
||||
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
|
||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||
return (
|
||||
<ComboboxItem
|
||||
@@ -333,7 +330,7 @@ function ControlledStatusBar({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[provider, theme.colors.foreground],
|
||||
[provider, providerDefinitions, theme.colors.foreground],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
@@ -355,7 +352,7 @@ function ControlledStatusBar({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
{platformIsWeb ? (
|
||||
<>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<>
|
||||
@@ -670,10 +667,7 @@ function ControlledStatusBar({
|
||||
onClose={onDropdownClose}
|
||||
renderTrigger={({ selectedModelLabel }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.sheetSelect,
|
||||
modelDisabled && styles.disabledSheetSelect,
|
||||
]}
|
||||
style={[styles.sheetSelect, modelDisabled && styles.disabledSheetSelect]}
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
@@ -747,7 +741,7 @@ function ControlledStatusBar({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="start">
|
||||
{modeOptions.map((mode) => {
|
||||
const visuals = getModeVisuals(provider, mode.id);
|
||||
const visuals = getModeVisuals(provider, mode.id, providerDefinitions);
|
||||
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
@@ -850,7 +844,11 @@ function ControlledStatusBar({
|
||||
|
||||
const EMPTY_MODES: AgentMode[] = [];
|
||||
|
||||
export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStatusBarProps) {
|
||||
export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
agentId,
|
||||
serverId,
|
||||
onDropdownClose,
|
||||
}: AgentStatusBarProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => {
|
||||
@@ -894,9 +892,11 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
const models = snapshotModels;
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
|
||||
const definition = agent?.provider
|
||||
? resolveProviderDefinition(agent.provider, snapshotEntries)
|
||||
: undefined;
|
||||
return definition ? [definition] : [];
|
||||
}, [agent?.provider]);
|
||||
}, [agent?.provider, snapshotEntries]);
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
@@ -929,7 +929,10 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||
}, [models]);
|
||||
const favoriteKeys = useMemo(
|
||||
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||
() =>
|
||||
new Set(
|
||||
(preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
|
||||
),
|
||||
[preferences.favoriteModels],
|
||||
);
|
||||
|
||||
@@ -948,7 +951,9 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
<ControlledStatusBar
|
||||
provider={agent.provider}
|
||||
modeOptions={
|
||||
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||
modeOptions.length > 0
|
||||
? modeOptions
|
||||
: [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||
}
|
||||
selectedModeId={agent.currentModeId ?? undefined}
|
||||
providerDefinitions={agentProviderDefinitions}
|
||||
@@ -967,15 +972,14 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void updatePreferences(
|
||||
(current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
model: modelId,
|
||||
},
|
||||
}),
|
||||
void updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
model: modelId,
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentStatusBar] persist model preference failed", error);
|
||||
});
|
||||
@@ -985,7 +989,9 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
||||
void updatePreferences((current) =>
|
||||
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
@@ -997,18 +1003,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
}
|
||||
const activeModelId = modelSelection.activeModelId;
|
||||
if (activeModelId) {
|
||||
void updatePreferences(
|
||||
(current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
model: activeModelId,
|
||||
thinkingByModel: {
|
||||
[activeModelId]: thinkingOptionId,
|
||||
},
|
||||
void updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
model: activeModelId,
|
||||
thinkingByModel: {
|
||||
[activeModelId]: thinkingOptionId,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentStatusBar] persist thinking preference failed", error);
|
||||
});
|
||||
@@ -1022,17 +1027,16 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void updatePreferences(
|
||||
(current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
featureValues: {
|
||||
[featureId]: value,
|
||||
},
|
||||
void updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: agent.provider,
|
||||
updates: {
|
||||
featureValues: {
|
||||
[featureId]: value,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentStatusBar] persist feature preference failed", error);
|
||||
});
|
||||
@@ -1046,7 +1050,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
||||
disabled={!client}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function DraftAgentStatusBar({
|
||||
providerDefinitions,
|
||||
@@ -1071,7 +1075,6 @@ export function DraftAgentStatusBar({
|
||||
onModelSelectorOpen,
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
@@ -1088,7 +1091,10 @@ export function DraftAgentStatusBar({
|
||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
||||
}, [thinkingOptions]);
|
||||
const favoriteKeys = useMemo(
|
||||
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
||||
() =>
|
||||
new Set(
|
||||
(preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
|
||||
),
|
||||
[preferences.favoriteModels],
|
||||
);
|
||||
|
||||
@@ -1096,7 +1102,7 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||
|
||||
if (isWeb) {
|
||||
if (platformIsWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
@@ -1107,7 +1113,9 @@ export function DraftAgentStatusBar({
|
||||
onSelect={onSelectProviderAndModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavorite={(provider, modelId) => {
|
||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
||||
void updatePreferences((current) =>
|
||||
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
||||
).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
@@ -1118,6 +1126,7 @@ export function DraftAgentStatusBar({
|
||||
/>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
@@ -1154,7 +1163,9 @@ export function DraftAgentStatusBar({
|
||||
isModelLoading={isAllModelsLoading}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onToggleFavoriteModel={(provider, modelId) => {
|
||||
void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => {
|
||||
void updatePreferences((current) =>
|
||||
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
||||
).catch((error) => {
|
||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||
});
|
||||
}}
|
||||
@@ -1214,6 +1225,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
prefsButton: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
|
||||
@@ -52,8 +52,7 @@ export function resolveAgentModelSelection(input: {
|
||||
: null;
|
||||
const preferredModelId =
|
||||
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||
const fallbackModel =
|
||||
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||
const fallbackModel = models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||
const selectedModel =
|
||||
models && preferredModelId
|
||||
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
WORKING_INDICATOR_CYCLE_MS,
|
||||
WORKING_INDICATOR_OFFSETS,
|
||||
} from "@/utils/working-indicator";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -216,7 +217,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return buildAgentStreamRenderModel({
|
||||
tail: streamItems,
|
||||
head: streamHead ?? [],
|
||||
platform: Platform.OS === "web" ? "web" : "native",
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
@@ -255,7 +256,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return looseGap;
|
||||
}
|
||||
if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
|
||||
if (
|
||||
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
||||
isToolSequenceItem(belowItem)
|
||||
) {
|
||||
return tightGap;
|
||||
}
|
||||
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||
@@ -328,6 +332,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
client={client}
|
||||
/>
|
||||
);
|
||||
case "thought": {
|
||||
@@ -369,10 +375,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
data.detail.input.trim()
|
||||
) {
|
||||
return (
|
||||
<SpeakMessage
|
||||
message={data.detail.input}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
/>
|
||||
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -915,7 +918,9 @@ function PermissionRequestCard({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
|
||||
{planMarkdown ? (
|
||||
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
|
||||
) : null}
|
||||
|
||||
{!isPlanRequest ? (
|
||||
<ToolCallDetailsContent
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
clearAssistantImageMetadataCache,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import {
|
||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||
@@ -128,6 +132,25 @@ describe("estimateStreamItemHeight", () => {
|
||||
|
||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
||||
});
|
||||
|
||||
it("uses cached assistant image metadata when available", () => {
|
||||
clearAssistantImageMetadataCache();
|
||||
setAssistantImageMetadata(
|
||||
{
|
||||
source: "https://example.com/tall.png",
|
||||
},
|
||||
{ width: 800, height: 1600 },
|
||||
);
|
||||
|
||||
const item: StreamItem = {
|
||||
kind: "assistant_message",
|
||||
id: "a-image",
|
||||
text: "Look at this\n\n",
|
||||
timestamp: createTimestamp(2),
|
||||
};
|
||||
|
||||
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web virtualization test overrides", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata";
|
||||
|
||||
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||
@@ -45,7 +46,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
||||
case "user_message":
|
||||
return item.images && item.images.length > 0 ? 220 : 96;
|
||||
case "assistant_message":
|
||||
return 220;
|
||||
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
||||
case "tool_call":
|
||||
return 136;
|
||||
case "thought":
|
||||
|
||||
@@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
@@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
@@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</View>
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={styles.contentScroll}
|
||||
style={[styles.contentScroll, webScrollbarStyle]}
|
||||
contentContainerStyle={styles.contentScrollContainer}
|
||||
>
|
||||
{artifact.type === "image" ? (
|
||||
@@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.codeContainer}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -1,46 +1,66 @@
|
||||
import { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||
|
||||
interface BranchSwitcherProps {
|
||||
currentBranchName: string | null;
|
||||
title: string;
|
||||
branchOptions: ComboboxOption[];
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onBranchSelect: (branchId: string) => void;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isGitCheckout: boolean;
|
||||
}
|
||||
|
||||
export function BranchSwitcher({
|
||||
currentBranchName,
|
||||
title,
|
||||
branchOptions,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onBranchSelect,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isGitCheckout,
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!currentBranchName) {
|
||||
return (
|
||||
<Text
|
||||
testID="workspace-header-title"
|
||||
style={styles.headerTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||
client,
|
||||
normalizedServerId: serverId,
|
||||
normalizedWorkspaceId: workspaceId,
|
||||
currentBranchName,
|
||||
isGitCheckout,
|
||||
isConnected,
|
||||
toast,
|
||||
queryClient,
|
||||
});
|
||||
|
||||
const titleContent = (
|
||||
<>
|
||||
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
</>
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<View ref={anchorRef} collapsable={false}>
|
||||
<Pressable
|
||||
testID="workspace-header-branch-switcher"
|
||||
onPress={() => onOpenChange(true)}
|
||||
onPress={() => setIsOpen(true)}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.branchSwitcherTrigger,
|
||||
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
||||
@@ -48,33 +68,20 @@ export function BranchSwitcher({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
>
|
||||
<GitBranch
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
testID="workspace-header-title"
|
||||
style={styles.headerTitle}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
{titleContent}
|
||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={branchOptions}
|
||||
value={currentBranchName}
|
||||
onSelect={onBranchSelect}
|
||||
onSelect={handleBranchSelect}
|
||||
searchable
|
||||
placeholder="Switch branch..."
|
||||
searchPlaceholder="Filter branches..."
|
||||
emptyText="No branches found."
|
||||
title="Switch branch"
|
||||
open={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
onOpenChange={setIsOpen}
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
@@ -86,12 +93,7 @@ export function BranchSwitcher({
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={
|
||||
<GitBranch
|
||||
size={14}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
}
|
||||
leadingSlot={<GitBranch size={14} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -113,7 +115,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
marginLeft: {
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: {
|
||||
xs: 0,
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 1,
|
||||
|
||||
@@ -42,14 +42,25 @@ describe("combined model selector helpers", () => {
|
||||
];
|
||||
|
||||
it("keeps enough data to search by model and provider name", async () => {
|
||||
const rows = buildModelRows(providerDefinitions, new Map([
|
||||
["claude", claudeModels],
|
||||
["codex", codexModels],
|
||||
]));
|
||||
const rows = buildModelRows(
|
||||
providerDefinitions,
|
||||
new Map([
|
||||
["claude", claudeModels],
|
||||
["codex", codexModels],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
|
||||
expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
|
||||
expect.objectContaining({
|
||||
providerLabel: "Claude",
|
||||
modelLabel: "Sonnet 4.6",
|
||||
modelId: "sonnet-4.6",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
providerLabel: "Codex",
|
||||
modelLabel: "GPT-5.4",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(matchesSearch(rows[0]!, "claude")).toBe(true);
|
||||
@@ -58,6 +69,6 @@ describe("combined model selector helpers", () => {
|
||||
|
||||
it("builds an explicit trigger label for the selected provider and model", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("Codex: GPT-5.4");
|
||||
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,26 +4,17 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
} from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Star,
|
||||
} from "lucide-react-native";
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = platformIsWeb;
|
||||
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
@@ -127,7 +118,10 @@ function sortFavoritesFirst(
|
||||
function groupRowsByProvider(
|
||||
rows: SelectorModelRow[],
|
||||
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
|
||||
const grouped = new Map<string, { providerId: string; providerLabel: string; rows: SelectorModelRow[] }>();
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = grouped.get(row.provider);
|
||||
@@ -174,8 +168,7 @@ function ModelRow({
|
||||
[onToggleFavorite, row.modelId, row.provider],
|
||||
);
|
||||
|
||||
const showDescription =
|
||||
row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
@@ -292,7 +285,9 @@ function GroupedProviderRows({
|
||||
return (
|
||||
<View>
|
||||
{groupedRows.map((group, index) => {
|
||||
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
|
||||
const providerDefinition = providerDefinitions.find(
|
||||
(definition) => definition.id === group.providerId,
|
||||
);
|
||||
const ProvIcon = getProviderIcon(group.providerId);
|
||||
const isInline = viewKind === "provider";
|
||||
|
||||
@@ -354,7 +349,7 @@ function ProviderSearchInput({
|
||||
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
||||
if (autoFocus && platformIsWeb && inputRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
@@ -368,7 +363,7 @@ function ProviderSearchInput({
|
||||
<InputComponent
|
||||
ref={inputRef as any}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
|
||||
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
|
||||
placeholder="Search models..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
@@ -523,10 +518,9 @@ export function CombinedModelSelector({
|
||||
disabled = false,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isWeb = Platform.OS === "web";
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isContentReady, setIsContentReady] = useState(isWeb);
|
||||
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -596,7 +590,7 @@ export function CombinedModelSelector({
|
||||
}, [selectedModelLabel, selectedProviderLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) {
|
||||
if (platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -610,7 +604,7 @@ export function CombinedModelSelector({
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [isOpen, isWeb]);
|
||||
}, [isOpen, platformIsWeb]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -640,7 +634,9 @@ export function CombinedModelSelector({
|
||||
) : (
|
||||
<>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{triggerLabel}</Text>
|
||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</>
|
||||
)}
|
||||
@@ -673,7 +669,7 @@ export function CombinedModelSelector({
|
||||
<ProviderSearchInput
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus={Platform.OS === "web"}
|
||||
autoFocus={platformIsWeb}
|
||||
/>
|
||||
</View>
|
||||
) : undefined
|
||||
@@ -710,6 +706,8 @@ export function CombinedModelSelector({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
@@ -727,6 +725,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
|
||||
@@ -8,7 +8,9 @@ export function resolveProviderLabel(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
providerId: string,
|
||||
): string {
|
||||
return providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId;
|
||||
return (
|
||||
providerDefinitions.find((definition) => definition.id === providerId)?.label ?? providerId
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
||||
@@ -19,7 +21,9 @@ export function buildModelRows(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||
): SelectorModelRow[] {
|
||||
const providerLabelMap = new Map(providerDefinitions.map((definition) => [definition.id, definition.label]));
|
||||
const providerLabelMap = new Map(
|
||||
providerDefinitions.map((definition) => [definition.id, definition.label]),
|
||||
);
|
||||
const rows: SelectorModelRow[] = [];
|
||||
|
||||
for (const definition of providerDefinitions) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { memo, useEffect, useRef, type ReactNode } from "react";
|
||||
import { Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||
return `${agent.serverId}:${agent.id}`;
|
||||
@@ -90,7 +91,7 @@ export function CommandCenter() {
|
||||
}
|
||||
}, [activeIndex, open]);
|
||||
|
||||
if (Platform.OS !== "web" || !open) return null;
|
||||
if (isNative || !open) return null;
|
||||
|
||||
const actionItems = items.filter((item) => item.kind === "action");
|
||||
const agentItems = items.filter((item) => item.kind === "agent");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
/**
|
||||
* VS Code-style titlebar drag region for Electron.
|
||||
@@ -22,7 +22,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
|
||||
* Place as FIRST child of any positioned container that should be draggable.
|
||||
*/
|
||||
export function TitlebarDragRegion() {
|
||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
||||
if (isNative || !getIsElectronRuntime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
@@ -14,12 +15,14 @@ export function DiffScroll({
|
||||
style,
|
||||
contentContainerStyle,
|
||||
}: DiffScrollProps) {
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={style}
|
||||
style={[style, webScrollbarStyle]}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from "react";
|
||||
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
|
||||
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
||||
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
|
||||
interface DiffViewerProps {
|
||||
diffLines: DiffLine[];
|
||||
@@ -22,6 +24,7 @@ export function DiffViewer({
|
||||
fillAvailableHeight = false,
|
||||
}: DiffViewerProps) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
if (!diffLines.length) {
|
||||
return (
|
||||
@@ -37,6 +40,7 @@ export function DiffViewer({
|
||||
styles.verticalScroll,
|
||||
maxHeight !== undefined && { maxHeight },
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.verticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -46,6 +50,7 @@ export function DiffViewer({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.horizontalContent}
|
||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
@@ -130,7 +135,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
@@ -26,6 +25,7 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
|
||||
|
||||
// Mobile: full-screen overlay with gesture.
|
||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
|
||||
|
||||
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||
@@ -270,7 +270,11 @@ export function ExplorerSidebar({
|
||||
<Animated.View
|
||||
style={[
|
||||
explorerStaticStyles.mobileSidebar,
|
||||
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
|
||||
{
|
||||
width: windowWidth,
|
||||
paddingTop: insets.top,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
sidebarAnimatedStyle,
|
||||
mobileKeyboardInsetStyle,
|
||||
]}
|
||||
@@ -299,13 +303,13 @@ export function ExplorerSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||
<Animated.View
|
||||
style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
|
||||
>
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||
</GestureDetector>
|
||||
|
||||
<SidebarContent
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { View, Text, Platform } from "react-native";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||
import { useEffect } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "./message-input";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface FileDropZoneProps {
|
||||
children: React.ReactNode;
|
||||
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -52,6 +51,7 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
@@ -91,7 +91,7 @@ export function FileExplorerPane({
|
||||
}: FileExplorerPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
const daemons = useHosts();
|
||||
const daemonProfile = useMemo(
|
||||
@@ -118,23 +118,18 @@ export function FileExplorerPane({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const {
|
||||
requestDirectoryListing,
|
||||
requestFileDownloadToken,
|
||||
selectExplorerEntry,
|
||||
} = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
||||
useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const expandedPathsArray = usePanelStore((state) =>
|
||||
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||
);
|
||||
const setExpandedPathsForWorkspace = usePanelStore(
|
||||
(state) => state.setExpandedPathsForWorkspace,
|
||||
);
|
||||
const setExpandedPathsForWorkspace = usePanelStore((state) => state.setExpandedPathsForWorkspace);
|
||||
const expandedPaths = useMemo(
|
||||
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||
[expandedPathsArray],
|
||||
@@ -172,22 +167,32 @@ export function FileExplorerPane({
|
||||
if (hasInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
// Mark initialized eagerly so concurrent effect re-runs don't double-fetch.
|
||||
// If the root listing fails (e.g. client not yet connected), we reset the
|
||||
// flag so the next time requestDirectoryListing is recreated (when client
|
||||
// becomes available) this effect retries automatically.
|
||||
hasInitializedRef.current = true;
|
||||
void requestDirectoryListing(".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}).then((succeeded) => {
|
||||
if (!succeeded) {
|
||||
hasInitializedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const persistedPaths =
|
||||
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
@@ -201,10 +206,7 @@ export function FileExplorerPane({
|
||||
if (newPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), ...newPaths],
|
||||
);
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
|
||||
newPaths.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
void requestDirectoryListing(path, {
|
||||
@@ -234,10 +236,7 @@ export function FileExplorerPane({
|
||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||
);
|
||||
} else {
|
||||
setExpandedPathsForWorkspace(
|
||||
workspaceStateKey,
|
||||
[...Array.from(expandedPaths), entry.path],
|
||||
);
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
|
||||
if (!directories.has(entry.path)) {
|
||||
void requestDirectoryListing(entry.path, {
|
||||
recordHistory: false,
|
||||
|
||||
23
packages/app/src/components/file-pane-render-mode.test.ts
Normal file
23
packages/app/src/components/file-pane-render-mode.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
|
||||
describe("isRenderedMarkdownFile", () => {
|
||||
it("detects .md files", () => {
|
||||
expect(isRenderedMarkdownFile("README.md")).toBe(true);
|
||||
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects .markdown files", () => {
|
||||
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
|
||||
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat .mdx files as rendered markdown", () => {
|
||||
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat other text files as rendered markdown", () => {
|
||||
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
|
||||
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
4
packages/app/src/components/file-pane-render-mode.ts
Normal file
4
packages/app/src/components/file-pane-render-mode.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isRenderedMarkdownFile(filePath: string): boolean {
|
||||
const normalizedPath = filePath.trim().toLowerCase();
|
||||
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import {
|
||||
highlightCode,
|
||||
darkHighlightColors,
|
||||
@@ -21,6 +22,9 @@ import {
|
||||
type HighlightStyle,
|
||||
} from "@getpaseo/highlight";
|
||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -68,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({
|
||||
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
||||
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
||||
</View>
|
||||
<Text style={codeLineStyles.lineText}>
|
||||
<Text selectable style={codeLineStyles.lineText}>
|
||||
{tokens.map((token, index) => (
|
||||
<Text
|
||||
key={index}
|
||||
@@ -117,19 +121,23 @@ function FilePreviewBody({
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
||||
|
||||
const previewScrollRef = useRef<RNScrollView>(null);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
});
|
||||
|
||||
const highlightedLines = useMemo(() => {
|
||||
if (!preview || preview.kind !== "text") {
|
||||
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return highlightCode(preview.content ?? "", filePath);
|
||||
}, [preview?.kind, preview?.content, filePath]);
|
||||
}, [isMarkdownFile, preview?.kind, preview?.content, filePath]);
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!highlightedLines) return 0;
|
||||
@@ -154,6 +162,28 @@ function FilePreviewBody({
|
||||
}
|
||||
|
||||
if (preview.kind === "text") {
|
||||
if (isMarkdownFile) {
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
ref={previewScrollRef}
|
||||
style={styles.previewContent}
|
||||
contentContainerStyle={styles.previewMarkdownScrollContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
||||
{preview.content ?? ""}
|
||||
</Markdown>
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
||||
const codeLines = (
|
||||
<View>
|
||||
@@ -188,6 +218,7 @@ function FilePreviewBody({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.previewCodeScrollContent}
|
||||
>
|
||||
{codeLines}
|
||||
@@ -243,7 +274,7 @@ export function FilePane({
|
||||
filePath: string;
|
||||
}) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
@@ -264,6 +295,7 @@ export function FilePane({
|
||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||
},
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -328,6 +360,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
previewCodeScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
previewMarkdownScrollContent: {
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
previewImageScrollContent: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing[4],
|
||||
|
||||
@@ -15,6 +15,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
baseRefAvailable: true,
|
||||
baseRefLabel: "main",
|
||||
aheadCount: 0,
|
||||
behindBaseCount: 0,
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
shouldPromoteArchive: false,
|
||||
@@ -25,6 +26,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
pull: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
push: {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
@@ -56,89 +62,102 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
}
|
||||
|
||||
describe("git-actions-policy", () => {
|
||||
it("keeps the secondary menu order stable while the primary action changes", () => {
|
||||
const noPrActions = buildGitActions(createInput());
|
||||
const withPrActions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/123",
|
||||
aheadCount: 3,
|
||||
aheadOfOrigin: 2,
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
it("shows only remote sync actions on the base branch", () => {
|
||||
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||
|
||||
expect(noPrActions.primary).toBeNull();
|
||||
expect(withPrActions.primary?.id).toBe("push");
|
||||
expect(noPrActions.secondary.map((action) => action.id)).toEqual([
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
]);
|
||||
expect(withPrActions.secondary.map((action) => action.id)).toEqual([
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-from-base",
|
||||
"push",
|
||||
]);
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
|
||||
});
|
||||
|
||||
it("disables hidden-before actions with explanations instead", () => {
|
||||
const actions = buildGitActions(createInput());
|
||||
const actionById = new Map(actions.secondary.map((action) => [action.id, action]));
|
||||
|
||||
expect(actionById.get("push")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No remote configured",
|
||||
});
|
||||
expect(actionById.get("pr")).toMatchObject({
|
||||
label: "Create PR",
|
||||
disabled: true,
|
||||
description: "Branch has no commits ahead of main",
|
||||
});
|
||||
expect(actionById.get("merge-branch")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No commits to merge into main",
|
||||
});
|
||||
expect(actionById.get("merge-from-base")).toMatchObject({
|
||||
disabled: true,
|
||||
description: "No remote configured",
|
||||
});
|
||||
expect(actionById.has("archive-worktree")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the current primary action visible in the menu", () => {
|
||||
it("prioritizes pull when the branch is behind origin", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
behindOfOrigin: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
|
||||
});
|
||||
|
||||
it("keeps push clickable with a clearer message when the branch diverged", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
aheadOfOrigin: 1,
|
||||
behindOfOrigin: 1,
|
||||
}),
|
||||
);
|
||||
const pushAction = actions.secondary.find((action) => action.id === "push");
|
||||
|
||||
expect(pushAction).toMatchObject({
|
||||
disabled: false,
|
||||
unavailableMessage:
|
||||
"Push isn't available yet because there are newer changes to bring in first",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows update-from-base only on feature branches that are behind the base branch", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
behindBaseCount: 3,
|
||||
}),
|
||||
);
|
||||
const updateAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||
|
||||
expect(updateAction).toMatchObject({
|
||||
label: "Update from main",
|
||||
disabled: false,
|
||||
unavailableMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a clear sentence when pull is unavailable", () => {
|
||||
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||
const pullAction = actions.secondary.find((action) => action.id === "pull");
|
||||
|
||||
expect(pullAction).toMatchObject({
|
||||
disabled: false,
|
||||
unavailableMessage: "Pull isn't available because this branch is already up to date",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps update-from-base off the base branch entirely", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
behindOfOrigin: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps feature branch actions available off the base branch", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
behindBaseCount: 1,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary?.id).toBe("pr");
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("disables sync on the base branch when already up to date", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
}),
|
||||
);
|
||||
const syncAction = actions.secondary.find((action) => action.id === "merge-from-base");
|
||||
|
||||
expect(syncAction).toMatchObject({
|
||||
label: "Sync",
|
||||
disabled: true,
|
||||
description: "Already up to date",
|
||||
});
|
||||
});
|
||||
|
||||
it("only shows archive worktree for paseo worktrees", () => {
|
||||
const hidden = buildGitActions(createInput());
|
||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "pr"
|
||||
| "merge-branch"
|
||||
@@ -17,7 +18,7 @@ export interface GitAction {
|
||||
successLabel: string;
|
||||
disabled: boolean;
|
||||
status: ActionStatus;
|
||||
description?: string;
|
||||
unavailableMessage?: string;
|
||||
icon?: ReactElement;
|
||||
handler: () => void;
|
||||
}
|
||||
@@ -47,6 +48,7 @@ export interface BuildGitActionsInput {
|
||||
baseRefAvailable: boolean;
|
||||
baseRefLabel: string;
|
||||
aheadCount: number;
|
||||
behindBaseCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
shouldPromoteArchive: boolean;
|
||||
@@ -54,7 +56,8 @@ export interface BuildGitActionsInput {
|
||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||
}
|
||||
|
||||
const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"];
|
||||
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push"];
|
||||
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
|
||||
|
||||
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
if (!input.isGit) {
|
||||
@@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
handler: input.runtime.commit.handler,
|
||||
});
|
||||
|
||||
allActions.set("pull", {
|
||||
id: "pull",
|
||||
label: "Pull",
|
||||
pendingLabel: "Pulling...",
|
||||
successLabel: "Pulled",
|
||||
disabled: input.runtime.pull.disabled,
|
||||
status: input.runtime.pull.status,
|
||||
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
|
||||
icon: input.runtime.pull.icon,
|
||||
handler: input.runtime.pull.handler,
|
||||
});
|
||||
|
||||
allActions.set("push", {
|
||||
id: "push",
|
||||
label: "Push",
|
||||
pendingLabel: "Pushing...",
|
||||
successLabel: "Pushed",
|
||||
disabled: input.runtime.push.disabled || !input.hasRemote,
|
||||
disabled: input.runtime.push.disabled,
|
||||
status: input.runtime.push.status,
|
||||
description: input.hasRemote ? undefined : "No remote configured",
|
||||
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
|
||||
icon: input.runtime.push.icon,
|
||||
handler: input.runtime.push.handler,
|
||||
});
|
||||
@@ -93,25 +108,25 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
label: `Merge into ${input.baseRefLabel}`,
|
||||
pendingLabel: "Merging...",
|
||||
successLabel: "Merged",
|
||||
disabled:
|
||||
input.runtime["merge-branch"].disabled ||
|
||||
!input.baseRefAvailable ||
|
||||
input.hasUncommittedChanges ||
|
||||
input.aheadCount === 0,
|
||||
disabled: input.runtime["merge-branch"].disabled,
|
||||
status: input.runtime["merge-branch"].status,
|
||||
description: getMergeBranchDescription(input),
|
||||
unavailableMessage: input.runtime["merge-branch"].disabled
|
||||
? undefined
|
||||
: getMergeBranchUnavailableMessage(input),
|
||||
icon: input.runtime["merge-branch"].icon,
|
||||
handler: input.runtime["merge-branch"].handler,
|
||||
});
|
||||
|
||||
allActions.set("merge-from-base", {
|
||||
id: "merge-from-base",
|
||||
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
|
||||
label: `Update from ${input.baseRefLabel}`,
|
||||
pendingLabel: "Updating...",
|
||||
successLabel: "Updated",
|
||||
disabled: input.runtime["merge-from-base"].disabled || !canMergeFromBase(input),
|
||||
disabled: input.runtime["merge-from-base"].disabled,
|
||||
status: input.runtime["merge-from-base"].status,
|
||||
description: getMergeFromBaseDescription(input),
|
||||
unavailableMessage: input.runtime["merge-from-base"].disabled
|
||||
? undefined
|
||||
: getMergeFromBaseUnavailableMessage(input),
|
||||
icon: input.runtime["merge-from-base"].icon,
|
||||
handler: input.runtime["merge-from-base"].handler,
|
||||
});
|
||||
@@ -121,21 +136,32 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
label: "Archive worktree",
|
||||
pendingLabel: "Archiving...",
|
||||
successLabel: "Archived",
|
||||
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
|
||||
disabled: input.runtime["archive-worktree"].disabled,
|
||||
status: input.runtime["archive-worktree"].status,
|
||||
description: input.isPaseoOwnedWorktree ? undefined : "Only available for Paseo worktrees",
|
||||
unavailableMessage:
|
||||
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
|
||||
? undefined
|
||||
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
|
||||
icon: input.runtime["archive-worktree"].icon,
|
||||
handler: input.runtime["archive-worktree"].handler,
|
||||
});
|
||||
|
||||
const primaryActionId = getPrimaryActionId(input);
|
||||
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
||||
const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!);
|
||||
|
||||
const secondaryIds = [...REMOTE_ACTION_IDS];
|
||||
if (!input.isOnBaseBranch) {
|
||||
secondaryIds.push(...FEATURE_ACTION_IDS);
|
||||
}
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondary.push(allActions.get("archive-worktree")!);
|
||||
secondaryIds.push("archive-worktree");
|
||||
}
|
||||
|
||||
return { primary, secondary, menu: [] };
|
||||
return {
|
||||
primary,
|
||||
secondary: secondaryIds.map((id) => allActions.get(id)!),
|
||||
menu: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
@@ -145,20 +171,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "commit";
|
||||
}
|
||||
if (input.aheadOfOrigin > 0 && input.hasRemote) {
|
||||
if (canPull(input)) {
|
||||
return "pull";
|
||||
}
|
||||
if (canPush(input)) {
|
||||
return "push";
|
||||
}
|
||||
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
||||
return "merge-from-base";
|
||||
}
|
||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||
return "pr";
|
||||
}
|
||||
if (
|
||||
input.isOnBaseBranch &&
|
||||
input.hasRemote &&
|
||||
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
|
||||
) {
|
||||
return "merge-from-base";
|
||||
}
|
||||
if (input.aheadCount > 0) {
|
||||
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||
}
|
||||
return null;
|
||||
@@ -171,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
|
||||
disabled: input.runtime.pr.disabled,
|
||||
status: input.runtime.pr.status,
|
||||
description: input.githubFeaturesEnabled ? undefined : "GitHub features unavailable",
|
||||
unavailableMessage:
|
||||
input.runtime.pr.disabled || input.githubFeaturesEnabled
|
||||
? undefined
|
||||
: "View PR isn't available right now because GitHub isn't connected",
|
||||
icon: input.runtime.pr.icon,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
@@ -184,65 +212,100 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
label: "Create PR",
|
||||
pendingLabel: "Creating PR...",
|
||||
successLabel: "PR Created",
|
||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
|
||||
disabled: input.runtime.pr.disabled,
|
||||
status: input.runtime.pr.status,
|
||||
description: getCreatePrDescription(input),
|
||||
unavailableMessage: input.runtime.pr.disabled
|
||||
? undefined
|
||||
: getCreatePrUnavailableMessage(input),
|
||||
icon: input.runtime.pr.icon,
|
||||
handler: input.runtime.pr.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function canPull(input: BuildGitActionsInput): boolean {
|
||||
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
|
||||
}
|
||||
|
||||
function canPush(input: BuildGitActionsInput): boolean {
|
||||
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
|
||||
}
|
||||
|
||||
function canMergeBranch(input: BuildGitActionsInput): boolean {
|
||||
return (
|
||||
!input.isOnBaseBranch &&
|
||||
input.baseRefAvailable &&
|
||||
!input.hasUncommittedChanges &&
|
||||
input.aheadCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isOnBaseBranch) {
|
||||
return true;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return false;
|
||||
}
|
||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 0;
|
||||
return (
|
||||
!input.isOnBaseBranch &&
|
||||
input.baseRefAvailable &&
|
||||
!input.hasUncommittedChanges &&
|
||||
input.behindBaseCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
function getCreatePrDescription(input: BuildGitActionsInput): string | undefined {
|
||||
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.hasRemote) {
|
||||
return "Pull isn't available here because this branch is not connected to a remote yet";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Pull isn't available while you have local changes so commit or stash them first";
|
||||
}
|
||||
if (input.behindOfOrigin === 0) {
|
||||
return "Pull isn't available because this branch is already up to date";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.hasRemote) {
|
||||
return "Push isn't available here because this branch is not connected to a remote yet";
|
||||
}
|
||||
if (input.behindOfOrigin > 0) {
|
||||
return "Push isn't available yet because there are newer changes to bring in first";
|
||||
}
|
||||
if (input.aheadOfOrigin === 0) {
|
||||
return "Push isn't available because there is nothing new to send";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.githubFeaturesEnabled) {
|
||||
return "GitHub features unavailable";
|
||||
return "Create PR isn't available right now because GitHub isn't connected";
|
||||
}
|
||||
if (input.aheadCount === 0) {
|
||||
return `Branch has no commits ahead of ${input.baseRefLabel}`;
|
||||
return "Create PR isn't available because this branch doesn't have any new commits yet";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
|
||||
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.baseRefAvailable) {
|
||||
return "Base ref unavailable";
|
||||
return "Merge isn't available because we couldn't determine the base branch";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Requires clean working tree";
|
||||
return "Merge isn't available while you have local changes so commit or stash them first";
|
||||
}
|
||||
if (input.aheadCount === 0) {
|
||||
return `No commits to merge into ${input.baseRefLabel}`;
|
||||
return "Merge isn't available because this branch doesn't have anything new to merge yet";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
|
||||
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||
if (!input.baseRefAvailable) {
|
||||
return "Base ref unavailable";
|
||||
return "Update isn't available because we couldn't determine the base branch";
|
||||
}
|
||||
if (input.hasUncommittedChanges) {
|
||||
return "Requires clean working tree";
|
||||
return "Update isn't available while you have local changes so commit or stash them first";
|
||||
}
|
||||
if (!input.isOnBaseBranch) {
|
||||
return undefined;
|
||||
}
|
||||
if (!input.hasRemote) {
|
||||
return "No remote configured";
|
||||
}
|
||||
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
|
||||
return "Already up to date";
|
||||
if (input.behindBaseCount === 0) {
|
||||
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
||||
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
@@ -19,6 +20,7 @@ interface GitActionsSplitButtonProps {
|
||||
|
||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
@@ -27,6 +29,20 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
return action.label;
|
||||
}, []);
|
||||
|
||||
const handleActionSelect = useCallback(
|
||||
(action: GitAction) => {
|
||||
if (action.unavailableMessage) {
|
||||
toast.show(action.unavailableMessage, {
|
||||
durationMs: 3200,
|
||||
icon: <Info size={16} color={theme.colors.foreground} />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
action.handler();
|
||||
},
|
||||
[theme.colors.foreground, toast],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
{gitActions.primary ? (
|
||||
@@ -73,7 +89,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
||||
{gitActions.secondary.map((action, index) => {
|
||||
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
|
||||
const needsSeparator =
|
||||
action.id === "merge-from-base" || action.id === "archive-worktree";
|
||||
return (
|
||||
<View key={action.id}>
|
||||
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||
@@ -81,11 +98,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
testID={`changes-menu-${action.id}`}
|
||||
leading={action.icon}
|
||||
trailing={
|
||||
action.id === "archive-worktree" && archiveShortcutKeys
|
||||
? <Shortcut chord={archiveShortcutKeys} />
|
||||
: undefined
|
||||
action.id === "archive-worktree" && archiveShortcutKeys ? (
|
||||
<Shortcut chord={archiveShortcutKeys} />
|
||||
) : undefined
|
||||
}
|
||||
disabled={action.disabled}
|
||||
muted={Boolean(action.unavailableMessage)}
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
@@ -94,8 +112,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
action.id === "pr" &&
|
||||
action.label === "View PR"
|
||||
}
|
||||
description={action.description}
|
||||
onSelect={action.handler}
|
||||
onSelect={() => handleActionSelect(action)}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
@@ -125,11 +142,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
testID={`changes-menu-${action.id}`}
|
||||
leading={action.icon}
|
||||
disabled={action.disabled}
|
||||
muted={Boolean(action.unavailableMessage)}
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
successLabel={action.successLabel}
|
||||
closeOnSelect={false}
|
||||
onSelect={action.handler}
|
||||
onSelect={() => handleActionSelect(action)}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
memo,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
View,
|
||||
@@ -14,7 +6,6 @@ import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
FlatList,
|
||||
Platform,
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
@@ -28,6 +19,7 @@ import {
|
||||
Archive,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
Download,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
@@ -80,11 +72,13 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
formatDiffContentText,
|
||||
formatDiffGutterText,
|
||||
hasVisibleDiffTokens,
|
||||
} from "@/utils/diff-rendering";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
@@ -105,7 +99,7 @@ type WrappedWebTextStyle = TextStyle & {
|
||||
};
|
||||
|
||||
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return undefined;
|
||||
}
|
||||
return wrapLines
|
||||
@@ -126,12 +120,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
{ lineHeight, ...getWrappedTextStyle(wrapLines) },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }]}>
|
||||
{tokens.map((token, index) => (
|
||||
<Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
|
||||
{token.text}
|
||||
@@ -181,13 +170,7 @@ function DiffGutterCell({
|
||||
);
|
||||
}
|
||||
|
||||
function DiffTextLine({
|
||||
line,
|
||||
wrapLines,
|
||||
}: {
|
||||
line: DiffLine;
|
||||
wrapLines: boolean;
|
||||
}) {
|
||||
function DiffTextLine({ line, wrapLines }: { line: DiffLine; wrapLines: boolean }) {
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
return (
|
||||
@@ -257,12 +240,7 @@ function DiffLineView({
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.diffLineContainer,
|
||||
lineTypeBackground(line.type),
|
||||
]}
|
||||
>
|
||||
<View style={[styles.diffLineContainer, lineTypeBackground(line.type)]}>
|
||||
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
||||
<Text
|
||||
style={[
|
||||
@@ -306,12 +284,7 @@ function SplitDiffLine({
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.diffLineContainer,
|
||||
lineTypeBackground(line?.type),
|
||||
]}
|
||||
>
|
||||
<View style={[styles.diffLineContainer, lineTypeBackground(line?.type)]}>
|
||||
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
||||
<Text
|
||||
style={[
|
||||
@@ -385,14 +358,30 @@ function SplitDiffColumn({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow]}>
|
||||
<View
|
||||
style={[styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow]}
|
||||
>
|
||||
<View style={styles.gutterColumn}>
|
||||
{rows.map((row, i) => {
|
||||
if (row.kind === "header") {
|
||||
return <DiffGutterCell key={`g-${i}`} lineNumber={null} type="header" gutterWidth={gutterWidth} />;
|
||||
return (
|
||||
<DiffGutterCell
|
||||
key={`g-${i}`}
|
||||
lineNumber={null}
|
||||
type="header"
|
||||
gutterWidth={gutterWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
return <DiffGutterCell key={`g-${i}`} lineNumber={line?.lineNumber ?? null} type={line?.type} gutterWidth={gutterWidth} />;
|
||||
return (
|
||||
<DiffGutterCell
|
||||
key={`g-${i}`}
|
||||
lineNumber={line?.lineNumber ?? null}
|
||||
type={line?.type}
|
||||
gutterWidth={gutterWidth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<DiffScroll
|
||||
@@ -410,7 +399,13 @@ function SplitDiffColumn({
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <SplitTextLine key={`t-${i}`} line={side === "left" ? row.left : row.right} wrapLines={false} />;
|
||||
return (
|
||||
<SplitTextLine
|
||||
key={`t-${i}`}
|
||||
line={side === "left" ? row.left : row.right}
|
||||
wrapLines={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</DiffScroll>
|
||||
@@ -458,7 +453,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
}}
|
||||
onPressOut={(event) => {
|
||||
if (
|
||||
Platform.OS !== "web" &&
|
||||
isNative &&
|
||||
!pressHandledRef.current &&
|
||||
layoutYRef.current === 0 &&
|
||||
pressInRef.current
|
||||
@@ -539,7 +534,11 @@ function DiffFileBody({
|
||||
|
||||
let maxLineNo = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
|
||||
maxLineNo = Math.max(
|
||||
maxLineNo,
|
||||
hunk.oldStart + hunk.oldCount,
|
||||
hunk.newStart + hunk.newCount,
|
||||
);
|
||||
}
|
||||
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||
|
||||
@@ -547,8 +546,19 @@ function DiffFileBody({
|
||||
const rows = buildSplitDiffRows(file);
|
||||
return (
|
||||
<View style={[styles.diffContent, styles.splitRow]}>
|
||||
<SplitDiffColumn rows={rows} side="left" gutterWidth={gutterWidth} wrapLines={wrapLines} />
|
||||
<SplitDiffColumn rows={rows} side="right" gutterWidth={gutterWidth} wrapLines={wrapLines} showDivider />
|
||||
<SplitDiffColumn
|
||||
rows={rows}
|
||||
side="left"
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
<SplitDiffColumn
|
||||
rows={rows}
|
||||
side="right"
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
showDivider
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -560,7 +570,13 @@ function DiffFileBody({
|
||||
<View style={styles.diffContent}>
|
||||
<View style={styles.linesContainer}>
|
||||
{computedLines.map(({ line, lineNumber, key }) => (
|
||||
<DiffLineView key={key} line={line} lineNumber={lineNumber} gutterWidth={gutterWidth} wrapLines={wrapLines} />
|
||||
<DiffLineView
|
||||
key={key}
|
||||
line={line}
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
@@ -572,7 +588,12 @@ function DiffFileBody({
|
||||
<View style={[styles.diffContent, styles.diffContentRow]}>
|
||||
<View style={styles.gutterColumn}>
|
||||
{computedLines.map(({ line, lineNumber, key }) => (
|
||||
<DiffGutterCell key={key} lineNumber={lineNumber} type={line.type} gutterWidth={gutterWidth} />
|
||||
<DiffGutterCell
|
||||
key={key}
|
||||
lineNumber={lineNumber}
|
||||
type={line.type}
|
||||
gutterWidth={gutterWidth}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<DiffScroll
|
||||
@@ -581,7 +602,9 @@ function DiffFileBody({
|
||||
style={styles.splitColumnScroll}
|
||||
contentContainerStyle={styles.diffContentInner}
|
||||
>
|
||||
<View style={[styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }]}>
|
||||
<View
|
||||
style={[styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }]}
|
||||
>
|
||||
{computedLines.map(({ line, key }) => (
|
||||
<DiffTextLine key={key} line={line} wrapLines={false} />
|
||||
))}
|
||||
@@ -607,12 +630,12 @@ type DiffFlatItem =
|
||||
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
const canUseSplitLayout = isWeb && !isMobile;
|
||||
const router = useRouter();
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||
@@ -699,10 +722,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const setDiffExpandedPathsForWorkspace = usePanelStore(
|
||||
(state) => state.setDiffExpandedPathsForWorkspace,
|
||||
);
|
||||
const expandedPaths = useMemo(
|
||||
() => new Set(expandedPathsArray ?? []),
|
||||
[expandedPathsArray],
|
||||
);
|
||||
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||
enabled: showDesktopWebScrollbar,
|
||||
@@ -896,6 +916,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
||||
);
|
||||
const pullStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "pull" }),
|
||||
);
|
||||
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "push" }),
|
||||
);
|
||||
@@ -913,83 +936,123 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
);
|
||||
|
||||
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||
const runPull = useCheckoutGitActionsStore((state) => state.pull);
|
||||
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
||||
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
||||
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
||||
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
||||
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
|
||||
const toastActionError = useCallback(
|
||||
(error: unknown, fallback: string) => {
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
toast.error(message);
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const toastActionSuccess = useCallback(
|
||||
(message: string) => {
|
||||
toast.show(message, { variant: "success" });
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const handleCommit = useCallback(() => {
|
||||
setActionError(null);
|
||||
void runCommit({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to commit";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runCommit, serverId, cwd]);
|
||||
void runCommit({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Committed");
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to commit");
|
||||
});
|
||||
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePull = useCallback(() => {
|
||||
void runPull({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Pulled");
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to pull");
|
||||
});
|
||||
}, [cwd, runPull, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePush = useCallback(() => {
|
||||
setActionError(null);
|
||||
void runPush({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to push";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [runPush, serverId, cwd]);
|
||||
void runPush({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Pushed");
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to push");
|
||||
});
|
||||
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleCreatePr = useCallback(() => {
|
||||
void persistShipDefault("pr");
|
||||
setActionError(null);
|
||||
void runCreatePr({ serverId, cwd }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to create PR";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [persistShipDefault, runCreatePr, serverId, cwd]);
|
||||
void runCreatePr({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("PR created");
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to create PR");
|
||||
});
|
||||
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleMergeBranch = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
setActionError("Base ref unavailable");
|
||||
toast.error("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
setActionError(null);
|
||||
void runMergeBranch({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
toastActionSuccess("Merged");
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionError(message);
|
||||
toastActionError(err, "Failed to merge");
|
||||
});
|
||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
||||
}, [
|
||||
baseRef,
|
||||
cwd,
|
||||
persistShipDefault,
|
||||
runMergeBranch,
|
||||
serverId,
|
||||
toast,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
]);
|
||||
|
||||
const handleMergeFromBase = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
setActionError("Base ref unavailable");
|
||||
toast.error("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge from base";
|
||||
setActionError(message);
|
||||
});
|
||||
}, [baseRef, runMergeFromBase, serverId, cwd]);
|
||||
void runMergeFromBase({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
toastActionSuccess("Updated");
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to merge from base");
|
||||
});
|
||||
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const worktreePath = status?.cwd;
|
||||
if (!worktreePath) {
|
||||
setActionError("Worktree path unavailable");
|
||||
toast.error("Worktree path unavailable");
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
setActionError(message);
|
||||
toastActionError(err, "Failed to archive worktree");
|
||||
});
|
||||
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
||||
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
@@ -1041,6 +1104,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
: "Unknown";
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
|
||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||
const baseRefLabel = useMemo(() => {
|
||||
@@ -1066,6 +1130,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
(postShipArchiveSuggested || isMergedPullRequest);
|
||||
|
||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||
const pullDisabled = actionsDisabled || pullStatus === "pending";
|
||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
||||
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
||||
@@ -1172,6 +1237,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shouldPromoteArchive,
|
||||
@@ -1183,6 +1249,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handleCommit,
|
||||
},
|
||||
pull: {
|
||||
disabled: pullDisabled,
|
||||
status: pullStatus,
|
||||
icon: <Download size={16} color={theme.colors.foregroundMuted} />,
|
||||
handler: handlePull,
|
||||
},
|
||||
push: {
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
@@ -1227,6 +1299,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
@@ -1237,18 +1310,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
commitDisabled,
|
||||
pullDisabled,
|
||||
pushDisabled,
|
||||
prDisabled,
|
||||
mergeDisabled,
|
||||
mergeFromBaseDisabled,
|
||||
archiveDisabled,
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
prCreateStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleCreatePr,
|
||||
handleMergeBranch,
|
||||
@@ -1458,7 +1534,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
|
||||
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
||||
|
||||
<View style={styles.diffContainer}>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Platform,
|
||||
Text,
|
||||
View,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface HeaderToggleButtonState {
|
||||
hovered: boolean;
|
||||
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
|
||||
: undefined;
|
||||
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
||||
const ariaExpandedProps =
|
||||
Platform.OS === "web" && typeof expandedState === "boolean"
|
||||
isWeb && typeof expandedState === "boolean"
|
||||
? ({ "aria-expanded": expandedState } as any)
|
||||
: null;
|
||||
|
||||
|
||||
@@ -23,7 +23,13 @@ interface ScreenHeaderProps {
|
||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||
* and safe-area logic in one place.
|
||||
*/
|
||||
export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) {
|
||||
export function ScreenHeader({
|
||||
left,
|
||||
right,
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
borderless,
|
||||
}: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
|
||||
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
||||
const storeReady = useStoreReady();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
|
||||
if (!storeReady) {
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -24,17 +24,11 @@ const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function hasBundledEditorAppIcon(
|
||||
editorId: EditorTargetId,
|
||||
): editorId is KnownEditorTargetId {
|
||||
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
|
||||
return isKnownEditorTargetId(editorId);
|
||||
}
|
||||
|
||||
export function EditorAppIcon({
|
||||
editorId,
|
||||
size = 16,
|
||||
color,
|
||||
}: EditorAppIconProps) {
|
||||
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
|
||||
if (!hasBundledEditorAppIcon(editorId)) {
|
||||
return <SquareTerminal size={size} color={color} />;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
type Dispatch,
|
||||
type ReactElement,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
View,
|
||||
Pressable,
|
||||
Text,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
@@ -44,8 +43,8 @@ import {
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -59,6 +58,7 @@ import {
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
|
||||
@@ -69,18 +69,12 @@ interface LeftSidebarProps {
|
||||
selectedAgentId?: string;
|
||||
}
|
||||
|
||||
interface HostOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface SidebarSharedProps {
|
||||
theme: SidebarTheme;
|
||||
activeServerId: string | null;
|
||||
activeHostLabel: string;
|
||||
activeHostStatusColor: string;
|
||||
hostOptions: HostOption[];
|
||||
hostOptions: ComboboxOption[];
|
||||
hostTriggerRef: RefObject<View | null>;
|
||||
isHostPickerOpen: boolean;
|
||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -95,6 +89,12 @@ interface SidebarSharedProps {
|
||||
handleHostSelect: (nextServerId: string) => void;
|
||||
handleOpenProject: () => void;
|
||||
handleSettings: () => void;
|
||||
renderHostOption: (input: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
}
|
||||
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
@@ -124,28 +124,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeConnectionStatusSignature = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() =>
|
||||
daemons
|
||||
.map(
|
||||
(daemon) =>
|
||||
`${daemon.serverId}:${
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||
}`,
|
||||
)
|
||||
.join("|"),
|
||||
() =>
|
||||
daemons
|
||||
.map(
|
||||
(daemon) =>
|
||||
`${daemon.serverId}:${
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"
|
||||
}`,
|
||||
)
|
||||
.join("|"),
|
||||
);
|
||||
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
const activeDaemon = useMemo(() => {
|
||||
if (daemons.length === 0) {
|
||||
@@ -165,8 +143,9 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const trimmed = activeDaemon.label?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
||||
}, [activeDaemon]);
|
||||
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
|
||||
const activeHostStatus = activeServerId
|
||||
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
|
||||
? (activeHostSnapshot?.connectionStatus ?? "connecting")
|
||||
: "idle";
|
||||
const activeHostStatusColor =
|
||||
activeHostStatus === "online"
|
||||
@@ -179,11 +158,30 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
daemons.map((daemon) => ({
|
||||
id: daemon.serverId,
|
||||
label: daemon.label?.trim() || daemon.serverId,
|
||||
description: formatConnectionStatus(
|
||||
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting",
|
||||
),
|
||||
})),
|
||||
[daemons, runtime, runtimeConnectionStatusSignature],
|
||||
[daemons],
|
||||
);
|
||||
const renderHostOption = useCallback(
|
||||
({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<HostSwitchOption
|
||||
serverId={option.id}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
const hostTriggerRef = useRef<View | null>(null);
|
||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||
@@ -194,11 +192,8 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
serverId: activeServerId,
|
||||
enabled: isOpen,
|
||||
});
|
||||
const {
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
} = useSidebarShortcutModel(projects);
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel(projects);
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
@@ -276,6 +271,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
};
|
||||
|
||||
if (isCompactLayout) {
|
||||
@@ -305,6 +301,33 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
);
|
||||
});
|
||||
|
||||
function HostSwitchOption({
|
||||
serverId,
|
||||
label,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
serverId: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
description={formatConnectionStatus(connectionStatus)}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsButton({ onPress }: { onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const pathname = usePathname();
|
||||
@@ -361,6 +384,7 @@ function MobileSidebar({
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
handleOpenProject,
|
||||
handleSettings,
|
||||
insetsTop,
|
||||
@@ -503,7 +527,7 @@ function MobileSidebar({
|
||||
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
|
||||
}));
|
||||
|
||||
const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none";
|
||||
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||
@@ -511,7 +535,12 @@ function MobileSidebar({
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View
|
||||
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||
style={[
|
||||
staticStyles.mobileSidebar,
|
||||
mobileSidebarInsetStyle,
|
||||
sidebarAnimatedStyle,
|
||||
{ backgroundColor: theme.colors.surfaceSidebar },
|
||||
]}
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -528,7 +557,6 @@ function MobileSidebar({
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
@@ -606,6 +634,7 @@ function MobileSidebar({
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleHostSelect}
|
||||
renderOption={renderHostOption}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
@@ -639,6 +668,7 @@ function DesktopSidebar({
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
handleOpenProject,
|
||||
handleSettings,
|
||||
insetsTop,
|
||||
@@ -695,115 +725,116 @@ function DesktopSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||
<Animated.View
|
||||
style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}
|
||||
>
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||
<View style={styles.sidebarHeader}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||
<View style={styles.sidebarHeader}>
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SessionsButton onPress={handleViewMore} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
/>
|
||||
)}
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
<SidebarWorkspaceList
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View style={hostStatusDotStyle} />
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View style={styles.sidebarFooter}>
|
||||
<View style={styles.footerHostSlot}>
|
||||
<Pressable
|
||||
ref={hostTriggerRef}
|
||||
style={({ hovered = false }) => [
|
||||
styles.hostTrigger,
|
||||
hovered && styles.hostTriggerHovered,
|
||||
]}
|
||||
onPress={() => setIsHostPickerOpen(true)}
|
||||
disabled={hostOptions.length === 0}
|
||||
>
|
||||
<View style={hostStatusDotStyle} />
|
||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||
{activeHostLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProject}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettings}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleHostSelect}
|
||||
renderOption={renderHostOption}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProject}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
nativeID="sidebar-settings"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
onPress={handleSettings}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Settings
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<Combobox
|
||||
options={hostOptions}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleHostSelect}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
anchorRef={hostTriggerRef}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Resize handle - absolutely positioned over right border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||
/>
|
||||
</GestureDetector>
|
||||
{/* Resize handle - absolutely positioned over right border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
@@ -1,128 +1,128 @@
|
||||
// Auto-generated from material-icon-theme. Do not edit manually.
|
||||
|
||||
const SVG_ICONS: Record<string, string> = {
|
||||
"_default": `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
|
||||
"astro": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>`,
|
||||
"c": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>`,
|
||||
"clojure": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>`,
|
||||
"console": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>`,
|
||||
"cpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
|
||||
"csharp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
|
||||
"css": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>`,
|
||||
"dart": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>`,
|
||||
"database": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>`,
|
||||
"document": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>`,
|
||||
"elixir": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>`,
|
||||
"erlang": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>`,
|
||||
"go": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00acc1" d="M2 12h4v2H2zm-2 4h6v2H0zm4 4h2v2H4zm16.954-5H14v3h3.239a4.42 4.42 0 0 1-3.531 2 2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 15.292 13a2.73 2.73 0 0 1 1.749.584l2.962-1.185A5.6 5.6 0 0 0 15.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 6.4 6.4 0 0 0 .003-1.5"/><path fill="#00acc1" d="M26.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 5.614 5.614 0 0 0-5.659-6.5m2.681 6.137A4.515 4.515 0 0 1 24.708 20a2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 26.292 13a2.65 2.65 0 0 1 2.053.858 2.86 2.86 0 0 1 .628 2.28Z"/></svg>`,
|
||||
"gradle": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0097a7" d="M16 10v2h6c-2 0-3-2-6-2"/><path fill="#0097a7" d="M26 4h-2a4 4 0 0 0-4 4h4a1 1 0 0 1 2 0v4H16v-2h-5.317A2.683 2.683 0 0 0 8 12.683v2.634A2.683 2.683 0 0 0 10.683 18H16v2h-5.98A4.02 4.02 0 0 1 6 16v-2c-2 0-4 4-4 8 0 5 1 6 2 6h4v-4h4v4h4v-4h4v4h4v-6a2 2 0 0 0 2-2v-2a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4m-4 12h-2a2 2 0 0 1-2-2h6a2 2 0 0 1-2 2"/></svg>`,
|
||||
"graphql": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M6 20h20v2H6z"/><circle cx="7" cy="21" r="3" fill="#ec407a"/><circle cx="16" cy="27" r="3" fill="#ec407a"/><circle cx="25" cy="21" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 10h20v2H6z"/><circle cx="7" cy="11" r="3" fill="#ec407a"/><circle cx="16" cy="5" r="3" fill="#ec407a"/><circle cx="25" cy="11" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 12h2v10H6zm18-2h2v12h-2z"/><path fill="#ec407a" d="m5.014 19.41 11.674 6.866L15.674 28 4 21.134z"/><path fill="#ec407a" d="M26.688 21.724 15.014 28.59 14 26.866 25.674 20zM5.124 10.382l11.415-7.29 1.077 1.686L6.2 12.068z"/><path fill="#ec407a" d="m25.798 12.067-11.415-7.29 1.077-1.685 11.415 7.29zM6.2 19.932l11.416 7.29-1.077 1.686-11.415-7.29z"/><path fill="#ec407a" d="m26.875 21.619-11.415 7.29-1.077-1.687 11.415-7.289zM5.877 22.6 16.04 3.686l1.762.946L7.638 23.546z"/><path fill="#ec407a" d="M24.361 23.545 14.197 4.633l1.761-.947 10.165 18.913z"/></svg>`,
|
||||
"groovy": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#26c6da" d="M19.322 2a6.5 6.5 0 0 1 4.352 1.419 4.55 4.55 0 0 1 1.685 3.662 5.82 5.82 0 0 1-1.886 4.275 6.04 6.04 0 0 1-4.34 1.846 4.15 4.15 0 0 1-2.385-.649 1.91 1.91 0 0 1-.936-1.603 1.6 1.6 0 0 1 .356-1.024 1.1 1.1 0 0 1 .861-.447q.469 0 .468.504a.79.79 0 0 0 .358.693 1.43 1.43 0 0 0 .826.245 3.1 3.1 0 0 0 2.39-1.573 5.66 5.66 0 0 0 1.154-3.39 2.64 2.64 0 0 0-.891-2.064 3.28 3.28 0 0 0-2.293-.812 6.18 6.18 0 0 0-4.086 1.736 12.9 12.9 0 0 0-3.215 4.557 13.4 13.4 0 0 0-1.233 5.36 5.86 5.86 0 0 0 1.091 3.723 3.53 3.53 0 0 0 2.905 1.372q3.058 0 5.848-4.002l2.935-.388q.546-.07.545.246a8 8 0 0 1-.423 1.24q-.421 1.097-1.152 3.668A12.7 12.7 0 0 0 26 17.72v1.66a14.2 14.2 0 0 1-4.055 2.57 10.38 10.38 0 0 1-2.764 5.931 6.7 6.7 0 0 1-4.806 2.11 3.3 3.3 0 0 1-2.012-.55 1.8 1.8 0 0 1-.718-1.514q0-2.685 5.634-5.212.532-1.766 1.152-3.507a8.6 8.6 0 0 1-2.853 2.323 7.4 7.4 0 0 1-3.48 1.01 5.46 5.46 0 0 1-4.366-2.093A8.1 8.1 0 0 1 6 15.122a11.6 11.6 0 0 1 1.966-6.426 14.7 14.7 0 0 1 5.162-4.862A12.44 12.44 0 0 1 19.322 2m-2.407 22.17q-4.055 1.875-4.054 3.695a.87.87 0 0 0 .999.97q1.964 0 3.055-4.665"/></svg>`,
|
||||
"h": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M18.5 11a5.49 5.49 0 0 0-4.5 2.344V4H8v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
|
||||
"haskell": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g stroke-width="2.422"><path fill="#ef5350" d="m23.928 240.5 59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z"/><path fill="#ffa726" d="m83.869 240.5 59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z"/><path fill="#ffee58" d="m228.72 188.08-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924-19.98-29.953h99.901v29.953z"/></g></svg>`,
|
||||
"hcl": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#eceff1" d="M18 1.2V14h-4v-4l-4 2v16.37l4 2.43V18h4v4l4-2V3.63z"/><path fill="#eceff1" d="M14 1.2 2 8.49v15.02l4 2.43v-15.2l8-4.86zm12 4.86v15.2l-8 4.86v4.68l12-7.29V8.49z"/></svg>`,
|
||||
"hpp": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 6V2h-2v4h-6V2h-2v4h-4v2h4v4h2V8h6v4h2V8h4V6zm-15.5 5A5.49 5.49 0 0 0 8 13.344V4H2v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
|
||||
"html": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#e65100" d="m4 4 2 22 10 2 10-2 2-22Zm19.72 7H11.28l.29 3h11.86l-.802 9.335L15.99 25l-6.635-1.646L8.93 19h3.02l.19 2 3.86.77 3.84-.77.29-4H8.84L8 8h16Z"/></svg>`,
|
||||
"image": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M8.5 6h4l-4-4zM3.875 1H9.5l4 4v8.6c0 .773-.616 1.4-1.375 1.4h-8.25c-.76 0-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4 1.375-1.4M4 13.6h8V8l-2.625 2.8L8 9.4zm1.25-7.7c-.76 0-1.375.627-1.375 1.4s.616 1.4 1.375 1.4c.76 0 1.375-.627 1.375-1.4S6.009 5.9 5.25 5.9"/></svg>`,
|
||||
"java": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="M4 26h24v2H4zM28 4H7a1 1 0 0 0-1 1v13a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-4h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 8h-4V6h4Z"/></svg>`,
|
||||
"javascript": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2 2v12h12V2zm6 6h1v4a1.003 1.003 0 0 1-1 1H7a1.003 1.003 0 0 1-1-1v-1h1v1h1zm3 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
|
||||
"json": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>`,
|
||||
"kotlin": `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><defs><linearGradient id="a" x1="1.725" x2="22.185" y1="22.67" y2="1.982" gradientTransform="translate(1.306 1.129)scale(.89324)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7c4dff"/><stop offset=".5" stop-color="#d500f9"/><stop offset="1" stop-color="#ef5350"/></linearGradient></defs><path fill="url(#a)" d="M2.975 2.976v18.048h18.05v-.03l-4.478-4.511-4.48-4.515 4.48-4.515 4.443-4.477z"/></svg>`,
|
||||
"less": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0277bd" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3z"/></svg>`,
|
||||
"lock": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffd54f" d="M25 12h-3V8a6 6 0 0 0-12 0v4H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V13a1 1 0 0 0-1-1M14 8a2 2 0 0 1 4 0v4h-4Zm2 17a4 4 0 1 1 4-4 4 4 0 0 1-4 4"/></svg>`,
|
||||
"lua": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="M30 6a3.86 3.86 0 0 1-1.167 2.833 4.024 4.024 0 0 1-5.666 0A3.86 3.86 0 0 1 22 6a3.86 3.86 0 0 1 1.167-2.833 4.024 4.024 0 0 1 5.666 0A3.86 3.86 0 0 1 30 6m-9.208 5.208A10.6 10.6 0 0 0 13 8a10.6 10.6 0 0 0-7.792 3.208A10.6 10.6 0 0 0 2 19a10.6 10.6 0 0 0 3.208 7.792A10.6 10.6 0 0 0 13 30a10.6 10.6 0 0 0 7.792-3.208A10.6 10.6 0 0 0 24 19a10.6 10.6 0 0 0-3.208-7.792m-1.959 7.625a4.024 4.024 0 0 1-5.666 0 4.024 4.024 0 0 1 0-5.666 4.024 4.024 0 0 1 5.666 0 4.024 4.024 0 0 1 0 5.666"/></svg>`,
|
||||
"markdown": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"/></svg>`,
|
||||
"nix": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g stroke-width=".395"><path fill="#1976d2" d="M133.347 451.499c0-.295-2.752-5.283-6.116-11.084s-6.116-10.776-6.116-11.055 9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694s-11.836.21-26.302.154c-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.364.96h-13.23c-10.895 0-13.228-.095-13.228-.538zm167.58-125.61c-.134-.216 1.189-2.863 2.939-5.882 6.924-11.944 84.29-145.75 96.49-166.88 7.143-12.371 13.143-22.465 13.334-22.433.362.062 25.86 43.105 25.86 43.655 0 .174-6.761 11.952-15.025 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.563.564 30.213.564h29.996l.957 1.48c.527.814 3.296 5.547 6.155 10.518s5.45 9.29 5.757 9.597c.705.705.703.724-.16 1.572-.396.388-3.36 5.323-6.588 10.965-3.228 5.643-6.056 10.387-6.285 10.543s-19.695.171-43.256.034l-42.84-.249-.803 1.15c-.442.632-7.505 12.736-15.696 26.897l-14.892 25.747h-15.486c-8.518 0-20.015.116-25.551.259-6.55.168-10.15.121-10.308-.135zm-133.75-157.86c-56.373-.055-102.5-.182-102.5-.282s5.617-10.132 12.481-22.294L89.64 123.34h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427s-14.797-25.917-14.797-26.04 2.682-4.853 5.96-10.51 6.003-10.578 6.056-10.934c.086-.586 1.375-.648 13.572-.648 7.412 0 13.463.143 13.446.317-.018.174.22.707.53 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.117.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z"/><path fill="#64b5f6" d="M23.046 238.939c-6.098 10.563-6.69 11.711-6.224 12.078.282.224 3.18 5.044 6.44 10.712s6.016 10.355 6.123 10.417c.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.473.273-1.08 3.094-14.637 26.574l-15.166 26.269 12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956s23.257-39.831 51.492-88.624c11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.096-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.023.037c-.012.003-1.622-.001-1.826 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.567.03-1.147-.03-1.29-.132-.141-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.2 21.131 89.393 155.03 96.276 167 1.503 2.613 2.92 4.803 3.443 5.348.9-1.249 3.532-5.63 7.954-13.219a1343 1343 0 0 1 10.05-17.76l6.606-11.443c.691-1.403.753-1.818.652-2.117-.161-.48-6.903-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.99-26.32a.73.73 0 0 1-.01-.366l-.426-.913 21.636-36.976c3.69-6.307 6.425-11.042 9.471-16.29 9.158-15.948 12.036-21.189 11.895-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.144.035-.42.112-5.455.195-11.19.185s-11.22.024-12.187.073l-1.76.089-14.998 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.271.05-.256-.148-7.144-11.902-15.306-26.119L279.4 48.817c-.116-.186-.444-.744-.458-.752-.476-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.043.48.328 1.05 25.338 43.735 25.772 43.985.206.119 14.178.239 31.05.266 26.65.044 30.749.152 31.234.832.307.43 9.987 17.214 21.513 37.296s21.152 36.627 21.394 36.767 5.926.243 12.633.23c6.705-.013 12.4.099 12.657.246.131.076.381-.141.851-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.44-9.774-.196-.712-.066-.822 1.155-.98 1.956-.252 57.397-.057 58.071.205.237.092.79-.569 2.593-3.497 1.866-3.067 5.03-8.524 11.001-18.866 7.22-12.505 13.043-22.784 12.941-22.843s-.77-.051-1.489.016l-.046.001c-4.451.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.12.01-2.095.076-2.66.172a.3.3 0 0 0-.062.083z"/></g></svg>`,
|
||||
"ocaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m12.019 15.021.003-.008c-.005-.021-.006-.026-.003.008"/><path fill="#ff9800" d="M4.51 3.273a2.523 2.523 0 0 0-2.524 2.523V11.3c.361-.13.88-.898 1.043-1.085.285-.327.337-.743.478-1.006C3.83 8.612 3.886 8.2 4.62 8.2c.342 0 .478.08.71.39.16.216.438.615.568.882.15.307.396.724.503.808q.122.095.233.137c.119.044.218-.037.297-.1.102-.082.145-.247.24-.467.135-.317.283-.697.367-.83.146-.23.195-.501.352-.633.232-.195.535-.208.618-.225.466-.092.677.225.907.43.15.133.355.403.5.765.114.283.26.544.32.707.059.158.203.41.289.713.077.275.286.486.365.616 0 0 .121.34.858.65.16.067.482.176.674.246.32.116.63.101 1.025.054.281 0 .434-.408.562-.734.075-.193.148-.745.197-.902.048-.153-.064-.27.031-.405.112-.156.178-.164.242-.368.138-.436.936-.458 1.384-.458.374 0 .327.363.96.239.364-.072.714.046 1.1.149.324.086.63.184.812.398.119.139.412.834.113.863.029.035.05.099.104.134-.067.262-.357.075-.518.041-.217-.045-.37.007-.583.101-.363.162-.894.143-1.21.407-.27.223-.269.721-.394 1 0 0-.348.895-1.106 1.443-.194.14-.574.477-1.4.605a5.3 5.3 0 0 1-1.1.043c-.186-.009-.362-.018-.549-.02-.11-.002-.48-.013-.461.022l-.041.103.024.138c.015.083.019.149.022.225.006.157-.013.32-.005.478.017.328.138.627.154.958.017.368.199.758.375 1.059.067.114.169.128.213.269.052.161.003.333.028.505.1.668.292 1.366.592 1.97l.008.014c.371-.062.743-.196 1.226-.267.885-.132 2.115-.064 2.906-.138 2-.188 3.085.82 4.882.407V5.796a2.523 2.523 0 0 0-2.523-2.523zm-.907 11.144q-.022 0-.046.003c-.159.025-.313.08-.412.24-.08.13-.108.355-.164.505-.064.175-.176.338-.274.505-.18.305-.504.581-.644.879-.028.06-.053.13-.076.2v3.402c.163.028.333.062.524.113 1.407.375 1.75.407 3.13.25l.13-.018c.105-.22.187-.968.255-1.2.054-.178.127-.32.155-.5.026-.173-.003-.337-.017-.493-.04-.393.285-.533.44-.87.14-.304.22-.651.336-.963.11-.298.284-.721.579-.872-.036-.041-.617-.06-.772-.076a5 5 0 0 1-.5-.07c-.314-.064-.656-.126-.965-.2a10 10 0 0 1-.947-.328c-.298-.138-.503-.497-.732-.507m5.737.83c-.74.149-.97.876-1.32 1.451-.192.319-.396.59-.548.928-.14.312-.128.657-.368.924a2.55 2.55 0 0 0-.528.922c-.023.067-.088.776-.158.943l1.101-.078c1.026.07.73.464 2.332.378l2.529-.078a7 7 0 0 0-.228-.588c-.07-.147-.16-.434-.218-.56a3.5 3.5 0 0 0-.309-.526c-.184-.215-.227-.23-.28-.503-.095-.473-.344-1.33-.637-1.923-.151-.306-.403-.562-.634-.784-.2-.195-.655-.522-.734-.505z"/></svg>`,
|
||||
"php": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09q-.42.33-1.29.33h-.87l.53-2.76zm-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59zm7.25-8.33-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18s.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35zM18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76zm-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75z"/></svg>`,
|
||||
"python": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"/><path fill="#fdd835" d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"/></svg>`,
|
||||
"r": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1976d2" d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1 1 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z"/></svg>`,
|
||||
"react": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00bcd4" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#00bcd4" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#00bcd4" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#00bcd4" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
|
||||
"react_ts": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#0288d1" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#0288d1" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#0288d1" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
|
||||
"ruby": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f44336" d="M18.041 3.177c2.24.382 2.879 1.919 2.843 3.527V6.67l-1.013 13.266-13.132.897h.008c-1.093-.044-3.518-.151-3.634-3.545l1.217-2.222 2.462 5.74 2.097-6.77-.045.009.018-.018 6.85 2.186L13.945 9.3l6.53-.409-5.144-4.212 2.71-1.51v.009M3.113 17.252v.017zM6.916 6.874c2.63-2.622 6.033-4.168 7.34-2.844 1.297 1.306-.072 4.523-2.702 7.135-2.666 2.613-6.015 4.248-7.322 2.933-1.306-1.324.036-4.612 2.675-7.224z"/></svg>`,
|
||||
"rust": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff7043" d="m30 12-4-2V6h-4l-2-4-4 2-4-2-2 4H6v4l-4 2 2 4-2 4 4 2v4h4l2 4 4-2 4 2 2-4h4v-4l4-2-2-4ZM6 16a9.9 9.9 0 0 1 .842-4H10v8H6.842A9.9 9.9 0 0 1 6 16m10 10a9.98 9.98 0 0 1-7.978-4H16v-2h-2v-2h4c.819.819.297 2.308 1.179 3.37a1.89 1.89 0 0 0 1.46.63h3.34A9.98 9.98 0 0 1 16 26m-2-12v-2h4a1 1 0 0 1 0 2Zm11.158 6H24a2.006 2.006 0 0 1-2-2 2 2 0 0 0-2-2 3 3 0 0 0 3-3q0-.08-.004-.161A3.115 3.115 0 0 0 19.83 10H8.022a9.986 9.986 0 0 1 17.136 10"/></svg>`,
|
||||
"sass": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M27.837 5.673a4.33 4.33 0 0 0-2.293-2.701c-2.362-1.261-6.11-1.298-9.548-.092a26.3 26.3 0 0 0-8.76 4.966c-2.752 2.542-3.438 4.925-3.189 6.194.523 2.668 3.274 4.539 5.485 6.042.418.284.822.559 1.175.816-1.429.76-4.261 2.444-5.088 4.248a3.88 3.88 0 0 0-.118 3.332A2.37 2.37 0 0 0 6.869 29.8a5.6 5.6 0 0 0 1.49.2 6.35 6.35 0 0 0 5.19-2.856 6.74 6.74 0 0 0 .864-5.382 7.3 7.3 0 0 1 2.044-.03 3.92 3.92 0 0 1 2.816 1.311 1.82 1.82 0 0 1 .423 1.262 1.55 1.55 0 0 1-.772 1.05c-.234.14-.586.355-.504.803.036.194.198.633.894.512a2.93 2.93 0 0 0 2.145-2.651 4 4 0 0 0-1.197-2.904 5.94 5.94 0 0 0-4.396-1.626 10.6 10.6 0 0 0-2.672.304 20 20 0 0 0-2.203-1.846c-1.712-1.3-3.33-2.529-3.235-4.26.125-2.263 2.468-4.532 6.964-6.744 4.016-1.976 7.254-2.037 8.944-1.438a2 2 0 0 1 1.204.883 2.77 2.77 0 0 1-.36 2.47 9.71 9.71 0 0 1-7.425 4.304 3.86 3.86 0 0 1-3.238-.757c-.278-.302-.593-.645-1.074-.383q-.565.31-.225 1.189a3.9 3.9 0 0 0 2.407 1.92 11.7 11.7 0 0 0 7.128-.671c3.527-1.35 6.681-5.202 5.756-8.787M11.895 24.475a4 4 0 0 1-.192.468 4.5 4.5 0 0 1-.753 1.081 2.83 2.83 0 0 1-2.533 1.107c-.056-.032-.078-.146-.085-.193a3.28 3.28 0 0 1 1.076-2.284 11.3 11.3 0 0 1 2.644-1.933 3.85 3.85 0 0 1-.157 1.754"/></svg>`,
|
||||
"scala": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="m6.457 9.894 12.523 5.163-.456 1.211L6 11.105Zm7.02-3.091L26 11.966l-.457 1.21L13.02 8.015ZM6.465 18.885l12.524 5.163-.457 1.21L6.01 20.097Zm7.007-3.086 12.524 5.163-.456 1.21-12.524-5.162Z"/><path fill="#f44336" d="M6 24.07V30l19.997-3.106V20.96zM6 5.11v5.99l20-3.11V2zm0 9.96v5.03l20-3.11v-5.03z"/></svg>`,
|
||||
"settings": `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.6.6 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1q.09.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64zm-1.98-1.71c.04.31.05.52.05.73s-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2"/></svg>`,
|
||||
"svelte": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><path fill="#ff5722" d="M175.94 24.328c-13.037.252-26.009 3.872-37.471 11.174L79.912 72.818a67.13 67.13 0 0 0-30.355 44.906 70.8 70.8 0 0 0 6.959 45.445 67.2 67.2 0 0 0-10.035 25.102 71.54 71.54 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.36 67.36 0 0 0 30.355-44.906 70.77 70.77 0 0 0-6.982-45.422 67.65 67.65 0 0 0 10.059-25.102 71.63 71.63 0 0 0-12.236-54.156v-.18c-15.324-21.925-40.453-33.727-65.342-33.246zm5.137 28.68a46.5 46.5 0 0 1 36.09 19.969 42.98 42.98 0 0 1 7.365 32.557 45 45 0 0 1-1.393 5.455l-1.123 3.37-2.986-2.247a75.9 75.9 0 0 0-22.902-11.45l-2.244-.651.201-2.246a13.16 13.16 0 0 0-2.379-8.711 13.99 13.99 0 0 0-14.953-5.412 12.8 12.8 0 0 0-3.594 1.572l-58.578 37.25a12.24 12.24 0 0 0-5.502 8.15 13.1 13.1 0 0 0 2.246 9.834 14.03 14.03 0 0 0 14.93 5.569 13.5 13.5 0 0 0 3.594-1.573l22.453-14.234a41.8 41.8 0 0 1 11.898-5.232 46.48 46.48 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.363 32.557 40.42 40.42 0 0 1-18.254 27.078l-58.58 37.316a43 43 0 0 1-11.898 5.23A46.545 46.545 0 0 1 82.81 227.14a42.98 42.98 0 0 1-7.341-32.557 38 38 0 0 1 1.39-5.41l1.102-3.37 3.008 2.246a75.9 75.9 0 0 0 22.836 11.361l2.244.65-.201 2.247a13.25 13.25 0 0 0 2.447 8.644 14.03 14.03 0 0 0 15.043 5.569 13.1 13.1 0 0 0 3.592-1.573l58.467-37.316a12.17 12.17 0 0 0 5.502-8.173 12.96 12.96 0 0 0-2.246-9.811 14.03 14.03 0 0 0-15.043-5.568 12.8 12.8 0 0 0-3.592 1.57l-22.453 14.258a42.9 42.9 0 0 1-11.877 5.209 46.52 46.52 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.297-32.557A40.42 40.42 0 0 1 96.798 96.98l58.646-37.316a42.8 42.8 0 0 1 11.811-5.21 46.5 46.5 0 0 1 13.822-1.444z"/></svg>`,
|
||||
"svg": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffb300" d="M29.168 14.03a2.7 2.7 0 0 0-1.968-.83 2.51 2.51 0 0 0-1.929.8h-4.443l3.078-3.078a2.835 2.835 0 0 0 2.857-2.842 2.6 2.6 0 0 0-.831-1.969 2.82 2.82 0 0 0-2.014-.788 2.67 2.67 0 0 0-1.968.788 2.36 2.36 0 0 0-.812 1.922L18 11.17V6.726a2.51 2.51 0 0 0 .8-1.929 2.7 2.7 0 0 0-.832-1.968 2.745 2.745 0 0 0-3.936 0 2.7 2.7 0 0 0-.832 1.968 2.51 2.51 0 0 0 .8 1.93v4.443l-3.138-3.138a2.36 2.36 0 0 0-.812-1.922 2.66 2.66 0 0 0-1.968-.788 2.83 2.83 0 0 0-2.014.788 2.6 2.6 0 0 0-.831 1.969 2.74 2.74 0 0 0 .831 2.013 2.8 2.8 0 0 0 2.026.829l3.078 3.078H6.729a2.51 2.51 0 0 0-1.929-.8 2.7 2.7 0 0 0-1.968.831 2.745 2.745 0 0 0 0 3.937 2.7 2.7 0 0 0 1.968.832 2.51 2.51 0 0 0 1.929-.8h4.443l-3.078 3.077a2.835 2.835 0 0 0-2.857 2.842 2.6 2.6 0 0 0 .831 1.969 2.82 2.82 0 0 0 2.014.788 2.67 2.67 0 0 0 1.968-.788 2.36 2.36 0 0 0 .812-1.922L14 20.827v4.444a2.51 2.51 0 0 0-.8 1.929 2.784 2.784 0 0 0 4.768 1.968A2.7 2.7 0 0 0 18.8 27.2a2.51 2.51 0 0 0-.8-1.929v-4.444l3.138 3.138a2.36 2.36 0 0 0 .812 1.922 2.66 2.66 0 0 0 1.968.788 2.83 2.83 0 0 0 2.014-.788 2.6 2.6 0 0 0 .831-1.969 2.74 2.74 0 0 0-.831-2.013 2.8 2.8 0 0 0-2.026-.829L20.828 18h4.443a2.51 2.51 0 0 0 1.93.8 2.784 2.784 0 0 0 1.967-4.769Z"/></svg>`,
|
||||
"swift": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff6e40" d="M17.087 19.721c-2.36 1.36-5.59 1.5-8.86.1a13.8 13.8 0 0 1-6.23-5.32c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17"/></svg>`,
|
||||
"terraform": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#5c6bc0" d="m2 10 8 4V6L2 2zm10 5 8 4v-8l-8-4zm0 11 8 4v-8l-8-4zm10-14v8l8-4V8z"/></svg>`,
|
||||
"toml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cfd8dc" d="M4 6V4h8v2H9v7H7V6z"/><path fill="#ef5350" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z"/></svg>`,
|
||||
"typescript": `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 16 16"><path fill="#0288d1" d="M2 2v12h12V2zm4 6h3v1H8v4H7V9H6zm5 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
|
||||
"vue": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#41b883" d="M1.791 3.851 12 21.471 22.209 3.936V3.85H18.24l-6.18 10.616L5.906 3.851z"/><path fill="#35495e" d="m5.907 3.851 6.152 10.617L18.24 3.851h-3.723L12.084 8.03 9.66 3.85z"/></svg>`,
|
||||
"webassembly": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M22 18h4v4h-4z"/><path fill="#7c4dff" d="M20 2a4 4 0 0 1-8 0H2v28h28V2Zm-2 24h-2v2h-4v-2h-2v2H6v-2H4V16h2v10h4V16h2v10h4V16h2Zm10 2h-2v-4h-4v4h-2V18h2v-2h4v2h2Z"/></svg>`,
|
||||
"xml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8bc34a" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5 3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41zm11.16 0-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41z"/></svg>`,
|
||||
"yaml": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff5252" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2m12 16v-2H9v2zm-4-4v-2H6v2z"/></svg>`,
|
||||
"zig": `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f9a825" d="M2 8h6v4H2zm8 0h12v4H10zm0 12h12v4H10zm14 0h2v4h-2zM8 20l-3 4H2V12h4v8zm14-8h-6l-6 8h6z"/><path fill="#f9a825" d="M16 20h-6l-6 8m12-16h6l6-8m2 4v16h-4V12h-2l3-4z"/></svg>`,
|
||||
_default: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
|
||||
astro: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M12.106 25.849c-1.262-1.156-1.63-3.586-1.105-5.346a5.18 5.18 0 0 0 3.484 1.66 9.68 9.68 0 0 0 5.882-.734c.215-.106.413-.247.648-.39a3.5 3.5 0 0 1 .16 1.555 4.26 4.26 0 0 1-1.798 3.021c-.404.3-.832.569-1.25.852a2.613 2.613 0 0 0-1.15 3.372l.048.161a3.4 3.4 0 0 1-1.5-1.285 3.6 3.6 0 0 1-.578-1.962 9 9 0 0 0-.05-1.037c-.114-.831-.504-1.204-1.238-1.225a1.45 1.45 0 0 0-1.507 1.18c-.012.056-.028.112-.046.178M4.901 20a17.75 17.75 0 0 1 7.4-2l2.913-8.38a.765.765 0 0 1 1.527 0L19.7 18a14.24 14.24 0 0 1 7.399 2S20.704 2.877 20.692 2.842C20.51 2.33 20.202 2 19.787 2h-7.619c-.415 0-.71.33-.904.842z"/></svg>`,
|
||||
c: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M19.563 22A5.57 5.57 0 0 1 14 16.437v-2.873A5.57 5.57 0 0 1 19.563 8H24V2h-4.437A11.563 11.563 0 0 0 8 13.563v2.873A11.564 11.564 0 0 0 19.563 28H24v-6Z"/></svg>`,
|
||||
clojure: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><path fill="#64dd17" d="M123.456 129.975a507 507 0 0 0-3.54 7.846c-4.406 9.981-9.284 22.127-11.066 29.908-.64 2.77-1.037 6.205-1.03 10.013 0 1.506.081 3.09.21 4.702a58.1 58.1 0 0 0 19.98 3.559 58.2 58.2 0 0 0 18.29-2.98c-1.352-1.237-2.642-2.554-3.816-4.038-7.796-9.942-12.146-24.512-19.028-49.01m-28.784-49.39C79.782 91.08 70.039 108.387 70.002 128c.037 19.32 9.487 36.403 24.002 46.94 3.56-14.83 12.485-28.41 25.868-55.63a219 219 0 0 0-2.714-7.083c-3.708-9.3-9.059-20.102-13.834-24.993-2.435-2.555-5.389-4.763-8.652-6.648"/><path fill="#7cb342" d="M178.532 194.535c-7.683-.963-14.023-2.124-19.57-4.081a69.4 69.4 0 0 1-30.958 7.249c-38.491 0-69.693-31.198-69.698-69.7 0-20.891 9.203-39.62 23.764-52.392-3.895-.94-7.956-1.49-12.104-1.482-20.45.193-42.037 11.51-51.025 42.075-.84 4.45-.64 7.813-.64 11.8 0 60.591 49.12 109.715 109.705 109.715 37.104 0 69.882-18.437 89.732-46.633-10.736 2.675-21.06 3.955-29.902 3.982-3.314 0-6.425-.177-9.305-.53"/><path fill="#29b6f6" d="M157.922 173.271c.678.336 2.213.884 4.35 1.49 14.375-10.553 23.717-27.552 23.754-46.764h-.005c-.055-32.03-25.974-57.945-58.011-58.009a58.2 58.2 0 0 0-18.213 2.961c11.779 13.426 17.443 32.613 22.922 53.6l.01.025c.01.017 1.752 5.828 4.743 13.538 2.97 7.7 7.203 17.231 11.818 24.178 3.03 4.655 6.363 8 8.632 8.981"/><path fill="#1e88e5" d="M128.009 18.29c-36.746 0-69.25 18.089-89.16 45.826 10.361-6.49 20.941-8.83 30.174-8.747 12.753.037 22.779 3.991 27.589 6.696a51 51 0 0 1 3.345 2.131 69.4 69.4 0 0 1 28.049-5.894c38.496.004 69.703 31.202 69.709 69.698h-.006c0 19.409-7.938 36.957-20.736 49.594 3.142.352 6.492.571 9.912.554 12.15.006 25.284-2.675 35.13-10.956 6.42-5.408 11.798-13.327 14.78-25.199.584-4.586.92-9.247.92-13.991 0-60.588-49.116-109.715-109.705-109.715"/></svg>`,
|
||||
console: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff7043" d="M2 2a1 1 0 0 0-1 1v10c0 .554.446 1 1 1h12c.554 0 1-.446 1-1V3a1 1 0 0 0-1-1zm0 3h12v8H2zm1 2 2 2-2 2 1 1 3-3-3-3zm5 3.5V12h5v-1.5z"/></svg>`,
|
||||
cpp: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 14v-4h-2v4h-6v-4h-2v4h-4v2h4v4h2v-4h6v4h2v-4h4v-2z"/><path fill="#0288d1" d="M13.563 22A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
|
||||
csharp: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M30 14v-2h-2V8h-2v4h-2V8h-2v4h-2v2h2v2h-2v2h2v4h2v-4h2v4h2v-4h2v-2h-2v-2Zm-4 2h-2v-2h2Zm-12.437 6A5.57 5.57 0 0 1 8 16.437v-2.873A5.57 5.57 0 0 1 13.563 8H18V2h-4.437A11.563 11.563 0 0 0 2 13.563v2.873A11.564 11.564 0 0 0 13.563 28H18v-6Z"/></svg>`,
|
||||
css: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7e57c2" d="M20 18h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 20 22h2v2h2v-2c0-.388-.562-.851-1.254-1.034C20.356 20.34 20 18.84 20 18m-3.254 2.966C14.356 20.34 14 18.84 14 18h-2v-2h-2v8h2v-2h4v2h2v-2c0-.388-.562-.851-1.254-1.034"/><path fill="#7e57c2" d="M24 4H4v20a4 4 0 0 0 4 4h16.16A3.84 3.84 0 0 0 28 24.16V8a4 4 0 0 0-4-4m2 14h-2v-2h-2v2c0 .193 0 .703 1.254 1.033A3.345 3.345 0 0 1 26 22v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2 2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2 2 2 0 0 1 2-2h2a2 2 0 0 1 2 2Z"/></svg>`,
|
||||
dart: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#4fc3f7" d="M16.83 2a1.3 1.3 0 0 0-.916.377l-.013.01L7.323 7.34l8.556 8.55v.005l10.283 10.277 1.96-3.529-7.068-16.96-3.299-3.297A1.3 1.3 0 0 0 16.828 2Z"/><path fill="#01579b" d="m7.343 7.32-4.955 8.565-.01.013a1.297 1.297 0 0 0 .004 1.835l.005.005 4.106 4.107 16.064 6.314 3.632-2.015-.098-.098-.025.002L15.995 15.97h-.012z"/><path fill="#01579b" d="m7.321 7.324 8.753 8.755h.013L26.16 26.156l3.835-.73L30 14.089l-4.049-3.965a6.5 6.5 0 0 0-3.618-1.612l.002-.043L7.323 7.325Z"/><path fill="#64b5f6" d="m7.332 7.335 8.758 8.75v.013l10.079 10.071L25.436 30H14.09l-3.967-4.048a6.5 6.5 0 0 1-1.611-3.618l-.045.004Z"/></svg>`,
|
||||
database: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M16 24c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-8c-5.525 0-10-.9-10-2v4c0 1.1 4.475 2 10 2s10-.9 10-2v-4c0 1.1-4.475 2-10 2m0-12C10.477 4 6 4.895 6 6v4c0 1.1 4.475 2 10 2s10-.9 10-2V6c0-1.105-4.477-2-10-2"/></svg>`,
|
||||
document: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>`,
|
||||
elixir: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#9575cd" d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"/></svg>`,
|
||||
erlang: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path fill="#f44336" d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"/></svg>`,
|
||||
go: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00acc1" d="M2 12h4v2H2zm-2 4h6v2H0zm4 4h2v2H4zm16.954-5H14v3h3.239a4.42 4.42 0 0 1-3.531 2 2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 15.292 13a2.73 2.73 0 0 1 1.749.584l2.962-1.185A5.6 5.6 0 0 0 15.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 6.4 6.4 0 0 0 .003-1.5"/><path fill="#00acc1" d="M26.292 10a7.526 7.526 0 0 0-7.243 6.5 5.614 5.614 0 0 0 5.659 6.5 7.526 7.526 0 0 0 7.243-6.5 5.614 5.614 0 0 0-5.659-6.5m2.681 6.137A4.515 4.515 0 0 1 24.708 20a2.65 2.65 0 0 1-2.053-.858 2.86 2.86 0 0 1-.628-2.28A4.515 4.515 0 0 1 26.292 13a2.65 2.65 0 0 1 2.053.858 2.86 2.86 0 0 1 .628 2.28Z"/></svg>`,
|
||||
gradle: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0097a7" d="M16 10v2h6c-2 0-3-2-6-2"/><path fill="#0097a7" d="M26 4h-2a4 4 0 0 0-4 4h4a1 1 0 0 1 2 0v4H16v-2h-5.317A2.683 2.683 0 0 0 8 12.683v2.634A2.683 2.683 0 0 0 10.683 18H16v2h-5.98A4.02 4.02 0 0 1 6 16v-2c-2 0-4 4-4 8 0 5 1 6 2 6h4v-4h4v4h4v-4h4v4h4v-6a2 2 0 0 0 2-2v-2a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4m-4 12h-2a2 2 0 0 1-2-2h6a2 2 0 0 1-2 2"/></svg>`,
|
||||
graphql: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M6 20h20v2H6z"/><circle cx="7" cy="21" r="3" fill="#ec407a"/><circle cx="16" cy="27" r="3" fill="#ec407a"/><circle cx="25" cy="21" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 10h20v2H6z"/><circle cx="7" cy="11" r="3" fill="#ec407a"/><circle cx="16" cy="5" r="3" fill="#ec407a"/><circle cx="25" cy="11" r="3" fill="#ec407a"/><path fill="#ec407a" d="M6 12h2v10H6zm18-2h2v12h-2z"/><path fill="#ec407a" d="m5.014 19.41 11.674 6.866L15.674 28 4 21.134z"/><path fill="#ec407a" d="M26.688 21.724 15.014 28.59 14 26.866 25.674 20zM5.124 10.382l11.415-7.29 1.077 1.686L6.2 12.068z"/><path fill="#ec407a" d="m25.798 12.067-11.415-7.29 1.077-1.685 11.415 7.29zM6.2 19.932l11.416 7.29-1.077 1.686-11.415-7.29z"/><path fill="#ec407a" d="m26.875 21.619-11.415 7.29-1.077-1.687 11.415-7.289zM5.877 22.6 16.04 3.686l1.762.946L7.638 23.546z"/><path fill="#ec407a" d="M24.361 23.545 14.197 4.633l1.761-.947 10.165 18.913z"/></svg>`,
|
||||
groovy: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#26c6da" d="M19.322 2a6.5 6.5 0 0 1 4.352 1.419 4.55 4.55 0 0 1 1.685 3.662 5.82 5.82 0 0 1-1.886 4.275 6.04 6.04 0 0 1-4.34 1.846 4.15 4.15 0 0 1-2.385-.649 1.91 1.91 0 0 1-.936-1.603 1.6 1.6 0 0 1 .356-1.024 1.1 1.1 0 0 1 .861-.447q.469 0 .468.504a.79.79 0 0 0 .358.693 1.43 1.43 0 0 0 .826.245 3.1 3.1 0 0 0 2.39-1.573 5.66 5.66 0 0 0 1.154-3.39 2.64 2.64 0 0 0-.891-2.064 3.28 3.28 0 0 0-2.293-.812 6.18 6.18 0 0 0-4.086 1.736 12.9 12.9 0 0 0-3.215 4.557 13.4 13.4 0 0 0-1.233 5.36 5.86 5.86 0 0 0 1.091 3.723 3.53 3.53 0 0 0 2.905 1.372q3.058 0 5.848-4.002l2.935-.388q.546-.07.545.246a8 8 0 0 1-.423 1.24q-.421 1.097-1.152 3.668A12.7 12.7 0 0 0 26 17.72v1.66a14.2 14.2 0 0 1-4.055 2.57 10.38 10.38 0 0 1-2.764 5.931 6.7 6.7 0 0 1-4.806 2.11 3.3 3.3 0 0 1-2.012-.55 1.8 1.8 0 0 1-.718-1.514q0-2.685 5.634-5.212.532-1.766 1.152-3.507a8.6 8.6 0 0 1-2.853 2.323 7.4 7.4 0 0 1-3.48 1.01 5.46 5.46 0 0 1-4.366-2.093A8.1 8.1 0 0 1 6 15.122a11.6 11.6 0 0 1 1.966-6.426 14.7 14.7 0 0 1 5.162-4.862A12.44 12.44 0 0 1 19.322 2m-2.407 22.17q-4.055 1.875-4.054 3.695a.87.87 0 0 0 .999.97q1.964 0 3.055-4.665"/></svg>`,
|
||||
h: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M18.5 11a5.49 5.49 0 0 0-4.5 2.344V4H8v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
|
||||
haskell: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><g stroke-width="2.422"><path fill="#ef5350" d="m23.928 240.5 59.94-89.852-59.94-89.855h44.955l59.94 89.855-59.94 89.852z"/><path fill="#ffa726" d="m83.869 240.5 59.94-89.852-59.94-89.855h44.955l119.88 179.71h-44.95l-37.46-56.156-37.468 56.156z"/><path fill="#ffee58" d="m228.72 188.08-19.98-29.953h69.93v29.956h-49.95zm-29.97-44.924-19.98-29.953h99.901v29.953z"/></g></svg>`,
|
||||
hcl: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#eceff1" d="M18 1.2V14h-4v-4l-4 2v16.37l4 2.43V18h4v4l4-2V3.63z"/><path fill="#eceff1" d="M14 1.2 2 8.49v15.02l4 2.43v-15.2l8-4.86zm12 4.86v15.2l-8 4.86v4.68l12-7.29V8.49z"/></svg>`,
|
||||
hpp: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M28 6V2h-2v4h-6V2h-2v4h-4v2h4v4h2V8h6v4h2V8h4V6zm-15.5 5A5.49 5.49 0 0 0 8 13.344V4H2v24h6V17a2 2 0 0 1 4 0v11h6V16.5a5.5 5.5 0 0 0-5.5-5.5"/></svg>`,
|
||||
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#e65100" d="m4 4 2 22 10 2 10-2 2-22Zm19.72 7H11.28l.29 3h11.86l-.802 9.335L15.99 25l-6.635-1.646L8.93 19h3.02l.19 2 3.86.77 3.84-.77.29-4H8.84L8 8h16Z"/></svg>`,
|
||||
image: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#26a69a" d="M8.5 6h4l-4-4zM3.875 1H9.5l4 4v8.6c0 .773-.616 1.4-1.375 1.4h-8.25c-.76 0-1.375-.627-1.375-1.4V2.4c0-.777.612-1.4 1.375-1.4M4 13.6h8V8l-2.625 2.8L8 9.4zm1.25-7.7c-.76 0-1.375.627-1.375 1.4s.616 1.4 1.375 1.4c.76 0 1.375-.627 1.375-1.4S6.009 5.9 5.25 5.9"/></svg>`,
|
||||
java: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="M4 26h24v2H4zM28 4H7a1 1 0 0 0-1 1v13a4 4 0 0 0 4 4h10a4 4 0 0 0 4-4v-4h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 8h-4V6h4Z"/></svg>`,
|
||||
javascript: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M2 2v12h12V2zm6 6h1v4a1.003 1.003 0 0 1-1 1H7a1.003 1.003 0 0 1-1-1v-1h1v1h1zm3 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
|
||||
json: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path fill="#f9a825" d="M560-160v-80h120q17 0 28.5-11.5T720-280v-80q0-38 22-69t58-44v-14q-36-13-58-44t-22-69v-80q0-17-11.5-28.5T680-720H560v-80h120q50 0 85 35t35 85v80q0 17 11.5 28.5T840-560h40v160h-40q-17 0-28.5 11.5T800-360v80q0 50-35 85t-85 35zm-280 0q-50 0-85-35t-35-85v-80q0-17-11.5-28.5T120-400H80v-160h40q17 0 28.5-11.5T160-600v-80q0-50 35-85t85-35h120v80H280q-17 0-28.5 11.5T240-680v80q0 38-22 69t-58 44v14q36 13 58 44t22 69v80q0 17 11.5 28.5T280-240h120v80z"/></svg>`,
|
||||
kotlin: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><defs><linearGradient id="a" x1="1.725" x2="22.185" y1="22.67" y2="1.982" gradientTransform="translate(1.306 1.129)scale(.89324)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#7c4dff"/><stop offset=".5" stop-color="#d500f9"/><stop offset="1" stop-color="#ef5350"/></linearGradient></defs><path fill="url(#a)" d="M2.975 2.976v18.048h18.05v-.03l-4.478-4.511-4.48-4.515 4.48-4.515 4.443-4.477z"/></svg>`,
|
||||
less: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0277bd" d="M8 3a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2H3v2h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h2v-2H8v-5a2 2 0 0 0-2-2 2 2 0 0 0 2-2V5h2V3m6 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5h-2V3z"/></svg>`,
|
||||
lock: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffd54f" d="M25 12h-3V8a6 6 0 0 0-12 0v4H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V13a1 1 0 0 0-1-1M14 8a2 2 0 0 1 4 0v4h-4Zm2 17a4 4 0 1 1 4-4 4 4 0 0 1-4 4"/></svg>`,
|
||||
lua: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="M30 6a3.86 3.86 0 0 1-1.167 2.833 4.024 4.024 0 0 1-5.666 0A3.86 3.86 0 0 1 22 6a3.86 3.86 0 0 1 1.167-2.833 4.024 4.024 0 0 1 5.666 0A3.86 3.86 0 0 1 30 6m-9.208 5.208A10.6 10.6 0 0 0 13 8a10.6 10.6 0 0 0-7.792 3.208A10.6 10.6 0 0 0 2 19a10.6 10.6 0 0 0 3.208 7.792A10.6 10.6 0 0 0 13 30a10.6 10.6 0 0 0 7.792-3.208A10.6 10.6 0 0 0 24 19a10.6 10.6 0 0 0-3.208-7.792m-1.959 7.625a4.024 4.024 0 0 1-5.666 0 4.024 4.024 0 0 1 0-5.666 4.024 4.024 0 0 1 5.666 0 4.024 4.024 0 0 1 0 5.666"/></svg>`,
|
||||
markdown: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#42a5f5" d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"/></svg>`,
|
||||
nix: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g stroke-width=".395"><path fill="#1976d2" d="M133.347 451.499c0-.295-2.752-5.283-6.116-11.084s-6.116-10.776-6.116-11.055 9.514-16.889 21.143-36.912c11.629-20.022 21.323-36.798 21.542-37.279.346-.76-1.608-4.363-14.896-27.466-8.412-14.625-15.294-26.785-15.294-27.023 0-.5 24.46-43.501 25.206-44.31.414-.45.592-.384 1.078.395.32.513 16.876 29.256 36.791 63.87 62.62 108.85 74.852 130.01 75.41 130.46.3.242.544.554.544.694s-11.836.21-26.302.154c-23.023-.09-26.313-.175-26.393-.694-.11-.714-27.662-48.825-28.86-50.392-.746-.978-.906-1.035-1.426-.51-.688.696-28.954 49.323-29.49 50.733l-.364.96h-13.23c-10.895 0-13.228-.095-13.228-.538zm167.58-125.61c-.134-.216 1.189-2.863 2.939-5.882 6.924-11.944 84.29-145.75 96.49-166.88 7.143-12.371 13.143-22.465 13.334-22.433.362.062 25.86 43.105 25.86 43.655 0 .174-6.761 11.952-15.025 26.173-8.46 14.557-14.932 26.104-14.81 26.421.185.483 4.563.564 30.213.564h29.996l.957 1.48c.527.814 3.296 5.547 6.155 10.518s5.45 9.29 5.757 9.597c.705.705.703.724-.16 1.572-.396.388-3.36 5.323-6.588 10.965-3.228 5.643-6.056 10.387-6.285 10.543s-19.695.171-43.256.034l-42.84-.249-.803 1.15c-.442.632-7.505 12.736-15.696 26.897l-14.892 25.747h-15.486c-8.518 0-20.015.116-25.551.259-6.55.168-10.15.121-10.308-.135zm-133.75-157.86c-56.373-.055-102.5-.182-102.5-.282s5.617-10.132 12.481-22.294L89.64 123.34h30.332c27.113 0 30.332-.065 30.332-.611 0-.336-6.659-12.228-14.797-26.427s-14.797-25.917-14.797-26.04 2.682-4.853 5.96-10.51 6.003-10.578 6.056-10.934c.086-.586 1.375-.648 13.572-.648 7.412 0 13.463.143 13.446.317-.018.174.22.707.53 1.184.31.476 9.763 16.937 21.007 36.578 11.244 19.64 20.71 36.022 21.036 36.4.554.647 2.549.691 31.428.691h30.837l12.896 22.145c7.093 12.18 12.8 22.301 12.682 22.492-.117.19-4.776.303-10.352.249-5.575-.054-56.26-.143-112.63-.198z"/><path fill="#64b5f6" d="M23.046 238.939c-6.098 10.563-6.69 11.711-6.224 12.078.282.224 3.18 5.044 6.44 10.712s6.016 10.355 6.123 10.417c.106.061 13.585.153 29.95.204 16.367.052 29.994.23 30.285.399.473.273-1.08 3.094-14.637 26.574l-15.166 26.269 12.907 21.865c7.1 12.026 12.982 21.906 13.068 21.956s23.257-39.831 51.492-88.624c11.352-19.617 21.214-36.64 30.37-52.442 23.308-40.452 30.68-53.468 30.73-54.132-1.096-.11-6.141-.187-13.006-.216-3.945-.01-7.82-.02-12.75-.002l-25.341.092-15.42 26.706c-14.256 24.693-15.445 26.663-16.278 26.86l-.023.037c-.012.003-1.622-.001-1.826 0-4.29.062-20.453.063-40.226-.01-22.632-.082-41.615-.125-42.183-.096-.567.03-1.147-.03-1.29-.132-.141-.102-3.29 5.066-6.996 11.485zm205.16-190.3c-.123.149 5.62 10.392 12.761 22.763 12.2 21.131 89.393 155.03 96.276 167 1.503 2.613 2.92 4.803 3.443 5.348.9-1.249 3.532-5.63 7.954-13.219a1343 1343 0 0 1 10.05-17.76l6.606-11.443c.691-1.403.753-1.818.652-2.117-.161-.48-6.903-12.332-14.982-26.337-8.078-14.005-14.824-25.849-14.99-26.32a.73.73 0 0 1-.01-.366l-.426-.913 21.636-36.976c3.69-6.307 6.425-11.042 9.471-16.29 9.158-15.948 12.036-21.189 11.895-21.55-.126-.324-2.7-4.83-5.72-10.017-3.021-5.185-5.845-10.148-6.275-11.026-.483-.987-.734-1.364-1.1-1.456-.054.014-.083.018-.144.035-.42.112-5.455.195-11.19.185s-11.22.024-12.187.073l-1.76.089-14.998 25.978c-12.824 22.212-15.084 25.964-15.595 25.883-.024-.004-.15-.189-.235-.301-.109.066-.2.09-.271.05-.256-.148-7.144-11.902-15.306-26.119L279.4 48.817c-.116-.186-.444-.744-.458-.752-.476-.275-50.502.287-50.737.57zm-18.646 283.09c-.047.109-.026.262.043.48.328 1.05 25.338 43.735 25.772 43.985.206.119 14.178.239 31.05.266 26.65.044 30.749.152 31.234.832.307.43 9.987 17.214 21.513 37.296s21.152 36.627 21.394 36.767 5.926.243 12.633.23c6.705-.013 12.4.099 12.657.246.131.076.381-.141.851-.795l6.008-10.406c5.234-9.065 6.62-11.684 6.294-11.888-.575-.36-15.597-26.643-23.859-41.482-3.09-5.45-5.37-9.516-5.44-9.774-.196-.712-.066-.822 1.155-.98 1.956-.252 57.397-.057 58.071.205.237.092.79-.569 2.593-3.497 1.866-3.067 5.03-8.524 11.001-18.866 7.22-12.505 13.043-22.784 12.941-22.843s-.77-.051-1.489.016l-.046.001c-4.451.204-33.918.203-149.74.025-38.96-.06-69.786-.09-71.912-.072-1.12.01-2.095.076-2.66.172a.3.3 0 0 0-.062.083z"/></g></svg>`,
|
||||
ocaml: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m12.019 15.021.003-.008c-.005-.021-.006-.026-.003.008"/><path fill="#ff9800" d="M4.51 3.273a2.523 2.523 0 0 0-2.524 2.523V11.3c.361-.13.88-.898 1.043-1.085.285-.327.337-.743.478-1.006C3.83 8.612 3.886 8.2 4.62 8.2c.342 0 .478.08.71.39.16.216.438.615.568.882.15.307.396.724.503.808q.122.095.233.137c.119.044.218-.037.297-.1.102-.082.145-.247.24-.467.135-.317.283-.697.367-.83.146-.23.195-.501.352-.633.232-.195.535-.208.618-.225.466-.092.677.225.907.43.15.133.355.403.5.765.114.283.26.544.32.707.059.158.203.41.289.713.077.275.286.486.365.616 0 0 .121.34.858.65.16.067.482.176.674.246.32.116.63.101 1.025.054.281 0 .434-.408.562-.734.075-.193.148-.745.197-.902.048-.153-.064-.27.031-.405.112-.156.178-.164.242-.368.138-.436.936-.458 1.384-.458.374 0 .327.363.96.239.364-.072.714.046 1.1.149.324.086.63.184.812.398.119.139.412.834.113.863.029.035.05.099.104.134-.067.262-.357.075-.518.041-.217-.045-.37.007-.583.101-.363.162-.894.143-1.21.407-.27.223-.269.721-.394 1 0 0-.348.895-1.106 1.443-.194.14-.574.477-1.4.605a5.3 5.3 0 0 1-1.1.043c-.186-.009-.362-.018-.549-.02-.11-.002-.48-.013-.461.022l-.041.103.024.138c.015.083.019.149.022.225.006.157-.013.32-.005.478.017.328.138.627.154.958.017.368.199.758.375 1.059.067.114.169.128.213.269.052.161.003.333.028.505.1.668.292 1.366.592 1.97l.008.014c.371-.062.743-.196 1.226-.267.885-.132 2.115-.064 2.906-.138 2-.188 3.085.82 4.882.407V5.796a2.523 2.523 0 0 0-2.523-2.523zm-.907 11.144q-.022 0-.046.003c-.159.025-.313.08-.412.24-.08.13-.108.355-.164.505-.064.175-.176.338-.274.505-.18.305-.504.581-.644.879-.028.06-.053.13-.076.2v3.402c.163.028.333.062.524.113 1.407.375 1.75.407 3.13.25l.13-.018c.105-.22.187-.968.255-1.2.054-.178.127-.32.155-.5.026-.173-.003-.337-.017-.493-.04-.393.285-.533.44-.87.14-.304.22-.651.336-.963.11-.298.284-.721.579-.872-.036-.041-.617-.06-.772-.076a5 5 0 0 1-.5-.07c-.314-.064-.656-.126-.965-.2a10 10 0 0 1-.947-.328c-.298-.138-.503-.497-.732-.507m5.737.83c-.74.149-.97.876-1.32 1.451-.192.319-.396.59-.548.928-.14.312-.128.657-.368.924a2.55 2.55 0 0 0-.528.922c-.023.067-.088.776-.158.943l1.101-.078c1.026.07.73.464 2.332.378l2.529-.078a7 7 0 0 0-.228-.588c-.07-.147-.16-.434-.218-.56a3.5 3.5 0 0 0-.309-.526c-.184-.215-.227-.23-.28-.503-.095-.473-.344-1.33-.637-1.923-.151-.306-.403-.562-.634-.784-.2-.195-.655-.522-.734-.505z"/></svg>`,
|
||||
php: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1e88e5" d="M12 18.08c-6.63 0-12-2.72-12-6.08s5.37-6.08 12-6.08S24 8.64 24 12s-5.37 6.08-12 6.08m-5.19-7.95c.54 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.58 1.09q-.42.33-1.29.33h-.87l.53-2.76zm-3.5 5.55h1.44l.34-1.75h1.23c.54 0 .98-.06 1.33-.17.35-.12.67-.31.96-.58.24-.22.43-.46.58-.73.15-.26.26-.56.31-.88.16-.78.05-1.39-.33-1.82-.39-.44-.99-.65-1.82-.65H4.59zm7.25-8.33-1.28 6.58h1.42l.74-3.77h1.14c.36 0 .6.06.71.18s.13.34.07.66l-.57 2.93h1.45l.59-3.07c.13-.62.03-1.07-.27-1.36-.3-.27-.85-.4-1.65-.4h-1.27L12 7.35zM18 10.13c.55 0 .91.1 1.09.31.18.2.22.56.13 1.03-.1.53-.29.87-.57 1.09-.29.22-.72.33-1.3.33h-.85l.5-2.76zm-3.5 5.55h1.44l.34-1.75h1.22c.55 0 1-.06 1.35-.17.35-.12.65-.31.95-.58.24-.22.44-.46.58-.73.15-.26.26-.56.32-.88.15-.78.04-1.39-.34-1.82-.36-.44-.99-.65-1.82-.65h-2.75z"/></svg>`,
|
||||
python: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#0288d1" d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"/><path fill="#fdd835" d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"/></svg>`,
|
||||
r: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#1976d2" d="M11.956 4.05c-5.694 0-10.354 3.106-10.354 6.947 0 3.396 3.686 6.212 8.531 6.813v2.205h3.53V17.82c.88-.093 1.699-.259 2.475-.497l1.43 2.692h3.996l-2.402-4.048c1.936-1.263 3.147-3.034 3.147-4.97 0-3.841-4.659-6.947-10.354-6.947m1.584 2.712c4.349 0 7.558 1.45 7.558 4.753 0 1.77-.952 3.013-2.505 3.779a1 1 0 0 1-.228-.156c-.373-.165-.994-.352-.994-.352s3.085-.227 3.085-3.302-3.23-3.127-3.23-3.127h-7.092v7.413c-2.64-.766-4.462-2.392-4.462-4.255 0-2.63 3.52-4.753 7.868-4.753m.156 4.12h2.143s.983-.05.983.974c0 1.004-.983 1.004-.983 1.004h-2.143v-1.977m-.031 4.566h.952c.186 0 .28.052.445.207.135.103.28.3.404.476-.57.073-1.17.104-1.801.104z"/></svg>`,
|
||||
react: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#00bcd4" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#00bcd4" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#00bcd4" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#00bcd4" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
|
||||
react_ts: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#0288d1" d="M16 12c7.444 0 12 2.59 12 4s-4.556 4-12 4-12-2.59-12-4 4.556-4 12-4m0-2c-7.732 0-14 2.686-14 6s6.268 6 14 6 14-2.686 14-6-6.268-6-14-6"/><path fill="#0288d1" d="M16 14a2 2 0 1 0 2 2 2 2 0 0 0-2-2"/><path fill="#0288d1" d="M10.458 5.507c2.017 0 5.937 3.177 9.006 8.493 3.722 6.447 3.757 11.687 2.536 12.392a.9.9 0 0 1-.457.1c-2.017 0-5.938-3.176-9.007-8.492C8.814 11.553 8.779 6.313 10 5.608a.9.9 0 0 1 .458-.1m-.001-2A2.87 2.87 0 0 0 9 3.875C6.13 5.532 6.938 12.304 10.804 19c3.284 5.69 7.72 9.493 10.74 9.493A2.87 2.87 0 0 0 23 28.124c2.87-1.656 2.062-8.428-1.804-15.124-3.284-5.69-7.72-9.493-10.74-9.493Z"/><path fill="#0288d1" d="M21.543 5.507a.9.9 0 0 1 .457.1c1.221.706 1.186 5.946-2.536 12.393-3.07 5.316-6.99 8.493-9.007 8.493a.9.9 0 0 1-.457-.1C8.779 25.686 8.814 20.446 12.536 14c3.07-5.316 6.99-8.493 9.007-8.493m0-2c-3.02 0-7.455 3.804-10.74 9.493C6.939 19.696 6.13 26.468 9 28.124a2.87 2.87 0 0 0 1.457.369c3.02 0 7.455-3.804 10.74-9.493C25.061 12.304 25.87 5.532 23 3.876a2.87 2.87 0 0 0-1.457-.369"/></svg>`,
|
||||
ruby: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f44336" d="M18.041 3.177c2.24.382 2.879 1.919 2.843 3.527V6.67l-1.013 13.266-13.132.897h.008c-1.093-.044-3.518-.151-3.634-3.545l1.217-2.222 2.462 5.74 2.097-6.77-.045.009.018-.018 6.85 2.186L13.945 9.3l6.53-.409-5.144-4.212 2.71-1.51v.009M3.113 17.252v.017zM6.916 6.874c2.63-2.622 6.033-4.168 7.34-2.844 1.297 1.306-.072 4.523-2.702 7.135-2.666 2.613-6.015 4.248-7.322 2.933-1.306-1.324.036-4.612 2.675-7.224z"/></svg>`,
|
||||
rust: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff7043" d="m30 12-4-2V6h-4l-2-4-4 2-4-2-2 4H6v4l-4 2 2 4-2 4 4 2v4h4l2 4 4-2 4 2 2-4h4v-4l4-2-2-4ZM6 16a9.9 9.9 0 0 1 .842-4H10v8H6.842A9.9 9.9 0 0 1 6 16m10 10a9.98 9.98 0 0 1-7.978-4H16v-2h-2v-2h4c.819.819.297 2.308 1.179 3.37a1.89 1.89 0 0 0 1.46.63h3.34A9.98 9.98 0 0 1 16 26m-2-12v-2h4a1 1 0 0 1 0 2Zm11.158 6H24a2.006 2.006 0 0 1-2-2 2 2 0 0 0-2-2 3 3 0 0 0 3-3q0-.08-.004-.161A3.115 3.115 0 0 0 19.83 10H8.022a9.986 9.986 0 0 1 17.136 10"/></svg>`,
|
||||
sass: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ec407a" d="M27.837 5.673a4.33 4.33 0 0 0-2.293-2.701c-2.362-1.261-6.11-1.298-9.548-.092a26.3 26.3 0 0 0-8.76 4.966c-2.752 2.542-3.438 4.925-3.189 6.194.523 2.668 3.274 4.539 5.485 6.042.418.284.822.559 1.175.816-1.429.76-4.261 2.444-5.088 4.248a3.88 3.88 0 0 0-.118 3.332A2.37 2.37 0 0 0 6.869 29.8a5.6 5.6 0 0 0 1.49.2 6.35 6.35 0 0 0 5.19-2.856 6.74 6.74 0 0 0 .864-5.382 7.3 7.3 0 0 1 2.044-.03 3.92 3.92 0 0 1 2.816 1.311 1.82 1.82 0 0 1 .423 1.262 1.55 1.55 0 0 1-.772 1.05c-.234.14-.586.355-.504.803.036.194.198.633.894.512a2.93 2.93 0 0 0 2.145-2.651 4 4 0 0 0-1.197-2.904 5.94 5.94 0 0 0-4.396-1.626 10.6 10.6 0 0 0-2.672.304 20 20 0 0 0-2.203-1.846c-1.712-1.3-3.33-2.529-3.235-4.26.125-2.263 2.468-4.532 6.964-6.744 4.016-1.976 7.254-2.037 8.944-1.438a2 2 0 0 1 1.204.883 2.77 2.77 0 0 1-.36 2.47 9.71 9.71 0 0 1-7.425 4.304 3.86 3.86 0 0 1-3.238-.757c-.278-.302-.593-.645-1.074-.383q-.565.31-.225 1.189a3.9 3.9 0 0 0 2.407 1.92 11.7 11.7 0 0 0 7.128-.671c3.527-1.35 6.681-5.202 5.756-8.787M11.895 24.475a4 4 0 0 1-.192.468 4.5 4.5 0 0 1-.753 1.081 2.83 2.83 0 0 1-2.533 1.107c-.056-.032-.078-.146-.085-.193a3.28 3.28 0 0 1 1.076-2.284 11.3 11.3 0 0 1 2.644-1.933 3.85 3.85 0 0 1-.157 1.754"/></svg>`,
|
||||
scala: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f44336" d="m6.457 9.894 12.523 5.163-.456 1.211L6 11.105Zm7.02-3.091L26 11.966l-.457 1.21L13.02 8.015ZM6.465 18.885l12.524 5.163-.457 1.21L6.01 20.097Zm7.007-3.086 12.524 5.163-.456 1.21-12.524-5.162Z"/><path fill="#f44336" d="M6 24.07V30l19.997-3.106V20.96zM6 5.11v5.99l20-3.11V2zm0 9.96v5.03l20-3.11v-5.03z"/></svg>`,
|
||||
settings: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.6.6 0 0 0-.18-.03c-.17 0-.34.09-.43.25l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1q.09.03.18.03c.17 0 .34-.09.43-.25l2-3.46c.12-.22.07-.49-.12-.64zm-1.98-1.71c.04.31.05.52.05.73s-.02.43-.05.73l-.14 1.13.89.7 1.08.84-.7 1.21-1.27-.51-1.04-.42-.9.68c-.43.32-.84.56-1.25.73l-1.06.43-.16 1.13-.2 1.35h-1.4l-.19-1.35-.16-1.13-1.06-.43c-.43-.18-.83-.41-1.23-.71l-.91-.7-1.06.43-1.27.51-.7-1.21 1.08-.84.89-.7-.14-1.13c-.03-.31-.05-.54-.05-.74s.02-.43.05-.73l.14-1.13-.89-.7-1.08-.84.7-1.21 1.27.51 1.04.42.9-.68c.43-.32.84-.56 1.25-.73l1.06-.43.16-1.13.2-1.35h1.39l.19 1.35.16 1.13 1.06.43c.43.18.83.41 1.23.71l.91.7 1.06-.43 1.27-.51.7 1.21-1.07.85-.89.7zM12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4m0 6c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2"/></svg>`,
|
||||
svelte: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300"><path fill="#ff5722" d="M175.94 24.328c-13.037.252-26.009 3.872-37.471 11.174L79.912 72.818a67.13 67.13 0 0 0-30.355 44.906 70.8 70.8 0 0 0 6.959 45.445 67.2 67.2 0 0 0-10.035 25.102 71.54 71.54 0 0 0 12.236 54.156c23.351 33.41 69.468 43.311 102.81 22.07l58.559-37.158a67.36 67.36 0 0 0 30.355-44.906 70.77 70.77 0 0 0-6.982-45.422 67.65 67.65 0 0 0 10.059-25.102 71.63 71.63 0 0 0-12.236-54.156v-.18c-15.324-21.925-40.453-33.727-65.342-33.246zm5.137 28.68a46.5 46.5 0 0 1 36.09 19.969 42.98 42.98 0 0 1 7.365 32.557 45 45 0 0 1-1.393 5.455l-1.123 3.37-2.986-2.247a75.9 75.9 0 0 0-22.902-11.45l-2.244-.651.201-2.246a13.16 13.16 0 0 0-2.379-8.711 13.99 13.99 0 0 0-14.953-5.412 12.8 12.8 0 0 0-3.594 1.572l-58.578 37.25a12.24 12.24 0 0 0-5.502 8.15 13.1 13.1 0 0 0 2.246 9.834 14.03 14.03 0 0 0 14.93 5.569 13.5 13.5 0 0 0 3.594-1.573l22.453-14.234a41.8 41.8 0 0 1 11.898-5.232 46.48 46.48 0 0 1 49.914 18.502 43.02 43.02 0 0 1 7.363 32.557 40.42 40.42 0 0 1-18.254 27.078l-58.58 37.316a43 43 0 0 1-11.898 5.23A46.545 46.545 0 0 1 82.81 227.14a42.98 42.98 0 0 1-7.341-32.557 38 38 0 0 1 1.39-5.41l1.102-3.37 3.008 2.246a75.9 75.9 0 0 0 22.836 11.361l2.244.65-.201 2.247a13.25 13.25 0 0 0 2.447 8.644 14.03 14.03 0 0 0 15.043 5.569 13.1 13.1 0 0 0 3.592-1.573l58.467-37.316a12.17 12.17 0 0 0 5.502-8.173 12.96 12.96 0 0 0-2.246-9.811 14.03 14.03 0 0 0-15.043-5.568 12.8 12.8 0 0 0-3.592 1.57l-22.453 14.258a42.9 42.9 0 0 1-11.877 5.209 46.52 46.52 0 0 1-49.846-18.5 43.02 43.02 0 0 1-7.297-32.557A40.42 40.42 0 0 1 96.798 96.98l58.646-37.316a42.8 42.8 0 0 1 11.811-5.21 46.5 46.5 0 0 1 13.822-1.444z"/></svg>`,
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffb300" d="M29.168 14.03a2.7 2.7 0 0 0-1.968-.83 2.51 2.51 0 0 0-1.929.8h-4.443l3.078-3.078a2.835 2.835 0 0 0 2.857-2.842 2.6 2.6 0 0 0-.831-1.969 2.82 2.82 0 0 0-2.014-.788 2.67 2.67 0 0 0-1.968.788 2.36 2.36 0 0 0-.812 1.922L18 11.17V6.726a2.51 2.51 0 0 0 .8-1.929 2.7 2.7 0 0 0-.832-1.968 2.745 2.745 0 0 0-3.936 0 2.7 2.7 0 0 0-.832 1.968 2.51 2.51 0 0 0 .8 1.93v4.443l-3.138-3.138a2.36 2.36 0 0 0-.812-1.922 2.66 2.66 0 0 0-1.968-.788 2.83 2.83 0 0 0-2.014.788 2.6 2.6 0 0 0-.831 1.969 2.74 2.74 0 0 0 .831 2.013 2.8 2.8 0 0 0 2.026.829l3.078 3.078H6.729a2.51 2.51 0 0 0-1.929-.8 2.7 2.7 0 0 0-1.968.831 2.745 2.745 0 0 0 0 3.937 2.7 2.7 0 0 0 1.968.832 2.51 2.51 0 0 0 1.929-.8h4.443l-3.078 3.077a2.835 2.835 0 0 0-2.857 2.842 2.6 2.6 0 0 0 .831 1.969 2.82 2.82 0 0 0 2.014.788 2.67 2.67 0 0 0 1.968-.788 2.36 2.36 0 0 0 .812-1.922L14 20.827v4.444a2.51 2.51 0 0 0-.8 1.929 2.784 2.784 0 0 0 4.768 1.968A2.7 2.7 0 0 0 18.8 27.2a2.51 2.51 0 0 0-.8-1.929v-4.444l3.138 3.138a2.36 2.36 0 0 0 .812 1.922 2.66 2.66 0 0 0 1.968.788 2.83 2.83 0 0 0 2.014-.788 2.6 2.6 0 0 0 .831-1.969 2.74 2.74 0 0 0-.831-2.013 2.8 2.8 0 0 0-2.026-.829L20.828 18h4.443a2.51 2.51 0 0 0 1.93.8 2.784 2.784 0 0 0 1.967-4.769Z"/></svg>`,
|
||||
swift: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff6e40" d="M17.087 19.721c-2.36 1.36-5.59 1.5-8.86.1a13.8 13.8 0 0 1-6.23-5.32c.67.55 1.46 1 2.3 1.4 3.37 1.57 6.73 1.46 9.1 0-3.37-2.59-6.24-5.96-8.37-8.71-.45-.45-.78-1.01-1.12-1.51 8.28 6.05 7.92 7.59 2.41-1.01 4.89 4.94 9.43 7.74 9.43 7.74.16.09.25.16.36.22.1-.25.19-.51.26-.78.79-2.85-.11-6.12-2.08-8.81 4.55 2.75 7.25 7.91 6.12 12.24-.03.11-.06.22-.05.39 2.24 2.83 1.64 5.78 1.35 5.22-1.21-2.39-3.48-1.65-4.62-1.17"/></svg>`,
|
||||
terraform: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#5c6bc0" d="m2 10 8 4V6L2 2zm10 5 8 4v-8l-8-4zm0 11 8 4v-8l-8-4zm10-14v8l8-4V8z"/></svg>`,
|
||||
toml: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#cfd8dc" d="M4 6V4h8v2H9v7H7V6z"/><path fill="#ef5350" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z"/></svg>`,
|
||||
typescript: `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 16 16"><path fill="#0288d1" d="M2 2v12h12V2zm4 6h3v1H8v4H7V9H6zm5 0h2v1h-2v1h1a1.003 1.003 0 0 1 1 1v1a1.003 1.003 0 0 1-1 1h-2v-1h2v-1h-1a1.003 1.003 0 0 1-1-1V9a1.003 1.003 0 0 1 1-1"/></svg>`,
|
||||
vue: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#41b883" d="M1.791 3.851 12 21.471 22.209 3.936V3.85H18.24l-6.18 10.616L5.906 3.851z"/><path fill="#35495e" d="m5.907 3.851 6.152 10.617L18.24 3.851h-3.723L12.084 8.03 9.66 3.85z"/></svg>`,
|
||||
webassembly: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#7c4dff" d="M22 18h4v4h-4z"/><path fill="#7c4dff" d="M20 2a4 4 0 0 1-8 0H2v28h28V2Zm-2 24h-2v2h-4v-2h-2v2H6v-2H4V16h2v10h4V16h2v10h4V16h2Zm10 2h-2v-4h-4v4h-2V18h2v-2h4v2h2Z"/></svg>`,
|
||||
xml: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#8bc34a" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4c0-1.11.89-2 2-2m.12 13.5 3.74 3.74 1.42-1.41-2.33-2.33 2.33-2.33-1.42-1.41zm11.16 0-3.74-3.74-1.42 1.41 2.33 2.33-2.33 2.33 1.42 1.41z"/></svg>`,
|
||||
yaml: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#ff5252" d="M13 9h5.5L13 3.5zM6 2h8l6 6v12c0 1.1-.9 2-2 2H6c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2m12 16v-2H9v2zm-4-4v-2H6v2z"/></svg>`,
|
||||
zig: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#f9a825" d="M2 8h6v4H2zm8 0h12v4H10zm0 12h12v4H10zm14 0h2v4h-2zM8 20l-3 4H2V12h4v8zm14-8h-6l-6 8h6z"/><path fill="#f9a825" d="M16 20h-6l-6 8m12-16h6l6-8m2 4v16h-4V12h-2l3-4z"/></svg>`,
|
||||
};
|
||||
|
||||
const EXTENSION_TO_ICON: Record<string, string> = {
|
||||
"astro": "astro",
|
||||
"bash": "console",
|
||||
"c": "c",
|
||||
"cfg": "settings",
|
||||
"clj": "clojure",
|
||||
"conf": "settings",
|
||||
"cpp": "cpp",
|
||||
"cs": "csharp",
|
||||
"css": "css",
|
||||
"dart": "dart",
|
||||
"erl": "erlang",
|
||||
"ex": "elixir",
|
||||
"exs": "elixir",
|
||||
"gif": "image",
|
||||
"go": "go",
|
||||
"gql": "graphql",
|
||||
"gradle": "gradle",
|
||||
"graphql": "graphql",
|
||||
"groovy": "groovy",
|
||||
"h": "h",
|
||||
"hcl": "hcl",
|
||||
"hpp": "hpp",
|
||||
"hs": "haskell",
|
||||
"html": "html",
|
||||
"ico": "image",
|
||||
"ini": "settings",
|
||||
"java": "java",
|
||||
"jpeg": "image",
|
||||
"jpg": "image",
|
||||
"js": "javascript",
|
||||
"json": "json",
|
||||
"jsx": "react",
|
||||
"kt": "kotlin",
|
||||
"less": "less",
|
||||
"lock": "lock",
|
||||
"lua": "lua",
|
||||
"markdown": "markdown",
|
||||
"md": "markdown",
|
||||
"ml": "ocaml",
|
||||
"nix": "nix",
|
||||
"php": "php",
|
||||
"png": "image",
|
||||
"py": "python",
|
||||
"r": "r",
|
||||
"rb": "ruby",
|
||||
"rs": "rust",
|
||||
"scala": "scala",
|
||||
"scss": "sass",
|
||||
"sh": "console",
|
||||
"sql": "database",
|
||||
"svelte": "svelte",
|
||||
"svg": "svg",
|
||||
"swift": "swift",
|
||||
"tf": "terraform",
|
||||
"toml": "toml",
|
||||
"ts": "typescript",
|
||||
"tsx": "react_ts",
|
||||
"txt": "document",
|
||||
"vue": "vue",
|
||||
"wasm": "webassembly",
|
||||
"webp": "image",
|
||||
"xml": "xml",
|
||||
"yaml": "yaml",
|
||||
"yml": "yaml",
|
||||
"zig": "zig",
|
||||
astro: "astro",
|
||||
bash: "console",
|
||||
c: "c",
|
||||
cfg: "settings",
|
||||
clj: "clojure",
|
||||
conf: "settings",
|
||||
cpp: "cpp",
|
||||
cs: "csharp",
|
||||
css: "css",
|
||||
dart: "dart",
|
||||
erl: "erlang",
|
||||
ex: "elixir",
|
||||
exs: "elixir",
|
||||
gif: "image",
|
||||
go: "go",
|
||||
gql: "graphql",
|
||||
gradle: "gradle",
|
||||
graphql: "graphql",
|
||||
groovy: "groovy",
|
||||
h: "h",
|
||||
hcl: "hcl",
|
||||
hpp: "hpp",
|
||||
hs: "haskell",
|
||||
html: "html",
|
||||
ico: "image",
|
||||
ini: "settings",
|
||||
java: "java",
|
||||
jpeg: "image",
|
||||
jpg: "image",
|
||||
js: "javascript",
|
||||
json: "json",
|
||||
jsx: "react",
|
||||
kt: "kotlin",
|
||||
less: "less",
|
||||
lock: "lock",
|
||||
lua: "lua",
|
||||
markdown: "markdown",
|
||||
md: "markdown",
|
||||
ml: "ocaml",
|
||||
nix: "nix",
|
||||
php: "php",
|
||||
png: "image",
|
||||
py: "python",
|
||||
r: "r",
|
||||
rb: "ruby",
|
||||
rs: "rust",
|
||||
scala: "scala",
|
||||
scss: "sass",
|
||||
sh: "console",
|
||||
sql: "database",
|
||||
svelte: "svelte",
|
||||
svg: "svg",
|
||||
swift: "swift",
|
||||
tf: "terraform",
|
||||
toml: "toml",
|
||||
ts: "typescript",
|
||||
tsx: "react_ts",
|
||||
txt: "document",
|
||||
vue: "vue",
|
||||
wasm: "webassembly",
|
||||
webp: "image",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
zig: "zig",
|
||||
};
|
||||
|
||||
export function getFileIconSvg(fileName: string): string {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
TextInputKeyPressEventData,
|
||||
TextInputSelectionChangeEventData,
|
||||
Image,
|
||||
Platform,
|
||||
BackHandler,
|
||||
} from "react-native";
|
||||
import {
|
||||
@@ -43,11 +42,15 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import {
|
||||
markScrollInvestigationEvent,
|
||||
markScrollInvestigationRender,
|
||||
} from "@/utils/scroll-jank-investigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export type ImageAttachment = AttachmentMetadata;
|
||||
|
||||
@@ -75,8 +78,8 @@ export interface MessageInputProps {
|
||||
autoFocus?: boolean;
|
||||
autoFocusKey?: string;
|
||||
disabled?: boolean;
|
||||
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isInputActive?: boolean;
|
||||
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
|
||||
isPaneFocused?: boolean;
|
||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||
leftContent?: React.ReactNode;
|
||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||
@@ -115,13 +118,15 @@ export interface MessageInputRef {
|
||||
|
||||
const MIN_INPUT_HEIGHT = 30;
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
TextInputKeyPressEventData & {
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -204,7 +209,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
disabled = false,
|
||||
isInputActive = true,
|
||||
isPaneFocused = true,
|
||||
leftContent,
|
||||
beforeVoiceContent,
|
||||
rightContent,
|
||||
@@ -222,7 +227,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
ref,
|
||||
) {
|
||||
const { theme } = useUnistyles();
|
||||
const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
|
||||
markScrollInvestigationRender(investigationComponentId);
|
||||
const toast = useToast();
|
||||
@@ -231,7 +236,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
||||
const queueKeys = useShortcutKeys("message-input-queue");
|
||||
const focusInputKeys = useShortcutKeys("focus-message-input");
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const rootRef = useRef<View | null>(null);
|
||||
const inputWrapperRef = useRef<View | null>(null);
|
||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
||||
@@ -293,7 +300,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
return false;
|
||||
},
|
||||
getNativeElement: () => {
|
||||
if (!IS_WEB) return null;
|
||||
if (!isWeb) return null;
|
||||
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
|
||||
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
|
||||
return native instanceof HTMLElement ? native : null;
|
||||
@@ -328,7 +335,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !autoFocus) return;
|
||||
if (!isWeb || !autoFocus) return;
|
||||
return focusWithRetries({
|
||||
focus: () => textInputRef.current?.focus(),
|
||||
isFocused: () => {
|
||||
@@ -371,7 +378,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onChangeText(nextValue);
|
||||
}
|
||||
|
||||
if (IS_WEB && typeof requestAnimationFrame === "function") {
|
||||
if (isWeb && typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
measureWebInputHeight("dictation");
|
||||
});
|
||||
@@ -425,7 +432,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
canConfirm: canConfirmDictation,
|
||||
autoStopWhenHidden: { isVisible: isInputActive },
|
||||
autoStopWhenHidden: { isVisible: isPaneFocused },
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
@@ -627,13 +634,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||
}
|
||||
}, [getWebTextArea]);
|
||||
|
||||
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
||||
enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT,
|
||||
enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT,
|
||||
});
|
||||
|
||||
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
|
||||
@@ -647,7 +654,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || !onAddImages) {
|
||||
if (!isWeb || !onAddImages) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -700,7 +707,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB || typeof ResizeObserver === "undefined") {
|
||||
if (!isWeb || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -754,7 +761,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}, [getWebElement, getWebTextArea]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_WEB) {
|
||||
if (!isWeb) {
|
||||
return;
|
||||
}
|
||||
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
|
||||
@@ -792,7 +799,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}, [getWebTextArea]);
|
||||
|
||||
function measureWebInputHeight(source: string): boolean {
|
||||
if (!IS_WEB) return false;
|
||||
if (!isWeb) return false;
|
||||
const textarea = getWebTextArea();
|
||||
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
||||
const scrollHeight = textarea.scrollHeight ?? 0;
|
||||
@@ -846,7 +853,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||
) {
|
||||
const contentHeight = event.nativeEvent.contentSize.height;
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
logWebStickyBottom("composer_content_size_change", {
|
||||
reportedHeight: contentHeight,
|
||||
});
|
||||
@@ -866,7 +873,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||
const start = event.nativeEvent.selection?.start ?? 0;
|
||||
const end = event.nativeEvent.selection?.end ?? start;
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
const textarea = getWebTextArea();
|
||||
logWebStickyBottom("composer_selection_changed", {
|
||||
now: getDebugNow(),
|
||||
@@ -880,12 +887,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onSelectionChangeCallback?.({ start, end });
|
||||
}
|
||||
|
||||
const shouldHandleDesktopSubmit = IS_WEB;
|
||||
const shouldHandleDesktopSubmit = isWeb;
|
||||
|
||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
markScrollInvestigationEvent(investigationComponentId, "keyPress");
|
||||
if (!shouldHandleDesktopSubmit) return;
|
||||
|
||||
// IME composition in progress (e.g. CJK input) — all key events belong to the
|
||||
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
|
||||
// cleared before the keydown fires.
|
||||
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
|
||||
|
||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||
if (onKeyPressCallback) {
|
||||
const handled = onKeyPressCallback({
|
||||
@@ -935,7 +947,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
(nextValue: string) => {
|
||||
markScrollInvestigationEvent(investigationComponentId, "inputChange");
|
||||
onChangeText(nextValue);
|
||||
if (IS_WEB) {
|
||||
if (isWeb) {
|
||||
logWebStickyBottom("composer_text_changed", {
|
||||
valueLength: nextValue.length,
|
||||
lineCount: nextValue.split("\n").length,
|
||||
@@ -966,7 +978,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<View
|
||||
style={[
|
||||
styles.removeImageButton,
|
||||
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
|
||||
(hovered || !isWeb) && styles.removeImageButtonVisible,
|
||||
]}
|
||||
>
|
||||
<X size={theme.iconSize.md} color="white" />
|
||||
@@ -990,15 +1002,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
accessibilityLabel="Message agent..."
|
||||
onFocus={() => {
|
||||
isInputFocusedRef.current = true;
|
||||
setIsInputFocused(true);
|
||||
onFocusChange?.(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
isInputFocusedRef.current = false;
|
||||
setIsInputFocused(false);
|
||||
onFocusChange?.(false);
|
||||
}}
|
||||
style={[
|
||||
styles.textInput,
|
||||
IS_WEB
|
||||
isWeb
|
||||
? {
|
||||
height: inputHeight,
|
||||
minHeight: MIN_INPUT_HEIGHT,
|
||||
@@ -1010,14 +1024,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
},
|
||||
]}
|
||||
multiline
|
||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
autoFocus={IS_WEB && autoFocus}
|
||||
autoFocus={isWeb && autoFocus}
|
||||
/>
|
||||
{inputScrollbar}
|
||||
{isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
|
||||
<Text style={styles.focusHintText} pointerEvents="none">
|
||||
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Button row */}
|
||||
@@ -1206,7 +1225,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[4],
|
||||
},
|
||||
...(IS_WEB
|
||||
...(isWeb
|
||||
? {
|
||||
transitionProperty: "border-color",
|
||||
transitionDuration: "200ms",
|
||||
@@ -1225,7 +1244,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
...(IS_WEB
|
||||
...(isWeb
|
||||
? {
|
||||
cursor: "pointer",
|
||||
}
|
||||
@@ -1250,7 +1269,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
opacity: 0,
|
||||
...(IS_WEB
|
||||
...(isWeb
|
||||
? {
|
||||
transitionProperty: "opacity",
|
||||
transitionDuration: "150ms",
|
||||
@@ -1263,13 +1282,21 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
textInputScrollWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
focusHintText: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
opacity: 0.5,
|
||||
},
|
||||
textInput: {
|
||||
width: "100%",
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
...(IS_WEB
|
||||
...(isWeb
|
||||
? {
|
||||
outlineStyle: "none" as const,
|
||||
outlineWidth: 0,
|
||||
@@ -1283,14 +1310,18 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
leftButtonGroup: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexGrow: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
rightButtonGroup: {
|
||||
flexShrink: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
||||
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
|
||||
},
|
||||
attachButton: {
|
||||
width: 28,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
type LayoutChangeEvent,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Circle,
|
||||
@@ -72,11 +73,19 @@ import {
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import {
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
interface UserMessageProps {
|
||||
message: string;
|
||||
@@ -125,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
|
||||
type ScrollAxis = "x" | "y";
|
||||
|
||||
function ensureWebToolCallShimmerKeyframes() {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
if (typeof document === "undefined") {
|
||||
@@ -332,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [messageHovered, setMessageHovered] = useState(false);
|
||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
|
||||
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -352,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
|
||||
>
|
||||
<Pressable
|
||||
style={userMessageStylesheet.content}
|
||||
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
|
||||
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
|
||||
onHoverIn={() => setMessageHovered(true)}
|
||||
onHoverOut={() => setMessageHovered(false)}
|
||||
>
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
{hasImages ? (
|
||||
@@ -399,6 +409,8 @@ interface AssistantMessageProps {
|
||||
timestamp: number;
|
||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
@@ -423,10 +435,217 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
userSelect: Platform.OS === "web" ? "text" : "auto",
|
||||
userSelect: isWeb ? "text" : "auto",
|
||||
},
|
||||
imageFrame: {
|
||||
width: "100%",
|
||||
minHeight: 160,
|
||||
marginHorizontal: -theme.spacing[1],
|
||||
},
|
||||
imageSurface: {
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
},
|
||||
image: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
imageState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
imageErrorText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
|
||||
|
||||
const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({
|
||||
uri,
|
||||
alt,
|
||||
containerStyle,
|
||||
source,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: {
|
||||
uri: string;
|
||||
alt?: string;
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
source: string;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const cachedMetadata = useMemo(
|
||||
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
|
||||
[serverId, source, workspaceRoot],
|
||||
);
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(
|
||||
cachedMetadata?.aspectRatio ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedMetadata) {
|
||||
setAspectRatio(cachedMetadata.aspectRatio);
|
||||
return;
|
||||
}
|
||||
|
||||
setAspectRatio(null);
|
||||
let cancelled = false;
|
||||
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (width > 0 && height > 0) {
|
||||
const metadata = setAssistantImageMetadata(
|
||||
{ source, workspaceRoot, serverId },
|
||||
{ width, height },
|
||||
);
|
||||
setAspectRatio(metadata?.aspectRatio ?? width / height);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setAspectRatio(null);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cachedMetadata, serverId, source, uri, workspaceRoot]);
|
||||
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
assistantMessageStylesheet.imageSurface,
|
||||
aspectRatio ? { aspectRatio } : { minHeight: ASSISTANT_IMAGE_MIN_HEIGHT },
|
||||
],
|
||||
[aspectRatio],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[assistantMessageStylesheet.imageFrame, containerStyle]}>
|
||||
<View style={surfaceStyle}>
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={assistantMessageStylesheet.image}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={alt}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function AssistantMarkdownImage({
|
||||
source,
|
||||
alt,
|
||||
hasLeadingContent,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
alt?: string;
|
||||
hasLeadingContent: boolean;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const resolution = useMemo(
|
||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||
[source, workspaceRoot],
|
||||
);
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => ({
|
||||
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
|
||||
marginBottom: 0,
|
||||
}),
|
||||
[hasLeadingContent, theme],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [
|
||||
"assistantMarkdownImage",
|
||||
serverId ?? "unknown-server",
|
||||
resolution?.kind === "file_rpc" ? resolution.cwd : null,
|
||||
resolution?.kind === "file_rpc" ? resolution.path : null,
|
||||
],
|
||||
enabled: Boolean(client && resolution?.kind === "file_rpc"),
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!client || !resolution || resolution.kind !== "file_rpc") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file");
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
|
||||
throw new Error("Image preview unavailable.");
|
||||
}
|
||||
|
||||
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
|
||||
},
|
||||
});
|
||||
|
||||
const directUri = resolution?.kind === "direct" ? resolution.uri : null;
|
||||
const resolvedUri = directUri ?? query.data ?? null;
|
||||
|
||||
if (resolvedUri) {
|
||||
return (
|
||||
<AssistantMarkdownResolvedImage
|
||||
uri={resolvedUri}
|
||||
alt={alt}
|
||||
containerStyle={containerStyle}
|
||||
source={source}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
assistantMessageStylesheet.imageFrame,
|
||||
containerStyle,
|
||||
assistantMessageStylesheet.imageState,
|
||||
]}
|
||||
>
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
assistantMessageStylesheet.imageFrame,
|
||||
containerStyle,
|
||||
assistantMessageStylesheet.imageState,
|
||||
]}
|
||||
>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||
{query.error instanceof Error ? query.error.message : "Unable to load image preview."}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownLink({
|
||||
href,
|
||||
style,
|
||||
@@ -439,7 +658,7 @@ function MarkdownLink({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return (
|
||||
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
|
||||
{children}
|
||||
@@ -561,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleCopy}
|
||||
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined}
|
||||
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
|
||||
onHoverIn={() => onHoverChange?.(true)}
|
||||
onHoverOut={() => onHoverChange?.(false)}
|
||||
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
@@ -712,11 +931,35 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
interface MemoizedMarkdownBlockProps {
|
||||
text: string;
|
||||
styles: ReturnType<typeof createMarkdownStyles>;
|
||||
rules: RenderRules;
|
||||
parser: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
}
|
||||
|
||||
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
text,
|
||||
styles,
|
||||
rules,
|
||||
parser,
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
return (
|
||||
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}>
|
||||
{text}
|
||||
</Markdown>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp,
|
||||
onInlinePathPress,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
client,
|
||||
disableOuterSpacing,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme, rt } = useUnistyles();
|
||||
@@ -753,7 +996,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
[onInlinePathPress, workspaceRoot],
|
||||
);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
const markdownRules = useMemo<RenderRules>(() => {
|
||||
return {
|
||||
text: (
|
||||
node: any,
|
||||
@@ -818,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||
selectable={Platform.OS === "web" ? undefined : false}
|
||||
selectable={isWeb ? undefined : false}
|
||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||
>
|
||||
{content}
|
||||
@@ -868,14 +1111,11 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||
return (
|
||||
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||
<View key={node.key} style={[styles.paragraph, { marginBottom: 0 }]}>
|
||||
{children}
|
||||
</View>
|
||||
),
|
||||
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||
<MarkdownLink
|
||||
key={node.key}
|
||||
@@ -892,8 +1132,32 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
)}
|
||||
</MarkdownLink>
|
||||
),
|
||||
image: (node: any, _children: ReactNode[], parent: any, styles: any) => {
|
||||
const paragraphNode = Array.isArray(parent)
|
||||
? parent.find((ancestor: any) => ancestor?.type === "paragraph")
|
||||
: null;
|
||||
const paragraphChildren = Array.isArray(paragraphNode?.children)
|
||||
? paragraphNode.children
|
||||
: [];
|
||||
const imageIndex = paragraphChildren.findIndex((child: any) => child?.key === node.key);
|
||||
const hasLeadingContent = imageIndex > 0;
|
||||
|
||||
return (
|
||||
<AssistantMarkdownImage
|
||||
key={node.key}
|
||||
source={String(node.attributes?.src ?? "")}
|
||||
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||
hasLeadingContent={hasLeadingContent}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [handleLinkPress, markdownParser, onInlinePathPress]);
|
||||
}, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -903,14 +1167,20 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
||||
]}
|
||||
>
|
||||
<Markdown
|
||||
style={markdownStyles}
|
||||
rules={markdownRules}
|
||||
markdownit={markdownParser}
|
||||
onLinkPress={handleLinkPress}
|
||||
>
|
||||
{message}
|
||||
</Markdown>
|
||||
{blocks.map((block, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={index < blocks.length - 1 ? { marginBottom: theme.spacing[3] } : undefined}
|
||||
>
|
||||
<MemoizedMarkdownBlock
|
||||
text={block}
|
||||
styles={markdownStyles}
|
||||
rules={markdownRules}
|
||||
parser={markdownParser}
|
||||
onLinkPress={handleLinkPress}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -1384,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
32,
|
||||
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
|
||||
);
|
||||
const isWebShimmer = isLoading && Platform.OS === "web";
|
||||
const isWebShimmer = isLoading && isWeb;
|
||||
const shouldMeasureWebShimmer = isWebShimmer;
|
||||
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
|
||||
const shouldMeasureNativeShimmer = isLoading && isNative;
|
||||
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
|
||||
const webShimmerSpanStartX = labelOffsetX;
|
||||
const webShimmerSpanEndX = secondaryLabel
|
||||
@@ -1463,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
||||
if (isNative || !isExpanded || !hasDetailContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1938,7 +2208,11 @@ export const ToolCall = memo(function ToolCall({
|
||||
|
||||
if (effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
|
||||
<PlanCard
|
||||
title="Plan"
|
||||
text={effectiveDetail.text}
|
||||
disableOuterSpacing={disableOuterSpacing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!open || Platform.OS !== "web") return;
|
||||
if (!open || isNative) return;
|
||||
|
||||
function handler(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
||||
import { AlertCircle, Search } from "lucide-react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
interface ProviderDiagnosticSheetProps {
|
||||
provider: string;
|
||||
@@ -21,82 +27,333 @@ export function ProviderDiagnosticSheet({
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
|
||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||
const providerEntry = useMemo(
|
||||
() => snapshotEntries?.find((entry) => entry.provider === provider),
|
||||
[snapshotEntries, provider],
|
||||
);
|
||||
const models = providerEntry?.models ?? [];
|
||||
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||
const providerErrorMessage =
|
||||
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
|
||||
const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
|
||||
|
||||
const fetchDiagnostic = useCallback(async () => {
|
||||
if (!client || !provider) return;
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
const fetchedAtLabel = useMemo(() => {
|
||||
if (!providerEntry?.fetchedAt) return null;
|
||||
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
||||
// clockTick triggers re-computation on timer
|
||||
}, [providerEntry?.fetchedAt, clockTick]);
|
||||
|
||||
setLoading(true);
|
||||
setDiagnostic(null);
|
||||
const q = query.trim().toLowerCase();
|
||||
const filteredModels = q
|
||||
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
|
||||
: models;
|
||||
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, provider]);
|
||||
const fetchDiagnostic = useCallback(
|
||||
async (options?: { keepCurrent?: boolean }) => {
|
||||
if (!client || !provider) return;
|
||||
|
||||
setLoading(true);
|
||||
if (!options?.keepCurrent) {
|
||||
setDiagnostic(null);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[client, provider],
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refresh([provider as AgentProvider]);
|
||||
void fetchDiagnostic({ keepCurrent: true });
|
||||
}, [fetchDiagnostic, provider, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchDiagnostic();
|
||||
} else {
|
||||
setDiagnostic(null);
|
||||
setQuery("");
|
||||
}
|
||||
}, [visible, fetchDiagnostic]);
|
||||
|
||||
function renderModelsBody() {
|
||||
if (models.length === 0 && providerSnapshotRefreshing) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>Loading models…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (models.length === 0 && providerErrorMessage) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>No models detected.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (filteredModels.length === 0) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>No models match your search</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return filteredModels.map((model: AgentModelDefinition, index) => (
|
||||
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
|
||||
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
|
||||
{model.id}
|
||||
</Text>
|
||||
</View>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title={providerLabel}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={["50%", "85%"]}
|
||||
>
|
||||
{loading ? (
|
||||
<View style={sheetStyles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.loadingText}>Fetching diagnostic…</Text>
|
||||
</View>
|
||||
) : diagnostic ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
style={sheetStyles.scrollContainer}
|
||||
contentContainerStyle={sheetStyles.scrollContent}
|
||||
scrollable={false}
|
||||
headerActions={
|
||||
<Pressable
|
||||
onPress={handleRefresh}
|
||||
disabled={refreshInFlight}
|
||||
hitSlop={8}
|
||||
style={({ hovered, pressed }) => [
|
||||
sheetStyles.iconButton,
|
||||
(hovered || pressed) && sheetStyles.iconButtonHovered,
|
||||
refreshInFlight ? sheetStyles.disabled : null,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||
>
|
||||
<Text style={sheetStyles.diagnosticText} selectable>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
<SpinningRefreshIcon
|
||||
spinning={refreshInFlight}
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
<View style={sheetStyles.section}>
|
||||
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
|
||||
<View style={sheetStyles.codeBlock}>
|
||||
{loading && !diagnostic ? (
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>Running diagnostic…</Text>
|
||||
</View>
|
||||
) : diagnostic ? (
|
||||
<ScrollView
|
||||
style={sheetStyles.codeScroll}
|
||||
contentContainerStyle={sheetStyles.codeContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<Text style={sheetStyles.codeText} selectable>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={sheetStyles.modelsSection}>
|
||||
<View style={sheetStyles.modelsHeader}>
|
||||
<Text style={sheetStyles.sectionTitle}>Models</Text>
|
||||
<View style={sheetStyles.modelsHeaderMeta}>
|
||||
<Text style={sheetStyles.countText}>{models.length}</Text>
|
||||
{fetchedAtLabel ? (
|
||||
<>
|
||||
<Text style={sheetStyles.metaDot}>·</Text>
|
||||
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{models.length > 0 ? (
|
||||
<View style={sheetStyles.searchContainer}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<AdaptiveTextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search models"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<ScrollView
|
||||
style={sheetStyles.modelsScroll}
|
||||
contentContainerStyle={sheetStyles.modelsScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderModelsBody()}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetStyles = StyleSheet.create((theme) => ({
|
||||
loadingContainer: {
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
mutedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
iconButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
codeBlock: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.base,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
overflow: "hidden",
|
||||
maxHeight: 180,
|
||||
},
|
||||
codeScroll: {
|
||||
maxHeight: 180,
|
||||
},
|
||||
codeContent: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
},
|
||||
codeBlockLoading: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
modelsSection: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
modelsHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
modelsHeaderMeta: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
metaDot: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
countText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
modelsScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
modelsScrollContent: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
modelRow: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
modelRowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
modelLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
modelId: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: Fonts.mono,
|
||||
marginTop: 2,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[6],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
diagnosticText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: "monospace",
|
||||
lineHeight: theme.fontSize.sm * 1.6,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
|
||||
import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface QuestionOption {
|
||||
label: string;
|
||||
@@ -60,7 +61,7 @@ interface QuestionFormCardProps {
|
||||
isResponding: boolean;
|
||||
}
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -44,7 +44,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
|
||||
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -83,6 +83,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -100,7 +101,10 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
||||
function getWorkspacePrIconColor(theme: ReturnType<typeof useUnistyles>["theme"], state: PrHint["state"]) {
|
||||
function getWorkspacePrIconColor(
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
state: PrHint["state"],
|
||||
) {
|
||||
switch (state) {
|
||||
case "merged":
|
||||
return theme.colors.palette.purple[500];
|
||||
@@ -196,21 +200,12 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
style={({ pressed }) => [
|
||||
styles.workspacePrBadge,
|
||||
pressed && styles.workspacePrBadgePressed,
|
||||
]}
|
||||
onHoverIn={() => setIsHovered(true)}
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]}
|
||||
>
|
||||
<GitPullRequest size={12} color={iconColor} />
|
||||
<Text
|
||||
style={[
|
||||
styles.workspacePrBadgeText,
|
||||
{ color: textColor },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
<Text style={[styles.workspacePrBadgeText, { color: textColor }]} numberOfLines={1}>
|
||||
#{hint.number}
|
||||
</Text>
|
||||
{isHovered && <ExternalLink size={10} color={textColor} />}
|
||||
@@ -241,7 +236,14 @@ function WorkspaceStatusIndicator({
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
||||
<SyncedLoader
|
||||
size={11}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -255,11 +257,7 @@ function WorkspaceStatusIndicator({
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspaceKind === "local_checkout"
|
||||
? Monitor
|
||||
: workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
: null;
|
||||
workspaceKind === "local_checkout" ? Monitor : workspaceKind === "worktree" ? FolderGit2 : null;
|
||||
if (!KindIcon) return null;
|
||||
|
||||
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
|
||||
@@ -349,7 +347,14 @@ function ProjectLeadingVisual({
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
||||
<SyncedLoader
|
||||
size={11}
|
||||
color={
|
||||
theme.colorScheme === "light"
|
||||
? theme.colors.palette.amber[700]
|
||||
: theme.colors.palette.amber[500]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -553,7 +558,7 @@ function useLongPressDragInteraction(input: {
|
||||
input.drag();
|
||||
}, DRAG_ARM_DELAY_MS);
|
||||
|
||||
if (!input.menuController || Platform.OS === "web") {
|
||||
if (!input.menuController || platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -803,7 +808,12 @@ function ProjectHeaderRow({
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
visible={
|
||||
isHovered ||
|
||||
platformIsNative ||
|
||||
isMobileBreakpoint ||
|
||||
createWorktreeMutation.isPending
|
||||
}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
@@ -811,8 +821,11 @@ function ProjectHeaderRow({
|
||||
) : null}
|
||||
{onRemoveProject ? (
|
||||
<View
|
||||
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
|
||||
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
|
||||
style={
|
||||
!(isHovered || platformIsNative || isMobileBreakpoint) &&
|
||||
styles.projectKebabButtonHidden
|
||||
}
|
||||
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -922,8 +935,8 @@ function WorkspaceRowInner({
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isTouchPlatform = Platform.OS !== "web";
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
@@ -988,7 +1001,7 @@ function WorkspaceRowInner({
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isTouchPlatform) ? (
|
||||
{onArchive && (isHovered || platformIsNative || isCompact) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
@@ -1607,17 +1620,13 @@ export function SidebarWorkspaceList({
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isNative = Platform.OS !== "web";
|
||||
const pathname = usePathname();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown);
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown);
|
||||
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown);
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
@@ -1798,34 +1807,31 @@ export function SidebarWorkspaceList({
|
||||
[getWorkspaceOrder, serverId, setWorkspaceOrder],
|
||||
);
|
||||
|
||||
const handleWorktreeCreated = useCallback(
|
||||
(workspaceId: string) => {
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(workspaceId);
|
||||
return next;
|
||||
});
|
||||
const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
creatingWorkspaceTimeoutsRef.current.set(
|
||||
workspaceId,
|
||||
setTimeout(() => {
|
||||
creatingWorkspaceTimeoutsRef.current.delete(workspaceId);
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
if (!current.has(workspaceId)) {
|
||||
return current;
|
||||
}
|
||||
const next = new Set(current);
|
||||
next.delete(workspaceId);
|
||||
return next;
|
||||
});
|
||||
}, 3000),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleWorktreeCreated = useCallback((workspaceId: string) => {
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(workspaceId);
|
||||
return next;
|
||||
});
|
||||
const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
creatingWorkspaceTimeoutsRef.current.set(
|
||||
workspaceId,
|
||||
setTimeout(() => {
|
||||
creatingWorkspaceTimeoutsRef.current.delete(workspaceId);
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
if (!current.has(workspaceId)) {
|
||||
return current;
|
||||
}
|
||||
const next = new Set(current);
|
||||
next.delete(workspaceId);
|
||||
return next;
|
||||
});
|
||||
}, 3000),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderProject = useCallback(
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
||||
@@ -1847,7 +1853,7 @@ export function SidebarWorkspaceList({
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
useNestable={isNative}
|
||||
useNestable={platformIsNative}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
/>
|
||||
);
|
||||
@@ -1864,7 +1870,7 @@ export function SidebarWorkspaceList({
|
||||
serverId,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
isNative,
|
||||
platformIsNative,
|
||||
creatingWorkspaceIds,
|
||||
],
|
||||
);
|
||||
@@ -1875,12 +1881,7 @@ export function SidebarWorkspaceList({
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyTitle}>No projects yet</Text>
|
||||
<Text style={styles.emptyText}>Add a project to get started</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={Plus}
|
||||
onPress={onAddProject}
|
||||
>
|
||||
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
||||
Add project
|
||||
</Button>
|
||||
</View>
|
||||
@@ -1893,7 +1894,7 @@ export function SidebarWorkspaceList({
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={isNative}
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
@@ -1904,7 +1905,7 @@ export function SidebarWorkspaceList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isNative ? (
|
||||
{platformIsNative ? (
|
||||
<NestableScrollContainer
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
|
||||
67
packages/app/src/components/spinning-refresh-icon.tsx
Normal file
67
packages/app/src/components/spinning-refresh-icon.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from "react";
|
||||
import { RefreshCw } from "lucide-react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface SpinningRefreshIconProps {
|
||||
spinning: boolean;
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
|
||||
const rotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (spinning) {
|
||||
rotation.value = 0;
|
||||
rotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation(rotation);
|
||||
const remainder = rotation.value % 360;
|
||||
if (Math.abs(remainder) < 0.001) {
|
||||
rotation.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
rotation.value = withTiming(360, {
|
||||
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
|
||||
easing: Easing.linear,
|
||||
});
|
||||
}, [rotation, spinning]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
<RefreshCw size={size} color={color} />
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { Platform, View, Text } from "react-native";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface SplitContainerProps {
|
||||
layout: WorkspaceLayout;
|
||||
@@ -177,7 +178,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
paneId,
|
||||
buildPaneContentModel,
|
||||
}: MountedTabSlotProps) {
|
||||
|
||||
const content = useMemo(
|
||||
() =>
|
||||
buildPaneContentModel({
|
||||
@@ -839,7 +839,7 @@ function SplitPaneView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -877,12 +877,7 @@ function SplitPaneView({
|
||||
|
||||
return (
|
||||
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
||||
<View
|
||||
style={[
|
||||
styles.paneTabs,
|
||||
{ paddingLeft: padding.left, paddingRight: padding.right },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }]}>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
paneId={pane.id}
|
||||
@@ -920,27 +915,25 @@ function SplitPaneView({
|
||||
</View>
|
||||
|
||||
<View style={styles.paneContent}>
|
||||
{mountedPaneTabIds.length > 0 ? (
|
||||
mountedPaneTabIds.map((tabId) => {
|
||||
const tabDescriptor = tabDescriptorMap.get(tabId);
|
||||
if (!tabDescriptor) {
|
||||
return null;
|
||||
}
|
||||
{mountedPaneTabIds.length > 0
|
||||
? mountedPaneTabIds.map((tabId) => {
|
||||
const tabDescriptor = tabDescriptorMap.get(tabId);
|
||||
if (!tabDescriptor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MountedTabSlot
|
||||
key={tabId}
|
||||
tabDescriptor={tabDescriptor}
|
||||
isVisible={tabId === activeTabDescriptor?.tabId}
|
||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||
paneId={pane.id}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
(renderPaneEmptyState?.() ?? null)
|
||||
)}
|
||||
return (
|
||||
<MountedTabSlot
|
||||
key={tabId}
|
||||
tabDescriptor={tabDescriptor}
|
||||
isVisible={tabId === activeTabDescriptor?.tabId}
|
||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||
paneId={pane.id}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: (renderPaneEmptyState?.() ?? null)}
|
||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -16,7 +16,7 @@ const DOT_COUNT = DOT_SEQUENCE.length;
|
||||
const GRID_ROWS = 3;
|
||||
const GRID_COLUMNS = 2;
|
||||
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.72, 0.46, 0.22, 0] as const;
|
||||
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
|
||||
const sharedStepProgress = makeMutable(0);
|
||||
let sharedLoopStarted = false;
|
||||
|
||||
|
||||
@@ -82,12 +82,7 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||
return `${input.serverId}:${input.cwd}`;
|
||||
}
|
||||
|
||||
export function TerminalPane({
|
||||
serverId,
|
||||
cwd,
|
||||
terminalId,
|
||||
isPaneFocused,
|
||||
}: TerminalPaneProps) {
|
||||
export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) {
|
||||
const isScreenFocused = useIsFocused();
|
||||
const isAppVisible = useAppVisible();
|
||||
const { theme } = useUnistyles();
|
||||
@@ -108,7 +103,10 @@ export function TerminalPane({
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
|
||||
const workspaceTerminalSession = useMemo(
|
||||
() => getWorkspaceTerminalSession({ scopeKey }),
|
||||
[scopeKey],
|
||||
);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
@@ -473,26 +471,32 @@ export function TerminalPane({
|
||||
],
|
||||
);
|
||||
|
||||
const handleTerminalResize = useStableEvent(
|
||||
(input: { rows: number; cols: number }) => {
|
||||
const { rows, cols } = input;
|
||||
if (!client || !terminalId || !isPaneFocused || !isScreenFocused || !isAppVisible || rows <= 0 || cols <= 0) {
|
||||
return;
|
||||
}
|
||||
const normalizedRows = Math.floor(rows);
|
||||
const normalizedCols = Math.floor(cols);
|
||||
const previous = lastReportedSizeRef.current;
|
||||
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
|
||||
return;
|
||||
}
|
||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
});
|
||||
},
|
||||
);
|
||||
const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => {
|
||||
const { rows, cols } = input;
|
||||
if (
|
||||
!client ||
|
||||
!terminalId ||
|
||||
!isPaneFocused ||
|
||||
!isScreenFocused ||
|
||||
!isAppVisible ||
|
||||
rows <= 0 ||
|
||||
cols <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const normalizedRows = Math.floor(rows);
|
||||
const normalizedCols = Math.floor(cols);
|
||||
const previous = lastReportedSizeRef.current;
|
||||
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
|
||||
return;
|
||||
}
|
||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
});
|
||||
});
|
||||
|
||||
const handleTerminalKey = useCallback(
|
||||
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-nati
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ export function useToastHost(): {
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
} {
|
||||
const { theme } = useUnistyles();
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
@@ -84,11 +86,11 @@ export function useToastHost(): {
|
||||
copied: (label?: string) =>
|
||||
show(label ? `Copied ${label}` : "Copied", {
|
||||
variant: "success",
|
||||
icon: <CheckCircle2 size={18} />,
|
||||
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
|
||||
}),
|
||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
||||
}),
|
||||
[show],
|
||||
[show, theme.colors.foreground],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
@@ -113,6 +115,8 @@ export function ToastViewport({
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(-8)).current;
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dismissDeadlineRef = useRef<number | null>(null);
|
||||
const remainingDurationRef = useRef(0);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
@@ -143,9 +147,39 @@ export function ToastViewport({
|
||||
});
|
||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||
|
||||
const scheduleDismiss = useCallback(
|
||||
(durationMs: number) => {
|
||||
clearTimer();
|
||||
const nextDurationMs = Math.max(0, durationMs);
|
||||
remainingDurationRef.current = nextDurationMs;
|
||||
dismissDeadlineRef.current = Date.now() + nextDurationMs;
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animateOut();
|
||||
}, nextDurationMs);
|
||||
},
|
||||
[animateOut, clearTimer],
|
||||
);
|
||||
|
||||
const pauseDismiss = useCallback(() => {
|
||||
if (dismissDeadlineRef.current !== null) {
|
||||
remainingDurationRef.current = Math.max(0, dismissDeadlineRef.current - Date.now());
|
||||
}
|
||||
dismissDeadlineRef.current = null;
|
||||
clearTimer();
|
||||
}, [clearTimer]);
|
||||
|
||||
const resumeDismiss = useCallback(() => {
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
scheduleDismiss(remainingDurationRef.current || toast.durationMs);
|
||||
}, [scheduleDismiss, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
clearTimer();
|
||||
dismissDeadlineRef.current = null;
|
||||
remainingDurationRef.current = 0;
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
return;
|
||||
@@ -170,14 +204,13 @@ export function ToastViewport({
|
||||
}),
|
||||
]).start();
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animateOut();
|
||||
}, toast.durationMs);
|
||||
remainingDurationRef.current = toast.durationMs;
|
||||
scheduleDismiss(toast.durationMs);
|
||||
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
||||
}, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
|
||||
|
||||
if (!toast) {
|
||||
return null;
|
||||
@@ -202,6 +235,8 @@ export function ToastViewport({
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "success" ? styles.toastSuccess : null,
|
||||
@@ -219,7 +254,6 @@ export function ToastViewport({
|
||||
<Text
|
||||
testID="app-toast-message"
|
||||
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{toast.content}
|
||||
</Text>
|
||||
@@ -232,7 +266,7 @@ export function ToastViewport({
|
||||
</View>
|
||||
);
|
||||
|
||||
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
|
||||
if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
@@ -255,7 +289,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[2],
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useMemo, ReactNode } from "react";
|
||||
import { View, Text, Platform, ScrollView as RNScrollView } from "react-native";
|
||||
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
|
||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
||||
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
|
||||
// ---- Content Component ----
|
||||
|
||||
@@ -29,6 +31,7 @@ export function ToolCallDetailsContent({
|
||||
showLoadingSkeleton = false,
|
||||
}: ToolCallDetailsContentProps) {
|
||||
const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
// Compute diff lines for edit type
|
||||
const diffLines = useMemo(() => {
|
||||
@@ -64,6 +67,7 @@ export function ToolCallDetailsContent({
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -73,6 +77,7 @@ export function ToolCallDetailsContent({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
@@ -98,6 +103,7 @@ export function ToolCallDetailsContent({
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -107,6 +113,7 @@ export function ToolCallDetailsContent({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
@@ -136,6 +143,7 @@ export function ToolCallDetailsContent({
|
||||
styles.codeVerticalScroll,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -145,6 +153,7 @@ export function ToolCallDetailsContent({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
@@ -180,12 +189,18 @@ export function ToolCallDetailsContent({
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
@@ -203,12 +218,18 @@ export function ToolCallDetailsContent({
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
@@ -235,12 +256,18 @@ export function ToolCallDetailsContent({
|
||||
style={[
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
@@ -285,12 +312,18 @@ export function ToolCallDetailsContent({
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
|
||||
</Text>
|
||||
@@ -355,7 +388,7 @@ export function ToolCallDetailsContent({
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
style={styles.jsonScroll}
|
||||
style={[styles.jsonScroll, webScrollbarStyle]}
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
@@ -377,7 +410,7 @@ export function ToolCallDetailsContent({
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
style={[styles.jsonScroll, styles.jsonScrollError]}
|
||||
style={[styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle]}
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
@@ -511,7 +544,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
|
||||
@@ -136,21 +136,32 @@ export function Button({
|
||||
return <View>{leftIcon}</View>;
|
||||
}
|
||||
|
||||
const color = variant === "default"
|
||||
? theme.colors.accentForeground
|
||||
: variant === "ghost"
|
||||
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
|
||||
: theme.colors.foreground;
|
||||
const color =
|
||||
variant === "default"
|
||||
? theme.colors.accentForeground
|
||||
: variant === "ghost"
|
||||
? isGhostHovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
: theme.colors.foreground;
|
||||
const iconSize = ICON_SIZE[size];
|
||||
|
||||
// Render function
|
||||
if (typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0) {
|
||||
if (
|
||||
typeof leftIcon === "function" &&
|
||||
!leftIcon.prototype?.isReactComponent &&
|
||||
leftIcon.length > 0
|
||||
) {
|
||||
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
|
||||
}
|
||||
|
||||
// Component type
|
||||
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
|
||||
return <View><Icon color={color} size={iconSize} /></View>;
|
||||
return (
|
||||
<View>
|
||||
<Icon color={color} size={iconSize} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -37,8 +37,9 @@ import {
|
||||
shouldShowCustomComboboxOption,
|
||||
} from "./combobox-options";
|
||||
import type { ComboboxOptionModel } from "./combobox-options";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export type ComboboxOption = ComboboxOptionModel;
|
||||
|
||||
@@ -107,6 +108,7 @@ export interface SearchInputProps {
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
autoFocus?: boolean;
|
||||
useBottomSheetInput?: boolean;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
@@ -115,10 +117,11 @@ export function SearchInput({
|
||||
onChangeText,
|
||||
onSubmitEditing,
|
||||
autoFocus = false,
|
||||
useBottomSheetInput = false,
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
||||
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
@@ -264,9 +267,8 @@ export function Combobox({
|
||||
}: ComboboxProps): ReactElement {
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||
const isDesktopAboveSearch =
|
||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
||||
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const hasPresentedBottomSheetRef = useRef(false);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
@@ -274,11 +276,13 @@ export function Combobox({
|
||||
null,
|
||||
);
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
|
||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
|
||||
|
||||
const isControlled = typeof open === "boolean";
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
@@ -322,8 +326,8 @@ export function Combobox({
|
||||
|
||||
const middleware = useMemo(
|
||||
() => [
|
||||
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
||||
floatingOffset(isWeb ? 5 : 4),
|
||||
...(isWeb ? [] : [flip({ padding: collisionPadding })]),
|
||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||
floatingSize({
|
||||
padding: collisionPadding,
|
||||
@@ -336,6 +340,9 @@ export function Combobox({
|
||||
});
|
||||
setReferenceWidth((prev) => {
|
||||
const next = rects.reference.width;
|
||||
if (!(next > 0)) {
|
||||
return prev;
|
||||
}
|
||||
if (prev === next) return prev;
|
||||
return next;
|
||||
});
|
||||
@@ -346,7 +353,7 @@ export function Combobox({
|
||||
);
|
||||
|
||||
const { refs, floatingStyles, update } = useFloating({
|
||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
||||
placement: isWeb ? desktopPlacement : "bottom-start",
|
||||
middleware,
|
||||
sameScrollView: false,
|
||||
elements: {
|
||||
@@ -357,15 +364,18 @@ export function Combobox({
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setAvailableSize(null);
|
||||
setDesktopContentWidth(null);
|
||||
setReferenceLeft(null);
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => void update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
||||
}, [desktopPlacement, isMobile, isOpen, update]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || isMobile) {
|
||||
setReferenceLeft(null);
|
||||
setReferenceAtOrigin(false);
|
||||
setReferenceTop(null);
|
||||
return;
|
||||
@@ -379,9 +389,16 @@ export function Combobox({
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
referenceEl.measureInWindow((x, y) => {
|
||||
referenceEl.measureInWindow((x, y, width, height) => {
|
||||
setReferenceLeft((prev) => (prev === x ? prev : x));
|
||||
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
||||
setReferenceTop((prev) => (prev === y ? prev : y));
|
||||
setReferenceWidth((prev) => {
|
||||
if (!(width > 0)) {
|
||||
return prev;
|
||||
}
|
||||
return prev === width ? prev : width;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -396,32 +413,46 @@ export function Combobox({
|
||||
isDesktopAboveSearch && referenceTop !== null
|
||||
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||
: null;
|
||||
const hasResolvedDesktopPosition =
|
||||
referenceWidth !== null &&
|
||||
floatingLeft !== null &&
|
||||
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
|
||||
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
|
||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||
// For top-placed popups: once position resolves, use bottom-based CSS positioning
|
||||
// so height changes grow upward naturally without floating-ui needing to reposition.
|
||||
const useStableBottom =
|
||||
const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
|
||||
const useMeasuredTopStartPosition =
|
||||
!isDesktopAboveSearch &&
|
||||
IS_WEB &&
|
||||
!isMobile &&
|
||||
hasResolvedDesktopPosition &&
|
||||
desktopPlacement.startsWith("top") &&
|
||||
referenceTop !== null;
|
||||
desktopPlacement === "top-start" &&
|
||||
referenceTop !== null &&
|
||||
referenceLeft !== null &&
|
||||
desktopContentWidth !== null;
|
||||
const clampedMeasuredTopStartLeft = useMeasuredTopStartPosition
|
||||
? Math.max(
|
||||
collisionPadding,
|
||||
Math.min(windowWidth - desktopContentWidth - collisionPadding, referenceLeft),
|
||||
)
|
||||
: null;
|
||||
const measuredTopStartBottom = useMeasuredTopStartPosition
|
||||
? Math.max(windowHeight - referenceTop + 5, collisionPadding)
|
||||
: null;
|
||||
const hasResolvedDesktopPosition =
|
||||
referenceWidth !== null &&
|
||||
referenceWidth > 0 &&
|
||||
(isDesktopAboveSearch
|
||||
? floatingLeft !== null && desktopAboveSearchBottom !== null
|
||||
: useMeasuredTopStartPosition
|
||||
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
|
||||
: floatingLeft !== null &&
|
||||
floatingTop !== null &&
|
||||
(hasNonZeroFloatingPosition || !referenceAtOrigin));
|
||||
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
|
||||
const shouldUseDesktopFade = !desktopPreventInitialFlash;
|
||||
|
||||
const desktopPositionStyle = isDesktopAboveSearch
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: desktopAboveSearchBottom ?? 0,
|
||||
}
|
||||
: useStableBottom
|
||||
: useMeasuredTopStartPosition
|
||||
? {
|
||||
left: floatingLeft ?? 0,
|
||||
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
|
||||
left: clampedMeasuredTopStartLeft ?? 0,
|
||||
bottom: measuredTopStartBottom ?? 0,
|
||||
}
|
||||
: floatingStyles;
|
||||
|
||||
@@ -626,6 +657,7 @@ export function Combobox({
|
||||
onChangeText={setSearchQueryWithCallback}
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
autoFocus={!isMobile}
|
||||
useBottomSheetInput={isMobile}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -725,7 +757,13 @@ export function Combobox({
|
||||
]}
|
||||
ref={refs.setFloating}
|
||||
collapsable={false}
|
||||
onLayout={() => update()}
|
||||
onLayout={(event) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setDesktopContentWidth((prev) => (prev === width ? prev : width));
|
||||
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
|
||||
void update();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children ? (
|
||||
<>
|
||||
@@ -852,6 +890,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
comboboxItemLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 0,
|
||||
},
|
||||
comboboxItemDescription: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
// Keep parity with dropdown-menu action statuses.
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -254,8 +256,7 @@ export function ContextMenuTrigger({
|
||||
>): ReactElement {
|
||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||
|
||||
const shouldEnableOnThisPlatform =
|
||||
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
|
||||
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
|
||||
|
||||
const openAtEvent = useCallback(
|
||||
(event: unknown) => {
|
||||
@@ -294,7 +295,7 @@ export function ContextMenuTrigger({
|
||||
disabled={disabled}
|
||||
delayLongPress={longPressDelayMs}
|
||||
onLongPress={(event) => {
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
props.onLongPress?.(event);
|
||||
return;
|
||||
}
|
||||
@@ -303,7 +304,7 @@ export function ContextMenuTrigger({
|
||||
}}
|
||||
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
||||
onContextMenu={(event: unknown) => {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
const e: any = event;
|
||||
@@ -348,6 +349,7 @@ export function ContextMenuContent({
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const context = useContextMenuContext("ContextMenuContent");
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||
@@ -537,6 +539,7 @@ export function ContextMenuContent({
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -28,6 +28,7 @@ import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
// Action status for menu items with loading/success feedback
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -267,6 +268,7 @@ export function DropdownMenuContent({
|
||||
}>): ReactElement | null {
|
||||
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
@@ -411,6 +413,7 @@ export function DropdownMenuContent({
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
{children}
|
||||
@@ -460,6 +463,7 @@ export function DropdownMenuItem({
|
||||
description,
|
||||
onSelect,
|
||||
disabled,
|
||||
muted = false,
|
||||
destructive,
|
||||
selected,
|
||||
showSelectedCheck = false,
|
||||
@@ -477,6 +481,7 @@ export function DropdownMenuItem({
|
||||
description?: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
muted?: boolean;
|
||||
destructive?: boolean;
|
||||
selected?: boolean;
|
||||
showSelectedCheck?: boolean;
|
||||
@@ -550,6 +555,7 @@ export function DropdownMenuItem({
|
||||
? styles.itemSelectedInteractive
|
||||
: null,
|
||||
isDisabled ? styles.itemDisabled : null,
|
||||
muted && !isDisabled ? styles.itemMuted : null,
|
||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||
pressed && !isDisabled ? styles.itemPressed : null,
|
||||
]}
|
||||
@@ -568,6 +574,7 @@ export function DropdownMenuItem({
|
||||
destructive && !isSuccess ? styles.itemTextDestructive : null,
|
||||
isSuccess ? styles.itemTextSuccess : null,
|
||||
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
|
||||
muted && !isDisabled ? styles.itemTextMuted : null,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
@@ -678,11 +685,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
itemDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
itemMuted: {
|
||||
opacity: 0.72,
|
||||
},
|
||||
itemText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
itemTextMuted: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
itemTextDestructive: {
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
type Align = "start" | "center" | "end";
|
||||
@@ -108,18 +110,6 @@ function measureElement(element: View): Promise<Rect> {
|
||||
});
|
||||
}
|
||||
|
||||
function isMobileTooltipEnvironment(): boolean {
|
||||
if (Platform.OS !== "web") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
||||
}
|
||||
|
||||
function computePosition({
|
||||
triggerRect,
|
||||
contentSize,
|
||||
@@ -227,8 +217,8 @@ export function Tooltip({
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
const isMobile = isMobileTooltipEnvironment();
|
||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -236,10 +226,10 @@ export function Tooltip({
|
||||
setOpen: setIsOpen,
|
||||
triggerRef,
|
||||
enabled,
|
||||
openOnPress: isMobile,
|
||||
openOnPress: isCompact,
|
||||
delayDuration,
|
||||
}),
|
||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
||||
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
|
||||
);
|
||||
|
||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||
@@ -354,7 +344,7 @@ export function TooltipTrigger({
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
onPress: handlePress,
|
||||
...(Platform.OS === "web"
|
||||
...(isWeb
|
||||
? ({
|
||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||
onPointerEnter: handleHoverIn,
|
||||
@@ -473,7 +463,7 @@ export function TooltipContent({
|
||||
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
|
||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||
if (Platform.OS === "web") {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
||||
import {
|
||||
Platform,
|
||||
type FlatList,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
useWebDesktopScrollbarMetrics,
|
||||
type ScrollbarMetrics,
|
||||
} from "./web-desktop-scrollbar";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
||||
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
|
||||
contentRef?: RefObject<HTMLElement | null>;
|
||||
},
|
||||
): ReactNode {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const enabled = (options?.enabled ?? true) && isWeb;
|
||||
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||
const contentRef = options?.contentRef;
|
||||
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
@@ -105,7 +104,9 @@ export function useWebElementScrollbar(
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return <WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />;
|
||||
return (
|
||||
<WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />
|
||||
);
|
||||
}
|
||||
|
||||
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
|
||||
@@ -123,8 +124,7 @@ export function useWebScrollViewScrollbar(
|
||||
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
||||
options?: { enabled?: boolean },
|
||||
): WebScrollViewScrollbar {
|
||||
const isWeb = Platform.OS === "web";
|
||||
const enabled = (options?.enabled ?? true) && isWeb;
|
||||
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||
const metricsHook = useWebDesktopScrollbarMetrics();
|
||||
|
||||
const onScrollToOffset = useCallback(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
PanResponder,
|
||||
Platform,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
computeScrollOffsetFromDragDelta,
|
||||
computeVerticalScrollbarGeometry,
|
||||
} from "./web-desktop-scrollbar.math";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
|
||||
const METRICS_EPSILON = 0.5;
|
||||
const HANDLE_WIDTH_IDLE = 6;
|
||||
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
|
||||
maxScrollOffset: 0,
|
||||
});
|
||||
const onScrollToOffsetRef = useRef(onScrollToOffset);
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
|
||||
const normalizedOffset = inverted
|
||||
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
);
|
||||
|
||||
const panResponder = useMemo(() => {
|
||||
if (isWeb) {
|
||||
if (platformIsWeb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
|
||||
setIsDragging(false);
|
||||
},
|
||||
});
|
||||
}, [applyDragDelta, isWeb]);
|
||||
}, [applyDragDelta, platformIsWeb]);
|
||||
|
||||
const startWebDrag = useCallback(
|
||||
(event: any) => {
|
||||
if (!isWeb) {
|
||||
if (!platformIsWeb) {
|
||||
return;
|
||||
}
|
||||
const clientY = readClientY(event);
|
||||
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
dragStartClientYRef.current = clientY;
|
||||
setIsDragging(true);
|
||||
},
|
||||
[isWeb],
|
||||
[platformIsWeb],
|
||||
);
|
||||
|
||||
const handleGrabHoverIn = useCallback(() => {
|
||||
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !isDragging) {
|
||||
if (!platformIsWeb || !isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
};
|
||||
}, [applyDragDelta, isDragging, isWeb]);
|
||||
}, [applyDragDelta, isDragging, platformIsWeb]);
|
||||
|
||||
if (!enabled || !geometry.isVisible) {
|
||||
return null;
|
||||
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
height: thumbRegionHeight,
|
||||
transform: [{ translateY: thumbRegionOffset }],
|
||||
},
|
||||
isWeb &&
|
||||
platformIsWeb &&
|
||||
({
|
||||
cursor: handleCursor,
|
||||
touchAction: "none",
|
||||
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
]}
|
||||
pointerEvents={handleVisible ? "auto" : "none"}
|
||||
{...(panResponder?.panHandlers ?? {})}
|
||||
{...(isWeb
|
||||
{...(platformIsWeb
|
||||
? ({
|
||||
onPointerDown: startWebDrag,
|
||||
onPointerEnter: handleGrabHoverIn,
|
||||
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
|
||||
backgroundColor: handleColor,
|
||||
opacity: handleOpacity,
|
||||
},
|
||||
isWeb &&
|
||||
platformIsWeb &&
|
||||
({
|
||||
transitionProperty: "opacity, width, background-color",
|
||||
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { Pressable, Text, View, Platform, ScrollView } from "react-native";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
||||
@@ -18,6 +18,7 @@ import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
type WelcomeAction = {
|
||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||
@@ -255,52 +256,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
[router],
|
||||
);
|
||||
|
||||
const actions: WelcomeAction[] =
|
||||
Platform.OS === "web"
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: "Scan QR code",
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
onPress: () => router.push("/pair-scan?source=onboarding"),
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
];
|
||||
const actions: WelcomeAction[] = isWeb
|
||||
? [
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: true,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "scan-qr",
|
||||
label: "Scan QR code",
|
||||
testID: "welcome-scan-qr",
|
||||
primary: true,
|
||||
icon: QrCode,
|
||||
onPress: () => router.push("/pair-scan?source=onboarding"),
|
||||
},
|
||||
{
|
||||
key: "direct-connection",
|
||||
label: "Direct connection",
|
||||
testID: "welcome-direct-connection",
|
||||
primary: false,
|
||||
icon: Link2,
|
||||
onPress: () => setIsDirectOpen(true),
|
||||
},
|
||||
{
|
||||
key: "paste-pairing-link",
|
||||
label: "Paste pairing link",
|
||||
testID: "welcome-paste-pairing-link",
|
||||
primary: false,
|
||||
icon: ClipboardPaste,
|
||||
onPress: () => setIsPasteLinkOpen(true),
|
||||
},
|
||||
];
|
||||
|
||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||
|
||||
@@ -321,15 +321,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||
</Text>
|
||||
|
||||
{!showHostList && Platform.OS !== "web" && (
|
||||
{!showHostList && isNative && (
|
||||
<>
|
||||
<Text style={styles.setupHint}>
|
||||
You need the Paseo desktop app or server running on your computer first.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.setupLink}
|
||||
onPress={() => openExternalUrl("https://paseo.sh")}
|
||||
>
|
||||
<Pressable style={styles.setupLink} onPress={() => openExternalUrl("https://paseo.sh")}>
|
||||
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
|
||||
<ExternalLink size={14} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
export const FOOTER_HEIGHT = 75;
|
||||
|
||||
@@ -24,43 +23,10 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
||||
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
||||
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
||||
|
||||
// Check if running in the Electron desktop runtime (any OS)
|
||||
function isElectronDesktopRuntime(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
return isElectronRuntime();
|
||||
}
|
||||
|
||||
// Check if running in the Electron desktop runtime on macOS
|
||||
function isElectronDesktopRuntimeMac(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
return isElectronRuntimeMac();
|
||||
}
|
||||
|
||||
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
|
||||
let _isElectronRuntimeMacCached: boolean | null = null;
|
||||
let _isElectronRuntimeCached: boolean | null = null;
|
||||
|
||||
export function getIsElectronRuntimeMac(): boolean {
|
||||
if (_isElectronRuntimeMacCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isElectronDesktopRuntimeMac();
|
||||
if (result) {
|
||||
_isElectronRuntimeMacCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getIsElectronRuntime(): boolean {
|
||||
if (_isElectronRuntimeCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isElectronDesktopRuntime();
|
||||
if (result) {
|
||||
_isElectronRuntimeCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export {
|
||||
getIsElectron as getIsElectronRuntime,
|
||||
getIsElectronMac as getIsElectronRuntimeMac,
|
||||
} from "./platform";
|
||||
|
||||
/**
|
||||
* Reactive hook — re-renders the component when the breakpoint changes.
|
||||
@@ -75,5 +41,5 @@ export function useIsCompactFormFactor(): boolean {
|
||||
// Keep that capability distinct from desktop-width layout so touch tablets
|
||||
// can use the desktop shell without entering web-only code paths.
|
||||
export function supportsDesktopPaneSplits(): boolean {
|
||||
return Platform.OS === "web";
|
||||
return isWeb;
|
||||
}
|
||||
|
||||
49
packages/app/src/constants/platform.ts
Normal file
49
packages/app/src/constants/platform.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Platform } from "react-native";
|
||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime environment constants
|
||||
//
|
||||
// These are the ONLY platform gates in the app. See CLAUDE.md for the
|
||||
// decision matrix on when to use each one.
|
||||
//
|
||||
// Default is cross-platform. Gate only when you must:
|
||||
// isWeb → DOM APIs (document, window, <div>, addEventListener)
|
||||
// isNative → Native-only APIs (Haptics, StatusBar, push tokens, camera)
|
||||
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
|
||||
//
|
||||
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
|
||||
// For hover, use onHoverIn/onHoverOut on Pressable — no platform gate needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Browser or Electron — the JS runtime has access to the DOM. */
|
||||
export const isWeb = Platform.OS === "web";
|
||||
|
||||
/** iOS or Android — the JS runtime is React Native. */
|
||||
export const isNative = Platform.OS !== "web";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Electron detection (cached — only caches `true`, keeps checking if false
|
||||
// because the desktop bridge may load after initial module evaluation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _isElectronCached: boolean | null = null;
|
||||
let _isElectronMacCached: boolean | null = null;
|
||||
|
||||
/** Running inside the Electron desktop wrapper (any OS). */
|
||||
export function getIsElectron(): boolean {
|
||||
if (_isElectronCached === true) return true;
|
||||
if (!isWeb) return false;
|
||||
const result = isElectronRuntime();
|
||||
if (result) _isElectronCached = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Running inside the Electron desktop wrapper on macOS. */
|
||||
export function getIsElectronMac(): boolean {
|
||||
if (_isElectronMacCached === true) return true;
|
||||
if (!isWeb) return false;
|
||||
const result = isElectronRuntimeMac();
|
||||
if (result) _isElectronMacCached = true;
|
||||
return result;
|
||||
}
|
||||
@@ -91,13 +91,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
||||
|
||||
translateX.value = targets.translateX;
|
||||
backdropOpacity.value = targets.backdropOpacity;
|
||||
}, [
|
||||
isOpen,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
windowWidth,
|
||||
isGesturing,
|
||||
]);
|
||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||
|
||||
const animateToOpen = () => {
|
||||
"worklet";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { AppState } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
||||
@@ -52,6 +52,7 @@ import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -547,7 +548,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
(awayMs: number) => {
|
||||
scheduleAuthoritativeRevalidation();
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agentId = session?.focusedAgentId;
|
||||
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
|
||||
@@ -1155,7 +1156,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
const existingWorkspace = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.workspaces.get(workspace.id);
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
});
|
||||
|
||||
const unsubStatus = client.on("status", (message) => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
flushHeadToTail,
|
||||
hydrateStreamState,
|
||||
reduceStreamUpdate,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
@@ -214,12 +219,24 @@ export function processTimelineResponse(
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
// Flush head to tail before appending canonical entries so that
|
||||
// chronological ordering is preserved. This matters on mobile where
|
||||
// the server drops live events for backgrounded/unfocused agents:
|
||||
// when the client catches up, the head may still hold stale live
|
||||
// items from before the gap that must be committed ahead of the
|
||||
// canonical gap-fill entries.
|
||||
const baseTail =
|
||||
currentHead.length > 0 ? flushHeadToTail(currentTail, currentHead) : currentTail;
|
||||
if (currentHead.length > 0) {
|
||||
nextHead = [];
|
||||
}
|
||||
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
baseTail,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,7 @@ import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Play,
|
||||
Pause,
|
||||
RotateCw,
|
||||
Copy,
|
||||
FileText,
|
||||
Activity,
|
||||
} from "lucide-react-native";
|
||||
import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -106,7 +98,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
});
|
||||
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]);
|
||||
}, [
|
||||
daemonActionLabel,
|
||||
daemonStatus?.status,
|
||||
isRestartingDaemon,
|
||||
refetch,
|
||||
setStatus,
|
||||
showSection,
|
||||
]);
|
||||
|
||||
const handleToggleDaemonManagement = useCallback(() => {
|
||||
if (isUpdatingDaemonManagement) {
|
||||
@@ -394,7 +393,9 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
snapPoints={["70%", "92%"]}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{daemonLogs?.logPath ?? "Log path unavailable."}
|
||||
</Text>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
|
||||
@@ -87,7 +87,9 @@ export function IntegrationsSection() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
leftIcon={
|
||||
<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
|
||||
@@ -98,7 +100,9 @@ export function IntegrationsSection() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
|
||||
leftIcon={
|
||||
<ArrowUpRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
}
|
||||
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||
style={settingsStyles.sectionHeaderLink}
|
||||
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
|
||||
|
||||
@@ -38,10 +38,7 @@ export interface DesktopOpenerBridge {
|
||||
}
|
||||
|
||||
export interface DesktopMenuBridge {
|
||||
showContextMenu?: (input?: {
|
||||
kind?: "terminal";
|
||||
hasSelection?: boolean;
|
||||
}) => Promise<void>;
|
||||
showContextMenu?: (input?: { kind?: "terminal"; hasSelection?: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DesktopWindowControlsOverlayUpdate {
|
||||
|
||||
@@ -12,7 +12,9 @@ const originalGlobals: GlobalSnapshot = {
|
||||
Notification: (globalThis as { Notification?: unknown }).Notification,
|
||||
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
|
||||
paseoDesktop:
|
||||
typeof window === "undefined" ? undefined : (window as { paseoDesktop?: unknown }).paseoDesktop,
|
||||
typeof globalThis.window === "undefined"
|
||||
? undefined
|
||||
: (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop,
|
||||
};
|
||||
|
||||
function setNavigator(value: unknown): void {
|
||||
@@ -32,8 +34,8 @@ function restoreGlobals(): void {
|
||||
delete (globalThis as { navigator?: unknown }).navigator;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop;
|
||||
if (typeof globalThis.window !== "undefined") {
|
||||
(globalThis.window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ describe("desktop-permissions", () => {
|
||||
|
||||
expect(shouldShowDesktopPermissionSection()).toBe(false);
|
||||
|
||||
(window as { paseoDesktop?: unknown }).paseoDesktop = {};
|
||||
globalThis.window = { paseoDesktop: {} } as unknown as Window & typeof globalThis;
|
||||
expect(shouldShowDesktopPermissionSection()).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
export type DesktopPermissionKind = "notifications" | "microphone";
|
||||
|
||||
@@ -45,7 +45,7 @@ type NavigatorLike = {
|
||||
};
|
||||
|
||||
export function shouldShowDesktopPermissionSection(): boolean {
|
||||
return Platform.OS === "web" && getDesktopHost() !== null;
|
||||
return isWeb && getDesktopHost() !== null;
|
||||
}
|
||||
|
||||
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
||||
@@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
|
||||
}
|
||||
|
||||
function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return null;
|
||||
}
|
||||
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
|
||||
@@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
||||
}
|
||||
|
||||
function getNavigatorLike(): NavigatorLike | null {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return null;
|
||||
}
|
||||
const webNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||
@@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
|
||||
}
|
||||
|
||||
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop notification status is only available on web runtime.",
|
||||
@@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
|
||||
}
|
||||
|
||||
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop microphone status is only available on web runtime.",
|
||||
@@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus>
|
||||
}
|
||||
|
||||
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop notification requests are only available on web runtime.",
|
||||
@@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
|
||||
}
|
||||
|
||||
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||
if (Platform.OS !== "web") {
|
||||
if (isNative) {
|
||||
return status({
|
||||
state: "unavailable",
|
||||
detail: "Desktop microphone requests are only available on web runtime.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user