mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a298b8f70 | ||
|
|
bf1d5d2ab2 | ||
|
|
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 | ||
|
|
05734e8b1b | ||
|
|
c2f3bb73a6 | ||
|
|
1ea5e5a769 | ||
|
|
49e67363b2 | ||
|
|
b9a8ba054d | ||
|
|
2f77674c55 | ||
|
|
120c1b46a4 | ||
|
|
033c4bcbf2 | ||
|
|
0f005172ce | ||
|
|
0e7dfcaf2a | ||
|
|
ebfad945e5 | ||
|
|
b5a0ee9954 | ||
|
|
8a9d738438 | ||
|
|
1d41a50e1f | ||
|
|
32fe4b3beb | ||
|
|
a692c616cb | ||
|
|
edd5503fe8 | ||
|
|
34bd8dfd1b | ||
|
|
201eb6a671 | ||
|
|
9f09a19a09 | ||
|
|
edfd2564a3 | ||
|
|
c1ebb8c915 | ||
|
|
29af07e383 | ||
|
|
17bd036359 | ||
|
|
2683dae5f9 | ||
|
|
914a5dc6ae | ||
|
|
30842398e7 | ||
|
|
b27c1b0729 | ||
|
|
fcf2c14485 | ||
|
|
57eafda6ef | ||
|
|
4925b94743 | ||
|
|
f0b09d90fd | ||
|
|
bf91ea2cc9 | ||
|
|
f83a4d08da | ||
|
|
83f540f10a | ||
|
|
602b548745 | ||
|
|
d98d5457ed |
@@ -0,0 +1,209 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: npx biome format .
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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: Typecheck all packages
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
server-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Fetch origin/main (worktree tests)
|
||||||
|
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||||
|
|
||||||
|
- 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 server tests
|
||||||
|
run: npm run test --workspace=@getpaseo/server
|
||||||
|
env:
|
||||||
|
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/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
|
||||||
|
|
||||||
|
app-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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: Run app unit tests
|
||||||
|
run: npm run test --workspace=@getpaseo/app
|
||||||
|
|
||||||
|
playwright:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Build highlight dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/highlight
|
||||||
|
|
||||||
|
- 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:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|
||||||
|
- name: Upload test artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-results
|
||||||
|
path: |
|
||||||
|
packages/app/test-results/
|
||||||
|
packages/app/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
relay-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build relay
|
||||||
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Run relay tests
|
||||||
|
run: npm run test --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
cli-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Run CLI tests
|
||||||
|
run: npm run test --workspace=@getpaseo/cli
|
||||||
|
env:
|
||||||
|
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: '0'
|
||||||
|
PASEO_DICTATION_ENABLED: '0'
|
||||||
|
PASEO_VOICE_MODE_ENABLED: '0'
|
||||||
@@ -1,5 +1,74 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
|
||||||
|
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
|
||||||
|
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
|
||||||
|
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
|
||||||
|
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
|
||||||
|
- Initializing agents no longer show a loading spinner as if they're running.
|
||||||
|
- Reconnecting to a running agent now works even when session persistence is unavailable.
|
||||||
|
- Error screens on desktop are now scrollable.
|
||||||
|
- Model list refreshes in the background when you open the model selector.
|
||||||
|
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
|
||||||
|
|
||||||
|
## 0.1.51 - 2026-04-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
|
||||||
|
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
|
||||||
|
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Model selector no longer crashes on iPad.
|
||||||
|
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
|
||||||
|
- OpenCode agents show the correct terminal state and refresh models reliably.
|
||||||
|
- Follow-up messages to agents that just finished a turn now work correctly.
|
||||||
|
- Commands now load properly for Pi agents.
|
||||||
|
- Internal debug output no longer appears in Claude agent timelines.
|
||||||
|
- QR scan screen cleaned up with simpler visuals.
|
||||||
|
|
||||||
## 0.1.50 - 2026-04-07
|
## 0.1.50 - 2026-04-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# CI Test Status
|
||||||
|
|
||||||
|
Tracking progress toward all-green CI.
|
||||||
|
|
||||||
|
## CI Jobs
|
||||||
|
|
||||||
|
| Job | Status | Notes |
|
||||||
|
|-----|--------|-------|
|
||||||
|
| format | unknown | `npx biome format .` |
|
||||||
|
| typecheck | unknown | `npm run typecheck` |
|
||||||
|
| server-tests | unknown | unit + integration (vitest) |
|
||||||
|
| app-tests | unknown | unit tests (vitest) |
|
||||||
|
| playwright | unknown | E2E tests (playwright) |
|
||||||
|
| relay-tests | unknown | unit tests (vitest) |
|
||||||
|
| cli-tests | unknown | local tests |
|
||||||
|
|
||||||
|
## Log
|
||||||
|
|
||||||
|
- 2026-04-10: Branch created, automated agents begin iterating
|
||||||
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
|
|||||||
npm run cli -- ls -a -g # List all agents
|
npm run cli -- ls -a -g # List all agents
|
||||||
npm run cli -- daemon status # Check daemon status
|
npm run cli -- daemon status # Check daemon status
|
||||||
npm run typecheck # Always run after changes
|
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.
|
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||||
@@ -45,6 +47,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
- **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 add auth checks to tests** — agent providers handle their own auth.
|
||||||
- **Always run typecheck after every change.**
|
- **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:
|
- **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.
|
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||||
- Never change a field from optional to required.
|
- Never change a field from optional to required.
|
||||||
@@ -52,6 +55,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).
|
- 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?"
|
- 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
|
## Debugging
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
# Contributing to Paseo
|
||||||
|
|
||||||
|
Thanks for taking the time to contribute.
|
||||||
|
|
||||||
|
## How this project works
|
||||||
|
|
||||||
|
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
|
||||||
|
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
||||||
|
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
||||||
|
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
||||||
|
|
||||||
|
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||||
|
|
||||||
|
## How to contribute
|
||||||
|
|
||||||
|
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
||||||
|
2. **Keep it small.** One bug, one flow, one focused change.
|
||||||
|
3. **Open a PR** once there is alignment on scope.
|
||||||
|
|
||||||
|
If you want to propose a direction change, start a conversation.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
Please read these first:
|
||||||
|
|
||||||
|
- [README.md](README.md)
|
||||||
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||||
|
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
||||||
|
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
|
||||||
|
- [docs/TESTING.md](docs/TESTING.md)
|
||||||
|
- [CLAUDE.md](CLAUDE.md)
|
||||||
|
|
||||||
|
## What is most helpful
|
||||||
|
|
||||||
|
The most useful contributions right now are:
|
||||||
|
|
||||||
|
- bug fixes
|
||||||
|
- windows and linux specific fixes
|
||||||
|
- regression fixes
|
||||||
|
- doc improvements
|
||||||
|
- packaging / platform fixes
|
||||||
|
- focused UX improvements that fit the existing product direction
|
||||||
|
- tests that lock down important behavior
|
||||||
|
|
||||||
|
## Scope expectations
|
||||||
|
|
||||||
|
Please keep PRs narrow.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
- fix one bug
|
||||||
|
- improve one flow
|
||||||
|
- add one focused panel or command
|
||||||
|
- tighten one piece of UI
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
- combine multiple product ideas in one PR
|
||||||
|
- bundle unrelated refactors with a feature
|
||||||
|
- sneak in roadmap decisions
|
||||||
|
|
||||||
|
If a contribution contains multiple ideas, split it up.
|
||||||
|
|
||||||
|
## Product fit matters
|
||||||
|
|
||||||
|
Paseo is an opinionated product.
|
||||||
|
|
||||||
|
When reviewing contributions, the bar is not just:
|
||||||
|
|
||||||
|
- is this useful?
|
||||||
|
- is this well implemented?
|
||||||
|
|
||||||
|
It is also:
|
||||||
|
|
||||||
|
- does this fit Paseo?
|
||||||
|
- does this add product surface that will be hard to maintain?
|
||||||
|
- does the value justify the maintenance surface it adds?
|
||||||
|
- does this solve a common need or over-serve an edge case?
|
||||||
|
- does this preserve the product's current direction?
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js matching `.tool-versions`
|
||||||
|
- npm workspaces
|
||||||
|
|
||||||
|
### Start local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# runs both daemon and expo app
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:server
|
||||||
|
npm run dev:app
|
||||||
|
npm run dev:desktop
|
||||||
|
npm run dev:website
|
||||||
|
npm run cli -- ls -a -g
|
||||||
|
```
|
||||||
|
|
||||||
|
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||||
|
|
||||||
|
## Multi-platform testing
|
||||||
|
|
||||||
|
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
||||||
|
|
||||||
|
Common checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run test --workspaces --if-present
|
||||||
|
```
|
||||||
|
|
||||||
|
Important rules:
|
||||||
|
|
||||||
|
- always run `npm run typecheck` after changes
|
||||||
|
- tests should be deterministic
|
||||||
|
- prefer real dependencies over mocks when possible
|
||||||
|
- do not make breaking WebSocket / protocol changes
|
||||||
|
- app and daemon versions in the wild lag each other, so compatibility matters
|
||||||
|
|
||||||
|
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
## Coding standards
|
||||||
|
|
||||||
|
Paseo has explicit standards. Follow them.
|
||||||
|
|
||||||
|
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
||||||
|
|
||||||
|
## PR checklist
|
||||||
|
|
||||||
|
Before opening a PR, make sure:
|
||||||
|
|
||||||
|
- there was prior discussion and alignment on scope (issue or conversation)
|
||||||
|
- the change is focused, one idea per PR
|
||||||
|
- the PR description explains what changed and why
|
||||||
|
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
||||||
|
- UI changes have been tested on mobile and web at minimum
|
||||||
|
- typecheck passes
|
||||||
|
- tests pass, or you clearly explain what could not be run
|
||||||
|
- relevant docs were updated if needed
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
If you are unsure whether something fits, ask first.
|
||||||
|
|
||||||
|
That is especially true for:
|
||||||
|
|
||||||
|
- new core UX
|
||||||
|
- naming / terminology changes
|
||||||
|
- new extension points
|
||||||
|
- new orchestration models
|
||||||
|
- anything that would be hard to remove later
|
||||||
|
|
||||||
|
Early alignment saves everyone time.
|
||||||
|
|
||||||
|
## Forks are fine
|
||||||
|
|
||||||
|
If you want to explore a different product direction, a fork is completely fine.
|
||||||
|
|
||||||
|
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
||||||
@@ -4,6 +4,21 @@
|
|||||||
|
|
||||||
<h1 align="center">Paseo</h1>
|
<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">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
allowedHosts: 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.
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# Data Model
|
||||||
|
|
||||||
|
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
|
||||||
|
|
||||||
|
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
```
|
||||||
|
$PASEO_HOME/
|
||||||
|
├── config.json # Daemon configuration
|
||||||
|
├── agents/
|
||||||
|
│ └── {project-dir}/
|
||||||
|
│ └── {agentId}.json # One file per agent
|
||||||
|
├── schedules/
|
||||||
|
│ └── {scheduleId}.json # One file per schedule
|
||||||
|
├── chat/
|
||||||
|
│ └── rooms.json # All rooms + messages
|
||||||
|
├── loops/
|
||||||
|
│ └── loops.json # All loop records
|
||||||
|
├── projects/
|
||||||
|
│ ├── projects.json # Project registry
|
||||||
|
│ └── workspaces.json # Workspace registry
|
||||||
|
└── push-tokens.json # Expo push notification tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Agent Record
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||||
|
|
||||||
|
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | UUID, primary key |
|
||||||
|
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||||
|
| `cwd` | `string` | Working directory the agent operates in |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||||
|
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||||
|
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||||
|
| `title` | `string?` | User-visible title |
|
||||||
|
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
||||||
|
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||||
|
| `lastModeId` | `string?` | Last active mode ID |
|
||||||
|
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||||
|
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||||
|
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||||
|
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||||
|
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||||
|
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||||
|
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||||
|
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||||
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
|
### Nested: SerializableConfig
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `title` | `string?` | Configured title |
|
||||||
|
| `modeId` | `string?` | Configured mode |
|
||||||
|
| `model` | `string?` | Configured model |
|
||||||
|
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
|
||||||
|
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
|
||||||
|
| `extra` | `Record<string, any>?` | Provider-specific config |
|
||||||
|
| `systemPrompt` | `string?` | Custom system prompt |
|
||||||
|
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
|
||||||
|
|
||||||
|
### Nested: RuntimeInfo
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `provider` | `string` | Active provider |
|
||||||
|
| `sessionId` | `string?` | Active session ID |
|
||||||
|
| `model` | `string?` | Active model |
|
||||||
|
| `thinkingOptionId` | `string?` | Active thinking option |
|
||||||
|
| `modeId` | `string?` | Active mode |
|
||||||
|
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
|
||||||
|
|
||||||
|
### Nested: PersistenceHandle
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `provider` | `string` | Provider that owns the session |
|
||||||
|
| `sessionId` | `string` | Session ID for resumption |
|
||||||
|
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
|
||||||
|
| `metadata` | `Record<string, any>?` | Extra metadata |
|
||||||
|
|
||||||
|
### Nested: AgentFeature (discriminated union on `type`)
|
||||||
|
|
||||||
|
**Toggle:**
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `type` | `"toggle"` |
|
||||||
|
| `id` | `string` |
|
||||||
|
| `label` | `string` |
|
||||||
|
| `description` | `string?` |
|
||||||
|
| `tooltip` | `string?` |
|
||||||
|
| `icon` | `string?` |
|
||||||
|
| `value` | `boolean` |
|
||||||
|
|
||||||
|
**Select:**
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `type` | `"select"` |
|
||||||
|
| `id` | `string` |
|
||||||
|
| `label` | `string` |
|
||||||
|
| `description` | `string?` |
|
||||||
|
| `tooltip` | `string?` |
|
||||||
|
| `icon` | `string?` |
|
||||||
|
| `value` | `string?` |
|
||||||
|
| `options` | `AgentSelectOption[]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Daemon Configuration
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/config.json`
|
||||||
|
|
||||||
|
Single file, validated with `PersistedConfigSchema`.
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
daemon: {
|
||||||
|
listen: "127.0.0.1:6767",
|
||||||
|
allowedHosts: true | string[],
|
||||||
|
mcp: { enabled: boolean },
|
||||||
|
cors: { allowedOrigins: string[] },
|
||||||
|
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
baseUrl: string
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
openai: { apiKey: string },
|
||||||
|
local: { modelsDir: string }
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
providers: {
|
||||||
|
[provider: string]: {
|
||||||
|
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
||||||
|
env: Record<string, string>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||||
|
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
level, format,
|
||||||
|
console: { level, format },
|
||||||
|
file: { level, path, rotate: { maxSize, maxFiles } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All fields are optional with sensible defaults.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Schedule
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||||
|
|
||||||
|
One file per schedule. ID is 8 hex characters.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | 8-char hex ID |
|
||||||
|
| `name` | `string?` | Human-readable name |
|
||||||
|
| `prompt` | `string` | The prompt to send |
|
||||||
|
| `cadence` | `ScheduleCadence` | Timing (see below) |
|
||||||
|
| `target` | `ScheduleTarget` | What to run (see below) |
|
||||||
|
| `status` | `"active" \| "paused" \| "completed"` | Current state |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
|
||||||
|
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
|
||||||
|
| `pausedAt` | `string?` (ISO 8601) | When paused |
|
||||||
|
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
|
||||||
|
| `maxRuns` | `number?` | Max executions before completing |
|
||||||
|
| `runs` | `ScheduleRun[]` | Execution history |
|
||||||
|
|
||||||
|
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||||
|
|
||||||
|
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||||
|
- `{ type: "cron", expression: string }` — cron expression
|
||||||
|
|
||||||
|
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||||
|
|
||||||
|
- `{ type: "agent", agentId: string }` — send to existing agent
|
||||||
|
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||||
|
|
||||||
|
### Nested: ScheduleRun
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | Run ID |
|
||||||
|
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
|
||||||
|
| `startedAt` | `string` (ISO 8601) | |
|
||||||
|
| `endedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `status` | `"running" \| "succeeded" \| "failed"` | |
|
||||||
|
| `agentId` | `string?` (UUID) | Agent used for this run |
|
||||||
|
| `output` | `string?` | Agent output text |
|
||||||
|
| `error` | `string?` | Error message if failed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Chat
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/chat/rooms.json`
|
||||||
|
|
||||||
|
Single file containing all rooms and messages.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rooms": [ ... ],
|
||||||
|
"messages": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ChatRoom
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` (UUID) | |
|
||||||
|
| `name` | `string` | Unique room name (case-insensitive) |
|
||||||
|
| `purpose` | `string?` | Room description |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||||
|
|
||||||
|
### ChatMessage
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` (UUID) | |
|
||||||
|
| `roomId` | `string` | FK to ChatRoom.id |
|
||||||
|
| `authorAgentId` | `string` | Agent ID of the author |
|
||||||
|
| `body` | `string` | Message text (supports `@mentions`) |
|
||||||
|
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||||
|
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Loop
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||||
|
|
||||||
|
Single file containing an array of all loop records.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | 8-char UUID prefix |
|
||||||
|
| `name` | `string?` | Human-readable name |
|
||||||
|
| `prompt` | `string` | Worker prompt |
|
||||||
|
| `cwd` | `string` | Working directory |
|
||||||
|
| `provider` | `string` | Default provider |
|
||||||
|
| `model` | `string?` | Default model |
|
||||||
|
| `workerProvider` | `string?` | Override provider for workers |
|
||||||
|
| `workerModel` | `string?` | Override model for workers |
|
||||||
|
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||||
|
| `verifierModel` | `string?` | Override model for verifiers |
|
||||||
|
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||||
|
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||||
|
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||||
|
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||||
|
| `maxIterations` | `number?` | Cap on iterations |
|
||||||
|
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||||
|
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `startedAt` | `string` (ISO 8601) | |
|
||||||
|
| `completedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `iterations` | `LoopIteration[]` | |
|
||||||
|
| `logs` | `LoopLogEntry[]` | |
|
||||||
|
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||||
|
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||||
|
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||||
|
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||||
|
|
||||||
|
### Nested: LoopIteration
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `index` | `number` | 1-based iteration index |
|
||||||
|
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||||
|
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||||
|
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||||
|
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||||
|
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||||
|
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||||
|
| `failureReason` | `string?` | |
|
||||||
|
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||||
|
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||||
|
|
||||||
|
### Nested: LoopLogEntry
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `seq` | `number` (monotonic) |
|
||||||
|
| `timestamp` | `string` (ISO 8601) |
|
||||||
|
| `iteration` | `number?` |
|
||||||
|
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||||
|
| `level` | `"info" \| "error"` |
|
||||||
|
| `text` | `string` |
|
||||||
|
|
||||||
|
### Nested: LoopVerifyCheckResult
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `command` | `string` |
|
||||||
|
| `exitCode` | `number` |
|
||||||
|
| `passed` | `boolean` |
|
||||||
|
| `stdout` | `string` |
|
||||||
|
| `stderr` | `string` |
|
||||||
|
| `startedAt` | `string` (ISO 8601) |
|
||||||
|
| `completedAt` | `string` (ISO 8601) |
|
||||||
|
|
||||||
|
### Nested: LoopVerifyPromptResult
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|---|---|
|
||||||
|
| `passed` | `boolean` |
|
||||||
|
| `reason` | `string` |
|
||||||
|
| `verifierAgentId` | `string?` |
|
||||||
|
| `startedAt` | `string` (ISO 8601) |
|
||||||
|
| `completedAt` | `string` (ISO 8601) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Project Registry
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/projects/projects.json`
|
||||||
|
|
||||||
|
Array of project records.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `projectId` | `string` | Primary key |
|
||||||
|
| `rootPath` | `string` | Filesystem root of the project |
|
||||||
|
| `kind` | `"git" \| "non_git"` | |
|
||||||
|
| `displayName` | `string` | |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Workspace Registry
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
||||||
|
|
||||||
|
Array of workspace records. A workspace is a specific working directory within a project.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `workspaceId` | `string` | Primary key |
|
||||||
|
| `projectId` | `string` | FK to Project.projectId |
|
||||||
|
| `cwd` | `string` | Filesystem path |
|
||||||
|
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||||
|
| `displayName` | `string` | |
|
||||||
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
|
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Push Token Store
|
||||||
|
|
||||||
|
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tokens": ["ExponentPushToken[...]", ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client-side stores (App)
|
||||||
|
|
||||||
|
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
|
||||||
|
|
||||||
|
### Draft Store
|
||||||
|
|
||||||
|
**AsyncStorage key:** `paseo-drafts` (version 2)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
drafts: Record<draftKey, {
|
||||||
|
input: { text: string, images: AttachmentMetadata[] },
|
||||||
|
lifecycle: "active" | "abandoned" | "sent",
|
||||||
|
updatedAt: number, // epoch ms
|
||||||
|
version: number // optimistic concurrency
|
||||||
|
}>,
|
||||||
|
createModalDraft: DraftRecord | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attachment Store (Web)
|
||||||
|
|
||||||
|
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
|
||||||
|
|
||||||
|
Stores binary attachment blobs keyed by attachment ID.
|
||||||
|
|
||||||
|
### AttachmentMetadata
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | Unique attachment ID |
|
||||||
|
| `mimeType` | `string` | MIME type |
|
||||||
|
| `storageType` | `string` | Storage backend identifier |
|
||||||
|
| `storageKey` | `string` | Key within the storage backend |
|
||||||
|
| `createdAt` | `number` | Epoch ms |
|
||||||
|
| `fileName` | `string?` | Original filename |
|
||||||
|
| `byteSize` | `number?` | Size in bytes |
|
||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
|
npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# 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).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+73
-20
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -34906,16 +34906,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.51-rc.1",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -34990,6 +34990,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
@@ -34997,7 +34998,8 @@
|
|||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"wrangler": "^4.59.1"
|
"wrangler": "^4.59.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/app/node_modules/expo-clipboard": {
|
"packages/app/node_modules/expo-clipboard": {
|
||||||
@@ -35021,6 +35023,28 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"packages/app/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
@@ -35032,11 +35056,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.51-rc.1",
|
"@getpaseo/relay": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.51-rc.1",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -35077,11 +35101,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.51-rc.1",
|
"@getpaseo/cli": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.51-rc.1",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -35115,7 +35139,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35316,7 +35340,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -35342,7 +35366,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -35358,14 +35382,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/relay": "0.1.51-rc.1",
|
"@getpaseo/relay": "0.1.55-rc.1",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
@@ -35376,11 +35400,13 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.1.0",
|
"fast-uri": "^3.1.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"node-pty": "1.2.0-beta.11",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
|
"p-limit": "^7.3.0",
|
||||||
"pi-acp": "^0.0.24",
|
"pi-acp": "^0.0.24",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -35656,6 +35682,21 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"packages/server/node_modules/raw-body": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
@@ -35753,6 +35794,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"packages/server/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
@@ -35764,7 +35817,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -19,13 +19,15 @@ test.describe("Archive tab reconciliation", () => {
|
|||||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
|
test.describe.configure({ timeout: 120_000 });
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("archive-tab-");
|
tempRepo = await createTempGitRepo("archive-tab-");
|
||||||
client = await connectArchiveTabDaemonClient();
|
client = await connectArchiveTabDaemonClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
await client?.close();
|
await client?.close().catch(() => undefined);
|
||||||
await tempRepo?.cleanup();
|
await tempRepo?.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
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<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (config: {
|
new (
|
||||||
url: string;
|
config: DaemonClientConfig,
|
||||||
clientId: string;
|
) => DaemonClientInstance
|
||||||
clientType: "cli";
|
|
||||||
}) => DaemonClientInstance
|
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
).href;
|
).href;
|
||||||
const mod = (await import(moduleUrl)) as {
|
const mod = (await import(moduleUrl)) as {
|
||||||
DaemonClient: new (config: {
|
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
}) => DaemonClientInstance;
|
|
||||||
};
|
};
|
||||||
return mod.DaemonClient;
|
return mod.DaemonClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: getDaemonWsUrl(),
|
url: getDaemonWsUrl(),
|
||||||
clientId: `app-e2e-${randomUUID()}`,
|
clientId: `app-e2e-${randomUUID()}`,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return client;
|
return client;
|
||||||
@@ -127,7 +131,7 @@ export async function seedBottomAnchorAgent(input: {
|
|||||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||||
const created = await input.client.createAgent({
|
const created = await input.client.createAgent({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: "gpt-5.1-codex-mini",
|
model: "gpt-5.4-mini",
|
||||||
thinkingOptionId: "low",
|
thinkingOptionId: "low",
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import path from "node:path";
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||||
import { buildHostAgentDetailRoute, buildHostSessionsRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
|
buildHostSessionsRoute,
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
export type ArchiveTabAgent = {
|
export type ArchiveTabAgent = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,7 +23,7 @@ type ArchiveTabDaemonClient = {
|
|||||||
createAgent(options: {
|
createAgent(options: {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
thinkingOptionId: string;
|
thinkingOptionId?: string;
|
||||||
modeId: string;
|
modeId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -63,33 +68,36 @@ function buildSeededStoragePayload() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ArchiveTabDaemonClientConfig = {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
};
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
new (config: {
|
new (
|
||||||
url: string;
|
config: ArchiveTabDaemonClientConfig,
|
||||||
clientId: string;
|
) => ArchiveTabDaemonClient
|
||||||
clientType: "cli";
|
|
||||||
}) => ArchiveTabDaemonClient
|
|
||||||
> {
|
> {
|
||||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||||
const moduleUrl = pathToFileURL(
|
const moduleUrl = pathToFileURL(
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
).href;
|
).href;
|
||||||
const mod = (await import(moduleUrl)) as {
|
const mod = (await import(moduleUrl)) as {
|
||||||
DaemonClient: new (config: {
|
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
}) => ArchiveTabDaemonClient;
|
|
||||||
};
|
};
|
||||||
return mod.DaemonClient;
|
return mod.DaemonClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: getDaemonWsUrl(),
|
url: getDaemonWsUrl(),
|
||||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return client;
|
return client;
|
||||||
@@ -100,17 +108,18 @@ export async function createIdleAgent(
|
|||||||
input: { cwd: string; title: string },
|
input: { cwd: string; title: string },
|
||||||
): Promise<ArchiveTabAgent> {
|
): Promise<ArchiveTabAgent> {
|
||||||
const created = await client.createAgent({
|
const created = await client.createAgent({
|
||||||
provider: "codex",
|
provider: "opencode",
|
||||||
model: "gpt-5.1-codex-mini",
|
model: "opencode/gpt-5-nano",
|
||||||
thinkingOptionId: "low",
|
modeId: "default",
|
||||||
modeId: "full-access",
|
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
initialPrompt: "Reply with exactly READY.",
|
initialPrompt: "Reply with exactly READY.",
|
||||||
});
|
});
|
||||||
const finished = await client.waitForFinish(created.id, 120_000);
|
const finished = await client.waitForFinish(created.id, 120_000);
|
||||||
if (finished.status !== "idle") {
|
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 {
|
return {
|
||||||
id: created.id,
|
id: created.id,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
|
|||||||
|
|
||||||
export const TEST_PROVIDER_PREFERENCES = {
|
export const TEST_PROVIDER_PREFERENCES = {
|
||||||
claude: { model: "haiku" },
|
claude: { model: "haiku" },
|
||||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function buildDirectTcpConnection(endpoint: string) {
|
export function buildDirectTcpConnection(endpoint: string) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
export type TerminalPerfDaemonClient = {
|
export type TerminalPerfDaemonClient = {
|
||||||
@@ -43,29 +44,36 @@ function getServerId(): string {
|
|||||||
return serverId;
|
return serverId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TerminalPerfDaemonClientConfig = {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
};
|
||||||
|
|
||||||
async function loadDaemonClientConstructor(): Promise<
|
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(
|
const moduleUrl = pathToFileURL(
|
||||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
).href;
|
).href;
|
||||||
const mod = (await import(moduleUrl)) as {
|
const mod = (await import(moduleUrl)) as {
|
||||||
DaemonClient: new (config: {
|
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
}) => TerminalPerfDaemonClient;
|
|
||||||
};
|
};
|
||||||
return mod.DaemonClient;
|
return mod.DaemonClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
url: getDaemonWsUrl(),
|
url: getDaemonWsUrl(),
|
||||||
clientId: `terminal-perf-${randomUUID()}`,
|
clientId: `terminal-perf-${randomUUID()}`,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return client;
|
return client;
|
||||||
@@ -77,6 +85,10 @@ export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): stri
|
|||||||
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceUrl(cwd: string): string {
|
||||||
|
return buildHostWorkspaceRoute(getServerId(), cwd);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTerminalBufferText(page: Page): Promise<string> {
|
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const term = (window as any).__paseoTerminal;
|
const term = (window as any).__paseoTerminal;
|
||||||
@@ -118,33 +130,29 @@ export async function navigateToTerminal(
|
|||||||
// Boot the app at the workspace route directly.
|
// Boot the app at the workspace route directly.
|
||||||
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||||
// so the daemon registry is already configured when the app starts.
|
// 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);
|
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)
|
// 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 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 });
|
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||||
|
|
||||||
// Wait for loading overlay to disappear (terminal attached)
|
// Wait for loading overlay to disappear (terminal attached)
|
||||||
@@ -155,6 +163,7 @@ export async function navigateToTerminal(
|
|||||||
// overlay may never appear if attachment is instant
|
// overlay may never appear if attachment is instant
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await terminalSurface.scrollIntoViewIfNeeded();
|
||||||
await terminalSurface.click();
|
await terminalSurface.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 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;
|
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`,
|
`[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(
|
expect(
|
||||||
THROUGHPUT_BUDGET_MS,
|
elapsedMs,
|
||||||
);
|
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
|
||||||
|
).toBeLessThan(THROUGHPUT_BUDGET_MS);
|
||||||
} finally {
|
} finally {
|
||||||
await client.killTerminal(terminalId).catch(() => {});
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.51-rc.1",
|
"version": "0.1.55-rc.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
|
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.51-rc.1",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.51-rc.1",
|
"@getpaseo/server": "0.1.55-rc.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
@@ -115,6 +116,7 @@
|
|||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"wrangler": "^4.59.1"
|
"wrangler": "^4.59.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -57,7 +58,7 @@ import {
|
|||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
@@ -80,6 +81,7 @@ import {
|
|||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
@@ -89,6 +91,22 @@ export type HostRuntimeBootstrapState = {
|
|||||||
retry: () => void;
|
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>({
|
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||||
phase: "starting-daemon",
|
phase: "starting-daemon",
|
||||||
error: null,
|
error: null,
|
||||||
@@ -100,7 +118,7 @@ function PushNotificationRouter() {
|
|||||||
const lastHandledIdRef = useRef<string | null>(null);
|
const lastHandledIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
let removeDesktopNotificationListener: (() => void) | null = null;
|
let removeDesktopNotificationListener: (() => void) | null = null;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -357,6 +375,8 @@ interface AppContainerProps {
|
|||||||
chromeEnabled?: boolean;
|
chromeEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
|
||||||
|
|
||||||
function AppContainer({
|
function AppContainer({
|
||||||
children,
|
children,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
@@ -364,6 +384,7 @@ function AppContainer({
|
|||||||
}: AppContainerProps) {
|
}: AppContainerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||||
@@ -372,7 +393,13 @@ function AppContainer({
|
|||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
|
|
||||||
const isCompactLayout = isCompactFormFactor();
|
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;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -380,10 +407,10 @@ function AppContainer({
|
|||||||
const screenW = UnistylesRuntime.screen.width;
|
const screenW = UnistylesRuntime.screen.width;
|
||||||
const screenH = UnistylesRuntime.screen.height;
|
const screenH = UnistylesRuntime.screen.height;
|
||||||
const isElectron = getIsElectronRuntime();
|
const isElectron = getIsElectronRuntime();
|
||||||
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
|
const windowW = isWeb ? window.innerWidth : undefined;
|
||||||
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
|
const windowH = isWeb ? window.innerHeight : undefined;
|
||||||
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
|
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
||||||
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
|
const ua = isWeb ? navigator.userAgent : undefined;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"[layout-debug]",
|
"[layout-debug]",
|
||||||
@@ -412,6 +439,7 @@ function AppContainer({
|
|||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
|
cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -561,12 +589,12 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
UnistylesRuntime.setAdaptiveThemes(true);
|
UnistylesRuntime.setAdaptiveThemes(true);
|
||||||
} else {
|
} else {
|
||||||
UnistylesRuntime.setAdaptiveThemes(false);
|
UnistylesRuntime.setAdaptiveThemes(false);
|
||||||
UnistylesRuntime.setTheme(settings.theme);
|
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
|
||||||
}
|
}
|
||||||
}, [settingsLoading, settings.theme]);
|
}, [settingsLoading, settings.theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsLoading || Platform.OS !== "web") {
|
if (settingsLoading || isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,17 +802,21 @@ function RootStack() {
|
|||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="settings" />
|
<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.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</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.Screen name="index" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostAgentReadyRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostAgentReadyRouteContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,14 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|||||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||||
|
|
||||||
export default function HostIndexRoute() {
|
export default function HostIndexRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostIndexRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostIndexRouteContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
@@ -48,11 +57,6 @@ export default function HostIndexRoute() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
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];
|
const primaryAgent = visibleAgents[0];
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
if (primaryAgent?.cwd?.trim()) {
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
import { OpenProjectScreen } from "@/screens/open-project-screen";
|
||||||
|
|
||||||
export default function HostOpenProjectRoute() {
|
export default function HostOpenProjectRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostOpenProjectRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostOpenProjectRouteContent() {
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { SessionsScreen } from "@/screens/sessions-screen";
|
import { SessionsScreen } from "@/screens/sessions-screen";
|
||||||
|
|
||||||
export default function HostAgentsRoute() {
|
export default function HostAgentsRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostAgentsRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostAgentsRouteContent() {
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
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";
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
function getParamValue(value: string | string[] | undefined): string {
|
function getParamValue(value: string | string[] | undefined): string {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -35,8 +36,17 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
const router = useRouter();
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostWorkspaceLayoutContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostWorkspaceLayoutContent() {
|
||||||
|
const rootNavigationState = useRootNavigationState();
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
|
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
serverId?: string | string[];
|
serverId?: string | string[];
|
||||||
workspaceId?: string | string[];
|
workspaceId?: string | string[];
|
||||||
@@ -55,6 +65,9 @@ export default function HostWorkspaceLayout() {
|
|||||||
if (!openValue) {
|
if (!openValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!rootNavigationState?.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
@@ -63,19 +76,30 @@ export default function HostWorkspaceLayout() {
|
|||||||
consumedIntentRef.current = consumptionKey;
|
consumedIntentRef.current = consumptionKey;
|
||||||
|
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||||
const route = openIntent
|
if (openIntent) {
|
||||||
? prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target: getOpenIntentTarget(openIntent),
|
target: getOpenIntentTarget(openIntent),
|
||||||
pin: openIntent.kind === "agent",
|
pin: openIntent.kind === "agent",
|
||||||
})
|
});
|
||||||
: buildHostWorkspaceRoute(serverId, workspaceId);
|
}
|
||||||
|
|
||||||
router.replace(route as any);
|
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||||
}, [openValue, router, serverId, workspaceId]);
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import { usePathname, useRouter } from "expo-router";
|
import { usePathname, useRouter } from "expo-router";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import {
|
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||||
useHostRuntimeBootstrapState,
|
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||||
useStoreReady,
|
|
||||||
} from "@/app/_layout";
|
|
||||||
import {
|
|
||||||
getHostRuntimeStore,
|
|
||||||
isHostRuntimeConnected,
|
|
||||||
useHosts,
|
|
||||||
} from "@/runtime/host-runtime";
|
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
const WELCOME_ROUTE = "/welcome";
|
const WELCOME_ROUTE = "/welcome";
|
||||||
@@ -55,9 +48,7 @@ export default function Index() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetRoute = anyOnlineServerId
|
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
|
||||||
? buildHostRootRoute(anyOnlineServerId)
|
|
||||||
: WELCOME_ROUTE;
|
|
||||||
router.replace(targetRoute);
|
router.replace(targetRoute);
|
||||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } from "expo-camera";
|
import type { BarcodeScanningResult } from "expo-camera";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { NameHostModal } from "@/components/name-host-modal";
|
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
@@ -61,7 +60,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderColor: theme.colors.palette.blue[400],
|
borderColor: theme.colors.accent,
|
||||||
},
|
},
|
||||||
cornerTL: {
|
cornerTL: {
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -148,28 +147,11 @@ export default function PairScanScreen() {
|
|||||||
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
const lastScannedRef = useRef<string | null>(null);
|
||||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
|
||||||
serverId: string;
|
|
||||||
hostname: string | null;
|
|
||||||
} | null>(null);
|
|
||||||
const pendingNameHostname = useSessionStore(
|
|
||||||
useCallback(
|
|
||||||
(state) => {
|
|
||||||
if (!pendingNameHost) return null;
|
|
||||||
return (
|
|
||||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
|
||||||
pendingNameHost.hostname ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[pendingNameHost],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const returnToSource = useCallback(
|
const returnToSource = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
@@ -217,14 +199,13 @@ export default function PairScanScreen() {
|
|||||||
}, [router, source, sourceServerId, targetServerId]);
|
}, [router, source, sourceServerId, targetServerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "web") return;
|
if (isWeb) return;
|
||||||
if (permission && permission.granted) return;
|
if (permission && permission.granted) return;
|
||||||
void requestPermission().catch(() => undefined);
|
void requestPermission().catch(() => undefined);
|
||||||
}, [permission, requestPermission]);
|
}, [permission, requestPermission]);
|
||||||
|
|
||||||
const handleScan = useCallback(
|
const handleScan = useCallback(
|
||||||
async (result: BarcodeScanningResult) => {
|
async (result: BarcodeScanningResult) => {
|
||||||
if (pendingNameHost) return;
|
|
||||||
if (isPairing) return;
|
if (isPairing) return;
|
||||||
const offerUrl = extractOfferUrlFromScan(result);
|
const offerUrl = extractOfferUrlFromScan(result);
|
||||||
if (!offerUrl) return;
|
if (!offerUrl) return;
|
||||||
@@ -248,7 +229,7 @@ export default function PairScanScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { client } = await connectToDaemon(
|
const { client, hostname } = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "probe",
|
id: "probe",
|
||||||
type: "relay",
|
type: "relay",
|
||||||
@@ -259,13 +240,7 @@ export default function PairScanScreen() {
|
|||||||
);
|
);
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
|
||||||
|
|
||||||
if (isNewHost) {
|
|
||||||
setPendingNameHost({ serverId: profile.serverId, hostname: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
returnToSource(profile.serverId);
|
returnToSource(profile.serverId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -276,10 +251,10 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
@@ -307,25 +282,6 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{pendingNameHost ? (
|
|
||||||
<NameHostModal
|
|
||||||
visible
|
|
||||||
serverId={pendingNameHost.serverId}
|
|
||||||
hostname={pendingNameHostname}
|
|
||||||
onSkip={() => {
|
|
||||||
const serverId = pendingNameHost.serverId;
|
|
||||||
setPendingNameHost(null);
|
|
||||||
returnToSource(serverId);
|
|
||||||
}}
|
|
||||||
onSave={(label) => {
|
|
||||||
const serverId = pendingNameHost.serverId;
|
|
||||||
void renameHost(serverId, label).finally(() => {
|
|
||||||
setPendingNameHost(null);
|
|
||||||
returnToSource(serverId);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||||
<Pressable onPress={closeToSource}>
|
<Pressable onPress={closeToSource}>
|
||||||
@@ -359,7 +315,6 @@ export default function PairScanScreen() {
|
|||||||
<View style={[styles.corner, styles.cornerBL]} />
|
<View style={[styles.corner, styles.cornerBL]} />
|
||||||
<View style={[styles.corner, styles.cornerBR]} />
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
|
||||||
{isPairing ? (
|
{isPairing ? (
|
||||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||||
Pairing…
|
Pairing…
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import type { AttachmentStore } from "@/attachments/types";
|
import type { AttachmentStore } from "@/attachments/types";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
|
||||||
|
|
||||||
async function createAttachmentStore(): Promise<AttachmentStore> {
|
async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
if (isElectronRuntime()) {
|
if (isElectronRuntime()) {
|
||||||
const { createDesktopAttachmentStore } = await import(
|
const { createDesktopAttachmentStore } = await import(
|
||||||
"../desktop/attachments/desktop-attachment-store"
|
"../desktop/attachments/desktop-attachment-store"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 type { TextInputProps } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
type BottomSheetBackgroundProps,
|
type BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
@@ -119,7 +121,7 @@ export function AdaptiveModalSheet({
|
|||||||
testID,
|
testID,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const sheetRef = useRef<BottomSheetModal>(null);
|
const sheetRef = useRef<BottomSheetModal>(null);
|
||||||
const dismissingForVisibilityRef = useRef(false);
|
const dismissingForVisibilityRef = useRef(false);
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
@@ -215,7 +217,7 @@ export function AdaptiveModalSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// On web, use portal to overlay root for consistent stacking with toasts
|
// 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;
|
if (!visible) return null;
|
||||||
return createPortal(desktopContent, getOverlayRoot());
|
return createPortal(desktopContent, getOverlayRoot());
|
||||||
}
|
}
|
||||||
@@ -239,7 +241,7 @@ export function AdaptiveModalSheet({
|
|||||||
*/
|
*/
|
||||||
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
|
||||||
function AdaptiveTextInput(props, ref) {
|
function AdaptiveTextInput(props, ref) {
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
return <BottomSheetTextInput ref={ref as any} {...props} />;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback } from "react";
|
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
option: {
|
option: {
|
||||||
@@ -78,7 +79,7 @@ export function AddHostMethodModal({
|
|||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{Platform.OS !== "web" ? (
|
{isNative ? (
|
||||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||||
<QrCode size={18} color={theme.colors.foreground} />
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, Text, TextInput, View } from "react-native";
|
import { Alert, Text, TextInput, View } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Link2 } from "lucide-react-native";
|
import { Link2 } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
@@ -151,7 +152,7 @@ export function AddHostModal({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertDirectConnection } = useHostMutations();
|
const { upsertDirectConnection } = useHostMutations();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
@@ -218,6 +219,7 @@ export function AddHostModal({
|
|||||||
const profile = await upsertDirectConnection({
|
const profile = await upsertDirectConnection({
|
||||||
serverId,
|
serverId,
|
||||||
endpoint,
|
endpoint,
|
||||||
|
label: hostname ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ReactElement, ReactNode } 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 type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
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 { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||||
import { baseColors } from "@/styles/theme";
|
import { baseColors } from "@/styles/theme";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -168,7 +169,7 @@ export function SelectField({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
const key = getWebKey(event);
|
const key = getWebKey(event);
|
||||||
if (key === "Enter" || key === " ") {
|
if (key === "Enter" || key === " ") {
|
||||||
preventWebDefault(event);
|
preventWebDefault(event);
|
||||||
@@ -430,7 +431,7 @@ export function FormSelectTrigger({
|
|||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
const key = getWebKey(event);
|
const key = getWebKey(event);
|
||||||
if (key === "Enter" || key === " ") {
|
if (key === "Enter" || key === " ") {
|
||||||
preventWebDefault(event);
|
preventWebDefault(event);
|
||||||
@@ -556,7 +557,11 @@ export function AgentConfigRow({
|
|||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
|
||||||
|
|
||||||
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
|
const selectedModeVisuals = getModeVisuals(
|
||||||
|
selectedProvider,
|
||||||
|
effectiveSelectedMode,
|
||||||
|
providerDefinitions,
|
||||||
|
);
|
||||||
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
|
||||||
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
|
||||||
|
|
||||||
@@ -773,7 +778,8 @@ export function ModelDropdown({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const anchorRef = useRef<View>(null);
|
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 placeholder = isLoading && models.length === 0 ? "Loading..." : "Select model";
|
||||||
const helperText = error
|
const helperText = error
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
@@ -48,6 +49,8 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||||
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -116,7 +119,7 @@ export function AgentInputArea({
|
|||||||
}: AgentInputAreaProps) {
|
}: AgentInputAreaProps) {
|
||||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
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 insets = useSafeAreaInsets();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
@@ -131,6 +134,8 @@ export function AgentInputArea({
|
|||||||
agentDirectoryStatus === "revalidating" ||
|
agentDirectoryStatus === "revalidating" ||
|
||||||
agentDirectoryStatus === "error_after_ready");
|
agentDirectoryStatus === "error_after_ready");
|
||||||
|
|
||||||
|
const { settings: appSettings } = useAppSettings();
|
||||||
|
|
||||||
const agentState = useSessionStore(
|
const agentState = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
@@ -151,10 +156,8 @@ export function AgentInputArea({
|
|||||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||||
|
|
||||||
const isDesktopWebBreakpoint =
|
const isMobile = useIsCompactFormFactor();
|
||||||
Platform.OS === "web" &&
|
const isDesktopWebBreakpoint = isWeb && !isMobile;
|
||||||
UnistylesRuntime.breakpoint !== "xs" &&
|
|
||||||
UnistylesRuntime.breakpoint !== "sm";
|
|
||||||
const messagePlaceholder = isDesktopWebBreakpoint
|
const messagePlaceholder = isDesktopWebBreakpoint
|
||||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||||
@@ -215,7 +218,7 @@ export function AgentInputArea({
|
|||||||
}, [addImages, onAddImages]);
|
}, [addImages, onAddImages]);
|
||||||
|
|
||||||
const focusInput = useCallback(() => {
|
const focusInput = useCallback(() => {
|
||||||
if (Platform.OS !== "web") return;
|
if (isNative) return;
|
||||||
focusWithRetries({
|
focusWithRetries({
|
||||||
focus: () => messageInputRef.current?.focus(),
|
focus: () => messageInputRef.current?.focus(),
|
||||||
isFocused: () => {
|
isFocused: () => {
|
||||||
@@ -428,7 +431,7 @@ export function AgentInputArea({
|
|||||||
case "message-input.dictation-confirm":
|
case "message-input.dictation-confirm":
|
||||||
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
|
||||||
case "message-input.focus":
|
case "message-input.focus":
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
messageInputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -594,14 +597,14 @@ export function AgentInputArea({
|
|||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||||
{dictationCancelKeys ? (
|
{dictationCancelKeys ? (
|
||||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
@@ -643,9 +646,7 @@ export function AgentInputArea({
|
|||||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||||
typeof agentState.contextWindowUsedTokens === "number";
|
typeof agentState.contextWindowUsedTokens === "number";
|
||||||
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||||
const contextWindowUsedTokens = hasContextWindowMeter
|
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
||||||
? agentState.contextWindowUsedTokens
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const beforeVoiceContent = (
|
const beforeVoiceContent = (
|
||||||
<View style={styles.contextWindowMeterSlot}>
|
<View style={styles.contextWindowMeterSlot}>
|
||||||
@@ -742,6 +743,7 @@ export function AgentInputArea({
|
|||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
|
defaultSendBehavior={appSettings.sendBehavior}
|
||||||
onQueue={handleQueue}
|
onQueue={handleQueue}
|
||||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||||
onKeyPress={handleCommandKeyPress}
|
onKeyPress={handleCommandKeyPress}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
@@ -214,7 +215,7 @@ export function AgentList({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const actionClient = useSessionStore((state) =>
|
const actionClient = useSessionStore((state) =>
|
||||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||||
@@ -257,7 +258,8 @@ export function AgentList({
|
|||||||
if (!actionAgent || !actionClient) {
|
if (!actionAgent || !actionClient) {
|
||||||
return;
|
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);
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient]);
|
}, [actionAgent, actionClient]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { View, Text, Platform, Pressable, Keyboard } from "react-native";
|
import { View, Text, Pressable, Keyboard } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
@@ -17,6 +17,7 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
|
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||||
import {
|
import {
|
||||||
buildFavoriteModelKey,
|
buildFavoriteModelKey,
|
||||||
mergeProviderPreferences,
|
mergeProviderPreferences,
|
||||||
@@ -40,7 +41,6 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
|
||||||
getModeVisuals,
|
getModeVisuals,
|
||||||
type AgentModeColorTier,
|
type AgentModeColorTier,
|
||||||
type AgentModeIcon,
|
type AgentModeIcon,
|
||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/components/agent-status-bar.utils";
|
} from "@/components/agent-status-bar.utils";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,10 +60,6 @@ type StatusOption = {
|
|||||||
|
|
||||||
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||||
|
|
||||||
const PROVIDER_DEFINITION_MAP = new Map(
|
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
|
|
||||||
type ControlledAgentStatusBarProps = {
|
type ControlledAgentStatusBarProps = {
|
||||||
provider: string;
|
provider: string;
|
||||||
providerOptions?: StatusOption[];
|
providerOptions?: StatusOption[];
|
||||||
@@ -80,7 +77,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isModelLoading?: boolean;
|
isModelLoading?: boolean;
|
||||||
providerDefinitions?: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
allProviderModels?: Map<string, AgentModelDefinition[]>;
|
||||||
canSelectModelProvider?: (providerId: string) => boolean;
|
canSelectModelProvider?: (providerId: string) => boolean;
|
||||||
favoriteKeys?: Set<string>;
|
favoriteKeys?: Set<string>;
|
||||||
@@ -88,6 +85,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
|
onModelSelectorOpen?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
@@ -110,6 +108,7 @@ export interface DraftAgentStatusBarProps {
|
|||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
|
onModelSelectorOpen?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,9 +216,9 @@ function ControlledStatusBar({
|
|||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
|
onModelSelectorOpen,
|
||||||
}: ControlledAgentStatusBarProps) {
|
}: ControlledAgentStatusBarProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
||||||
|
|
||||||
@@ -249,7 +248,9 @@ function ControlledStatusBar({
|
|||||||
thinkingOptions?.[0]?.label ?? "Unknown",
|
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 ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||||
const ProviderIcon = getProviderIcon(provider);
|
const ProviderIcon = getProviderIcon(provider);
|
||||||
@@ -297,8 +298,7 @@ function ControlledStatusBar({
|
|||||||
);
|
);
|
||||||
return map;
|
return map;
|
||||||
}, [modelOptions, provider]);
|
}, [modelOptions, provider]);
|
||||||
const effectiveProviderDefinitions = providerDefinitions ??
|
const effectiveProviderDefinitions = providerDefinitions;
|
||||||
(PROVIDER_DEFINITION_MAP.has(provider) ? [PROVIDER_DEFINITION_MAP.get(provider)!] : []);
|
|
||||||
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
const effectiveAllProviderModels = allProviderModels ?? fallbackAllProviderModels;
|
||||||
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
const canSelectProviderInModelMenu = canSelectModelProvider ?? (() => true);
|
||||||
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
const comboboxThinkingOptions = useMemo<ComboboxOption[]>(
|
||||||
@@ -318,7 +318,7 @@ function ControlledStatusBar({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const visuals = getModeVisuals(provider, option.id);
|
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
|
||||||
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||||
return (
|
return (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
@@ -330,7 +330,7 @@ function ControlledStatusBar({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[provider, theme.colors.foreground],
|
[provider, providerDefinitions, theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
@@ -352,7 +352,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{isWeb ? (
|
{platformIsWeb ? (
|
||||||
<>
|
<>
|
||||||
{providerOptions && providerOptions.length > 0 ? (
|
{providerOptions && providerOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -411,6 +411,7 @@ function ControlledStatusBar({
|
|||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -662,13 +663,11 @@ function ControlledStatusBar({
|
|||||||
onToggleFavorite={onToggleFavoriteModel}
|
onToggleFavorite={onToggleFavoriteModel}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
disabled={modelDisabled}
|
disabled={modelDisabled}
|
||||||
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
renderTrigger={({ selectedModelLabel }) => (
|
renderTrigger={({ selectedModelLabel }) => (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[styles.sheetSelect, modelDisabled && styles.disabledSheetSelect]}
|
||||||
styles.sheetSelect,
|
|
||||||
modelDisabled && styles.disabledSheetSelect,
|
|
||||||
]}
|
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
testID="agent-preferences-model"
|
testID="agent-preferences-model"
|
||||||
>
|
>
|
||||||
@@ -742,7 +741,7 @@ function ControlledStatusBar({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" align="start">
|
<DropdownMenuContent side="top" align="start">
|
||||||
{modeOptions.map((mode) => {
|
{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;
|
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -845,7 +844,11 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
const EMPTY_MODES: AgentMode[] = [];
|
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 { preferences, updatePreferences } = useFormPreferences();
|
||||||
const agent = useSessionStore(
|
const agent = useSessionStore(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
@@ -875,6 +878,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
isFetching: snapshotIsFetching,
|
isFetching: snapshotIsFetching,
|
||||||
|
invalidate: invalidateSnapshot,
|
||||||
} = useProvidersSnapshot(serverId);
|
} = useProvidersSnapshot(serverId);
|
||||||
|
|
||||||
const snapshotModels = useMemo(() => {
|
const snapshotModels = useMemo(() => {
|
||||||
@@ -888,9 +892,11 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
const models = snapshotModels;
|
const models = snapshotModels;
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(() => {
|
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] : [];
|
return definition ? [definition] : [];
|
||||||
}, [agent?.provider]);
|
}, [agent?.provider, snapshotEntries]);
|
||||||
|
|
||||||
const agentProviderModels = useMemo(() => {
|
const agentProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
@@ -923,7 +929,10 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
return (models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||||
}, [models]);
|
}, [models]);
|
||||||
const favoriteKeys = useMemo(
|
const favoriteKeys = useMemo(
|
||||||
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
() =>
|
||||||
|
new Set(
|
||||||
|
(preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
|
||||||
|
),
|
||||||
[preferences.favoriteModels],
|
[preferences.favoriteModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -942,7 +951,9 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={agent.provider}
|
provider={agent.provider}
|
||||||
modeOptions={
|
modeOptions={
|
||||||
modeOptions.length > 0 ? modeOptions : [{ id: agent.currentModeId ?? "", label: displayMode }]
|
modeOptions.length > 0
|
||||||
|
? modeOptions
|
||||||
|
: [{ id: agent.currentModeId ?? "", label: displayMode }]
|
||||||
}
|
}
|
||||||
selectedModeId={agent.currentModeId ?? undefined}
|
selectedModeId={agent.currentModeId ?? undefined}
|
||||||
providerDefinitions={agentProviderDefinitions}
|
providerDefinitions={agentProviderDefinitions}
|
||||||
@@ -961,9 +972,9 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void updatePreferences(
|
void updatePreferences((current) =>
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences,
|
preferences: current,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
updates: {
|
updates: {
|
||||||
model: modelId,
|
model: modelId,
|
||||||
@@ -978,7 +989,9 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
}}
|
}}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
void updatePreferences((current) =>
|
||||||
|
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
||||||
|
).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
console.warn("[AgentStatusBar] toggle favorite model failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -990,9 +1003,9 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
}
|
}
|
||||||
const activeModelId = modelSelection.activeModelId;
|
const activeModelId = modelSelection.activeModelId;
|
||||||
if (activeModelId) {
|
if (activeModelId) {
|
||||||
void updatePreferences(
|
void updatePreferences((current) =>
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences,
|
preferences: current,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
updates: {
|
updates: {
|
||||||
model: activeModelId,
|
model: activeModelId,
|
||||||
@@ -1014,16 +1027,30 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
void updatePreferences((current) =>
|
||||||
|
mergeProviderPreferences({
|
||||||
|
preferences: current,
|
||||||
|
provider: agent.provider,
|
||||||
|
updates: {
|
||||||
|
featureValues: {
|
||||||
|
[featureId]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).catch((error) => {
|
||||||
|
console.warn("[AgentStatusBar] persist feature preference failed", error);
|
||||||
|
});
|
||||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||||
|
onModelSelectorOpen={invalidateSnapshot}
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function DraftAgentStatusBar({
|
export function DraftAgentStatusBar({
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
@@ -1045,9 +1072,9 @@ export function DraftAgentStatusBar({
|
|||||||
features,
|
features,
|
||||||
onSetFeature,
|
onSetFeature,
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
|
onModelSelectorOpen,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: DraftAgentStatusBarProps) {
|
}: DraftAgentStatusBarProps) {
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
|
|
||||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||||
@@ -1064,7 +1091,10 @@ export function DraftAgentStatusBar({
|
|||||||
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
return thinkingOptions.map((option) => ({ id: option.id, label: option.label }));
|
||||||
}, [thinkingOptions]);
|
}, [thinkingOptions]);
|
||||||
const favoriteKeys = useMemo(
|
const favoriteKeys = useMemo(
|
||||||
() => new Set((preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite))),
|
() =>
|
||||||
|
new Set(
|
||||||
|
(preferences.favoriteModels ?? []).map((favorite) => buildFavoriteModelKey(favorite)),
|
||||||
|
),
|
||||||
[preferences.favoriteModels],
|
[preferences.favoriteModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1072,7 +1102,7 @@ export function DraftAgentStatusBar({
|
|||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||||
|
|
||||||
if (isWeb) {
|
if (platformIsWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
@@ -1083,16 +1113,20 @@ export function DraftAgentStatusBar({
|
|||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavorite={(provider, modelId) => {
|
onToggleFavorite={(provider, modelId) => {
|
||||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
void updatePreferences((current) =>
|
||||||
|
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
||||||
|
).catch((error) => {
|
||||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isLoading={isAllModelsLoading}
|
isLoading={isAllModelsLoading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider}
|
||||||
|
providerDefinitions={providerDefinitions}
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={mappedModeOptions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
@@ -1129,7 +1163,9 @@ export function DraftAgentStatusBar({
|
|||||||
isModelLoading={isAllModelsLoading}
|
isModelLoading={isAllModelsLoading}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
onToggleFavoriteModel={(provider, modelId) => {
|
onToggleFavoriteModel={(provider, modelId) => {
|
||||||
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
|
void updatePreferences((current) =>
|
||||||
|
toggleFavoriteModel({ preferences: current, provider, modelId }),
|
||||||
|
).catch((error) => {
|
||||||
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -1138,6 +1174,7 @@ export function DraftAgentStatusBar({
|
|||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
features={features}
|
features={features}
|
||||||
onSetFeature={onSetFeature}
|
onSetFeature={onSetFeature}
|
||||||
|
onModelSelectorOpen={onModelSelectorOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ export function resolveAgentModelSelection(input: {
|
|||||||
: null;
|
: null;
|
||||||
const preferredModelId =
|
const preferredModelId =
|
||||||
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
|
||||||
const fallbackModel =
|
const fallbackModel = models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
||||||
models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
|
|
||||||
const selectedModel =
|
const selectedModel =
|
||||||
models && preferredModelId
|
models && preferredModelId
|
||||||
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -72,6 +73,7 @@ import {
|
|||||||
WORKING_INDICATOR_CYCLE_MS,
|
WORKING_INDICATOR_CYCLE_MS,
|
||||||
WORKING_INDICATOR_OFFSETS,
|
WORKING_INDICATOR_OFFSETS,
|
||||||
} from "@/utils/working-indicator";
|
} from "@/utils/working-indicator";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
@@ -109,7 +111,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const streamRenderStrategy = useMemo(
|
const streamRenderStrategy = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveStreamRenderStrategy({
|
resolveStreamRenderStrategy({
|
||||||
@@ -215,7 +217,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return buildAgentStreamRenderModel({
|
return buildAgentStreamRenderModel({
|
||||||
tail: streamItems,
|
tail: streamItems,
|
||||||
head: streamHead ?? [],
|
head: streamHead ?? [],
|
||||||
platform: Platform.OS === "web" ? "web" : "native",
|
platform: isWeb ? "web" : "native",
|
||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [isMobile, streamHead, streamItems]);
|
}, [isMobile, streamHead, streamItems]);
|
||||||
@@ -254,7 +256,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||||
return looseGap;
|
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;
|
return tightGap;
|
||||||
}
|
}
|
||||||
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||||
@@ -327,6 +332,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
onInlinePathPress={handleInlinePathPress}
|
onInlinePathPress={handleInlinePathPress}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
|
serverId={serverId}
|
||||||
|
client={client}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "thought": {
|
case "thought": {
|
||||||
@@ -368,10 +375,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
data.detail.input.trim()
|
data.detail.input.trim()
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<SpeakMessage
|
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
|
||||||
message={data.detail.input}
|
|
||||||
timestamp={item.timestamp.getTime()}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +711,7 @@ function PermissionRequestCard({
|
|||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const { request } = permission;
|
const { request } = permission;
|
||||||
const isPlanRequest = request.kind === "plan";
|
const isPlanRequest = request.kind === "plan";
|
||||||
@@ -914,7 +918,9 @@ function PermissionRequestCard({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
|
{planMarkdown ? (
|
||||||
|
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!isPlanRequest ? (
|
{!isPlanRequest ? (
|
||||||
<ToolCallDetailsContent
|
<ToolCallDetailsContent
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
clearAssistantImageMetadataCache,
|
||||||
|
setAssistantImageMetadata,
|
||||||
|
} from "@/utils/assistant-image-metadata";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||||
@@ -128,6 +132,25 @@ describe("estimateStreamItemHeight", () => {
|
|||||||
|
|
||||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
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", () => {
|
describe("web virtualization test overrides", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { StreamItem } from "@/types/stream";
|
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_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||||
@@ -45,7 +46,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
|||||||
case "user_message":
|
case "user_message":
|
||||||
return item.images && item.images.length > 0 ? 220 : 96;
|
return item.images && item.images.length > 0 ? 220 : 96;
|
||||||
case "assistant_message":
|
case "assistant_message":
|
||||||
return 220;
|
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
return 136;
|
return 136;
|
||||||
case "thought":
|
case "thought":
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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 { 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;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
isGitCheckout: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchSwitcher({
|
||||||
|
currentBranchName,
|
||||||
|
title,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
isGitCheckout,
|
||||||
|
}: BranchSwitcherProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const anchorRef = useRef<View>(null);
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const toast = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
|
||||||
|
client,
|
||||||
|
normalizedServerId: serverId,
|
||||||
|
normalizedWorkspaceId: workspaceId,
|
||||||
|
currentBranchName,
|
||||||
|
isGitCheckout,
|
||||||
|
isConnected,
|
||||||
|
toast,
|
||||||
|
queryClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentBranchName) {
|
||||||
|
return (
|
||||||
|
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View ref={anchorRef} collapsable={false}>
|
||||||
|
<Pressable
|
||||||
|
testID="workspace-header-branch-switcher"
|
||||||
|
onPress={() => setIsOpen(true)}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.branchSwitcherTrigger,
|
||||||
|
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
|
||||||
|
]}
|
||||||
|
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} />
|
||||||
|
</Pressable>
|
||||||
|
<Combobox
|
||||||
|
options={branchOptions}
|
||||||
|
value={currentBranchName}
|
||||||
|
onSelect={handleBranchSelect}
|
||||||
|
searchable
|
||||||
|
placeholder="Switch branch..."
|
||||||
|
searchPlaceholder="Filter branches..."
|
||||||
|
emptyText="No branches found."
|
||||||
|
title="Switch branch"
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
desktopPlacement="bottom-start"
|
||||||
|
desktopPreventInitialFlash
|
||||||
|
desktopMinWidth={280}
|
||||||
|
renderOption={({ option, selected, active, onPress }) => (
|
||||||
|
<ComboboxItem
|
||||||
|
key={option.id}
|
||||||
|
label={option.label}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={onPress}
|
||||||
|
leadingSlot={<GitBranch size={14} color={theme.colors.foregroundMuted} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: {
|
||||||
|
xs: "400",
|
||||||
|
md: "300",
|
||||||
|
},
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
branchSwitcherTrigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
branchSwitcherTriggerHovered: {
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -42,14 +42,25 @@ describe("combined model selector helpers", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
it("keeps enough data to search by model and provider name", async () => {
|
it("keeps enough data to search by model and provider name", async () => {
|
||||||
const rows = buildModelRows(providerDefinitions, new Map([
|
const rows = buildModelRows(
|
||||||
["claude", claudeModels],
|
providerDefinitions,
|
||||||
["codex", codexModels],
|
new Map([
|
||||||
]));
|
["claude", claudeModels],
|
||||||
|
["codex", codexModels],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
expect(rows).toEqual([
|
expect(rows).toEqual([
|
||||||
expect.objectContaining({ providerLabel: "Claude", modelLabel: "Sonnet 4.6", modelId: "sonnet-4.6" }),
|
expect.objectContaining({
|
||||||
expect.objectContaining({ providerLabel: "Codex", modelLabel: "GPT-5.4", modelId: "gpt-5.4" }),
|
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);
|
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", () => {
|
it("builds an explicit trigger label for the selected provider and model", () => {
|
||||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
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,25 +4,17 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Pressable,
|
Pressable,
|
||||||
Platform,
|
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
ArrowLeft,
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
ChevronDown,
|
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||||
ChevronRight,
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
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";
|
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 { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
@@ -58,6 +50,7 @@ interface CombinedModelSelectorProps {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
|
onOpen?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
@@ -125,7 +118,10 @@ function sortFavoritesFirst(
|
|||||||
function groupRowsByProvider(
|
function groupRowsByProvider(
|
||||||
rows: SelectorModelRow[],
|
rows: SelectorModelRow[],
|
||||||
): Array<{ providerId: string; providerLabel: string; 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) {
|
for (const row of rows) {
|
||||||
const existing = grouped.get(row.provider);
|
const existing = grouped.get(row.provider);
|
||||||
@@ -172,8 +168,7 @@ function ModelRow({
|
|||||||
[onToggleFavorite, row.modelId, row.provider],
|
[onToggleFavorite, row.modelId, row.provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showDescription =
|
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||||
row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
@@ -290,7 +285,9 @@ function GroupedProviderRows({
|
|||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{groupedRows.map((group, index) => {
|
{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 ProvIcon = getProviderIcon(group.providerId);
|
||||||
const isInline = viewKind === "provider";
|
const isInline = viewKind === "provider";
|
||||||
|
|
||||||
@@ -348,10 +345,11 @@ function ProviderSearchInput({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus && Platform.OS === "web" && inputRef.current) {
|
if (autoFocus && platformIsWeb && inputRef.current) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -365,7 +363,7 @@ function ProviderSearchInput({
|
|||||||
<InputComponent
|
<InputComponent
|
||||||
ref={inputRef as any}
|
ref={inputRef as any}
|
||||||
// @ts-expect-error - outlineStyle is web-only
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
|
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
|
||||||
placeholder="Search models..."
|
placeholder="Search models..."
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -515,14 +513,14 @@ export function CombinedModelSelector({
|
|||||||
favoriteKeys = new Set<string>(),
|
favoriteKeys = new Set<string>(),
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: CombinedModelSelectorProps) {
|
}: CombinedModelSelectorProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
const anchorRef = useRef<View>(null);
|
const anchorRef = useRef<View>(null);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isContentReady, setIsContentReady] = useState(isWeb);
|
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
||||||
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
@@ -539,12 +537,14 @@ export function CombinedModelSelector({
|
|||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
setView(singleProviderView ?? { kind: "all" });
|
setView(singleProviderView ?? { kind: "all" });
|
||||||
if (!open) {
|
if (open) {
|
||||||
|
onOpen?.();
|
||||||
|
} else {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClose, singleProviderView],
|
[onOpen, onClose, singleProviderView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
@@ -590,7 +590,7 @@ export function CombinedModelSelector({
|
|||||||
}, [selectedModelLabel, selectedProviderLabel]);
|
}, [selectedModelLabel, selectedProviderLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) {
|
if (platformIsWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +604,7 @@ export function CombinedModelSelector({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [isOpen, isWeb]);
|
}, [isOpen, platformIsWeb]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -667,7 +667,7 @@ export function CombinedModelSelector({
|
|||||||
<ProviderSearchInput
|
<ProviderSearchInput
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChangeText={setSearchQuery}
|
onChangeText={setSearchQuery}
|
||||||
autoFocus={Platform.OS === "web"}
|
autoFocus={platformIsWeb}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined
|
) : undefined
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export function resolveProviderLabel(
|
|||||||
providerDefinitions: AgentProviderDefinition[],
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
providerId: string,
|
providerId: string,
|
||||||
): 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 {
|
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
||||||
@@ -19,7 +21,9 @@ export function buildModelRows(
|
|||||||
providerDefinitions: AgentProviderDefinition[],
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||||
): SelectorModelRow[] {
|
): 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[] = [];
|
const rows: SelectorModelRow[] = [];
|
||||||
|
|
||||||
for (const definition of providerDefinitions) {
|
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 { memo, useEffect, useRef, type ReactNode } from "react";
|
||||||
import { Plus, Settings } from "lucide-react-native";
|
import { Plus, Settings } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -8,6 +8,7 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||||
return `${agent.serverId}:${agent.id}`;
|
return `${agent.serverId}:${agent.id}`;
|
||||||
@@ -90,7 +91,7 @@ export function CommandCenter() {
|
|||||||
}
|
}
|
||||||
}, [activeIndex, open]);
|
}, [activeIndex, open]);
|
||||||
|
|
||||||
if (Platform.OS !== "web" || !open) return null;
|
if (isNative || !open) return null;
|
||||||
|
|
||||||
const actionItems = items.filter((item) => item.kind === "action");
|
const actionItems = items.filter((item) => item.kind === "action");
|
||||||
const agentItems = items.filter((item) => item.kind === "agent");
|
const agentItems = items.filter((item) => item.kind === "agent");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VS Code-style titlebar drag region for Electron.
|
* 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.
|
* Place as FIRST child of any positioned container that should be draggable.
|
||||||
*/
|
*/
|
||||||
export function TitlebarDragRegion() {
|
export function TitlebarDragRegion() {
|
||||||
if (Platform.OS !== "web" || !getIsElectronRuntime()) {
|
if (isNative || !getIsElectronRuntime()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from "react";
|
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 { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
diffLines: DiffLine[];
|
diffLines: DiffLine[];
|
||||||
@@ -130,7 +131,7 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
overflowWrap: "normal",
|
overflowWrap: "normal",
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
Platform,
|
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
StyleSheet as RNStyleSheet,
|
StyleSheet as RNStyleSheet,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -20,12 +19,13 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { GitDiffPane } from "./git-diff-pane";
|
import { GitDiffPane } from "./git-diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||||
@@ -48,7 +48,7 @@ export function ExplorerSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = isCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
@@ -252,7 +252,7 @@ export function ExplorerSidebar({
|
|||||||
|
|
||||||
// Mobile: full-screen overlay with gesture.
|
// Mobile: full-screen overlay with gesture.
|
||||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
// 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
|
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||||
@@ -270,7 +270,11 @@ export function ExplorerSidebar({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
explorerStaticStyles.mobileSidebar,
|
explorerStaticStyles.mobileSidebar,
|
||||||
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
|
{
|
||||||
|
width: windowWidth,
|
||||||
|
paddingTop: insets.top,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
},
|
||||||
sidebarAnimatedStyle,
|
sidebarAnimatedStyle,
|
||||||
mobileKeyboardInsetStyle,
|
mobileKeyboardInsetStyle,
|
||||||
]}
|
]}
|
||||||
@@ -299,13 +303,13 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 }]}>
|
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||||
{/* Resize handle - absolutely positioned over left border */}
|
{/* Resize handle - absolutely positioned over left border */}
|
||||||
<GestureDetector gesture={resizeGesture}>
|
<GestureDetector gesture={resizeGesture}>
|
||||||
<View
|
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
|
||||||
/>
|
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
@@ -472,7 +476,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
tabText: {
|
tabText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Upload } from "lucide-react-native";
|
import { Upload } from "lucide-react-native";
|
||||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||||
import type { ImageAttachment } from "./message-input";
|
import type { ImageAttachment } from "./message-input";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface FileDropZoneProps {
|
interface FileDropZoneProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -12,7 +13,7 @@ interface FileDropZoneProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
Easing,
|
Easing,
|
||||||
@@ -51,6 +51,7 @@ import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
|||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||||
{ value: "name", label: "Name" },
|
{ value: "name", label: "Name" },
|
||||||
@@ -89,8 +90,8 @@ export function FileExplorerPane({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: FileExplorerPaneProps) {
|
}: FileExplorerPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
|
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const daemonProfile = useMemo(
|
const daemonProfile = useMemo(
|
||||||
@@ -117,23 +118,18 @@ export function FileExplorerPane({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
||||||
requestDirectoryListing,
|
useFileExplorerActions({
|
||||||
requestFileDownloadToken,
|
serverId,
|
||||||
selectExplorerEntry,
|
workspaceId,
|
||||||
} = useFileExplorerActions({
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
serverId,
|
});
|
||||||
workspaceId,
|
|
||||||
workspaceRoot: normalizedWorkspaceRoot,
|
|
||||||
});
|
|
||||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||||
const expandedPathsArray = usePanelStore((state) =>
|
const expandedPathsArray = usePanelStore((state) =>
|
||||||
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||||
);
|
);
|
||||||
const setExpandedPathsForWorkspace = usePanelStore(
|
const setExpandedPathsForWorkspace = usePanelStore((state) => state.setExpandedPathsForWorkspace);
|
||||||
(state) => state.setExpandedPathsForWorkspace,
|
|
||||||
);
|
|
||||||
const expandedPaths = useMemo(
|
const expandedPaths = useMemo(
|
||||||
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||||
[expandedPathsArray],
|
[expandedPathsArray],
|
||||||
@@ -176,7 +172,8 @@ export function FileExplorerPane({
|
|||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
const persistedPaths =
|
||||||
|
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||||
if (persistedPaths) {
|
if (persistedPaths) {
|
||||||
for (const path of persistedPaths) {
|
for (const path of persistedPaths) {
|
||||||
if (path !== ".") {
|
if (path !== ".") {
|
||||||
@@ -200,10 +197,7 @@ export function FileExplorerPane({
|
|||||||
if (newPaths.length === 0) {
|
if (newPaths.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExpandedPathsForWorkspace(
|
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
|
||||||
workspaceStateKey,
|
|
||||||
[...Array.from(expandedPaths), ...newPaths],
|
|
||||||
);
|
|
||||||
newPaths.forEach((path) => {
|
newPaths.forEach((path) => {
|
||||||
if (!directories.has(path)) {
|
if (!directories.has(path)) {
|
||||||
void requestDirectoryListing(path, {
|
void requestDirectoryListing(path, {
|
||||||
@@ -233,10 +227,7 @@ export function FileExplorerPane({
|
|||||||
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setExpandedPathsForWorkspace(
|
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
|
||||||
workspaceStateKey,
|
|
||||||
[...Array.from(expandedPaths), entry.path],
|
|
||||||
);
|
|
||||||
if (!directories.has(entry.path)) {
|
if (!directories.has(entry.path)) {
|
||||||
void requestDirectoryListing(entry.path, {
|
void requestDirectoryListing(entry.path, {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
@@ -848,7 +839,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
},
|
},
|
||||||
entryRowActive: {
|
entryRowActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
indentGuide: {
|
indentGuide: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
ScrollView as RNScrollView,
|
ScrollView as RNScrollView,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type HighlightStyle,
|
type HighlightStyle,
|
||||||
} from "@getpaseo/highlight";
|
} from "@getpaseo/highlight";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -241,8 +242,8 @@ export function FilePane({
|
|||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
}) {
|
}) {
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
|
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
baseRefAvailable: true,
|
baseRefAvailable: true,
|
||||||
baseRefLabel: "main",
|
baseRefLabel: "main",
|
||||||
aheadCount: 0,
|
aheadCount: 0,
|
||||||
|
behindBaseCount: 0,
|
||||||
aheadOfOrigin: 0,
|
aheadOfOrigin: 0,
|
||||||
behindOfOrigin: 0,
|
behindOfOrigin: 0,
|
||||||
shouldPromoteArchive: false,
|
shouldPromoteArchive: false,
|
||||||
@@ -25,6 +26,11 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
status: "idle",
|
status: "idle",
|
||||||
handler: () => undefined,
|
handler: () => undefined,
|
||||||
},
|
},
|
||||||
|
pull: {
|
||||||
|
disabled: false,
|
||||||
|
status: "idle",
|
||||||
|
handler: () => undefined,
|
||||||
|
},
|
||||||
push: {
|
push: {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
@@ -56,89 +62,102 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("git-actions-policy", () => {
|
describe("git-actions-policy", () => {
|
||||||
it("keeps the secondary menu order stable while the primary action changes", () => {
|
it("shows only remote sync actions on the base branch", () => {
|
||||||
const noPrActions = buildGitActions(createInput());
|
const actions = buildGitActions(createInput({ hasRemote: true }));
|
||||||
const withPrActions = buildGitActions(
|
|
||||||
createInput({
|
|
||||||
hasRemote: true,
|
|
||||||
hasPullRequest: true,
|
|
||||||
pullRequestUrl: "https://example.com/pr/123",
|
|
||||||
aheadCount: 3,
|
|
||||||
aheadOfOrigin: 2,
|
|
||||||
shipDefault: "pr",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(noPrActions.primary).toBeNull();
|
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push"]);
|
||||||
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",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables hidden-before actions with explanations instead", () => {
|
it("prioritizes pull when the branch is behind origin", () => {
|
||||||
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", () => {
|
|
||||||
const actions = buildGitActions(
|
const actions = buildGitActions(
|
||||||
createInput({
|
createInput({
|
||||||
hasRemote: true,
|
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,
|
hasPullRequest: true,
|
||||||
pullRequestUrl: "https://example.com/pr/456",
|
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(
|
expect(
|
||||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||||
).toBe(true);
|
).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", () => {
|
it("only shows archive worktree for paseo worktrees", () => {
|
||||||
const hidden = buildGitActions(createInput());
|
const hidden = buildGitActions(createInput());
|
||||||
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
|||||||
|
|
||||||
export type GitActionId =
|
export type GitActionId =
|
||||||
| "commit"
|
| "commit"
|
||||||
|
| "pull"
|
||||||
| "push"
|
| "push"
|
||||||
| "pr"
|
| "pr"
|
||||||
| "merge-branch"
|
| "merge-branch"
|
||||||
@@ -17,7 +18,7 @@ export interface GitAction {
|
|||||||
successLabel: string;
|
successLabel: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
status: ActionStatus;
|
status: ActionStatus;
|
||||||
description?: string;
|
unavailableMessage?: string;
|
||||||
icon?: ReactElement;
|
icon?: ReactElement;
|
||||||
handler: () => void;
|
handler: () => void;
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,7 @@ export interface BuildGitActionsInput {
|
|||||||
baseRefAvailable: boolean;
|
baseRefAvailable: boolean;
|
||||||
baseRefLabel: string;
|
baseRefLabel: string;
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
|
behindBaseCount: number;
|
||||||
aheadOfOrigin: number;
|
aheadOfOrigin: number;
|
||||||
behindOfOrigin: number;
|
behindOfOrigin: number;
|
||||||
shouldPromoteArchive: boolean;
|
shouldPromoteArchive: boolean;
|
||||||
@@ -54,7 +56,8 @@ export interface BuildGitActionsInput {
|
|||||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
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 {
|
export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||||
if (!input.isGit) {
|
if (!input.isGit) {
|
||||||
@@ -74,14 +77,26 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
handler: input.runtime.commit.handler,
|
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", {
|
allActions.set("push", {
|
||||||
id: "push",
|
id: "push",
|
||||||
label: "Push",
|
label: "Push",
|
||||||
pendingLabel: "Pushing...",
|
pendingLabel: "Pushing...",
|
||||||
successLabel: "Pushed",
|
successLabel: "Pushed",
|
||||||
disabled: input.runtime.push.disabled || !input.hasRemote,
|
disabled: input.runtime.push.disabled,
|
||||||
status: input.runtime.push.status,
|
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,
|
icon: input.runtime.push.icon,
|
||||||
handler: input.runtime.push.handler,
|
handler: input.runtime.push.handler,
|
||||||
});
|
});
|
||||||
@@ -93,25 +108,25 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
label: `Merge into ${input.baseRefLabel}`,
|
label: `Merge into ${input.baseRefLabel}`,
|
||||||
pendingLabel: "Merging...",
|
pendingLabel: "Merging...",
|
||||||
successLabel: "Merged",
|
successLabel: "Merged",
|
||||||
disabled:
|
disabled: input.runtime["merge-branch"].disabled,
|
||||||
input.runtime["merge-branch"].disabled ||
|
|
||||||
!input.baseRefAvailable ||
|
|
||||||
input.hasUncommittedChanges ||
|
|
||||||
input.aheadCount === 0,
|
|
||||||
status: input.runtime["merge-branch"].status,
|
status: input.runtime["merge-branch"].status,
|
||||||
description: getMergeBranchDescription(input),
|
unavailableMessage: input.runtime["merge-branch"].disabled
|
||||||
|
? undefined
|
||||||
|
: getMergeBranchUnavailableMessage(input),
|
||||||
icon: input.runtime["merge-branch"].icon,
|
icon: input.runtime["merge-branch"].icon,
|
||||||
handler: input.runtime["merge-branch"].handler,
|
handler: input.runtime["merge-branch"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
allActions.set("merge-from-base", {
|
allActions.set("merge-from-base", {
|
||||||
id: "merge-from-base",
|
id: "merge-from-base",
|
||||||
label: input.isOnBaseBranch ? "Sync" : `Update from ${input.baseRefLabel}`,
|
label: `Update from ${input.baseRefLabel}`,
|
||||||
pendingLabel: "Updating...",
|
pendingLabel: "Updating...",
|
||||||
successLabel: "Updated",
|
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,
|
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,
|
icon: input.runtime["merge-from-base"].icon,
|
||||||
handler: input.runtime["merge-from-base"].handler,
|
handler: input.runtime["merge-from-base"].handler,
|
||||||
});
|
});
|
||||||
@@ -121,21 +136,32 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
|||||||
label: "Archive worktree",
|
label: "Archive worktree",
|
||||||
pendingLabel: "Archiving...",
|
pendingLabel: "Archiving...",
|
||||||
successLabel: "Archived",
|
successLabel: "Archived",
|
||||||
disabled: input.runtime["archive-worktree"].disabled || !input.isPaseoOwnedWorktree,
|
disabled: input.runtime["archive-worktree"].disabled,
|
||||||
status: input.runtime["archive-worktree"].status,
|
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,
|
icon: input.runtime["archive-worktree"].icon,
|
||||||
handler: input.runtime["archive-worktree"].handler,
|
handler: input.runtime["archive-worktree"].handler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const primaryActionId = getPrimaryActionId(input);
|
const primaryActionId = getPrimaryActionId(input);
|
||||||
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
|
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) {
|
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 {
|
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||||
@@ -145,20 +171,19 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
|||||||
if (input.hasUncommittedChanges) {
|
if (input.hasUncommittedChanges) {
|
||||||
return "commit";
|
return "commit";
|
||||||
}
|
}
|
||||||
if (input.aheadOfOrigin > 0 && input.hasRemote) {
|
if (canPull(input)) {
|
||||||
|
return "pull";
|
||||||
|
}
|
||||||
|
if (canPush(input)) {
|
||||||
return "push";
|
return "push";
|
||||||
}
|
}
|
||||||
|
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
|
||||||
|
return "merge-from-base";
|
||||||
|
}
|
||||||
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
|
||||||
return "pr";
|
return "pr";
|
||||||
}
|
}
|
||||||
if (
|
if (!input.isOnBaseBranch && input.aheadCount > 0) {
|
||||||
input.isOnBaseBranch &&
|
|
||||||
input.hasRemote &&
|
|
||||||
(input.aheadOfOrigin > 0 || input.behindOfOrigin > 0)
|
|
||||||
) {
|
|
||||||
return "merge-from-base";
|
|
||||||
}
|
|
||||||
if (input.aheadCount > 0) {
|
|
||||||
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
return input.shipDefault === "merge" ? "merge-branch" : "pr";
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -171,9 +196,12 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "View PR",
|
label: "View PR",
|
||||||
pendingLabel: "View PR",
|
pendingLabel: "View PR",
|
||||||
successLabel: "View PR",
|
successLabel: "View PR",
|
||||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled,
|
disabled: input.runtime.pr.disabled,
|
||||||
status: input.runtime.pr.status,
|
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,
|
icon: input.runtime.pr.icon,
|
||||||
handler: input.runtime.pr.handler,
|
handler: input.runtime.pr.handler,
|
||||||
};
|
};
|
||||||
@@ -184,65 +212,100 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
|||||||
label: "Create PR",
|
label: "Create PR",
|
||||||
pendingLabel: "Creating PR...",
|
pendingLabel: "Creating PR...",
|
||||||
successLabel: "PR Created",
|
successLabel: "PR Created",
|
||||||
disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0,
|
disabled: input.runtime.pr.disabled,
|
||||||
status: input.runtime.pr.status,
|
status: input.runtime.pr.status,
|
||||||
description: getCreatePrDescription(input),
|
unavailableMessage: input.runtime.pr.disabled
|
||||||
|
? undefined
|
||||||
|
: getCreatePrUnavailableMessage(input),
|
||||||
icon: input.runtime.pr.icon,
|
icon: input.runtime.pr.icon,
|
||||||
handler: input.runtime.pr.handler,
|
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 {
|
function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||||
if (!input.baseRefAvailable || input.hasUncommittedChanges) {
|
return (
|
||||||
return false;
|
!input.isOnBaseBranch &&
|
||||||
}
|
input.baseRefAvailable &&
|
||||||
if (!input.isOnBaseBranch) {
|
!input.hasUncommittedChanges &&
|
||||||
return true;
|
input.behindBaseCount > 0
|
||||||
}
|
);
|
||||||
if (!input.hasRemote) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return input.aheadOfOrigin > 0 || input.behindOfOrigin > 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) {
|
if (!input.githubFeaturesEnabled) {
|
||||||
return "GitHub features unavailable";
|
return "Create PR isn't available right now because GitHub isn't connected";
|
||||||
}
|
}
|
||||||
if (input.aheadCount === 0) {
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMergeBranchDescription(input: BuildGitActionsInput): string | undefined {
|
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.baseRefAvailable) {
|
if (!input.baseRefAvailable) {
|
||||||
return "Base ref unavailable";
|
return "Merge isn't available because we couldn't determine the base branch";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
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) {
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMergeFromBaseDescription(input: BuildGitActionsInput): string | undefined {
|
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
|
||||||
if (!input.baseRefAvailable) {
|
if (!input.baseRefAvailable) {
|
||||||
return "Base ref unavailable";
|
return "Update isn't available because we couldn't determine the base branch";
|
||||||
}
|
}
|
||||||
if (input.hasUncommittedChanges) {
|
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) {
|
if (input.behindBaseCount === 0) {
|
||||||
return undefined;
|
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
|
||||||
}
|
|
||||||
if (!input.hasRemote) {
|
|
||||||
return "No remote configured";
|
|
||||||
}
|
|
||||||
if (input.aheadOfOrigin === 0 && input.behindOfOrigin === 0) {
|
|
||||||
return "Already up to date";
|
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
import { View, Text, ActivityIndicator, Pressable } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
interface GitActionsSplitButtonProps {
|
interface GitActionsSplitButtonProps {
|
||||||
@@ -19,6 +20,7 @@ interface GitActionsSplitButtonProps {
|
|||||||
|
|
||||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const toast = useToast();
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||||
|
|
||||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||||
@@ -27,6 +29,20 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
return action.label;
|
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 (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
{gitActions.primary ? (
|
{gitActions.primary ? (
|
||||||
@@ -73,7 +89,8 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
||||||
{gitActions.secondary.map((action, index) => {
|
{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 (
|
return (
|
||||||
<View key={action.id}>
|
<View key={action.id}>
|
||||||
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
|
||||||
@@ -81,11 +98,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
testID={`changes-menu-${action.id}`}
|
testID={`changes-menu-${action.id}`}
|
||||||
leading={action.icon}
|
leading={action.icon}
|
||||||
trailing={
|
trailing={
|
||||||
action.id === "archive-worktree" && archiveShortcutKeys
|
action.id === "archive-worktree" && archiveShortcutKeys ? (
|
||||||
? <Shortcut chord={archiveShortcutKeys} />
|
<Shortcut chord={archiveShortcutKeys} />
|
||||||
: undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
|
muted={Boolean(action.unavailableMessage)}
|
||||||
status={action.status}
|
status={action.status}
|
||||||
pendingLabel={action.pendingLabel}
|
pendingLabel={action.pendingLabel}
|
||||||
successLabel={action.successLabel}
|
successLabel={action.successLabel}
|
||||||
@@ -94,8 +112,7 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
action.id === "pr" &&
|
action.id === "pr" &&
|
||||||
action.label === "View PR"
|
action.label === "View PR"
|
||||||
}
|
}
|
||||||
description={action.description}
|
onSelect={() => handleActionSelect(action)}
|
||||||
onSelect={action.handler}
|
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -125,11 +142,12 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
testID={`changes-menu-${action.id}`}
|
testID={`changes-menu-${action.id}`}
|
||||||
leading={action.icon}
|
leading={action.icon}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
|
muted={Boolean(action.unavailableMessage)}
|
||||||
status={action.status}
|
status={action.status}
|
||||||
pendingLabel={action.pendingLabel}
|
pendingLabel={action.pendingLabel}
|
||||||
successLabel={action.successLabel}
|
successLabel={action.successLabel}
|
||||||
closeOnSelect={false}
|
closeOnSelect={false}
|
||||||
onSelect={action.handler}
|
onSelect={() => handleActionSelect(action)}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement } from "react";
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
memo,
|
|
||||||
type ReactElement,
|
|
||||||
} from "react";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -14,19 +6,20 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
AlignJustify,
|
AlignJustify,
|
||||||
Archive,
|
Archive,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Columns2,
|
Columns2,
|
||||||
|
Download,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
GitCommitHorizontal,
|
GitCommitHorizontal,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
@@ -39,11 +32,15 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||||
import {
|
import {
|
||||||
useCheckoutDiffQuery,
|
|
||||||
type ParsedDiffFile,
|
type ParsedDiffFile,
|
||||||
type DiffLine,
|
type DiffLine,
|
||||||
type HighlightToken,
|
type HighlightToken,
|
||||||
} from "@/hooks/use-checkout-diff-query";
|
} from "@/hooks/use-checkout-diff-query";
|
||||||
|
import {
|
||||||
|
useCheckoutDiffMetadataQuery,
|
||||||
|
type DiffFileMetadata,
|
||||||
|
} from "@/hooks/use-checkout-diff-metadata-query";
|
||||||
|
import { useFileHunks } from "@/hooks/use-file-hunks";
|
||||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||||
@@ -56,7 +53,12 @@ import {
|
|||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
|
||||||
import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout";
|
import {
|
||||||
|
buildSplitDiffRows,
|
||||||
|
buildUnifiedDiffLines,
|
||||||
|
type SplitDiffDisplayLine,
|
||||||
|
type SplitDiffRow,
|
||||||
|
} from "@/utils/diff-layout";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -74,6 +76,13 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
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";
|
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
@@ -94,7 +103,7 @@ type WrappedWebTextStyle = TextStyle & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return wrapLines
|
return wrapLines
|
||||||
@@ -115,12 +124,7 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text style={[styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }]}>
|
||||||
style={[
|
|
||||||
styles.diffLineText,
|
|
||||||
{ lineHeight, ...getWrappedTextStyle(wrapLines) },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{tokens.map((token, index) => (
|
{tokens.map((token, index) => (
|
||||||
<Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
|
<Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
|
||||||
{token.text}
|
{token.text}
|
||||||
@@ -130,8 +134,16 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DiffFileHeaderFields {
|
||||||
|
path: string;
|
||||||
|
isNew: boolean;
|
||||||
|
isDeleted: boolean;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface DiffFileSectionProps {
|
interface DiffFileSectionProps {
|
||||||
file: ParsedDiffFile;
|
file: DiffFileHeaderFields;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: (path: string) => void;
|
onToggle: (path: string) => void;
|
||||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||||
@@ -164,23 +176,19 @@ function DiffGutterCell({
|
|||||||
type === "remove" && styles.removeLineNumberText,
|
type === "remove" && styles.removeLineNumberText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{lineNumber != null ? String(lineNumber) : ""}
|
{formatDiffGutterText(lineNumber)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffTextLine({
|
function DiffTextLine({ line, wrapLines }: { line: DiffLine; wrapLines: boolean }) {
|
||||||
line,
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
wrapLines,
|
|
||||||
}: {
|
|
||||||
line: DiffLine;
|
|
||||||
wrapLines: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.textLineContainer, lineTypeBackground(line.type)]}>
|
<View style={[styles.textLineContainer, lineTypeBackground(line.type)]}>
|
||||||
{line.tokens && line.type !== "header" ? (
|
{line.type !== "header" && visibleTokens ? (
|
||||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -192,7 +200,7 @@ function DiffTextLine({
|
|||||||
line.type === "context" && styles.contextLineText,
|
line.type === "context" && styles.contextLineText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{line.content || " "}
|
{formatDiffContentText(line.content)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -206,10 +214,12 @@ function SplitTextLine({
|
|||||||
line: SplitDiffDisplayLine | null;
|
line: SplitDiffDisplayLine | null;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}>
|
<View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}>
|
||||||
{line?.tokens ? (
|
{visibleTokens ? (
|
||||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -221,7 +231,7 @@ function SplitTextLine({
|
|||||||
!line && styles.emptySplitCellText,
|
!line && styles.emptySplitCellText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{line?.content ?? ""}
|
{formatDiffContentText(line?.content)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -239,13 +249,10 @@ function DiffLineView({
|
|||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={[styles.diffLineContainer, lineTypeBackground(line.type)]}>
|
||||||
style={[
|
|
||||||
styles.diffLineContainer,
|
|
||||||
lineTypeBackground(line.type),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -254,11 +261,11 @@ function DiffLineView({
|
|||||||
line.type === "remove" && styles.removeLineNumberText,
|
line.type === "remove" && styles.removeLineNumberText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{lineNumber != null ? String(lineNumber) : ""}
|
{formatDiffGutterText(lineNumber)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{line.tokens && line.type !== "header" ? (
|
{line.type !== "header" && visibleTokens ? (
|
||||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -270,7 +277,7 @@ function DiffLineView({
|
|||||||
line.type === "context" && styles.contextLineText,
|
line.type === "context" && styles.contextLineText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{line.content || " "}
|
{formatDiffContentText(line.content)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -286,13 +293,10 @@ function SplitDiffLine({
|
|||||||
gutterWidth: number;
|
gutterWidth: number;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View style={[styles.diffLineContainer, lineTypeBackground(line?.type)]}>
|
||||||
style={[
|
|
||||||
styles.diffLineContainer,
|
|
||||||
lineTypeBackground(line?.type),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -301,11 +305,11 @@ function SplitDiffLine({
|
|||||||
line?.type === "remove" && styles.removeLineNumberText,
|
line?.type === "remove" && styles.removeLineNumberText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{line?.lineNumber != null ? String(line.lineNumber) : ""}
|
{formatDiffGutterText(line?.lineNumber ?? null)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{line?.tokens ? (
|
{visibleTokens ? (
|
||||||
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
@@ -317,7 +321,7 @@ function SplitDiffLine({
|
|||||||
!line && styles.emptySplitCellText,
|
!line && styles.emptySplitCellText,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{line?.content ?? ""}
|
{formatDiffContentText(line?.content)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -366,14 +370,30 @@ function SplitDiffColumn({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow]}>
|
<View
|
||||||
|
style={[styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow]}
|
||||||
|
>
|
||||||
<View style={styles.gutterColumn}>
|
<View style={styles.gutterColumn}>
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
if (row.kind === "header") {
|
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;
|
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>
|
</View>
|
||||||
<DiffScroll
|
<DiffScroll
|
||||||
@@ -391,7 +411,13 @@ function SplitDiffColumn({
|
|||||||
</View>
|
</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>
|
</View>
|
||||||
</DiffScroll>
|
</DiffScroll>
|
||||||
@@ -439,7 +465,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
}}
|
}}
|
||||||
onPressOut={(event) => {
|
onPressOut={(event) => {
|
||||||
if (
|
if (
|
||||||
Platform.OS !== "web" &&
|
isNative &&
|
||||||
!pressHandledRef.current &&
|
!pressHandledRef.current &&
|
||||||
layoutYRef.current === 0 &&
|
layoutYRef.current === 0 &&
|
||||||
pressInRef.current
|
pressInRef.current
|
||||||
@@ -520,7 +546,11 @@ function DiffFileBody({
|
|||||||
|
|
||||||
let maxLineNo = 0;
|
let maxLineNo = 0;
|
||||||
for (const hunk of file.hunks) {
|
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);
|
const gutterWidth = lineNumberGutterWidth(maxLineNo);
|
||||||
|
|
||||||
@@ -528,39 +558,37 @@ function DiffFileBody({
|
|||||||
const rows = buildSplitDiffRows(file);
|
const rows = buildSplitDiffRows(file);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.diffContent, styles.splitRow]}>
|
<View style={[styles.diffContent, styles.splitRow]}>
|
||||||
<SplitDiffColumn rows={rows} side="left" gutterWidth={gutterWidth} wrapLines={wrapLines} />
|
<SplitDiffColumn
|
||||||
<SplitDiffColumn rows={rows} side="right" gutterWidth={gutterWidth} wrapLines={wrapLines} showDivider />
|
rows={rows}
|
||||||
|
side="left"
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
wrapLines={wrapLines}
|
||||||
|
/>
|
||||||
|
<SplitDiffColumn
|
||||||
|
rows={rows}
|
||||||
|
side="right"
|
||||||
|
gutterWidth={gutterWidth}
|
||||||
|
wrapLines={wrapLines}
|
||||||
|
showDivider
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = [];
|
const computedLines = buildUnifiedDiffLines(file);
|
||||||
for (const [hunkIndex, hunk] of file.hunks.entries()) {
|
|
||||||
let oldLineNo = hunk.oldStart;
|
|
||||||
let newLineNo = hunk.newStart;
|
|
||||||
for (const [lineIndex, line] of hunk.lines.entries()) {
|
|
||||||
let lineNumber: number | null = null;
|
|
||||||
if (line.type === "remove") {
|
|
||||||
lineNumber = oldLineNo;
|
|
||||||
oldLineNo++;
|
|
||||||
} else if (line.type === "add") {
|
|
||||||
lineNumber = newLineNo;
|
|
||||||
newLineNo++;
|
|
||||||
} else if (line.type === "context") {
|
|
||||||
lineNumber = newLineNo;
|
|
||||||
oldLineNo++;
|
|
||||||
newLineNo++;
|
|
||||||
}
|
|
||||||
computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wrapLines) {
|
if (wrapLines) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.diffContent}>
|
<View style={styles.diffContent}>
|
||||||
<View style={styles.linesContainer}>
|
<View style={styles.linesContainer}>
|
||||||
{computedLines.map(({ line, lineNumber, key }) => (
|
{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>
|
||||||
</View>
|
</View>
|
||||||
@@ -572,7 +600,12 @@ function DiffFileBody({
|
|||||||
<View style={[styles.diffContent, styles.diffContentRow]}>
|
<View style={[styles.diffContent, styles.diffContentRow]}>
|
||||||
<View style={styles.gutterColumn}>
|
<View style={styles.gutterColumn}>
|
||||||
{computedLines.map(({ line, lineNumber, key }) => (
|
{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>
|
</View>
|
||||||
<DiffScroll
|
<DiffScroll
|
||||||
@@ -581,7 +614,9 @@ function DiffFileBody({
|
|||||||
style={styles.splitColumnScroll}
|
style={styles.splitColumnScroll}
|
||||||
contentContainerStyle={styles.diffContentInner}
|
contentContainerStyle={styles.diffContentInner}
|
||||||
>
|
>
|
||||||
<View style={[styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }]}>
|
<View
|
||||||
|
style={[styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }]}
|
||||||
|
>
|
||||||
{computedLines.map(({ line, key }) => (
|
{computedLines.map(({ line, key }) => (
|
||||||
<DiffTextLine key={key} line={line} wrapLines={false} />
|
<DiffTextLine key={key} line={line} wrapLines={false} />
|
||||||
))}
|
))}
|
||||||
@@ -594,6 +629,109 @@ function DiffFileBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LazyDiffFileBodyProps {
|
||||||
|
file: DiffFileMetadata;
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
mode: "uncommitted" | "base";
|
||||||
|
baseRef?: string;
|
||||||
|
ignoreWhitespace?: boolean;
|
||||||
|
layout: "unified" | "split";
|
||||||
|
wrapLines: boolean;
|
||||||
|
onBodyHeightChange?: (path: string, height: number) => void;
|
||||||
|
testID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LazyDiffFileBody({
|
||||||
|
file,
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode,
|
||||||
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
|
layout,
|
||||||
|
wrapLines,
|
||||||
|
onBodyHeightChange,
|
||||||
|
testID,
|
||||||
|
}: LazyDiffFileBodyProps) {
|
||||||
|
const {
|
||||||
|
file: resolvedFile,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useFileHunks({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode,
|
||||||
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
|
file,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (file.status === "binary" || file.status === "too_large") {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||||
|
onLayout={(event) => {
|
||||||
|
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||||
|
}}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
<View style={styles.statusMessageContainer}>
|
||||||
|
<Text style={styles.statusMessageText}>
|
||||||
|
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||||
|
onLayout={(event) => {
|
||||||
|
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||||
|
}}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
<View style={styles.statusMessageContainer}>
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !resolvedFile) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||||
|
onLayout={(event) => {
|
||||||
|
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||||
|
}}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
<View style={styles.statusMessageContainer}>
|
||||||
|
<Text style={styles.statusMessageText}>
|
||||||
|
{error instanceof Error ? error.message : "Failed to load diff"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DiffFileBody
|
||||||
|
file={resolvedFile}
|
||||||
|
layout={layout}
|
||||||
|
wrapLines={wrapLines}
|
||||||
|
onBodyHeightChange={onBodyHeightChange}
|
||||||
|
testID={testID}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface GitDiffPaneProps {
|
interface GitDiffPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
@@ -602,17 +740,17 @@ interface GitDiffPaneProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DiffFlatItem =
|
type DiffFlatItem =
|
||||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
| { type: "header"; file: DiffFileHeaderFields; fileIndex: number; isExpanded: boolean }
|
||||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
| { type: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
|
||||||
|
|
||||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const toast = useToast();
|
||||||
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
const isMobile = useIsCompactFormFactor();
|
||||||
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
|
const canUseSplitLayout = isWeb && !isMobile;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
|
||||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||||
@@ -664,7 +802,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
isError: isDiffError,
|
isError: isDiffError,
|
||||||
error: diffError,
|
error: diffError,
|
||||||
refresh: refreshDiff,
|
refresh: refreshDiff,
|
||||||
} = useCheckoutDiffQuery({
|
} = useCheckoutDiffMetadataQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
mode: diffMode,
|
mode: diffMode,
|
||||||
@@ -699,10 +837,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const setDiffExpandedPathsForWorkspace = usePanelStore(
|
const setDiffExpandedPathsForWorkspace = usePanelStore(
|
||||||
(state) => state.setDiffExpandedPathsForWorkspace,
|
(state) => state.setDiffExpandedPathsForWorkspace,
|
||||||
);
|
);
|
||||||
const expandedPaths = useMemo(
|
const expandedPaths = useMemo(() => new Set(expandedPathsArray ?? []), [expandedPathsArray]);
|
||||||
() => new Set(expandedPathsArray ?? []),
|
|
||||||
[expandedPathsArray],
|
|
||||||
);
|
|
||||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||||
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
@@ -768,7 +903,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
stickyIndices.push(items.length - 1);
|
stickyIndices.push(items.length - 1);
|
||||||
}
|
}
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
items.push({ type: "body", file, fileIndex: i });
|
items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||||
@@ -896,6 +1031,9 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const commitStatus = useCheckoutGitActionsStore((state) =>
|
const commitStatus = useCheckoutGitActionsStore((state) =>
|
||||||
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
state.getStatus({ serverId, cwd, actionId: "commit" }),
|
||||||
);
|
);
|
||||||
|
const pullStatus = useCheckoutGitActionsStore((state) =>
|
||||||
|
state.getStatus({ serverId, cwd, actionId: "pull" }),
|
||||||
|
);
|
||||||
const pushStatus = useCheckoutGitActionsStore((state) =>
|
const pushStatus = useCheckoutGitActionsStore((state) =>
|
||||||
state.getStatus({ serverId, cwd, actionId: "push" }),
|
state.getStatus({ serverId, cwd, actionId: "push" }),
|
||||||
);
|
);
|
||||||
@@ -913,83 +1051,123 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
|
||||||
|
const runPull = useCheckoutGitActionsStore((state) => state.pull);
|
||||||
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
const runPush = useCheckoutGitActionsStore((state) => state.push);
|
||||||
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
|
||||||
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
|
||||||
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
|
||||||
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
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(() => {
|
const handleCommit = useCallback(() => {
|
||||||
setActionError(null);
|
void runCommit({ serverId, cwd })
|
||||||
void runCommit({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to commit";
|
toastActionSuccess("Committed");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [runCommit, serverId, cwd]);
|
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(() => {
|
const handlePush = useCallback(() => {
|
||||||
setActionError(null);
|
void runPush({ serverId, cwd })
|
||||||
void runPush({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to push";
|
toastActionSuccess("Pushed");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [runPush, serverId, cwd]);
|
toastActionError(err, "Failed to push");
|
||||||
|
});
|
||||||
|
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleCreatePr = useCallback(() => {
|
const handleCreatePr = useCallback(() => {
|
||||||
void persistShipDefault("pr");
|
void persistShipDefault("pr");
|
||||||
setActionError(null);
|
void runCreatePr({ serverId, cwd })
|
||||||
void runCreatePr({ serverId, cwd }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to create PR";
|
toastActionSuccess("PR created");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [persistShipDefault, runCreatePr, serverId, cwd]);
|
toastActionError(err, "Failed to create PR");
|
||||||
|
});
|
||||||
|
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleMergeBranch = useCallback(() => {
|
const handleMergeBranch = useCallback(() => {
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
setActionError("Base ref unavailable");
|
toast.error("Base ref unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void persistShipDefault("merge");
|
void persistShipDefault("merge");
|
||||||
setActionError(null);
|
|
||||||
void runMergeBranch({ serverId, cwd, baseRef })
|
void runMergeBranch({ serverId, cwd, baseRef })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setPostShipArchiveSuggested(true);
|
setPostShipArchiveSuggested(true);
|
||||||
|
toastActionSuccess("Merged");
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
toastActionError(err, "Failed to merge");
|
||||||
setActionError(message);
|
|
||||||
});
|
});
|
||||||
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
|
}, [
|
||||||
|
baseRef,
|
||||||
|
cwd,
|
||||||
|
persistShipDefault,
|
||||||
|
runMergeBranch,
|
||||||
|
serverId,
|
||||||
|
toast,
|
||||||
|
toastActionError,
|
||||||
|
toastActionSuccess,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleMergeFromBase = useCallback(() => {
|
const handleMergeFromBase = useCallback(() => {
|
||||||
if (!baseRef) {
|
if (!baseRef) {
|
||||||
setActionError("Base ref unavailable");
|
toast.error("Base ref unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionError(null);
|
void runMergeFromBase({ serverId, cwd, baseRef })
|
||||||
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
|
.then(() => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to merge from base";
|
toastActionSuccess("Updated");
|
||||||
setActionError(message);
|
})
|
||||||
});
|
.catch((err) => {
|
||||||
}, [baseRef, runMergeFromBase, serverId, cwd]);
|
toastActionError(err, "Failed to merge from base");
|
||||||
|
});
|
||||||
|
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
|
||||||
|
|
||||||
const handleArchiveWorktree = useCallback(() => {
|
const handleArchiveWorktree = useCallback(() => {
|
||||||
const worktreePath = status?.cwd;
|
const worktreePath = status?.cwd;
|
||||||
if (!worktreePath) {
|
if (!worktreePath) {
|
||||||
setActionError("Worktree path unavailable");
|
toast.error("Worktree path unavailable");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActionError(null);
|
|
||||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
toastActionError(err, "Failed to archive worktree");
|
||||||
setActionError(message);
|
|
||||||
});
|
});
|
||||||
}, [runArchiveWorktree, router, serverId, cwd, status]);
|
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||||
|
|
||||||
const renderFlatItem = useCallback(
|
const renderFlatItem = useCallback(
|
||||||
({ item }: { item: DiffFlatItem }) => {
|
({ item }: { item: DiffFlatItem }) => {
|
||||||
@@ -1005,8 +1183,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DiffFileBody
|
<LazyDiffFileBody
|
||||||
file={item.file}
|
file={item.file}
|
||||||
|
serverId={serverId}
|
||||||
|
cwd={cwd}
|
||||||
|
mode={diffMode}
|
||||||
|
baseRef={baseRef}
|
||||||
|
ignoreWhitespace={changesPreferences.hideWhitespace}
|
||||||
layout={effectiveLayout}
|
layout={effectiveLayout}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
onBodyHeightChange={handleBodyHeightChange}
|
onBodyHeightChange={handleBodyHeightChange}
|
||||||
@@ -1015,10 +1198,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
baseRef,
|
||||||
|
changesPreferences.hideWhitespace,
|
||||||
|
cwd,
|
||||||
|
diffMode,
|
||||||
effectiveLayout,
|
effectiveLayout,
|
||||||
handleBodyHeightChange,
|
handleBodyHeightChange,
|
||||||
handleHeaderHeightChange,
|
handleHeaderHeightChange,
|
||||||
handleToggleExpanded,
|
handleToggleExpanded,
|
||||||
|
serverId,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1041,6 +1229,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
: "Unknown";
|
: "Unknown";
|
||||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||||
|
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
|
||||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||||
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
||||||
const baseRefLabel = useMemo(() => {
|
const baseRefLabel = useMemo(() => {
|
||||||
@@ -1066,6 +1255,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
(postShipArchiveSuggested || isMergedPullRequest);
|
(postShipArchiveSuggested || isMergedPullRequest);
|
||||||
|
|
||||||
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
||||||
|
const pullDisabled = actionsDisabled || pullStatus === "pending";
|
||||||
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
||||||
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
||||||
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
||||||
@@ -1172,6 +1362,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
baseRefAvailable: Boolean(baseRef),
|
baseRefAvailable: Boolean(baseRef),
|
||||||
baseRefLabel,
|
baseRefLabel,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
|
behindBaseCount,
|
||||||
aheadOfOrigin,
|
aheadOfOrigin,
|
||||||
behindOfOrigin,
|
behindOfOrigin,
|
||||||
shouldPromoteArchive,
|
shouldPromoteArchive,
|
||||||
@@ -1183,6 +1374,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
|
||||||
handler: handleCommit,
|
handler: handleCommit,
|
||||||
},
|
},
|
||||||
|
pull: {
|
||||||
|
disabled: pullDisabled,
|
||||||
|
status: pullStatus,
|
||||||
|
icon: <Download size={16} color={theme.colors.foregroundMuted} />,
|
||||||
|
handler: handlePull,
|
||||||
|
},
|
||||||
push: {
|
push: {
|
||||||
disabled: pushDisabled,
|
disabled: pushDisabled,
|
||||||
status: pushStatus,
|
status: pushStatus,
|
||||||
@@ -1227,6 +1424,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
hasPullRequest,
|
hasPullRequest,
|
||||||
prStatus?.url,
|
prStatus?.url,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
|
behindBaseCount,
|
||||||
isPaseoOwnedWorktree,
|
isPaseoOwnedWorktree,
|
||||||
isOnBaseBranch,
|
isOnBaseBranch,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
@@ -1237,18 +1435,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
baseRefLabel,
|
baseRefLabel,
|
||||||
shouldPromoteArchive,
|
shouldPromoteArchive,
|
||||||
commitDisabled,
|
commitDisabled,
|
||||||
|
pullDisabled,
|
||||||
pushDisabled,
|
pushDisabled,
|
||||||
prDisabled,
|
prDisabled,
|
||||||
mergeDisabled,
|
mergeDisabled,
|
||||||
mergeFromBaseDisabled,
|
mergeFromBaseDisabled,
|
||||||
archiveDisabled,
|
archiveDisabled,
|
||||||
commitStatus,
|
commitStatus,
|
||||||
|
pullStatus,
|
||||||
pushStatus,
|
pushStatus,
|
||||||
prCreateStatus,
|
prCreateStatus,
|
||||||
mergeStatus,
|
mergeStatus,
|
||||||
mergeFromBaseStatus,
|
mergeFromBaseStatus,
|
||||||
archiveStatus,
|
archiveStatus,
|
||||||
handleCommit,
|
handleCommit,
|
||||||
|
handlePull,
|
||||||
handlePush,
|
handlePush,
|
||||||
handleCreatePr,
|
handleCreatePr,
|
||||||
handleMergeBranch,
|
handleMergeBranch,
|
||||||
@@ -1458,7 +1659,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
|
|
||||||
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
||||||
|
|
||||||
<View style={styles.diffContainer}>
|
<View style={styles.diffContainer}>
|
||||||
@@ -1724,7 +1924,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
newBadgeText: {
|
newBadgeText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.green[400],
|
color: theme.colors.diffAddition,
|
||||||
},
|
},
|
||||||
deletedBadge: {
|
deletedBadge: {
|
||||||
backgroundColor: "rgba(248, 81, 73, 0.2)",
|
backgroundColor: "rgba(248, 81, 73, 0.2)",
|
||||||
@@ -1736,17 +1936,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
deletedBadgeText: {
|
deletedBadgeText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.red[500],
|
color: theme.colors.diffDeletion,
|
||||||
},
|
},
|
||||||
additions: {
|
additions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.green[400],
|
color: theme.colors.diffAddition,
|
||||||
},
|
},
|
||||||
deletions: {
|
deletions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.red[500],
|
color: theme.colors.diffDeletion,
|
||||||
},
|
},
|
||||||
diffContent: {
|
diffContent: {
|
||||||
borderTopWidth: theme.borderWidth[1],
|
borderTopWidth: theme.borderWidth[1],
|
||||||
@@ -1824,10 +2024,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
},
|
},
|
||||||
addLineNumberText: {
|
addLineNumberText: {
|
||||||
color: theme.colors.palette.green[400],
|
color: theme.colors.diffAddition,
|
||||||
},
|
},
|
||||||
removeLineNumberText: {
|
removeLineNumberText: {
|
||||||
color: theme.colors.palette.red[500],
|
color: theme.colors.diffDeletion,
|
||||||
},
|
},
|
||||||
diffLineText: {
|
diffLineText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import {
|
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||||
Platform,
|
|
||||||
Text,
|
|
||||||
View,
|
|
||||||
type PressableProps,
|
|
||||||
type StyleProp,
|
|
||||||
type ViewStyle,
|
|
||||||
} from "react-native";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface HeaderToggleButtonState {
|
interface HeaderToggleButtonState {
|
||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
@@ -44,7 +38,7 @@ export function HeaderToggleButton({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
|
||||||
const ariaExpandedProps =
|
const ariaExpandedProps =
|
||||||
Platform.OS === "web" && typeof expandedState === "boolean"
|
isWeb && typeof expandedState === "boolean"
|
||||||
? ({ "aria-expanded": expandedState } as any)
|
? ({ "aria-expanded": expandedState } as any)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native";
|
|||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
|
||||||
interface MenuHeaderProps {
|
interface MenuHeaderProps {
|
||||||
@@ -44,7 +44,7 @@ export function SidebarMenuToggle({
|
|||||||
nativeID = "menu-button",
|
nativeID = "menu-button",
|
||||||
}: SidebarMenuToggleProps = {}) {
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = isCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
HEADER_TOP_PADDING_MOBILE,
|
HEADER_TOP_PADDING_MOBILE,
|
||||||
isCompactFormFactor,
|
useIsCompactFormFactor,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
@@ -23,10 +23,16 @@ interface ScreenHeaderProps {
|
|||||||
* Shared frame for the home/back headers so we only maintain padding, border,
|
* Shared frame for the home/back headers so we only maintain padding, border,
|
||||||
* and safe-area logic in one place.
|
* 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 { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = isCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const padding = useWindowControlsPadding("header");
|
const padding = useWindowControlsPadding("header");
|
||||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { SquareTerminal } from "lucide-react-native";
|
||||||
import { Image, type ImageSourcePropType } from "react-native";
|
import { Image, type ImageSourcePropType } from "react-native";
|
||||||
import type { EditorTargetId } from "@server/shared/messages";
|
import {
|
||||||
|
isKnownEditorTargetId,
|
||||||
|
type EditorTargetId,
|
||||||
|
type KnownEditorTargetId,
|
||||||
|
} from "@server/shared/messages";
|
||||||
|
|
||||||
interface EditorAppIconProps {
|
interface EditorAppIconProps {
|
||||||
editorId: EditorTargetId;
|
editorId: EditorTargetId;
|
||||||
@@ -8,9 +13,10 @@ interface EditorAppIconProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
|
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||||
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||||
|
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
|
||||||
zed: require("../../../assets/images/editor-apps/zed.png"),
|
zed: require("../../../assets/images/editor-apps/zed.png"),
|
||||||
finder: require("../../../assets/images/editor-apps/finder.png"),
|
finder: require("../../../assets/images/editor-apps/finder.png"),
|
||||||
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
|
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||||
@@ -18,10 +24,15 @@ const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
|
|||||||
};
|
};
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
|
|
||||||
export function EditorAppIcon({
|
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
|
||||||
editorId,
|
return isKnownEditorTargetId(editorId);
|
||||||
size = 16,
|
}
|
||||||
}: EditorAppIconProps) {
|
|
||||||
|
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
|
||||||
|
if (!hasBundledEditorAppIcon(editorId)) {
|
||||||
|
return <SquareTerminal size={size} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
source={EDITOR_APP_IMAGES[editorId]}
|
source={EDITOR_APP_IMAGES[editorId]}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useSyncExternalStore,
|
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
|
type ReactElement,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
Platform,
|
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
StyleSheet as RNStyleSheet,
|
StyleSheet as RNStyleSheet,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
@@ -44,13 +43,13 @@ import {
|
|||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import {
|
import {
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
HEADER_INNER_HEIGHT_MOBILE,
|
||||||
isCompactFormFactor,
|
useIsCompactFormFactor,
|
||||||
} from "@/constants/layout";
|
} from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
@@ -59,6 +58,7 @@ import {
|
|||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
@@ -69,18 +69,12 @@ interface LeftSidebarProps {
|
|||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HostOption {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarSharedProps {
|
interface SidebarSharedProps {
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
activeServerId: string | null;
|
activeServerId: string | null;
|
||||||
activeHostLabel: string;
|
activeHostLabel: string;
|
||||||
activeHostStatusColor: string;
|
activeHostStatusColor: string;
|
||||||
hostOptions: HostOption[];
|
hostOptions: ComboboxOption[];
|
||||||
hostTriggerRef: RefObject<View | null>;
|
hostTriggerRef: RefObject<View | null>;
|
||||||
isHostPickerOpen: boolean;
|
isHostPickerOpen: boolean;
|
||||||
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
@@ -95,6 +89,12 @@ interface SidebarSharedProps {
|
|||||||
handleHostSelect: (nextServerId: string) => void;
|
handleHostSelect: (nextServerId: string) => void;
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleSettings: () => void;
|
handleSettings: () => void;
|
||||||
|
renderHostOption: (input: {
|
||||||
|
option: ComboboxOption;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MobileSidebarProps extends SidebarSharedProps {
|
interface MobileSidebarProps extends SidebarSharedProps {
|
||||||
@@ -118,34 +118,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isCompactLayout = isCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
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 activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||||
const activeDaemon = useMemo(() => {
|
const activeDaemon = useMemo(() => {
|
||||||
if (daemons.length === 0) {
|
if (daemons.length === 0) {
|
||||||
@@ -165,8 +143,9 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const trimmed = activeDaemon.label?.trim();
|
const trimmed = activeDaemon.label?.trim();
|
||||||
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
||||||
}, [activeDaemon]);
|
}, [activeDaemon]);
|
||||||
|
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
|
||||||
const activeHostStatus = activeServerId
|
const activeHostStatus = activeServerId
|
||||||
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting")
|
? (activeHostSnapshot?.connectionStatus ?? "connecting")
|
||||||
: "idle";
|
: "idle";
|
||||||
const activeHostStatusColor =
|
const activeHostStatusColor =
|
||||||
activeHostStatus === "online"
|
activeHostStatus === "online"
|
||||||
@@ -179,11 +158,30 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
daemons.map((daemon) => ({
|
daemons.map((daemon) => ({
|
||||||
id: daemon.serverId,
|
id: daemon.serverId,
|
||||||
label: daemon.label?.trim() || 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 hostTriggerRef = useRef<View | null>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||||
@@ -194,11 +192,8 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
enabled: isOpen,
|
enabled: isOpen,
|
||||||
});
|
});
|
||||||
const {
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
collapsedProjectKeys,
|
useSidebarShortcutModel(projects);
|
||||||
shortcutIndexByWorkspaceKey,
|
|
||||||
toggleProjectCollapsed,
|
|
||||||
} = useSidebarShortcutModel(projects);
|
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
@@ -276,6 +271,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleHostSelect,
|
handleHostSelect,
|
||||||
|
renderHostOption,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isCompactLayout) {
|
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 }) {
|
function SessionsButton({ onPress }: { onPress: () => void }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -361,6 +384,7 @@ function MobileSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleHostSelect,
|
handleHostSelect,
|
||||||
|
renderHostOption,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
insetsTop,
|
insetsTop,
|
||||||
@@ -503,7 +527,7 @@ function MobileSidebar({
|
|||||||
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
|
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 (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
@@ -511,7 +535,12 @@ function MobileSidebar({
|
|||||||
|
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
style={[
|
||||||
|
staticStyles.mobileSidebar,
|
||||||
|
mobileSidebarInsetStyle,
|
||||||
|
sidebarAnimatedStyle,
|
||||||
|
{ backgroundColor: theme.colors.surfaceSidebar },
|
||||||
|
]}
|
||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
>
|
>
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
@@ -528,7 +557,6 @@ function MobileSidebar({
|
|||||||
serverId={activeServerId}
|
serverId={activeServerId}
|
||||||
collapsedProjectKeys={collapsedProjectKeys}
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
|
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
@@ -606,6 +634,7 @@ function MobileSidebar({
|
|||||||
options={hostOptions}
|
options={hostOptions}
|
||||||
value={activeServerId ?? ""}
|
value={activeServerId ?? ""}
|
||||||
onSelect={handleHostSelect}
|
onSelect={handleHostSelect}
|
||||||
|
renderOption={renderHostOption}
|
||||||
searchable={false}
|
searchable={false}
|
||||||
title="Switch host"
|
title="Switch host"
|
||||||
searchPlaceholder="Search hosts..."
|
searchPlaceholder="Search hosts..."
|
||||||
@@ -639,6 +668,7 @@ function DesktopSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleHostSelect,
|
handleHostSelect,
|
||||||
|
renderHostOption,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
insetsTop,
|
insetsTop,
|
||||||
@@ -695,115 +725,116 @@ function DesktopSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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.desktopSidebarBorder, { flex: 1 }]}>
|
||||||
<View style={styles.sidebarDragArea}>
|
<View style={styles.sidebarDragArea}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||||
<View style={styles.sidebarHeader}>
|
<View style={styles.sidebarHeader}>
|
||||||
<View style={styles.sidebarHeaderRow}>
|
<View style={styles.sidebarHeaderRow}>
|
||||||
<SessionsButton onPress={handleViewMore} />
|
<SessionsButton onPress={handleViewMore} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<SidebarAgentListSkeleton />
|
<SidebarAgentListSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<SidebarWorkspaceList
|
<SidebarWorkspaceList
|
||||||
serverId={activeServerId}
|
serverId={activeServerId}
|
||||||
collapsedProjectKeys={collapsedProjectKeys}
|
collapsedProjectKeys={collapsedProjectKeys}
|
||||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.sidebarFooter}>
|
<View style={styles.sidebarFooter}>
|
||||||
<View style={styles.footerHostSlot}>
|
<View style={styles.footerHostSlot}>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={hostTriggerRef}
|
ref={hostTriggerRef}
|
||||||
style={({ hovered = false }) => [
|
style={({ hovered = false }) => [
|
||||||
styles.hostTrigger,
|
styles.hostTrigger,
|
||||||
hovered && styles.hostTriggerHovered,
|
hovered && styles.hostTriggerHovered,
|
||||||
]}
|
]}
|
||||||
onPress={() => setIsHostPickerOpen(true)}
|
onPress={() => setIsHostPickerOpen(true)}
|
||||||
disabled={hostOptions.length === 0}
|
disabled={hostOptions.length === 0}
|
||||||
>
|
>
|
||||||
<View style={hostStatusDotStyle} />
|
<View style={hostStatusDotStyle} />
|
||||||
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
<Text style={styles.hostTriggerText} numberOfLines={1}>
|
||||||
{activeHostLabel}
|
{activeHostLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</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>
|
||||||
<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 */}
|
{/* Resize handle - absolutely positioned over right border */}
|
||||||
<GestureDetector gesture={resizeGesture}>
|
<GestureDetector gesture={resizeGesture}>
|
||||||
<View
|
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
|
||||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
</GestureDetector>
|
||||||
/>
|
|
||||||
</GestureDetector>
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
@@ -885,10 +916,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
newAgentButtonHovered: {
|
newAgentButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
newAgentButtonActive: {
|
newAgentButtonActive: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
hostTrigger: {
|
hostTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -901,7 +932,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
hostTriggerHovered: {
|
hostTriggerHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
hostStatusDot: {
|
hostStatusDot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
|
|||||||
@@ -1,128 +1,128 @@
|
|||||||
// Auto-generated from material-icon-theme. Do not edit manually.
|
// Auto-generated from material-icon-theme. Do not edit manually.
|
||||||
|
|
||||||
const SVG_ICONS: Record<string, string> = {
|
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>`,
|
_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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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: `<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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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>`,
|
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> = {
|
const EXTENSION_TO_ICON: Record<string, string> = {
|
||||||
"astro": "astro",
|
astro: "astro",
|
||||||
"bash": "console",
|
bash: "console",
|
||||||
"c": "c",
|
c: "c",
|
||||||
"cfg": "settings",
|
cfg: "settings",
|
||||||
"clj": "clojure",
|
clj: "clojure",
|
||||||
"conf": "settings",
|
conf: "settings",
|
||||||
"cpp": "cpp",
|
cpp: "cpp",
|
||||||
"cs": "csharp",
|
cs: "csharp",
|
||||||
"css": "css",
|
css: "css",
|
||||||
"dart": "dart",
|
dart: "dart",
|
||||||
"erl": "erlang",
|
erl: "erlang",
|
||||||
"ex": "elixir",
|
ex: "elixir",
|
||||||
"exs": "elixir",
|
exs: "elixir",
|
||||||
"gif": "image",
|
gif: "image",
|
||||||
"go": "go",
|
go: "go",
|
||||||
"gql": "graphql",
|
gql: "graphql",
|
||||||
"gradle": "gradle",
|
gradle: "gradle",
|
||||||
"graphql": "graphql",
|
graphql: "graphql",
|
||||||
"groovy": "groovy",
|
groovy: "groovy",
|
||||||
"h": "h",
|
h: "h",
|
||||||
"hcl": "hcl",
|
hcl: "hcl",
|
||||||
"hpp": "hpp",
|
hpp: "hpp",
|
||||||
"hs": "haskell",
|
hs: "haskell",
|
||||||
"html": "html",
|
html: "html",
|
||||||
"ico": "image",
|
ico: "image",
|
||||||
"ini": "settings",
|
ini: "settings",
|
||||||
"java": "java",
|
java: "java",
|
||||||
"jpeg": "image",
|
jpeg: "image",
|
||||||
"jpg": "image",
|
jpg: "image",
|
||||||
"js": "javascript",
|
js: "javascript",
|
||||||
"json": "json",
|
json: "json",
|
||||||
"jsx": "react",
|
jsx: "react",
|
||||||
"kt": "kotlin",
|
kt: "kotlin",
|
||||||
"less": "less",
|
less: "less",
|
||||||
"lock": "lock",
|
lock: "lock",
|
||||||
"lua": "lua",
|
lua: "lua",
|
||||||
"markdown": "markdown",
|
markdown: "markdown",
|
||||||
"md": "markdown",
|
md: "markdown",
|
||||||
"ml": "ocaml",
|
ml: "ocaml",
|
||||||
"nix": "nix",
|
nix: "nix",
|
||||||
"php": "php",
|
php: "php",
|
||||||
"png": "image",
|
png: "image",
|
||||||
"py": "python",
|
py: "python",
|
||||||
"r": "r",
|
r: "r",
|
||||||
"rb": "ruby",
|
rb: "ruby",
|
||||||
"rs": "rust",
|
rs: "rust",
|
||||||
"scala": "scala",
|
scala: "scala",
|
||||||
"scss": "sass",
|
scss: "sass",
|
||||||
"sh": "console",
|
sh: "console",
|
||||||
"sql": "database",
|
sql: "database",
|
||||||
"svelte": "svelte",
|
svelte: "svelte",
|
||||||
"svg": "svg",
|
svg: "svg",
|
||||||
"swift": "swift",
|
swift: "swift",
|
||||||
"tf": "terraform",
|
tf: "terraform",
|
||||||
"toml": "toml",
|
toml: "toml",
|
||||||
"ts": "typescript",
|
ts: "typescript",
|
||||||
"tsx": "react_ts",
|
tsx: "react_ts",
|
||||||
"txt": "document",
|
txt: "document",
|
||||||
"vue": "vue",
|
vue: "vue",
|
||||||
"wasm": "webassembly",
|
wasm: "webassembly",
|
||||||
"webp": "image",
|
webp: "image",
|
||||||
"xml": "xml",
|
xml: "xml",
|
||||||
"yaml": "yaml",
|
yaml: "yaml",
|
||||||
"yml": "yaml",
|
yml: "yaml",
|
||||||
"zig": "zig",
|
zig: "zig",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFileIconSvg(fileName: string): string {
|
export function getFileIconSvg(fileName: string): string {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
TextInputKeyPressEventData,
|
TextInputKeyPressEventData,
|
||||||
TextInputSelectionChangeEventData,
|
TextInputSelectionChangeEventData,
|
||||||
Image,
|
Image,
|
||||||
Platform,
|
|
||||||
BackHandler,
|
BackHandler,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +47,7 @@ import {
|
|||||||
markScrollInvestigationEvent,
|
markScrollInvestigationEvent,
|
||||||
markScrollInvestigationRender,
|
markScrollInvestigationRender,
|
||||||
} from "@/utils/scroll-jank-investigation";
|
} from "@/utils/scroll-jank-investigation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
export type ImageAttachment = AttachmentMetadata;
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
@@ -87,6 +87,9 @@ export interface MessageInputProps {
|
|||||||
voiceAgentId?: string;
|
voiceAgentId?: string;
|
||||||
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
||||||
isAgentRunning?: boolean;
|
isAgentRunning?: boolean;
|
||||||
|
/** Controls what the default send action (Enter, send button, dictation) does
|
||||||
|
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
|
||||||
|
defaultSendBehavior?: "interrupt" | "queue";
|
||||||
/** Callback for queue button when agent is running */
|
/** Callback for queue button when agent is running */
|
||||||
onQueue?: (payload: MessagePayload) => void;
|
onQueue?: (payload: MessagePayload) => void;
|
||||||
/** Optional handler used when submit button is in loading state. */
|
/** Optional handler used when submit button is in loading state. */
|
||||||
@@ -112,13 +115,15 @@ export interface MessageInputRef {
|
|||||||
|
|
||||||
const MIN_INPUT_HEIGHT = 30;
|
const MIN_INPUT_HEIGHT = 30;
|
||||||
const MAX_INPUT_HEIGHT = 160;
|
const MAX_INPUT_HEIGHT = 160;
|
||||||
const IS_WEB = Platform.OS === "web";
|
|
||||||
|
|
||||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||||
TextInputKeyPressEventData & {
|
TextInputKeyPressEventData & {
|
||||||
metaKey?: boolean;
|
metaKey?: boolean;
|
||||||
ctrlKey?: boolean;
|
ctrlKey?: boolean;
|
||||||
shiftKey?: boolean;
|
shiftKey?: boolean;
|
||||||
|
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
|
||||||
|
isComposing?: boolean;
|
||||||
|
keyCode?: number;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -208,6 +213,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
voiceServerId,
|
voiceServerId,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
isAgentRunning = false,
|
isAgentRunning = false,
|
||||||
|
defaultSendBehavior = "interrupt",
|
||||||
onQueue,
|
onQueue,
|
||||||
onSubmitLoadingPress,
|
onSubmitLoadingPress,
|
||||||
onKeyPress: onKeyPressCallback,
|
onKeyPress: onKeyPressCallback,
|
||||||
@@ -218,7 +224,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { theme } = useUnistyles();
|
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"}`;
|
const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`;
|
||||||
markScrollInvestigationRender(investigationComponentId);
|
markScrollInvestigationRender(investigationComponentId);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -289,7 +295,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
getNativeElement: () => {
|
getNativeElement: () => {
|
||||||
if (!IS_WEB) return null;
|
if (!isWeb) return null;
|
||||||
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
|
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null;
|
||||||
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
|
const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current;
|
||||||
return native instanceof HTMLElement ? native : null;
|
return native instanceof HTMLElement ? native : null;
|
||||||
@@ -324,7 +330,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
|
|
||||||
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !autoFocus) return;
|
if (!isWeb || !autoFocus) return;
|
||||||
return focusWithRetries({
|
return focusWithRetries({
|
||||||
focus: () => textInputRef.current?.focus(),
|
focus: () => textInputRef.current?.focus(),
|
||||||
isFocused: () => {
|
isFocused: () => {
|
||||||
@@ -352,22 +358,28 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
|
|
||||||
if (shouldAutoSend) {
|
if (shouldAutoSend) {
|
||||||
const imageAttachments = images.length > 0 ? images : undefined;
|
const imageAttachments = images.length > 0 ? images : undefined;
|
||||||
onSubmit({
|
// Respect send behavior setting: when "queue", dictation queues too.
|
||||||
text: nextValue,
|
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
||||||
images: imageAttachments,
|
onQueue({ text: nextValue, images: imageAttachments });
|
||||||
forceSend: isAgentRunning || undefined,
|
onChangeText("");
|
||||||
});
|
} else {
|
||||||
|
onSubmit({
|
||||||
|
text: nextValue,
|
||||||
|
images: imageAttachments,
|
||||||
|
forceSend: isAgentRunning || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IS_WEB && typeof requestAnimationFrame === "function") {
|
if (isWeb && typeof requestAnimationFrame === "function") {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
measureWebInputHeight("dictation");
|
measureWebInputHeight("dictation");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChangeText, onSubmit, images, isAgentRunning],
|
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDictationError = useCallback(
|
const handleDictationError = useCallback(
|
||||||
@@ -578,6 +590,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onHeightChange?.(MIN_INPUT_HEIGHT);
|
onHeightChange?.(MIN_INPUT_HEIGHT);
|
||||||
}, [value, images, onQueue, onChangeText, onHeightChange]);
|
}, [value, images, onQueue, onChangeText, onHeightChange]);
|
||||||
|
|
||||||
|
// Default send action: respects the sendBehavior setting.
|
||||||
|
// When "interrupt" (default), primary action sends immediately (interrupts).
|
||||||
|
// When "queue", primary action queues when agent is running.
|
||||||
|
const handleDefaultSendAction = useCallback(() => {
|
||||||
|
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
||||||
|
handleQueueMessage();
|
||||||
|
} else {
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
|
||||||
|
|
||||||
|
// Alternate send action: always the opposite of the default.
|
||||||
|
const handleAlternateSendAction = useCallback(() => {
|
||||||
|
if (defaultSendBehavior === "queue") {
|
||||||
|
handleSendMessage(); // interrupt
|
||||||
|
} else if (onQueue) {
|
||||||
|
handleQueueMessage(); // queue
|
||||||
|
}
|
||||||
|
}, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]);
|
||||||
|
|
||||||
// Web input height measurement
|
// Web input height measurement
|
||||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||||
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
||||||
@@ -597,13 +629,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (IS_WEB) {
|
if (isWeb) {
|
||||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||||
}
|
}
|
||||||
}, [getWebTextArea]);
|
}, [getWebTextArea]);
|
||||||
|
|
||||||
const inputScrollbar = useWebElementScrollbar(webTextareaRef, {
|
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 => {
|
const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => {
|
||||||
@@ -617,7 +649,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !onAddImages) {
|
if (!isWeb || !onAddImages) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +702,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || typeof ResizeObserver === "undefined") {
|
if (!isWeb || typeof ResizeObserver === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,7 +756,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}, [getWebElement, getWebTextArea]);
|
}, [getWebElement, getWebTextArea]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB) {
|
if (!isWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
|
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null;
|
||||||
@@ -762,7 +794,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}, [getWebTextArea]);
|
}, [getWebTextArea]);
|
||||||
|
|
||||||
function measureWebInputHeight(source: string): boolean {
|
function measureWebInputHeight(source: string): boolean {
|
||||||
if (!IS_WEB) return false;
|
if (!isWeb) return false;
|
||||||
const textarea = getWebTextArea();
|
const textarea = getWebTextArea();
|
||||||
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
||||||
const scrollHeight = textarea.scrollHeight ?? 0;
|
const scrollHeight = textarea.scrollHeight ?? 0;
|
||||||
@@ -816,7 +848,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||||
) {
|
) {
|
||||||
const contentHeight = event.nativeEvent.contentSize.height;
|
const contentHeight = event.nativeEvent.contentSize.height;
|
||||||
if (IS_WEB) {
|
if (isWeb) {
|
||||||
logWebStickyBottom("composer_content_size_change", {
|
logWebStickyBottom("composer_content_size_change", {
|
||||||
reportedHeight: contentHeight,
|
reportedHeight: contentHeight,
|
||||||
});
|
});
|
||||||
@@ -836,7 +868,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||||
const start = event.nativeEvent.selection?.start ?? 0;
|
const start = event.nativeEvent.selection?.start ?? 0;
|
||||||
const end = event.nativeEvent.selection?.end ?? start;
|
const end = event.nativeEvent.selection?.end ?? start;
|
||||||
if (IS_WEB) {
|
if (isWeb) {
|
||||||
const textarea = getWebTextArea();
|
const textarea = getWebTextArea();
|
||||||
logWebStickyBottom("composer_selection_changed", {
|
logWebStickyBottom("composer_selection_changed", {
|
||||||
now: getDebugNow(),
|
now: getDebugNow(),
|
||||||
@@ -850,12 +882,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onSelectionChangeCallback?.({ start, end });
|
onSelectionChangeCallback?.({ start, end });
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldHandleDesktopSubmit = IS_WEB;
|
const shouldHandleDesktopSubmit = isWeb;
|
||||||
|
|
||||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
||||||
markScrollInvestigationEvent(investigationComponentId, "keyPress");
|
markScrollInvestigationEvent(investigationComponentId, "keyPress");
|
||||||
if (!shouldHandleDesktopSubmit) return;
|
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 (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
||||||
|
|
||||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||||
if (onKeyPressCallback) {
|
if (onKeyPressCallback) {
|
||||||
const handled = onKeyPressCallback({
|
const handled = onKeyPressCallback({
|
||||||
@@ -872,18 +909,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||||
if (shiftKey) return;
|
if (shiftKey) return;
|
||||||
|
|
||||||
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
|
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action
|
||||||
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
|
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
|
||||||
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleQueueMessage();
|
handleAlternateSendAction();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter: send (interrupts agent if running)
|
// Enter: default send action (interrupt or queue, based on setting)
|
||||||
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSendMessage();
|
handleDefaultSendAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
@@ -892,17 +929,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||||
const isSendButtonDisabled =
|
const isSendButtonDisabled =
|
||||||
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||||
|
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
|
||||||
const submitAccessibilityLabel = canPressLoadingButton
|
const submitAccessibilityLabel = canPressLoadingButton
|
||||||
? "Interrupt agent"
|
? "Interrupt agent"
|
||||||
: isAgentRunning
|
: defaultActionQueues
|
||||||
? "Send and interrupt"
|
? "Queue message"
|
||||||
: "Send message";
|
: isAgentRunning
|
||||||
|
? "Send and interrupt"
|
||||||
|
: "Send message";
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
const handleInputChange = useCallback(
|
||||||
(nextValue: string) => {
|
(nextValue: string) => {
|
||||||
markScrollInvestigationEvent(investigationComponentId, "inputChange");
|
markScrollInvestigationEvent(investigationComponentId, "inputChange");
|
||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
if (IS_WEB) {
|
if (isWeb) {
|
||||||
logWebStickyBottom("composer_text_changed", {
|
logWebStickyBottom("composer_text_changed", {
|
||||||
valueLength: nextValue.length,
|
valueLength: nextValue.length,
|
||||||
lineCount: nextValue.split("\n").length,
|
lineCount: nextValue.split("\n").length,
|
||||||
@@ -933,7 +973,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.removeImageButton,
|
styles.removeImageButton,
|
||||||
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
|
(hovered || !isWeb) && styles.removeImageButtonVisible,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<X size={theme.iconSize.md} color="white" />
|
<X size={theme.iconSize.md} color="white" />
|
||||||
@@ -965,7 +1005,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
styles.textInput,
|
styles.textInput,
|
||||||
IS_WEB
|
isWeb
|
||||||
? {
|
? {
|
||||||
height: inputHeight,
|
height: inputHeight,
|
||||||
minHeight: MIN_INPUT_HEIGHT,
|
minHeight: MIN_INPUT_HEIGHT,
|
||||||
@@ -977,12 +1017,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
multiline
|
multiline
|
||||||
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true}
|
||||||
onContentSizeChange={handleContentSizeChange}
|
onContentSizeChange={handleContentSizeChange}
|
||||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
autoFocus={IS_WEB && autoFocus}
|
autoFocus={isWeb && autoFocus}
|
||||||
/>
|
/>
|
||||||
{inputScrollbar}
|
{inputScrollbar}
|
||||||
</View>
|
</View>
|
||||||
@@ -1071,10 +1111,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{rightContent}
|
{rightContent}
|
||||||
{hasSendableContent && isAgentRunning && onQueue && (
|
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleQueueMessage}
|
onPress={handleAlternateSendAction}
|
||||||
disabled={!isConnected || disabled}
|
disabled={!isConnected || disabled}
|
||||||
accessibilityLabel="Queue message"
|
accessibilityLabel="Queue message"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -1099,7 +1139,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
{shouldShowSendButton && (
|
{shouldShowSendButton && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
|
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
|
||||||
disabled={isSendButtonDisabled}
|
disabled={isSendButtonDisabled}
|
||||||
accessibilityLabel={submitAccessibilityLabel}
|
accessibilityLabel={submitAccessibilityLabel}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -1113,7 +1153,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>Send</Text>
|
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
|
||||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -1173,7 +1213,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
xs: theme.spacing[3],
|
xs: theme.spacing[3],
|
||||||
md: theme.spacing[4],
|
md: theme.spacing[4],
|
||||||
},
|
},
|
||||||
...(IS_WEB
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
transitionProperty: "border-color",
|
transitionProperty: "border-color",
|
||||||
transitionDuration: "200ms",
|
transitionDuration: "200ms",
|
||||||
@@ -1192,7 +1232,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.borderAccent,
|
borderColor: theme.colors.borderAccent,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
...(IS_WEB
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
}
|
}
|
||||||
@@ -1217,7 +1257,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
...(IS_WEB
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
transitionProperty: "opacity",
|
transitionProperty: "opacity",
|
||||||
transitionDuration: "150ms",
|
transitionDuration: "150ms",
|
||||||
@@ -1236,7 +1276,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
lineHeight: theme.fontSize.base * 1.4,
|
lineHeight: theme.fontSize.base * 1.4,
|
||||||
...(IS_WEB
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
outlineStyle: "none" as const,
|
outlineStyle: "none" as const,
|
||||||
outlineWidth: 0,
|
outlineWidth: 0,
|
||||||
@@ -1257,7 +1297,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
rightButtonGroup: {
|
rightButtonGroup: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1],
|
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
|
||||||
},
|
},
|
||||||
attachButton: {
|
attachButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
Platform,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
cloneElement,
|
cloneElement,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactNode, ComponentType } 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 MaskedView from "@react-native-masked-view/masked-view";
|
||||||
import {
|
import {
|
||||||
Circle,
|
Circle,
|
||||||
@@ -41,7 +42,8 @@ import {
|
|||||||
Scissors,
|
Scissors,
|
||||||
MicVocal,
|
MicVocal,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
@@ -71,11 +73,19 @@ import {
|
|||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
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";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -124,7 +134,7 @@ const SCROLL_EDGE_EPSILON = 0.5;
|
|||||||
type ScrollAxis = "x" | "y";
|
type ScrollAxis = "x" | "y";
|
||||||
|
|
||||||
function ensureWebToolCallShimmerKeyframes() {
|
function ensureWebToolCallShimmerKeyframes() {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
@@ -331,12 +341,13 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
isLastInGroup = true,
|
isLastInGroup = true,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: UserMessageProps) {
|
}: UserMessageProps) {
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
const [messageHovered, setMessageHovered] = useState(false);
|
const [messageHovered, setMessageHovered] = useState(false);
|
||||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const hasText = message.trim().length > 0;
|
const hasText = message.trim().length > 0;
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered);
|
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -351,8 +362,8 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
>
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={userMessageStylesheet.content}
|
style={userMessageStylesheet.content}
|
||||||
onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined}
|
onHoverIn={() => setMessageHovered(true)}
|
||||||
onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined}
|
onHoverOut={() => setMessageHovered(false)}
|
||||||
>
|
>
|
||||||
<View style={userMessageStylesheet.bubble}>
|
<View style={userMessageStylesheet.bubble}>
|
||||||
{hasImages ? (
|
{hasImages ? (
|
||||||
@@ -398,6 +409,8 @@ interface AssistantMessageProps {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
client?: DaemonClient | null;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,10 +435,217 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontFamily: Fonts.mono,
|
fontFamily: Fonts.mono,
|
||||||
fontSize: 13,
|
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({
|
function MarkdownLink({
|
||||||
href,
|
href,
|
||||||
style,
|
style,
|
||||||
@@ -438,7 +658,7 @@ function MarkdownLink({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return (
|
return (
|
||||||
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
|
<Text accessibilityRole="link" onPress={() => onPress(href)} style={style}>
|
||||||
{children}
|
{children}
|
||||||
@@ -560,8 +780,8 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleCopy}
|
onPress={handleCopy}
|
||||||
onHoverIn={Platform.OS === "web" ? () => onHoverChange?.(true) : undefined}
|
onHoverIn={() => onHoverChange?.(true)}
|
||||||
onHoverOut={Platform.OS === "web" ? () => onHoverChange?.(false) : undefined}
|
onHoverOut={() => onHoverChange?.(false)}
|
||||||
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
style={[turnCopyButtonStylesheet.container, containerStyle]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={
|
accessibilityLabel={
|
||||||
@@ -711,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({
|
export const AssistantMessage = memo(function AssistantMessage({
|
||||||
message,
|
message,
|
||||||
timestamp,
|
timestamp,
|
||||||
onInlinePathPress,
|
onInlinePathPress,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
|
serverId,
|
||||||
|
client,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { theme, rt } = useUnistyles();
|
const { theme, rt } = useUnistyles();
|
||||||
@@ -752,7 +996,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
[onInlinePathPress, workspaceRoot],
|
[onInlinePathPress, workspaceRoot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const markdownRules = useMemo(() => {
|
const markdownRules = useMemo<RenderRules>(() => {
|
||||||
return {
|
return {
|
||||||
text: (
|
text: (
|
||||||
node: any,
|
node: any,
|
||||||
@@ -817,7 +1061,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
<Text
|
<Text
|
||||||
key={node.key}
|
key={node.key}
|
||||||
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
onPress={() => parsed && onInlinePathPress?.(parsed)}
|
||||||
selectable={Platform.OS === "web" ? undefined : false}
|
selectable={isWeb ? undefined : false}
|
||||||
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
@@ -867,14 +1111,11 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
paragraph: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
<View key={node.key} style={[styles.paragraph, { marginBottom: 0 }]}>
|
||||||
return (
|
{children}
|
||||||
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
</View>
|
||||||
{children}
|
),
|
||||||
</View>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||||
<MarkdownLink
|
<MarkdownLink
|
||||||
key={node.key}
|
key={node.key}
|
||||||
@@ -891,8 +1132,32 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
)}
|
)}
|
||||||
</MarkdownLink>
|
</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 (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -902,14 +1167,20 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Markdown
|
{blocks.map((block, index) => (
|
||||||
style={markdownStyles}
|
<View
|
||||||
rules={markdownRules}
|
key={index}
|
||||||
markdownit={markdownParser}
|
style={index < blocks.length - 1 ? { marginBottom: theme.spacing[3] } : undefined}
|
||||||
onLinkPress={handleLinkPress}
|
>
|
||||||
>
|
<MemoizedMarkdownBlock
|
||||||
{message}
|
text={block}
|
||||||
</Markdown>
|
styles={markdownStyles}
|
||||||
|
rules={markdownRules}
|
||||||
|
parser={markdownParser}
|
||||||
|
onLinkPress={handleLinkPress}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1383,9 +1654,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
32,
|
32,
|
||||||
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
|
Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0),
|
||||||
);
|
);
|
||||||
const isWebShimmer = isLoading && Platform.OS === "web";
|
const isWebShimmer = isLoading && isWeb;
|
||||||
const shouldMeasureWebShimmer = isWebShimmer;
|
const shouldMeasureWebShimmer = isWebShimmer;
|
||||||
const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web";
|
const shouldMeasureNativeShimmer = isLoading && isNative;
|
||||||
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
|
const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0;
|
||||||
const webShimmerSpanStartX = labelOffsetX;
|
const webShimmerSpanStartX = labelOffsetX;
|
||||||
const webShimmerSpanEndX = secondaryLabel
|
const webShimmerSpanEndX = secondaryLabel
|
||||||
@@ -1462,7 +1733,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
|||||||
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
}, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) {
|
if (isNative || !isExpanded || !hasDetailContent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1822,7 +2093,7 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
const effectiveDetail = useMemo<ToolCallDetail | undefined>(() => {
|
||||||
if (detail) {
|
if (detail) {
|
||||||
@@ -1937,7 +2208,11 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
|
|
||||||
if (effectiveDetail?.type === "plan") {
|
if (effectiveDetail?.type === "plan") {
|
||||||
return (
|
return (
|
||||||
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
|
<PlanCard
|
||||||
|
title="Plan"
|
||||||
|
text={effectiveDetail.text}
|
||||||
|
disableOuterSpacing={disableOuterSpacing}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
helper: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
field: {
|
|
||||||
marginTop: theme.spacing[3],
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
marginTop: theme.spacing[4],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export interface NameHostModalProps {
|
|
||||||
visible: boolean;
|
|
||||||
serverId: string;
|
|
||||||
hostname: string | null;
|
|
||||||
onSkip: () => void;
|
|
||||||
onSave: (label: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
|
|
||||||
const [label, setLabel] = useState("");
|
|
||||||
const hasEditedRef = useRef(false);
|
|
||||||
|
|
||||||
const suggested = (hostname?.trim() || serverId).trim();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible) return;
|
|
||||||
setLabel(suggested);
|
|
||||||
hasEditedRef.current = false;
|
|
||||||
}, [suggested, visible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible) return;
|
|
||||||
if (hasEditedRef.current) return;
|
|
||||||
if (!hostname) return;
|
|
||||||
const trimmed = label.trim();
|
|
||||||
if (trimmed.length === 0 || trimmed === serverId) {
|
|
||||||
setLabel(hostname.trim());
|
|
||||||
}
|
|
||||||
}, [hostname, label, serverId, visible]);
|
|
||||||
|
|
||||||
const handleChange = useCallback((value: string) => {
|
|
||||||
hasEditedRef.current = true;
|
|
||||||
setLabel(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
const trimmed = label.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
onSkip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSave(trimmed);
|
|
||||||
}, [label, onSave, onSkip]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdaptiveModalSheet
|
|
||||||
title="Name this host"
|
|
||||||
visible={visible}
|
|
||||||
onClose={onSkip}
|
|
||||||
testID="name-host-modal"
|
|
||||||
>
|
|
||||||
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
|
|
||||||
|
|
||||||
<View style={styles.field}>
|
|
||||||
<Text style={styles.label}>Label</Text>
|
|
||||||
<AdaptiveTextInput
|
|
||||||
value={label}
|
|
||||||
onChangeText={handleChange}
|
|
||||||
placeholder={suggested}
|
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
|
||||||
style={styles.input}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
returnKeyType="done"
|
|
||||||
onSubmitEditing={handleSave}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.actions}>
|
|
||||||
<Button style={{ flex: 1 }} variant="secondary" onPress={onSkip} testID="name-host-skip">
|
|
||||||
Skip
|
|
||||||
</Button>
|
|
||||||
<Button style={{ flex: 1 }} variant="default" onPress={handleSave} testID="name-host-save">
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</AdaptiveModalSheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Alert, Text, View } from "react-native";
|
import { Alert, Text, View } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Link } from "lucide-react-native";
|
import { Link } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
@@ -66,7 +67,7 @@ export function PairLinkModal({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const [offerUrl, setOfferUrl] = useState("");
|
const [offerUrl, setOfferUrl] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -146,7 +147,7 @@ export function PairLinkModal({
|
|||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||||
const profile = await upsertDaemonFromOfferUrl(raw);
|
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
||||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { Folder } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -11,6 +11,7 @@ import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/run
|
|||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
export function ProjectPickerModal() {
|
export function ProjectPickerModal() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -122,7 +123,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || Platform.OS !== "web") return;
|
if (!open || isNative) return;
|
||||||
|
|
||||||
function handler(event: KeyboardEvent) {
|
function handler(event: KeyboardEvent) {
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
|
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
|
||||||
|
|
||||||
interface ProviderDiagnosticSheetProps {
|
interface ProviderDiagnosticSheetProps {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -21,10 +22,11 @@ export function ProviderDiagnosticSheet({
|
|||||||
}: ProviderDiagnosticSheetProps) {
|
}: ProviderDiagnosticSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
||||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider;
|
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||||
|
|
||||||
const fetchDiagnostic = useCallback(async () => {
|
const fetchDiagnostic = useCallback(async () => {
|
||||||
if (!client || !provider) return;
|
if (!client || !provider) return;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useCallback } from "react";
|
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, UnistylesRuntime } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface QuestionOption {
|
interface QuestionOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -59,11 +61,11 @@ interface QuestionFormCardProps {
|
|||||||
isResponding: boolean;
|
isResponding: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = isWeb;
|
||||||
|
|
||||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const questions = parseQuestions(permission.request.input);
|
const questions = parseQuestions(permission.request.input);
|
||||||
|
|
||||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
|||||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||||
import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
@@ -53,7 +53,7 @@ import {
|
|||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
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 { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -83,6 +83,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-
|
|||||||
import { createNameId } from "mnemonic-id";
|
import { createNameId } from "mnemonic-id";
|
||||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
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 {
|
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
@@ -100,7 +101,10 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
|
|||||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||||
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
const DEFAULT_STATUS_DOT_OFFSET = 0;
|
||||||
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
|
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) {
|
switch (state) {
|
||||||
case "merged":
|
case "merged":
|
||||||
return theme.colors.palette.purple[500];
|
return theme.colors.palette.purple[500];
|
||||||
@@ -196,21 +200,12 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
|||||||
hitSlop={4}
|
hitSlop={4}
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onPointerEnter={() => setIsHovered(true)}
|
onHoverIn={() => setIsHovered(true)}
|
||||||
onPointerLeave={() => setIsHovered(false)}
|
onHoverOut={() => setIsHovered(false)}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]}
|
||||||
styles.workspacePrBadge,
|
|
||||||
pressed && styles.workspacePrBadgePressed,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<GitPullRequest size={12} color={iconColor} />
|
<GitPullRequest size={12} color={iconColor} />
|
||||||
<Text
|
<Text style={[styles.workspacePrBadgeText, { color: textColor }]} numberOfLines={1}>
|
||||||
style={[
|
|
||||||
styles.workspacePrBadgeText,
|
|
||||||
{ color: textColor },
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
#{hint.number}
|
#{hint.number}
|
||||||
</Text>
|
</Text>
|
||||||
{isHovered && <ExternalLink size={10} color={textColor} />}
|
{isHovered && <ExternalLink size={10} color={textColor} />}
|
||||||
@@ -255,11 +250,7 @@ function WorkspaceStatusIndicator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KindIcon =
|
const KindIcon =
|
||||||
workspaceKind === "local_checkout"
|
workspaceKind === "local_checkout" ? Monitor : workspaceKind === "worktree" ? FolderGit2 : null;
|
||||||
? Monitor
|
|
||||||
: workspaceKind === "worktree"
|
|
||||||
? FolderGit2
|
|
||||||
: null;
|
|
||||||
if (!KindIcon) return null;
|
if (!KindIcon) return null;
|
||||||
|
|
||||||
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
|
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
|
||||||
@@ -553,7 +544,7 @@ function useLongPressDragInteraction(input: {
|
|||||||
input.drag();
|
input.drag();
|
||||||
}, DRAG_ARM_DELAY_MS);
|
}, DRAG_ARM_DELAY_MS);
|
||||||
|
|
||||||
if (!input.menuController || Platform.OS === "web") {
|
if (!input.menuController || platformIsWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +704,7 @@ function ProjectHeaderRow({
|
|||||||
}: ProjectHeaderRowProps) {
|
}: ProjectHeaderRowProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobileBreakpoint = isCompactFormFactor();
|
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -803,7 +794,7 @@ function ProjectHeaderRow({
|
|||||||
<NewWorktreeButton
|
<NewWorktreeButton
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
onPress={() => createWorktreeMutation.mutate()}
|
onPress={() => createWorktreeMutation.mutate()}
|
||||||
visible={isHovered || isMobileBreakpoint}
|
visible={isHovered || platformIsNative || isMobileBreakpoint}
|
||||||
loading={createWorktreeMutation.isPending}
|
loading={createWorktreeMutation.isPending}
|
||||||
showShortcutHint={isProjectActive}
|
showShortcutHint={isProjectActive}
|
||||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||||
@@ -811,8 +802,11 @@ function ProjectHeaderRow({
|
|||||||
) : null}
|
) : null}
|
||||||
{onRemoveProject ? (
|
{onRemoveProject ? (
|
||||||
<View
|
<View
|
||||||
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
|
style={
|
||||||
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
|
!(isHovered || platformIsNative || isMobileBreakpoint) &&
|
||||||
|
styles.projectKebabButtonHidden
|
||||||
|
}
|
||||||
|
pointerEvents={isHovered || platformIsNative || isMobileBreakpoint ? "auto" : "none"}
|
||||||
>
|
>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -922,8 +916,8 @@ function WorkspaceRowInner({
|
|||||||
archiveShortcutKeys,
|
archiveShortcutKeys,
|
||||||
}: WorkspaceRowInnerProps) {
|
}: WorkspaceRowInnerProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isTouchPlatform = Platform.OS !== "web";
|
|
||||||
const prHint = useWorkspacePrHint({
|
const prHint = useWorkspacePrHint({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
cwd: workspace.workspaceId,
|
cwd: workspace.workspaceId,
|
||||||
@@ -988,7 +982,7 @@ function WorkspaceRowInner({
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.workspaceRowRight}>
|
<View style={styles.workspaceRowRight}>
|
||||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||||
{onArchive && (isHovered || isTouchPlatform) ? (
|
{onArchive && (isHovered || platformIsNative || isCompact) ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
@@ -1606,18 +1600,14 @@ export function SidebarWorkspaceList({
|
|||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
}: SidebarWorkspaceListProps) {
|
}: SidebarWorkspaceListProps) {
|
||||||
const isMobile = isCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const isNative = Platform.OS !== "web";
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
|
||||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
const isDesktopApp = getIsElectronRuntime();
|
const showShortcutBadges = useShowShortcutBadges();
|
||||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown);
|
|
||||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown);
|
|
||||||
const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown);
|
|
||||||
|
|
||||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||||
@@ -1798,34 +1788,31 @@ export function SidebarWorkspaceList({
|
|||||||
[getWorkspaceOrder, serverId, setWorkspaceOrder],
|
[getWorkspaceOrder, serverId, setWorkspaceOrder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorktreeCreated = useCallback(
|
const handleWorktreeCreated = useCallback((workspaceId: string) => {
|
||||||
(workspaceId: string) => {
|
setCreatingWorkspaceIds((current) => {
|
||||||
setCreatingWorkspaceIds((current) => {
|
const next = new Set(current);
|
||||||
const next = new Set(current);
|
next.add(workspaceId);
|
||||||
next.add(workspaceId);
|
return next;
|
||||||
return next;
|
});
|
||||||
});
|
const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId);
|
||||||
const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId);
|
if (existingTimeout) {
|
||||||
if (existingTimeout) {
|
clearTimeout(existingTimeout);
|
||||||
clearTimeout(existingTimeout);
|
}
|
||||||
}
|
creatingWorkspaceTimeoutsRef.current.set(
|
||||||
creatingWorkspaceTimeoutsRef.current.set(
|
workspaceId,
|
||||||
workspaceId,
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
creatingWorkspaceTimeoutsRef.current.delete(workspaceId);
|
||||||
creatingWorkspaceTimeoutsRef.current.delete(workspaceId);
|
setCreatingWorkspaceIds((current) => {
|
||||||
setCreatingWorkspaceIds((current) => {
|
if (!current.has(workspaceId)) {
|
||||||
if (!current.has(workspaceId)) {
|
return current;
|
||||||
return current;
|
}
|
||||||
}
|
const next = new Set(current);
|
||||||
const next = new Set(current);
|
next.delete(workspaceId);
|
||||||
next.delete(workspaceId);
|
return next;
|
||||||
return next;
|
});
|
||||||
});
|
}, 3000),
|
||||||
}, 3000),
|
);
|
||||||
);
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderProject = useCallback(
|
const renderProject = useCallback(
|
||||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
||||||
@@ -1847,7 +1834,7 @@ export function SidebarWorkspaceList({
|
|||||||
drag={drag}
|
drag={drag}
|
||||||
isDragging={isActive}
|
isDragging={isActive}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
useNestable={isNative}
|
useNestable={platformIsNative}
|
||||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1864,7 +1851,7 @@ export function SidebarWorkspaceList({
|
|||||||
serverId,
|
serverId,
|
||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
showShortcutBadges,
|
showShortcutBadges,
|
||||||
isNative,
|
platformIsNative,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1875,12 +1862,7 @@ export function SidebarWorkspaceList({
|
|||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<Text style={styles.emptyTitle}>No projects yet</Text>
|
<Text style={styles.emptyTitle}>No projects yet</Text>
|
||||||
<Text style={styles.emptyText}>Add a project to get started</Text>
|
<Text style={styles.emptyText}>Add a project to get started</Text>
|
||||||
<Button
|
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={Plus}
|
|
||||||
onPress={onAddProject}
|
|
||||||
>
|
|
||||||
Add project
|
Add project
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
@@ -1893,7 +1875,7 @@ export function SidebarWorkspaceList({
|
|||||||
onDragEnd={handleProjectDragEnd}
|
onDragEnd={handleProjectDragEnd}
|
||||||
scrollEnabled={false}
|
scrollEnabled={false}
|
||||||
useDragHandle
|
useDragHandle
|
||||||
nestable={isNative}
|
nestable={platformIsNative}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
simultaneousGestureRef={parentGestureRef}
|
||||||
containerStyle={styles.projectListContainer}
|
containerStyle={styles.projectListContainer}
|
||||||
/>
|
/>
|
||||||
@@ -1904,7 +1886,7 @@ export function SidebarWorkspaceList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{isNative ? (
|
{platformIsNative ? (
|
||||||
<NestableScrollContainer
|
<NestableScrollContainer
|
||||||
style={styles.list}
|
style={styles.list}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
@@ -1980,7 +1962,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
projectRowHovered: {
|
projectRowHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
projectRowPressed: {
|
projectRowPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -2050,7 +2032,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
projectActionButtonHovered: {
|
projectActionButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
projectActionButtonText: {
|
projectActionButtonText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -2065,7 +2047,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
projectIconActionButtonHovered: {
|
projectIconActionButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
projectIconActionButtonHidden: {
|
projectIconActionButtonHidden: {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
@@ -2143,7 +2125,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
workspaceRowHovered: {
|
workspaceRowHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
workspaceRowPressed: {
|
workspaceRowPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -2157,7 +2139,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
...theme.shadow.md,
|
...theme.shadow.md,
|
||||||
},
|
},
|
||||||
sidebarRowSelected: {
|
sidebarRowSelected: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||||
},
|
},
|
||||||
workspaceRowContainer: {
|
workspaceRowContainer: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -2241,12 +2223,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
diffStatAdditions: {
|
diffStatAdditions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.green[400],
|
color: theme.colors.diffAddition,
|
||||||
},
|
},
|
||||||
diffStatDeletions: {
|
diffStatDeletions: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.palette.red[500],
|
color: theme.colors.diffDeletion,
|
||||||
},
|
},
|
||||||
kebabButton: {
|
kebabButton: {
|
||||||
padding: 2,
|
padding: 2,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
type DragStartEvent,
|
type DragStartEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ResizeHandle } from "@/components/resize-handle";
|
import { ResizeHandle } from "@/components/resize-handle";
|
||||||
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
import { shouldFocusPaneFromEventTarget } from "@/components/split-container-pane-focus";
|
||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||||
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
|
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
interface SplitContainerProps {
|
interface SplitContainerProps {
|
||||||
layout: WorkspaceLayout;
|
layout: WorkspaceLayout;
|
||||||
@@ -177,7 +178,6 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
|||||||
paneId,
|
paneId,
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
}: MountedTabSlotProps) {
|
}: MountedTabSlotProps) {
|
||||||
|
|
||||||
const content = useMemo(
|
const content = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildPaneContentModel({
|
buildPaneContentModel({
|
||||||
@@ -839,7 +839,7 @@ function SplitPaneView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,12 +877,7 @@ function SplitPaneView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
||||||
<View
|
<View style={[styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }]}>
|
||||||
style={[
|
|
||||||
styles.paneTabs,
|
|
||||||
{ paddingLeft: padding.left, paddingRight: padding.right },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
<WorkspaceDesktopTabsRow
|
<WorkspaceDesktopTabsRow
|
||||||
paneId={pane.id}
|
paneId={pane.id}
|
||||||
@@ -920,27 +915,25 @@ function SplitPaneView({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.paneContent}>
|
<View style={styles.paneContent}>
|
||||||
{mountedPaneTabIds.length > 0 ? (
|
{mountedPaneTabIds.length > 0
|
||||||
mountedPaneTabIds.map((tabId) => {
|
? mountedPaneTabIds.map((tabId) => {
|
||||||
const tabDescriptor = tabDescriptorMap.get(tabId);
|
const tabDescriptor = tabDescriptorMap.get(tabId);
|
||||||
if (!tabDescriptor) {
|
if (!tabDescriptor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MountedTabSlot
|
<MountedTabSlot
|
||||||
key={tabId}
|
key={tabId}
|
||||||
tabDescriptor={tabDescriptor}
|
tabDescriptor={tabDescriptor}
|
||||||
isVisible={tabId === activeTabDescriptor?.tabId}
|
isVisible={tabId === activeTabDescriptor?.tabId}
|
||||||
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
|
||||||
paneId={pane.id}
|
paneId={pane.id}
|
||||||
buildPaneContentModel={buildPaneContentModel}
|
buildPaneContentModel={buildPaneContentModel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
: (renderPaneEmptyState?.() ?? null)}
|
||||||
(renderPaneEmptyState?.() ?? null)
|
|
||||||
)}
|
|
||||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -82,17 +82,12 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
|||||||
return `${input.serverId}:${input.cwd}`;
|
return `${input.serverId}:${input.cwd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalPane({
|
export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: TerminalPaneProps) {
|
||||||
serverId,
|
|
||||||
cwd,
|
|
||||||
terminalId,
|
|
||||||
isPaneFocused,
|
|
||||||
}: TerminalPaneProps) {
|
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppVisible();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
|
||||||
const isMobile = isCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||||
@@ -108,7 +103,10 @@ export function TerminalPane({
|
|||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | 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 [isAttaching, setIsAttaching] = useState(false);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||||
@@ -473,26 +471,32 @@ export function TerminalPane({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalResize = useStableEvent(
|
const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => {
|
||||||
(input: { rows: number; cols: number }) => {
|
const { rows, cols } = input;
|
||||||
const { rows, cols } = input;
|
if (
|
||||||
if (!client || !terminalId || !isPaneFocused || !isScreenFocused || !isAppVisible || rows <= 0 || cols <= 0) {
|
!client ||
|
||||||
return;
|
!terminalId ||
|
||||||
}
|
!isPaneFocused ||
|
||||||
const normalizedRows = Math.floor(rows);
|
!isScreenFocused ||
|
||||||
const normalizedCols = Math.floor(cols);
|
!isAppVisible ||
|
||||||
const previous = lastReportedSizeRef.current;
|
rows <= 0 ||
|
||||||
if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) {
|
cols <= 0
|
||||||
return;
|
) {
|
||||||
}
|
return;
|
||||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
}
|
||||||
client.sendTerminalInput(terminalId, {
|
const normalizedRows = Math.floor(rows);
|
||||||
type: "resize",
|
const normalizedCols = Math.floor(cols);
|
||||||
rows: normalizedRows,
|
const previous = lastReportedSizeRef.current;
|
||||||
cols: normalizedCols,
|
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(
|
const handleTerminalKey = useCallback(
|
||||||
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
|
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
|
import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
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 { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +48,7 @@ export function useToastHost(): {
|
|||||||
toast: ToastState | null;
|
toast: ToastState | null;
|
||||||
dismiss: () => void;
|
dismiss: () => void;
|
||||||
} {
|
} {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
const [toast, setToast] = useState<ToastState | null>(null);
|
const [toast, setToast] = useState<ToastState | null>(null);
|
||||||
const idRef = useRef(0);
|
const idRef = useRef(0);
|
||||||
|
|
||||||
@@ -83,11 +86,11 @@ export function useToastHost(): {
|
|||||||
copied: (label?: string) =>
|
copied: (label?: string) =>
|
||||||
show(label ? `Copied ${label}` : "Copied", {
|
show(label ? `Copied ${label}` : "Copied", {
|
||||||
variant: "success",
|
variant: "success",
|
||||||
icon: <CheckCircle2 size={18} />,
|
icon: <CheckCircle2 size={18} color={theme.colors.foreground} />,
|
||||||
}),
|
}),
|
||||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
||||||
}),
|
}),
|
||||||
[show],
|
[show, theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
@@ -108,9 +111,12 @@ export function ToastViewport({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const isMobile = useIsCompactFormFactor();
|
||||||
const opacity = useRef(new Animated.Value(0)).current;
|
const opacity = useRef(new Animated.Value(0)).current;
|
||||||
const translateY = useRef(new Animated.Value(-8)).current;
|
const translateY = useRef(new Animated.Value(-8)).current;
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const dismissDeadlineRef = useRef<number | null>(null);
|
||||||
|
const remainingDurationRef = useRef(0);
|
||||||
|
|
||||||
const clearTimer = useCallback(() => {
|
const clearTimer = useCallback(() => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
@@ -141,9 +147,39 @@ export function ToastViewport({
|
|||||||
});
|
});
|
||||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!toast) {
|
if (!toast) {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
|
dismissDeadlineRef.current = null;
|
||||||
|
remainingDurationRef.current = 0;
|
||||||
opacity.setValue(0);
|
opacity.setValue(0);
|
||||||
translateY.setValue(-8);
|
translateY.setValue(-8);
|
||||||
return;
|
return;
|
||||||
@@ -168,20 +204,18 @@ export function ToastViewport({
|
|||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
remainingDurationRef.current = toast.durationMs;
|
||||||
animateOut();
|
scheduleDismiss(toast.durationMs);
|
||||||
}, toast.durationMs);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
};
|
};
|
||||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
}, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
|
||||||
|
|
||||||
if (!toast) {
|
if (!toast) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||||
const topOffset =
|
const topOffset =
|
||||||
@@ -201,6 +235,8 @@ export function ToastViewport({
|
|||||||
<View style={styles.container} pointerEvents="box-none">
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
testID={toast.testID ?? "app-toast"}
|
testID={toast.testID ?? "app-toast"}
|
||||||
|
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||||
|
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||||
style={[
|
style={[
|
||||||
styles.toast,
|
styles.toast,
|
||||||
toast.variant === "success" ? styles.toastSuccess : null,
|
toast.variant === "success" ? styles.toastSuccess : null,
|
||||||
@@ -218,7 +254,6 @@ export function ToastViewport({
|
|||||||
<Text
|
<Text
|
||||||
testID="app-toast-message"
|
testID="app-toast-message"
|
||||||
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
|
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
|
||||||
numberOfLines={2}
|
|
||||||
>
|
>
|
||||||
{toast.content}
|
{toast.content}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -231,7 +266,7 @@ export function ToastViewport({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") {
|
if (placement === "app-shell" && isWeb && typeof document !== "undefined") {
|
||||||
return createPortal(content, getOverlayRoot());
|
return createPortal(content, getOverlayRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +289,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, ReactNode } from "react";
|
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 { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
@@ -8,8 +8,9 @@ import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers";
|
|||||||
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
|
||||||
import { DiffViewer } from "./diff-viewer";
|
import { DiffViewer } from "./diff-viewer";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const ScrollView = Platform.OS === "web" ? RNScrollView : GHScrollView;
|
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||||
|
|
||||||
// ---- Content Component ----
|
// ---- Content Component ----
|
||||||
|
|
||||||
@@ -511,7 +512,7 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? {
|
? {
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
overflowWrap: "normal",
|
overflowWrap: "normal",
|
||||||
|
|||||||
@@ -136,21 +136,32 @@ export function Button({
|
|||||||
return <View>{leftIcon}</View>;
|
return <View>{leftIcon}</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const color = variant === "default"
|
const color =
|
||||||
? theme.colors.accentForeground
|
variant === "default"
|
||||||
: variant === "ghost"
|
? theme.colors.accentForeground
|
||||||
? (isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted)
|
: variant === "ghost"
|
||||||
: theme.colors.foreground;
|
? isGhostHovered
|
||||||
|
? theme.colors.foreground
|
||||||
|
: theme.colors.foregroundMuted
|
||||||
|
: theme.colors.foreground;
|
||||||
const iconSize = ICON_SIZE[size];
|
const iconSize = ICON_SIZE[size];
|
||||||
|
|
||||||
// Render function
|
// 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>;
|
return <View>{(leftIcon as (color: string) => ReactElement)(color)}</View>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Component type
|
// Component type
|
||||||
const Icon = leftIcon as ComponentType<{ color: string; size: number }>;
|
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 (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildVisibleComboboxOptions,
|
buildVisibleComboboxOptions,
|
||||||
|
filterAndRankComboboxOptions,
|
||||||
getComboboxFallbackIndex,
|
getComboboxFallbackIndex,
|
||||||
orderVisibleComboboxOptions,
|
orderVisibleComboboxOptions,
|
||||||
} from "./combobox-options";
|
} from "./combobox-options";
|
||||||
@@ -47,6 +48,48 @@ describe("buildVisibleComboboxOptions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("filterAndRankComboboxOptions", () => {
|
||||||
|
const options = [
|
||||||
|
{ id: "feat/login", label: "feat/login" },
|
||||||
|
{ id: "main", label: "main" },
|
||||||
|
{ id: "feat/main-nav", label: "feat/main-nav" },
|
||||||
|
{ id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("returns all options when search is empty", () => {
|
||||||
|
expect(filterAndRankComboboxOptions(options, "")).toEqual(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by label substring", () => {
|
||||||
|
const result = filterAndRankComboboxOptions(options, "login");
|
||||||
|
expect(result.map((o) => o.id)).toEqual(["feat/login"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by id substring", () => {
|
||||||
|
const result = filterAndRankComboboxOptions(options, "fix/");
|
||||||
|
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by description substring", () => {
|
||||||
|
const result = filterAndRankComboboxOptions(options, "logout bug");
|
||||||
|
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks prefix matches above substring matches", () => {
|
||||||
|
const result = filterAndRankComboboxOptions(options, "main");
|
||||||
|
expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive", () => {
|
||||||
|
const items = [{ id: "Alpha", label: "Alpha" }];
|
||||||
|
expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when nothing matches", () => {
|
||||||
|
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("combobox above-search ordering", () => {
|
describe("combobox above-search ordering", () => {
|
||||||
const visible = [
|
const visible = [
|
||||||
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
||||||
|
|||||||
@@ -35,18 +35,33 @@ export function shouldShowCustomComboboxOption(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterAndRankComboboxOptions(
|
||||||
|
options: ComboboxOptionModel[],
|
||||||
|
search: string,
|
||||||
|
): ComboboxOptionModel[] {
|
||||||
|
if (!search) return options;
|
||||||
|
return options
|
||||||
|
.filter(
|
||||||
|
(opt) =>
|
||||||
|
opt.label.toLowerCase().includes(search) ||
|
||||||
|
opt.id.toLowerCase().includes(search) ||
|
||||||
|
opt.description?.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aPrefix =
|
||||||
|
a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
|
||||||
|
const bPrefix =
|
||||||
|
b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
|
||||||
|
if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildVisibleComboboxOptions(
|
export function buildVisibleComboboxOptions(
|
||||||
input: BuildVisibleComboboxOptionsInput,
|
input: BuildVisibleComboboxOptionsInput,
|
||||||
): ComboboxOptionModel[] {
|
): ComboboxOptionModel[] {
|
||||||
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
||||||
const filteredOptions = normalizedSearch
|
const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch);
|
||||||
? input.options.filter(
|
|
||||||
(opt) =>
|
|
||||||
opt.label.toLowerCase().includes(normalizedSearch) ||
|
|
||||||
opt.id.toLowerCase().includes(normalizedSearch) ||
|
|
||||||
opt.description?.toLowerCase().includes(normalizedSearch),
|
|
||||||
)
|
|
||||||
: input.options;
|
|
||||||
|
|
||||||
const sanitizedSearchValue = input.searchQuery.trim();
|
const sanitizedSearchValue = input.searchQuery.trim();
|
||||||
const showCustomOption = shouldShowCustomComboboxOption({
|
const showCustomOption = shouldShowCustomComboboxOption({
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
StatusBar,
|
StatusBar,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
@@ -36,8 +37,9 @@ import {
|
|||||||
shouldShowCustomComboboxOption,
|
shouldShowCustomComboboxOption,
|
||||||
} from "./combobox-options";
|
} from "./combobox-options";
|
||||||
import type { ComboboxOptionModel } 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;
|
export type ComboboxOption = ComboboxOptionModel;
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ export interface SearchInputProps {
|
|||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSubmitEditing?: () => void;
|
onSubmitEditing?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
useBottomSheetInput?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchInput({
|
export function SearchInput({
|
||||||
@@ -114,10 +117,11 @@ export function SearchInput({
|
|||||||
onChangeText,
|
onChangeText,
|
||||||
onSubmitEditing,
|
onSubmitEditing,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
|
useBottomSheetInput = false,
|
||||||
}: SearchInputProps): ReactElement {
|
}: SearchInputProps): ReactElement {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
|
const InputComponent = useBottomSheetInput ? BottomSheetTextInput : TextInput;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus && IS_WEB && inputRef.current) {
|
if (autoFocus && IS_WEB && inputRef.current) {
|
||||||
@@ -261,11 +265,10 @@ export function Combobox({
|
|||||||
anchorRef,
|
anchorRef,
|
||||||
children,
|
children,
|
||||||
}: ComboboxProps): ReactElement {
|
}: ComboboxProps): ReactElement {
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
|
||||||
const isDesktopAboveSearch =
|
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
|
||||||
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search";
|
const { height: windowHeight, width: windowWidth } = useWindowDimensions();
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const hasPresentedBottomSheetRef = useRef(false);
|
const hasPresentedBottomSheetRef = useRef(false);
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
@@ -273,11 +276,13 @@ export function Combobox({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||||
|
const [referenceLeft, setReferenceLeft] = useState<number | null>(null);
|
||||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||||
|
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
|
||||||
|
|
||||||
const isControlled = typeof open === "boolean";
|
const isControlled = typeof open === "boolean";
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
@@ -321,8 +326,8 @@ export function Combobox({
|
|||||||
|
|
||||||
const middleware = useMemo(
|
const middleware = useMemo(
|
||||||
() => [
|
() => [
|
||||||
floatingOffset(Platform.OS === "web" ? 0 : 4),
|
floatingOffset(isWeb ? 5 : 4),
|
||||||
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]),
|
...(isWeb ? [] : [flip({ padding: collisionPadding })]),
|
||||||
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
|
||||||
floatingSize({
|
floatingSize({
|
||||||
padding: collisionPadding,
|
padding: collisionPadding,
|
||||||
@@ -335,6 +340,9 @@ export function Combobox({
|
|||||||
});
|
});
|
||||||
setReferenceWidth((prev) => {
|
setReferenceWidth((prev) => {
|
||||||
const next = rects.reference.width;
|
const next = rects.reference.width;
|
||||||
|
if (!(next > 0)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
if (prev === next) return prev;
|
if (prev === next) return prev;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -345,7 +353,7 @@ export function Combobox({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { refs, floatingStyles, update } = useFloating({
|
const { refs, floatingStyles, update } = useFloating({
|
||||||
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start",
|
placement: isWeb ? desktopPlacement : "bottom-start",
|
||||||
middleware,
|
middleware,
|
||||||
sameScrollView: false,
|
sameScrollView: false,
|
||||||
elements: {
|
elements: {
|
||||||
@@ -356,15 +364,18 @@ export function Combobox({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || isMobile) {
|
if (!isOpen || isMobile) {
|
||||||
setAvailableSize(null);
|
setAvailableSize(null);
|
||||||
|
setDesktopContentWidth(null);
|
||||||
|
setReferenceLeft(null);
|
||||||
setReferenceWidth(null);
|
setReferenceWidth(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raf = requestAnimationFrame(() => void update());
|
const raf = requestAnimationFrame(() => void update());
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}, [desktopPlacement, isMobile, update, isOpen]);
|
}, [desktopPlacement, isMobile, isOpen, update]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || isMobile) {
|
if (!isOpen || isMobile) {
|
||||||
|
setReferenceLeft(null);
|
||||||
setReferenceAtOrigin(false);
|
setReferenceAtOrigin(false);
|
||||||
setReferenceTop(null);
|
setReferenceTop(null);
|
||||||
return;
|
return;
|
||||||
@@ -378,9 +389,16 @@ export function Combobox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const measure = () => {
|
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);
|
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1);
|
||||||
setReferenceTop((prev) => (prev === y ? prev : y));
|
setReferenceTop((prev) => (prev === y ? prev : y));
|
||||||
|
setReferenceWidth((prev) => {
|
||||||
|
if (!(width > 0)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return prev === width ? prev : width;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -395,32 +413,46 @@ export function Combobox({
|
|||||||
isDesktopAboveSearch && referenceTop !== null
|
isDesktopAboveSearch && referenceTop !== null
|
||||||
? Math.max(windowHeight - referenceTop, collisionPadding)
|
? Math.max(windowHeight - referenceTop, collisionPadding)
|
||||||
: null;
|
: null;
|
||||||
const hasResolvedDesktopPosition =
|
const hasNonZeroFloatingPosition = (floatingTop ?? 0) !== 0 || floatingLeft !== 0;
|
||||||
referenceWidth !== null &&
|
const useMeasuredTopStartPosition =
|
||||||
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 =
|
|
||||||
!isDesktopAboveSearch &&
|
!isDesktopAboveSearch &&
|
||||||
IS_WEB &&
|
IS_WEB &&
|
||||||
!isMobile &&
|
!isMobile &&
|
||||||
hasResolvedDesktopPosition &&
|
desktopPlacement === "top-start" &&
|
||||||
desktopPlacement.startsWith("top") &&
|
referenceTop !== null &&
|
||||||
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
|
const desktopPositionStyle = isDesktopAboveSearch
|
||||||
? {
|
? {
|
||||||
left: floatingLeft ?? 0,
|
left: floatingLeft ?? 0,
|
||||||
bottom: desktopAboveSearchBottom ?? 0,
|
bottom: desktopAboveSearchBottom ?? 0,
|
||||||
}
|
}
|
||||||
: useStableBottom
|
: useMeasuredTopStartPosition
|
||||||
? {
|
? {
|
||||||
left: floatingLeft ?? 0,
|
left: clampedMeasuredTopStartLeft ?? 0,
|
||||||
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
|
bottom: measuredTopStartBottom ?? 0,
|
||||||
}
|
}
|
||||||
: floatingStyles;
|
: floatingStyles;
|
||||||
|
|
||||||
@@ -625,6 +657,7 @@ export function Combobox({
|
|||||||
onChangeText={setSearchQueryWithCallback}
|
onChangeText={setSearchQueryWithCallback}
|
||||||
onSubmitEditing={handleSubmitSearch}
|
onSubmitEditing={handleSubmitSearch}
|
||||||
autoFocus={!isMobile}
|
autoFocus={!isMobile}
|
||||||
|
useBottomSheetInput={isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -659,13 +692,7 @@ export function Combobox({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultContent = (
|
const defaultContent = optionsList;
|
||||||
<>
|
|
||||||
{effectiveOptionsPosition === "above-search" ? optionsList : null}
|
|
||||||
{searchable ? searchInput : null}
|
|
||||||
{effectiveOptionsPosition === "below-search" ? optionsList : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = children ?? defaultContent;
|
const content = children ?? defaultContent;
|
||||||
|
|
||||||
@@ -690,6 +717,7 @@ export function Combobox({
|
|||||||
<Text style={styles.comboboxTitle}>{title}</Text>
|
<Text style={styles.comboboxTitle}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
{stickyHeader}
|
{stickyHeader}
|
||||||
|
{!children && searchable ? searchInput : null}
|
||||||
<BottomSheetScrollView
|
<BottomSheetScrollView
|
||||||
contentContainerStyle={styles.comboboxScrollContent}
|
contentContainerStyle={styles.comboboxScrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -729,7 +757,13 @@ export function Combobox({
|
|||||||
]}
|
]}
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
onLayout={() => update()}
|
onLayout={(event) => {
|
||||||
|
const { width, height } = event.nativeEvent.layout;
|
||||||
|
setDesktopContentWidth((prev) => (prev === width ? prev : width));
|
||||||
|
if (!useMeasuredTopStartPosition || !hasResolvedDesktopPosition) {
|
||||||
|
void update();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children ? (
|
{children ? (
|
||||||
<>
|
<>
|
||||||
@@ -745,6 +779,7 @@ export function Combobox({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{searchable ? searchInput : null}
|
||||||
{effectiveOptionsPosition === "above-search" ? (
|
{effectiveOptionsPosition === "above-search" ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={desktopOptionsScrollRef}
|
ref={desktopOptionsScrollRef}
|
||||||
@@ -759,9 +794,7 @@ export function Combobox({
|
|||||||
>
|
>
|
||||||
{optionsList}
|
{optionsList}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
) : (
|
||||||
{searchable ? searchInput : null}
|
|
||||||
{effectiveOptionsPosition === "below-search" ? (
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.desktopScrollContent}
|
contentContainerStyle={styles.desktopScrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
@@ -770,7 +803,7 @@ export function Combobox({
|
|||||||
>
|
>
|
||||||
{optionsList}
|
{optionsList}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -783,15 +816,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
searchInputContainer: {
|
searchInputContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
marginHorizontal: theme.spacing[2],
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||||
},
|
},
|
||||||
searchInput: {
|
searchInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { Check, CheckCircle } from "lucide-react-native";
|
import { Check, CheckCircle } from "lucide-react-native";
|
||||||
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
// Keep parity with dropdown-menu action statuses.
|
// Keep parity with dropdown-menu action statuses.
|
||||||
export type ActionStatus = "idle" | "pending" | "success";
|
export type ActionStatus = "idle" | "pending" | "success";
|
||||||
@@ -253,8 +255,7 @@ export function ContextMenuTrigger({
|
|||||||
>): ReactElement {
|
>): ReactElement {
|
||||||
const ctx = useContextMenuContext("ContextMenuTrigger");
|
const ctx = useContextMenuContext("ContextMenuTrigger");
|
||||||
|
|
||||||
const shouldEnableOnThisPlatform =
|
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
|
||||||
enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile);
|
|
||||||
|
|
||||||
const openAtEvent = useCallback(
|
const openAtEvent = useCallback(
|
||||||
(event: unknown) => {
|
(event: unknown) => {
|
||||||
@@ -293,7 +294,7 @@ export function ContextMenuTrigger({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
delayLongPress={longPressDelayMs}
|
delayLongPress={longPressDelayMs}
|
||||||
onLongPress={(event) => {
|
onLongPress={(event) => {
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
props.onLongPress?.(event);
|
props.onLongPress?.(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -302,7 +303,7 @@ export function ContextMenuTrigger({
|
|||||||
}}
|
}}
|
||||||
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
// @ts-ignore - onContextMenu is web-only and not in RN types.
|
||||||
onContextMenu={(event: unknown) => {
|
onContextMenu={(event: unknown) => {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const e: any = event;
|
const e: any = event;
|
||||||
@@ -347,7 +348,7 @@ export function ContextMenuContent({
|
|||||||
testID?: string;
|
testID?: string;
|
||||||
}>): ReactElement | null {
|
}>): ReactElement | null {
|
||||||
const context = useContextMenuContext("ContextMenuContent");
|
const context = useContextMenuContext("ContextMenuContent");
|
||||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
const isMobile = useIsCompactFormFactor();
|
||||||
const useMobileSheet = isMobile && mobileMode === "sheet";
|
const useMobileSheet = isMobile && mobileMode === "sheet";
|
||||||
const { open, setOpen, triggerRef, anchorRect } = context;
|
const { open, setOpen, triggerRef, anchorRect } = context;
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
|
|||||||
@@ -460,6 +460,7 @@ export function DropdownMenuItem({
|
|||||||
description,
|
description,
|
||||||
onSelect,
|
onSelect,
|
||||||
disabled,
|
disabled,
|
||||||
|
muted = false,
|
||||||
destructive,
|
destructive,
|
||||||
selected,
|
selected,
|
||||||
showSelectedCheck = false,
|
showSelectedCheck = false,
|
||||||
@@ -477,6 +478,7 @@ export function DropdownMenuItem({
|
|||||||
description?: string;
|
description?: string;
|
||||||
onSelect?: () => void;
|
onSelect?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
showSelectedCheck?: boolean;
|
showSelectedCheck?: boolean;
|
||||||
@@ -550,6 +552,7 @@ export function DropdownMenuItem({
|
|||||||
? styles.itemSelectedInteractive
|
? styles.itemSelectedInteractive
|
||||||
: null,
|
: null,
|
||||||
isDisabled ? styles.itemDisabled : null,
|
isDisabled ? styles.itemDisabled : null,
|
||||||
|
muted && !isDisabled ? styles.itemMuted : null,
|
||||||
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
|
||||||
pressed && !isDisabled ? styles.itemPressed : null,
|
pressed && !isDisabled ? styles.itemPressed : null,
|
||||||
]}
|
]}
|
||||||
@@ -568,6 +571,7 @@ export function DropdownMenuItem({
|
|||||||
destructive && !isSuccess ? styles.itemTextDestructive : null,
|
destructive && !isSuccess ? styles.itemTextDestructive : null,
|
||||||
isSuccess ? styles.itemTextSuccess : null,
|
isSuccess ? styles.itemTextSuccess : null,
|
||||||
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
|
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
|
||||||
|
muted && !isDisabled ? styles.itemTextMuted : null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -678,11 +682,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
itemDisabled: {
|
itemDisabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
itemMuted: {
|
||||||
|
opacity: 0.72,
|
||||||
|
},
|
||||||
itemText: {
|
itemText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
|
itemTextMuted: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
itemTextDestructive: {
|
itemTextDestructive: {
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.destructive,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { Portal } from "@gorhom/portal";
|
|||||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type Side = "top" | "bottom" | "left" | "right";
|
type Side = "top" | "bottom" | "left" | "right";
|
||||||
type Align = "start" | "center" | "end";
|
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({
|
function computePosition({
|
||||||
triggerRect,
|
triggerRect,
|
||||||
contentSize,
|
contentSize,
|
||||||
@@ -227,8 +217,8 @@ export function Tooltip({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMobile = isMobileTooltipEnvironment();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
|
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
|
||||||
|
|
||||||
const value = useMemo<TooltipContextValue>(
|
const value = useMemo<TooltipContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -236,10 +226,10 @@ export function Tooltip({
|
|||||||
setOpen: setIsOpen,
|
setOpen: setIsOpen,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
enabled,
|
enabled,
|
||||||
openOnPress: isMobile,
|
openOnPress: isCompact,
|
||||||
delayDuration,
|
delayDuration,
|
||||||
}),
|
}),
|
||||||
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
|
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||||
@@ -354,7 +344,7 @@ export function TooltipTrigger({
|
|||||||
onFocus: handleFocus,
|
onFocus: handleFocus,
|
||||||
onBlur: handleBlur,
|
onBlur: handleBlur,
|
||||||
onPress: handlePress,
|
onPress: handlePress,
|
||||||
...(Platform.OS === "web"
|
...(isWeb
|
||||||
? ({
|
? ({
|
||||||
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
|
||||||
onPointerEnter: handleHoverIn,
|
onPointerEnter: handleHoverIn,
|
||||||
@@ -473,7 +463,7 @@ export function TooltipContent({
|
|||||||
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
|
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
|
||||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||||
if (Platform.OS === "web") {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||||
@@ -551,9 +541,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.xl,
|
||||||
backgroundColor: theme.colors.popover,
|
backgroundColor: theme.colors.popover,
|
||||||
borderWidth: theme.borderWidth[2],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.borderAccent,
|
||||||
...theme.shadow.sm,
|
...theme.shadow.md,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
import { useCallback, useEffect, useState, type ReactNode, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
Platform,
|
|
||||||
type FlatList,
|
type FlatList,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
useWebDesktopScrollbarMetrics,
|
useWebDesktopScrollbarMetrics,
|
||||||
type ScrollbarMetrics,
|
type ScrollbarMetrics,
|
||||||
} from "./web-desktop-scrollbar";
|
} from "./web-desktop-scrollbar";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const METRICS_EPSILON = 0.5;
|
const METRICS_EPSILON = 0.5;
|
||||||
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar";
|
||||||
@@ -45,8 +45,7 @@ export function useWebElementScrollbar(
|
|||||||
contentRef?: RefObject<HTMLElement | null>;
|
contentRef?: RefObject<HTMLElement | null>;
|
||||||
},
|
},
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
const isWeb = Platform.OS === "web";
|
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||||
const enabled = (options?.enabled ?? true) && isWeb;
|
|
||||||
const contentRef = options?.contentRef;
|
const contentRef = options?.contentRef;
|
||||||
|
|
||||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||||
@@ -105,7 +104,9 @@ export function useWebElementScrollbar(
|
|||||||
|
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
|
|
||||||
return <WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />;
|
return (
|
||||||
|
<WebDesktopScrollbarOverlay enabled metrics={metrics} onScrollToOffset={onScrollToOffset} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
|
// ── RN ScrollView / FlatList scrollbar ───────────────────────────────
|
||||||
@@ -123,8 +124,7 @@ export function useWebScrollViewScrollbar(
|
|||||||
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
scrollableRef: RefObject<ScrollView | FlatList | null>,
|
||||||
options?: { enabled?: boolean },
|
options?: { enabled?: boolean },
|
||||||
): WebScrollViewScrollbar {
|
): WebScrollViewScrollbar {
|
||||||
const isWeb = Platform.OS === "web";
|
const enabled = (options?.enabled ?? true) && platformIsWeb;
|
||||||
const enabled = (options?.enabled ?? true) && isWeb;
|
|
||||||
const metricsHook = useWebDesktopScrollbarMetrics();
|
const metricsHook = useWebDesktopScrollbarMetrics();
|
||||||
|
|
||||||
const onScrollToOffset = useCallback(
|
const onScrollToOffset = useCallback(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
PanResponder,
|
PanResponder,
|
||||||
Platform,
|
|
||||||
View,
|
View,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
@@ -12,6 +11,7 @@ import {
|
|||||||
computeScrollOffsetFromDragDelta,
|
computeScrollOffsetFromDragDelta,
|
||||||
computeVerticalScrollbarGeometry,
|
computeVerticalScrollbarGeometry,
|
||||||
} from "./web-desktop-scrollbar.math";
|
} from "./web-desktop-scrollbar.math";
|
||||||
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
|
||||||
const METRICS_EPSILON = 0.5;
|
const METRICS_EPSILON = 0.5;
|
||||||
const HANDLE_WIDTH_IDLE = 6;
|
const HANDLE_WIDTH_IDLE = 6;
|
||||||
@@ -135,7 +135,6 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
maxScrollOffset: 0,
|
maxScrollOffset: 0,
|
||||||
});
|
});
|
||||||
const onScrollToOffsetRef = useRef(onScrollToOffset);
|
const onScrollToOffsetRef = useRef(onScrollToOffset);
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
|
|
||||||
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
|
const maxScrollOffset = Math.max(0, metrics.contentSize - metrics.viewportSize);
|
||||||
const normalizedOffset = inverted
|
const normalizedOffset = inverted
|
||||||
@@ -258,7 +257,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const panResponder = useMemo(() => {
|
const panResponder = useMemo(() => {
|
||||||
if (isWeb) {
|
if (platformIsWeb) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,11 +279,11 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [applyDragDelta, isWeb]);
|
}, [applyDragDelta, platformIsWeb]);
|
||||||
|
|
||||||
const startWebDrag = useCallback(
|
const startWebDrag = useCallback(
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
if (!isWeb) {
|
if (!platformIsWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clientY = readClientY(event);
|
const clientY = readClientY(event);
|
||||||
@@ -298,7 +297,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
dragStartClientYRef.current = clientY;
|
dragStartClientYRef.current = clientY;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
},
|
},
|
||||||
[isWeb],
|
[platformIsWeb],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGrabHoverIn = useCallback(() => {
|
const handleGrabHoverIn = useCallback(() => {
|
||||||
@@ -313,7 +312,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !isDragging) {
|
if (!platformIsWeb || !isDragging) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +334,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
window.removeEventListener("pointerup", stopDragging);
|
window.removeEventListener("pointerup", stopDragging);
|
||||||
window.removeEventListener("pointercancel", stopDragging);
|
window.removeEventListener("pointercancel", stopDragging);
|
||||||
};
|
};
|
||||||
}, [applyDragDelta, isDragging, isWeb]);
|
}, [applyDragDelta, isDragging, platformIsWeb]);
|
||||||
|
|
||||||
if (!enabled || !geometry.isVisible) {
|
if (!enabled || !geometry.isVisible) {
|
||||||
return null;
|
return null;
|
||||||
@@ -371,7 +370,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
height: thumbRegionHeight,
|
height: thumbRegionHeight,
|
||||||
transform: [{ translateY: thumbRegionOffset }],
|
transform: [{ translateY: thumbRegionOffset }],
|
||||||
},
|
},
|
||||||
isWeb &&
|
platformIsWeb &&
|
||||||
({
|
({
|
||||||
cursor: handleCursor,
|
cursor: handleCursor,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
@@ -383,7 +382,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
]}
|
]}
|
||||||
pointerEvents={handleVisible ? "auto" : "none"}
|
pointerEvents={handleVisible ? "auto" : "none"}
|
||||||
{...(panResponder?.panHandlers ?? {})}
|
{...(panResponder?.panHandlers ?? {})}
|
||||||
{...(isWeb
|
{...(platformIsWeb
|
||||||
? ({
|
? ({
|
||||||
onPointerDown: startWebDrag,
|
onPointerDown: startWebDrag,
|
||||||
onPointerEnter: handleGrabHoverIn,
|
onPointerEnter: handleGrabHoverIn,
|
||||||
@@ -403,7 +402,7 @@ export function WebDesktopScrollbarOverlay({
|
|||||||
backgroundColor: handleColor,
|
backgroundColor: handleColor,
|
||||||
opacity: handleOpacity,
|
opacity: handleOpacity,
|
||||||
},
|
},
|
||||||
isWeb &&
|
platformIsWeb &&
|
||||||
({
|
({
|
||||||
transitionProperty: "opacity, width, background-color",
|
transitionProperty: "opacity, width, background-color",
|
||||||
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
|
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 { 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 { useRouter } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native";
|
||||||
@@ -8,19 +8,17 @@ import type { HostProfile } from "@/types/host-connection";
|
|||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
isHostRuntimeConnected,
|
isHostRuntimeConnected,
|
||||||
useHostMutations,
|
|
||||||
useHostRuntimeSnapshot,
|
useHostRuntimeSnapshot,
|
||||||
useHosts,
|
useHosts,
|
||||||
} from "@/runtime/host-runtime";
|
} from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { AddHostModal } from "./add-host-modal";
|
import { AddHostModal } from "./add-host-modal";
|
||||||
import { PairLinkModal } from "./pair-link-modal";
|
import { PairLinkModal } from "./pair-link-modal";
|
||||||
import { NameHostModal } from "./name-host-modal";
|
|
||||||
import { resolveAppVersion } from "@/utils/app-version";
|
import { resolveAppVersion } from "@/utils/app-version";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
type WelcomeAction = {
|
type WelcomeAction = {
|
||||||
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
|
||||||
@@ -237,41 +235,19 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { renameHost } = useHostMutations();
|
|
||||||
const appVersion = resolveAppVersion();
|
const appVersion = resolveAppVersion();
|
||||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
|
||||||
serverId: string;
|
|
||||||
hostname: string | null;
|
|
||||||
} | null>(null);
|
|
||||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||||
const pendingNameHostname = useSessionStore(
|
|
||||||
useCallback(
|
|
||||||
(state) => {
|
|
||||||
if (!pendingNameHost) return null;
|
|
||||||
return (
|
|
||||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
|
||||||
pendingNameHost.hostname ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[pendingNameHost],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!anyOnlineServerId) {
|
if (!anyOnlineServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pendingNameHost) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(buildHostRootRoute(anyOnlineServerId));
|
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
}, [anyOnlineServerId, router]);
|
||||||
|
|
||||||
const finishOnboarding = useCallback(
|
const finishOnboarding = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
@@ -280,52 +256,51 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const actions: WelcomeAction[] =
|
const actions: WelcomeAction[] = isWeb
|
||||||
Platform.OS === "web"
|
? [
|
||||||
? [
|
{
|
||||||
{
|
key: "direct-connection",
|
||||||
key: "direct-connection",
|
label: "Direct connection",
|
||||||
label: "Direct connection",
|
testID: "welcome-direct-connection",
|
||||||
testID: "welcome-direct-connection",
|
primary: true,
|
||||||
primary: true,
|
icon: Link2,
|
||||||
icon: Link2,
|
onPress: () => setIsDirectOpen(true),
|
||||||
onPress: () => setIsDirectOpen(true),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "paste-pairing-link",
|
||||||
key: "paste-pairing-link",
|
label: "Paste pairing link",
|
||||||
label: "Paste pairing link",
|
testID: "welcome-paste-pairing-link",
|
||||||
testID: "welcome-paste-pairing-link",
|
primary: false,
|
||||||
primary: false,
|
icon: ClipboardPaste,
|
||||||
icon: ClipboardPaste,
|
onPress: () => setIsPasteLinkOpen(true),
|
||||||
onPress: () => setIsPasteLinkOpen(true),
|
},
|
||||||
},
|
]
|
||||||
]
|
: [
|
||||||
: [
|
{
|
||||||
{
|
key: "scan-qr",
|
||||||
key: "scan-qr",
|
label: "Scan QR code",
|
||||||
label: "Scan QR code",
|
testID: "welcome-scan-qr",
|
||||||
testID: "welcome-scan-qr",
|
primary: true,
|
||||||
primary: true,
|
icon: QrCode,
|
||||||
icon: QrCode,
|
onPress: () => router.push("/pair-scan?source=onboarding"),
|
||||||
onPress: () => router.push("/pair-scan?source=onboarding"),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "direct-connection",
|
||||||
key: "direct-connection",
|
label: "Direct connection",
|
||||||
label: "Direct connection",
|
testID: "welcome-direct-connection",
|
||||||
testID: "welcome-direct-connection",
|
primary: false,
|
||||||
primary: false,
|
icon: Link2,
|
||||||
icon: Link2,
|
onPress: () => setIsDirectOpen(true),
|
||||||
onPress: () => setIsDirectOpen(true),
|
},
|
||||||
},
|
{
|
||||||
{
|
key: "paste-pairing-link",
|
||||||
key: "paste-pairing-link",
|
label: "Paste pairing link",
|
||||||
label: "Paste pairing link",
|
testID: "welcome-paste-pairing-link",
|
||||||
testID: "welcome-paste-pairing-link",
|
primary: false,
|
||||||
primary: false,
|
icon: ClipboardPaste,
|
||||||
icon: ClipboardPaste,
|
onPress: () => setIsPasteLinkOpen(true),
|
||||||
onPress: () => setIsPasteLinkOpen(true),
|
},
|
||||||
},
|
];
|
||||||
];
|
|
||||||
|
|
||||||
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
const showHostList = hosts.length > 0 && !anyOnlineServerId;
|
||||||
|
|
||||||
@@ -346,15 +321,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
{showHostList ? "Connecting to your hosts…" : "Connect to your host to start"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!showHostList && Platform.OS !== "web" && (
|
{!showHostList && isNative && (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.setupHint}>
|
<Text style={styles.setupHint}>
|
||||||
You need the Paseo desktop app or server running on your computer first.
|
You need the Paseo desktop app or server running on your computer first.
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable style={styles.setupLink} onPress={() => openExternalUrl("https://paseo.sh")}>
|
||||||
style={styles.setupLink}
|
|
||||||
onPress={() => openExternalUrl("https://paseo.sh")}
|
|
||||||
>
|
|
||||||
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
|
<Text style={styles.setupLinkText}>Get started at paseo.sh</Text>
|
||||||
<ExternalLink size={14} color={theme.colors.accent} />
|
<ExternalLink size={14} color={theme.colors.accent} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -396,13 +368,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
<AddHostModal
|
<AddHostModal
|
||||||
visible={isDirectOpen}
|
visible={isDirectOpen}
|
||||||
onClose={() => setIsDirectOpen(false)}
|
onClose={() => setIsDirectOpen(false)}
|
||||||
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
onSaved={({ profile, serverId }) => {
|
||||||
onHostAdded?.(profile);
|
onHostAdded?.(profile);
|
||||||
setPendingRedirectServerId(serverId);
|
|
||||||
if (isNewHost) {
|
|
||||||
setPendingNameHost({ serverId, hostname });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finishOnboarding(serverId);
|
finishOnboarding(serverId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -410,38 +377,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
<PairLinkModal
|
<PairLinkModal
|
||||||
visible={isPasteLinkOpen}
|
visible={isPasteLinkOpen}
|
||||||
onClose={() => setIsPasteLinkOpen(false)}
|
onClose={() => setIsPasteLinkOpen(false)}
|
||||||
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
onSaved={({ profile, serverId }) => {
|
||||||
onHostAdded?.(profile);
|
onHostAdded?.(profile);
|
||||||
setPendingRedirectServerId(serverId);
|
|
||||||
if (isNewHost) {
|
|
||||||
setPendingNameHost({ serverId, hostname });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finishOnboarding(serverId);
|
finishOnboarding(serverId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{pendingNameHost && pendingRedirectServerId ? (
|
|
||||||
<NameHostModal
|
|
||||||
visible
|
|
||||||
serverId={pendingNameHost.serverId}
|
|
||||||
hostname={pendingNameHostname}
|
|
||||||
onSkip={() => {
|
|
||||||
const serverId = pendingRedirectServerId;
|
|
||||||
setPendingNameHost(null);
|
|
||||||
setPendingRedirectServerId(null);
|
|
||||||
finishOnboarding(serverId);
|
|
||||||
}}
|
|
||||||
onSave={(label) => {
|
|
||||||
const serverId = pendingRedirectServerId;
|
|
||||||
void renameHost(pendingNameHost.serverId, label).finally(() => {
|
|
||||||
setPendingNameHost(null);
|
|
||||||
setPendingRedirectServerId(null);
|
|
||||||
finishOnboarding(serverId);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
import { useUnistyles } from "react-native-unistyles";
|
||||||
import { UnistylesRuntime } from "react-native-unistyles";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
|
||||||
|
|
||||||
export const FOOTER_HEIGHT = 75;
|
export const FOOTER_HEIGHT = 75;
|
||||||
|
|
||||||
@@ -24,59 +23,23 @@ export const DESKTOP_TRAFFIC_LIGHT_HEIGHT = 45;
|
|||||||
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
export const DESKTOP_WINDOW_CONTROLS_WIDTH = 140;
|
||||||
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
export const DESKTOP_WINDOW_CONTROLS_HEIGHT = 48;
|
||||||
|
|
||||||
// Check if running in the Electron desktop runtime (any OS)
|
export {
|
||||||
function isElectronDesktopRuntime(): boolean {
|
getIsElectron as getIsElectronRuntime,
|
||||||
if (Platform.OS !== "web") return false;
|
getIsElectronMac as getIsElectronRuntimeMac,
|
||||||
return isElectronRuntime();
|
} from "./platform";
|
||||||
}
|
|
||||||
|
|
||||||
// Check if running in the Electron desktop runtime on macOS
|
/**
|
||||||
function isElectronDesktopRuntimeMac(): boolean {
|
* Reactive hook — re-renders the component when the breakpoint changes.
|
||||||
if (Platform.OS !== "web") return false;
|
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
|
||||||
return isElectronRuntimeMac();
|
*/
|
||||||
}
|
export function useIsCompactFormFactor(): boolean {
|
||||||
|
const { rt } = useUnistyles();
|
||||||
// Cached result - only cache true, keep checking if false (in case desktop globals load later)
|
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
|
||||||
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 function isCompactFormFactor(): boolean {
|
|
||||||
return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDesktopFormFactor(): boolean {
|
|
||||||
return !isCompactFormFactor();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTouchDesktopFormFactor(): boolean {
|
|
||||||
return Platform.OS !== "web" && isDesktopFormFactor();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
|
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
|
||||||
// Keep that capability distinct from desktop-width layout so touch tablets
|
// Keep that capability distinct from desktop-width layout so touch tablets
|
||||||
// can use the desktop shell without entering web-only code paths.
|
// can use the desktop shell without entering web-only code paths.
|
||||||
export function supportsDesktopPaneSplits(): boolean {
|
export function supportsDesktopPaneSplits(): boolean {
|
||||||
return Platform.OS === "web";
|
return isWeb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re
|
|||||||
import { useWindowDimensions } from "react-native";
|
import { useWindowDimensions } from "react-native";
|
||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getRightSidebarAnimationTargets,
|
getRightSidebarAnimationTargets,
|
||||||
@@ -29,7 +29,7 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
|||||||
|
|
||||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = isCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
|
|
||||||
@@ -91,13 +91,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
|
|
||||||
translateX.value = targets.translateX;
|
translateX.value = targets.translateX;
|
||||||
backdropOpacity.value = targets.backdropOpacity;
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [
|
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
||||||
isOpen,
|
|
||||||
translateX,
|
|
||||||
backdropOpacity,
|
|
||||||
windowWidth,
|
|
||||||
isGesturing,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const animateToOpen = () => {
|
const animateToOpen = () => {
|
||||||
"worklet";
|
"worklet";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
import { AppState, Platform } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useClientActivity } from "@/hooks/use-client-activity";
|
import { useClientActivity } from "@/hooks/use-client-activity";
|
||||||
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
|
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 { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
// Re-export types from session-store and draft-store for backward compatibility
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
@@ -547,7 +548,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
(awayMs: number) => {
|
(awayMs: number) => {
|
||||||
scheduleAuthoritativeRevalidation();
|
scheduleAuthoritativeRevalidation();
|
||||||
|
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
const agentId = session?.focusedAgentId;
|
const agentId = session?.focusedAgentId;
|
||||||
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
|
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { useWindowDimensions } from "react-native";
|
import { useWindowDimensions } from "react-native";
|
||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { isCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getLeftSidebarAnimationTargets,
|
getLeftSidebarAnimationTargets,
|
||||||
@@ -36,7 +36,7 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
|||||||
|
|
||||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = isCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
import { ActivityIndicator, Alert, Text, View } from "react-native";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { useFocusEffect } from "@react-navigation/native";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import {
|
import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native";
|
||||||
ArrowUpRight,
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
RotateCw,
|
|
||||||
Copy,
|
|
||||||
FileText,
|
|
||||||
Activity,
|
|
||||||
} from "lucide-react-native";
|
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
@@ -21,15 +12,12 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||||
import {
|
import {
|
||||||
getCliDaemonStatus,
|
getCliDaemonStatus,
|
||||||
getDesktopDaemonLogs,
|
|
||||||
getDesktopDaemonStatus,
|
|
||||||
restartDesktopDaemon,
|
restartDesktopDaemon,
|
||||||
shouldUseDesktopDaemon,
|
shouldUseDesktopDaemon,
|
||||||
startDesktopDaemon,
|
startDesktopDaemon,
|
||||||
stopDesktopDaemon,
|
stopDesktopDaemon,
|
||||||
type DesktopDaemonLogs,
|
|
||||||
type DesktopDaemonStatus,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
|
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||||
|
|
||||||
export interface LocalDaemonSectionProps {
|
export interface LocalDaemonSectionProps {
|
||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
@@ -40,44 +28,18 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const showSection = shouldUseDesktopDaemon();
|
const showSection = shouldUseDesktopDaemon();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
|
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
|
||||||
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
|
||||||
const [statusError, setStatusError] = useState<string | null>(null);
|
|
||||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||||
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
|
|
||||||
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
|
||||||
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
|
||||||
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
|
||||||
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
|
||||||
|
|
||||||
const loadDaemonData = useCallback(() => {
|
const daemonStatus = data?.status ?? null;
|
||||||
if (!showSection) {
|
const daemonLogs = data?.logs ?? null;
|
||||||
return Promise.resolve();
|
const daemonVersion = daemonStatus?.version ?? null;
|
||||||
}
|
|
||||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
|
|
||||||
.then(([status, logs]) => {
|
|
||||||
setDaemonStatus(status);
|
|
||||||
setDaemonLogs(logs);
|
|
||||||
setDaemonVersion(status.version);
|
|
||||||
setStatusError(null);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
setStatusError(message);
|
|
||||||
});
|
|
||||||
}, [showSection]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
if (!showSection) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
void loadDaemonData();
|
|
||||||
return undefined;
|
|
||||||
}, [loadDaemonData, showSection]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||||
const daemonStatusStateText =
|
const daemonStatusStateText =
|
||||||
@@ -116,12 +78,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
|
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
|
||||||
|
|
||||||
void action()
|
void action()
|
||||||
.then((status) => {
|
.then((newStatus) => {
|
||||||
setDaemonStatus(status);
|
setStatus(newStatus);
|
||||||
setStatusMessage(
|
setStatusMessage(
|
||||||
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
|
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
|
||||||
);
|
);
|
||||||
return loadDaemonData();
|
refetch();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("[Settings] Failed to change desktop daemon state", error);
|
console.error("[Settings] Failed to change desktop daemon state", error);
|
||||||
@@ -136,7 +98,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
|
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
|
||||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||||
});
|
});
|
||||||
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
|
}, [
|
||||||
|
daemonActionLabel,
|
||||||
|
daemonStatus?.status,
|
||||||
|
isRestartingDaemon,
|
||||||
|
refetch,
|
||||||
|
setStatus,
|
||||||
|
showSection,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggleDaemonManagement = useCallback(() => {
|
const handleToggleDaemonManagement = useCallback(() => {
|
||||||
if (isUpdatingDaemonManagement) {
|
if (isUpdatingDaemonManagement) {
|
||||||
@@ -182,9 +151,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
: Promise.resolve(daemonStatus ?? null);
|
: Promise.resolve(daemonStatus ?? null);
|
||||||
|
|
||||||
void stopPromise
|
void stopPromise
|
||||||
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
.then((newStatus) => {
|
||||||
.then(() => loadDaemonData())
|
if (newStatus) {
|
||||||
|
setStatus(newStatus);
|
||||||
|
}
|
||||||
|
return updateSettings({ manageBuiltInDaemon: false });
|
||||||
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
refetch();
|
||||||
setStatusMessage("Built-in daemon paused and stopped.");
|
setStatusMessage("Built-in daemon paused and stopped.");
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -202,7 +176,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
}, [
|
}, [
|
||||||
daemonStatus,
|
daemonStatus,
|
||||||
isUpdatingDaemonManagement,
|
isUpdatingDaemonManagement,
|
||||||
loadDaemonData,
|
refetch,
|
||||||
|
setStatus,
|
||||||
settings.manageBuiltInDaemon,
|
settings.manageBuiltInDaemon,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
]);
|
]);
|
||||||
@@ -277,126 +252,138 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
Advanced settings
|
Advanced settings
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View style={settingsStyles.card}>
|
{isLoading ? (
|
||||||
<View style={settingsStyles.row}>
|
<View style={[settingsStyles.card, styles.loadingCard]}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statusValueGroup}>
|
|
||||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
|
||||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
{showLifecycleControls ? (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<View style={settingsStyles.card}>
|
||||||
|
<View style={settingsStyles.row}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
Only the built-in desktop daemon is shown here.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.statusValueGroup}>
|
||||||
|
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||||
|
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{showLifecycleControls ? (
|
||||||
|
<>
|
||||||
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
{isDaemonManagementPaused
|
||||||
|
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||||
|
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={
|
||||||
|
isDaemonManagementPaused ? (
|
||||||
|
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||||
|
) : (
|
||||||
|
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onPress={handleToggleDaemonManagement}
|
||||||
|
disabled={isUpdatingDaemonManagement}
|
||||||
|
>
|
||||||
|
{isUpdatingDaemonManagement
|
||||||
|
? isDaemonManagementPaused
|
||||||
|
? "Resuming..."
|
||||||
|
: "Pausing..."
|
||||||
|
: isDaemonManagementPaused
|
||||||
|
? "Resume"
|
||||||
|
: "Pause"}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||||
|
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
|
onPress={handleUpdateLocalDaemon}
|
||||||
|
disabled={isRestartingDaemon}
|
||||||
|
>
|
||||||
|
{isRestartingDaemon
|
||||||
|
? daemonStatus?.status === "running"
|
||||||
|
? "Restarting..."
|
||||||
|
: "Starting..."
|
||||||
|
: daemonActionLabel}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
{isDaemonManagementPaused
|
{daemonLogs?.logPath ?? "Log path unavailable."}
|
||||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
</Text>
|
||||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
</View>
|
||||||
|
<View style={styles.actionGroup}>
|
||||||
|
{daemonLogs?.logPath ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
|
onPress={handleCopyLogPath}
|
||||||
|
>
|
||||||
|
Copy path
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
|
onPress={handleOpenLogs}
|
||||||
|
disabled={!daemonLogs}
|
||||||
|
>
|
||||||
|
Open logs
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||||
|
<View style={settingsStyles.rowContent}>
|
||||||
|
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||||
|
<Text style={settingsStyles.rowHint}>
|
||||||
|
Runs `paseo daemon status` and shows the output.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={
|
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||||
isDaemonManagementPaused ? (
|
onPress={() => void handleOpenCliStatus()}
|
||||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
disabled={isLoadingCliStatus}
|
||||||
) : (
|
|
||||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onPress={handleToggleDaemonManagement}
|
|
||||||
disabled={isUpdatingDaemonManagement}
|
|
||||||
>
|
>
|
||||||
{isUpdatingDaemonManagement
|
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||||
? isDaemonManagementPaused
|
|
||||||
? "Resuming..."
|
|
||||||
: "Pausing..."
|
|
||||||
: isDaemonManagementPaused
|
|
||||||
? "Resume"
|
|
||||||
: "Pause"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
|
||||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleUpdateLocalDaemon}
|
|
||||||
disabled={isRestartingDaemon}
|
|
||||||
>
|
|
||||||
{isRestartingDaemon
|
|
||||||
? daemonStatus?.status === "running"
|
|
||||||
? "Restarting..."
|
|
||||||
: "Starting..."
|
|
||||||
: daemonActionLabel}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actionGroup}>
|
|
||||||
{daemonLogs?.logPath ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleCopyLogPath}
|
|
||||||
>
|
|
||||||
Copy path
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
|
||||||
onPress={handleOpenLogs}
|
|
||||||
disabled={!daemonLogs}
|
|
||||||
>
|
|
||||||
Open logs
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
|
||||||
<View style={settingsStyles.rowContent}>
|
|
||||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
|
||||||
<Text style={settingsStyles.rowHint}>
|
|
||||||
Runs `paseo daemon status` and shows the output.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
|
||||||
onPress={() => void handleOpenCliStatus()}
|
|
||||||
disabled={isLoadingCliStatus}
|
|
||||||
>
|
|
||||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{daemonVersionMismatch ? (
|
{daemonVersionMismatch ? (
|
||||||
<View style={styles.warningCard}>
|
<View style={styles.warningCard}>
|
||||||
<Text style={styles.warningText}>
|
<Text style={styles.warningText}>
|
||||||
App and daemon versions don't match. Update both to the same version for the best
|
App and daemon versions don't match. Update both to the same version for the best
|
||||||
experience.
|
experience.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
visible={isLogsModalOpen}
|
visible={isLogsModalOpen}
|
||||||
@@ -406,7 +393,9 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
|||||||
snapPoints={["70%", "92%"]}
|
snapPoints={["70%", "92%"]}
|
||||||
>
|
>
|
||||||
<View style={styles.modalBody}>
|
<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>
|
<Text style={styles.logOutput} selectable>
|
||||||
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
{daemonLogs?.contents.length ? daemonLogs.contents : "(log file is empty)"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -447,6 +436,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
justifyContent: "flex-end",
|
justifyContent: "flex-end",
|
||||||
},
|
},
|
||||||
|
loadingCard: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingVertical: theme.spacing[6],
|
||||||
|
},
|
||||||
statusValueGroup: {
|
statusValueGroup: {
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ export function IntegrationsSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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}
|
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||||
style={settingsStyles.sectionHeaderLink}
|
style={settingsStyles.sectionHeaderLink}
|
||||||
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
|
onPress={() => void openExternalUrl(CLI_DOCS_URL)}
|
||||||
@@ -98,7 +100,9 @@ export function IntegrationsSection() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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}
|
textStyle={settingsStyles.sectionHeaderLinkText}
|
||||||
style={settingsStyles.sectionHeaderLink}
|
style={settingsStyles.sectionHeaderLink}
|
||||||
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
|
onPress={() => void openExternalUrl(SKILLS_DOCS_URL)}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getDesktopDaemonLogs,
|
||||||
|
getDesktopDaemonStatus,
|
||||||
|
shouldUseDesktopDaemon,
|
||||||
|
type DesktopDaemonLogs,
|
||||||
|
type DesktopDaemonStatus,
|
||||||
|
} from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
|
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
|
||||||
|
|
||||||
|
interface DaemonStatusData {
|
||||||
|
status: DesktopDaemonStatus;
|
||||||
|
logs: DesktopDaemonLogs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDaemonStatus() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const enabled = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
|
const query = useQuery<DaemonStatusData>({
|
||||||
|
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||||
|
enabled,
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
queryFn: async () => {
|
||||||
|
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||||
|
return { status, logs };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setStatus = useCallback(
|
||||||
|
(status: DesktopDaemonStatus) => {
|
||||||
|
queryClient.setQueryData<DaemonStatusData>(DAEMON_STATUS_QUERY_KEY, (prev) =>
|
||||||
|
prev ? { ...prev, status } : undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: DAEMON_STATUS_QUERY_KEY });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: query.data ?? null,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
error: query.error instanceof Error ? query.error.message : null,
|
||||||
|
setStatus,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,10 +38,7 @@ export interface DesktopOpenerBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopMenuBridge {
|
export interface DesktopMenuBridge {
|
||||||
showContextMenu?: (input?: {
|
showContextMenu?: (input?: { kind?: "terminal"; hasSelection?: boolean }) => Promise<void>;
|
||||||
kind?: "terminal";
|
|
||||||
hasSelection?: boolean;
|
|
||||||
}) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopWindowControlsOverlayUpdate {
|
export interface DesktopWindowControlsOverlayUpdate {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ const originalGlobals: GlobalSnapshot = {
|
|||||||
Notification: (globalThis as { Notification?: unknown }).Notification,
|
Notification: (globalThis as { Notification?: unknown }).Notification,
|
||||||
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
|
navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
|
||||||
paseoDesktop:
|
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 {
|
function setNavigator(value: unknown): void {
|
||||||
@@ -32,8 +34,8 @@ function restoreGlobals(): void {
|
|||||||
delete (globalThis as { navigator?: unknown }).navigator;
|
delete (globalThis as { navigator?: unknown }).navigator;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof globalThis.window !== "undefined") {
|
||||||
(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop;
|
(globalThis.window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +58,7 @@ describe("desktop-permissions", () => {
|
|||||||
|
|
||||||
expect(shouldShowDesktopPermissionSection()).toBe(false);
|
expect(shouldShowDesktopPermissionSection()).toBe(false);
|
||||||
|
|
||||||
(window as { paseoDesktop?: unknown }).paseoDesktop = {};
|
globalThis.window = { paseoDesktop: {} } as unknown as Window & typeof globalThis;
|
||||||
expect(shouldShowDesktopPermissionSection()).toBe(true);
|
expect(shouldShowDesktopPermissionSection()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
export type DesktopPermissionKind = "notifications" | "microphone";
|
export type DesktopPermissionKind = "notifications" | "microphone";
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ type NavigatorLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function shouldShowDesktopPermissionSection(): boolean {
|
export function shouldShowDesktopPermissionSection(): boolean {
|
||||||
return Platform.OS === "web" && getDesktopHost() !== null;
|
return isWeb && getDesktopHost() !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
function status(input: DesktopPermissionStatus): DesktopPermissionStatus {
|
||||||
@@ -83,7 +83,7 @@ function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
|
const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification;
|
||||||
@@ -97,7 +97,7 @@ function getWebNotificationConstructor(): NotificationConstructorLike | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getNavigatorLike(): NavigatorLike | null {
|
function getNavigatorLike(): NavigatorLike | null {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const webNavigator = (globalThis as { navigator?: unknown }).navigator;
|
const webNavigator = (globalThis as { navigator?: unknown }).navigator;
|
||||||
@@ -133,7 +133,7 @@ function mapNotificationPermissionString(permission: string): DesktopPermissionS
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return status({
|
return status({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
detail: "Desktop notification status is only available on web runtime.",
|
detail: "Desktop notification status is only available on web runtime.",
|
||||||
@@ -167,7 +167,7 @@ async function getNotificationPermissionStatus(): Promise<DesktopPermissionStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return status({
|
return status({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
detail: "Desktop microphone status is only available on web runtime.",
|
detail: "Desktop microphone status is only available on web runtime.",
|
||||||
@@ -237,7 +237,7 @@ async function getMicrophonePermissionStatus(): Promise<DesktopPermissionStatus>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function requestNotificationPermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return status({
|
return status({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
detail: "Desktop notification requests are only available on web runtime.",
|
detail: "Desktop notification requests are only available on web runtime.",
|
||||||
@@ -264,7 +264,7 @@ async function requestNotificationPermissionStatus(): Promise<DesktopPermissionS
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
async function requestMicrophonePermissionStatus(): Promise<DesktopPermissionStatus> {
|
||||||
if (Platform.OS !== "web") {
|
if (isNative) {
|
||||||
return status({
|
return status({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
detail: "Desktop microphone requests are only available on web runtime.",
|
detail: "Desktop microphone requests are only available on web runtime.",
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
|||||||
body: "If you can see this, desktop notifications work.",
|
body: "If you can see this, desktop notifications work.",
|
||||||
});
|
});
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
setTestNotificationError("Notification was not delivered. Check System Settings > Notifications.");
|
setTestNotificationError(
|
||||||
|
"Notification was not delivered. Check System Settings > Notifications.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setTestNotificationError("Failed to send notification.");
|
setTestNotificationError("Failed to send notification.");
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Platform } from "react-native";
|
|
||||||
import { isElectronRuntime } from "@/desktop/host";
|
import { isElectronRuntime } from "@/desktop/host";
|
||||||
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
import { invokeDesktopCommand } from "@/desktop/electron/invoke";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
export interface DesktopAppUpdateCheckResult {
|
export interface DesktopAppUpdateCheckResult {
|
||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
|
readyToInstall: boolean;
|
||||||
currentVersion: string | null;
|
currentVersion: string | null;
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
body: string | null;
|
body: string | null;
|
||||||
@@ -49,7 +50,7 @@ function toNumberOr(defaultValue: number, value: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldShowDesktopUpdateSection(): boolean {
|
export function shouldShowDesktopUpdateSection(): boolean {
|
||||||
return Platform.OS === "web" && isElectronRuntime();
|
return isWeb && isElectronRuntime();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult {
|
||||||
@@ -76,6 +77,7 @@ export async function checkDesktopAppUpdate(): Promise<DesktopAppUpdateCheckResu
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hasUpdate: result.hasUpdate === true,
|
hasUpdate: result.hasUpdate === true,
|
||||||
|
readyToInstall: result.readyToInstall === true,
|
||||||
currentVersion: toStringOrNull(result.currentVersion),
|
currentVersion: toStringOrNull(result.currentVersion),
|
||||||
latestVersion: toStringOrNull(result.latestVersion),
|
latestVersion: toStringOrNull(result.latestVersion),
|
||||||
body: toStringOrNull(result.body),
|
body: toStringOrNull(result.body),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user