mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
Compare commits
173
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 | ||
|
|
6bf8da8087 | ||
|
|
d39414064e | ||
|
|
ffcc35485d | ||
|
|
8ff94dc03e | ||
|
|
dace4f862f | ||
|
|
fc667d6312 | ||
|
|
aae4d9f8dd | ||
|
|
5eb8b300a3 | ||
|
|
6c9a832906 | ||
|
|
35430dab52 | ||
|
|
0eac4bc4b3 | ||
|
|
635de3d76a | ||
|
|
931d3ba81f | ||
|
|
6a4f439541 | ||
|
|
ac5e6df6c9 | ||
|
|
bf7d3c2775 | ||
|
|
5c93fbc955 | ||
|
|
5ac7b3f7c7 | ||
|
|
c742f17080 | ||
|
|
b4d6a5d6b8 | ||
|
|
43f01600ce | ||
|
|
e7cf9ee69d | ||
|
|
9c8b0c3aca | ||
|
|
8088c39fd6 | ||
|
|
1279f1d556 | ||
|
|
0defbc1dc3 | ||
|
|
76c6253ae0 | ||
|
|
5ec25687cd | ||
|
|
33a1557aed | ||
|
|
cbc2ce06e9 | ||
|
|
82466aaa9f | ||
|
|
d3e3a83a0d | ||
|
|
ee611d65b6 | ||
|
|
5ce4562eed | ||
|
|
21c7761403 | ||
|
|
682fc54778 | ||
|
|
e06b691d5d | ||
|
|
022eb33234 | ||
|
|
161b2c2378 | ||
|
|
52dfdb1913 | ||
|
|
bdaa6b65aa | ||
|
|
1900f43049 | ||
|
|
29b6f2a86f | ||
|
|
638c208609 | ||
|
|
7b1144dafe | ||
|
|
940bc6243b | ||
|
|
51b83768c7 | ||
|
|
cdbaa8d29c | ||
|
|
b2229a28b9 | ||
|
|
d888c8f126 | ||
|
|
102ef06c30 | ||
|
|
5cb424b2e6 | ||
|
|
fd9dfb0cc8 | ||
|
|
03380cfad0 | ||
|
|
6ce0e1e91f | ||
|
|
64c2515b94 | ||
|
|
e90241c445 | ||
|
|
06fbeb413b | ||
|
|
390a3402ab | ||
|
|
27ddc95862 | ||
|
|
c63240b18c | ||
|
|
a5aca2312b | ||
|
|
e01a0abdf2 | ||
|
|
3397e6c589 |
@@ -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'
|
||||||
@@ -35,8 +35,43 @@ env:
|
|||||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
create-release:
|
||||||
|
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
sparse-checkout: scripts
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||||
|
|
||||||
|
- name: Resolve release metadata
|
||||||
|
shell: bash
|
||||||
|
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||||
|
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||||
|
else
|
||||||
|
prerelease_flag=""
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
prerelease_flag="--prerelease"
|
||||||
|
fi
|
||||||
|
gh release create "$RELEASE_TAG" \
|
||||||
|
--repo "${{ github.repository }}" \
|
||||||
|
--title "Paseo $RELEASE_TAG" \
|
||||||
|
--generate-notes \
|
||||||
|
$prerelease_flag
|
||||||
|
fi
|
||||||
|
|
||||||
publish-macos:
|
publish-macos:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -219,7 +254,8 @@ jobs:
|
|||||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
publish-linux:
|
publish-linux:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
@@ -285,7 +321,8 @@ jobs:
|
|||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
|
|
||||||
publish-windows:
|
publish-windows:
|
||||||
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
|
needs: [create-release]
|
||||||
|
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
|
|||||||
+103
@@ -1,5 +1,108 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
||||||
|
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
||||||
|
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
||||||
|
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
|
||||||
|
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
|
||||||
|
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Workspaces load instantly on connect instead of waiting for a full sync.
|
||||||
|
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
||||||
|
- Closing a workspace tab is now instant.
|
||||||
|
- Settings shows a Refresh button for providers and displays error details inline.
|
||||||
|
- Reload agent moved away from the close button to prevent accidental taps.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Voice mode no longer drifts into false speech detection during long sessions.
|
||||||
|
- Garbled overlapping text on plan cards.
|
||||||
|
- Changes pane could show stale diffs when working with git worktrees.
|
||||||
|
- Restarting an agent quickly could crash the session.
|
||||||
|
- Copilot no longer pauses for permission prompts in autopilot mode.
|
||||||
|
- Connection and pairing dialogs now display correctly on tablets.
|
||||||
|
- Orchestration errors from agents are now surfaced instead of silently lost.
|
||||||
|
- Diff stats no longer reset to zero when reconnecting.
|
||||||
|
|
||||||
|
## 0.1.49 - 2026-04-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
||||||
|
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
||||||
|
- Model lists stay consistent regardless of which screen you open first.
|
||||||
|
|
||||||
## 0.1.48 - 2026-04-05
|
## 0.1.48 - 2026-04-05
|
||||||
|
|
||||||
### 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,13 +47,54 @@ 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.**
|
||||||
- **NEVER make breaking changes to WebSocket or message schemas.** The mobile app in the App Store always lags behind the daemon, and daemons in the wild lag behind new app releases. Both directions must work. Every schema change MUST be backward-compatible:
|
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||||
|
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
- 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.
|
||||||
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||||
- 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 |
|
||||||
+20
-1
@@ -11,6 +11,12 @@ There are two supported ways to ship from `main`:
|
|||||||
|
|
||||||
## Standard release (patch)
|
## Standard release (patch)
|
||||||
|
|
||||||
|
Before running any stable patch release command:
|
||||||
|
|
||||||
|
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||||
|
- Make sure local `npm run typecheck` passes on that commit.
|
||||||
|
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run release:patch
|
npm run release:patch
|
||||||
```
|
```
|
||||||
@@ -24,6 +30,7 @@ Use the direct stable path when the current `main` changes are ready to become t
|
|||||||
## Manual step-by-step
|
## Manual step-by-step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run typecheck # Verify the exact commit you intend to release
|
||||||
npm run release:check # Typecheck, build, dry-run pack
|
npm run release:check # Typecheck, build, dry-run pack
|
||||||
npm run version:all:patch # Bump version, create commit + tag
|
npm run version:all:patch # Bump version, create commit + tag
|
||||||
npm run release:publish # Publish to npm
|
npm run release:publish # Publish to npm
|
||||||
@@ -122,6 +129,16 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
|
|||||||
- **Only Claude should write changelog entries.**
|
- **Only Claude should write changelog entries.**
|
||||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||||
|
|
||||||
|
## Changelog voice
|
||||||
|
|
||||||
|
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||||
|
|
||||||
|
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||||
|
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||||
|
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||||
|
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||||
|
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||||
|
|
||||||
## Pre-release sanity check
|
## Pre-release sanity check
|
||||||
|
|
||||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||||
@@ -131,7 +148,7 @@ Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
|||||||
> Review the diff between the latest release tag and HEAD. Focus on:
|
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||||
>
|
>
|
||||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||||
> 2. **Backward compatibility** — mobile apps lag behind desktop/daemon updates by days. Users will update desktop and daemon immediately but keep running the old app. Flag anything that requires both sides to update in lockstep.
|
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||||
>
|
>
|
||||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||||
@@ -150,6 +167,8 @@ In other words, RCs are checkpoints along the way; the changelog only records th
|
|||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||||
|
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||||
|
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Plan Approval Normalization
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
|
||||||
|
|
||||||
|
## Compatibility Constraints
|
||||||
|
|
||||||
|
- Older clients must remain compatible with newer daemons.
|
||||||
|
- All new wire fields must be optional.
|
||||||
|
- Existing plan permissions without action metadata must still render and work.
|
||||||
|
- Existing question permissions must keep their current behavior.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Shared abstraction
|
||||||
|
|
||||||
|
Add optional permission action definitions to the shared permission request/response types.
|
||||||
|
|
||||||
|
- Permission requests may include `actions`.
|
||||||
|
- Permission responses may include `selectedActionId`.
|
||||||
|
- `kind: "plan"` remains the normalized concept for plan approval.
|
||||||
|
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
|
||||||
|
|
||||||
|
### Claude
|
||||||
|
|
||||||
|
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
|
||||||
|
|
||||||
|
- Always expose `Reject`.
|
||||||
|
- Always expose `Implement`.
|
||||||
|
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
|
||||||
|
- Resolve the selected action entirely inside `respondToPermission()`.
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
|
||||||
|
|
||||||
|
- Emit a plan permission with `Reject` and `Implement` actions.
|
||||||
|
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
|
||||||
|
- On `Reject`, resolve without starting a follow-up turn.
|
||||||
|
- Keep the implementation prompt and state transitions inside the Codex provider.
|
||||||
|
|
||||||
|
### Manager and state sync
|
||||||
|
|
||||||
|
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
|
||||||
|
|
||||||
|
- Refresh current mode
|
||||||
|
- Refresh pending permissions
|
||||||
|
- Refresh runtime info
|
||||||
|
- Refresh features
|
||||||
|
- Persist refreshed state
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
|
||||||
|
|
||||||
|
- If `actions` are absent, fall back to legacy buttons.
|
||||||
|
- Plan cards should use `Implement` as the default primary label.
|
||||||
|
- Do not add provider-specific rendering branches.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Shared schema/type tests for optional `actions` and `selectedActionId`
|
||||||
|
2. App tests for generic plan-action rendering
|
||||||
|
3. Claude tests for third action when resuming from a more permissive mode
|
||||||
|
4. Codex tests for synthetic plan approval and automatic implementation follow-up
|
||||||
|
5. Manager tests for post-permission state refresh
|
||||||
|
6. `npm run typecheck`
|
||||||
+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-ZLwOacIYQ6rUT2sYs3vNvOh7wTkwQ92cpOJDm29GLWs=";
|
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.48",
|
"version": "0.1.55-rc.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.48",
|
"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.48",
|
"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.48",
|
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.48",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@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.48",
|
"version": "0.1.55-rc.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.48",
|
"@getpaseo/relay": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@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.48",
|
"version": "0.1.55-rc.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.48",
|
"@getpaseo/cli": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@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.48",
|
"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.48",
|
"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.48",
|
"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.48",
|
"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.48",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/relay": "0.1.48",
|
"@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.48",
|
"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.48",
|
"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: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 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.48",
|
"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.48",
|
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||||
"@getpaseo/highlight": "0.1.48",
|
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||||
"@getpaseo/server": "0.1.48",
|
"@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,15 +384,53 @@ 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);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
|
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(() => {
|
||||||
|
const bp = UnistylesRuntime.breakpoint;
|
||||||
|
const screenW = UnistylesRuntime.screen.width;
|
||||||
|
const screenH = UnistylesRuntime.screen.height;
|
||||||
|
const isElectron = getIsElectronRuntime();
|
||||||
|
const windowW = isWeb ? window.innerWidth : undefined;
|
||||||
|
const windowH = isWeb ? window.innerHeight : undefined;
|
||||||
|
const dpr = isWeb ? window.devicePixelRatio : undefined;
|
||||||
|
const ua = isWeb ? navigator.userAgent : undefined;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[layout-debug]",
|
||||||
|
JSON.stringify({
|
||||||
|
breakpoint: bp,
|
||||||
|
isCompactLayout,
|
||||||
|
isElectron,
|
||||||
|
chromeEnabled,
|
||||||
|
isFocusModeEnabled,
|
||||||
|
agentListOpen,
|
||||||
|
sidebarWidth,
|
||||||
|
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
|
||||||
|
unistylesScreen: { w: screenW, h: screenH },
|
||||||
|
window: { w: windowW, h: windowH },
|
||||||
|
devicePixelRatio: dpr,
|
||||||
|
userAgent: ua,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
@@ -381,6 +439,7 @@ function AppContainer({
|
|||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
|
cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
@@ -530,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +633,7 @@ function OfferLinkListener({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const serverId = (profile as any)?.serverId;
|
const serverId = (profile as any)?.serverId;
|
||||||
if (typeof serverId !== "string" || !serverId) return;
|
if (typeof serverId !== "string" || !serverId) return;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -692,7 +751,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
if (hosts.some((host) => host.serverId === activeServerId)) {
|
if (hosts.some((host) => host.serverId === activeServerId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
|
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
|
||||||
}, [activeServerId, hosts, pathname, router]);
|
}, [activeServerId, hosts, pathname, router]);
|
||||||
|
|
||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
@@ -743,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;
|
||||||
@@ -58,7 +67,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||||
|
|
||||||
@@ -89,14 +98,14 @@ export default function HostAgentReadyRoute() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled || redirectedRef.current) {
|
if (cancelled || redirectedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -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()) {
|
||||||
@@ -68,11 +72,11 @@ export default function HostIndexRoute() {
|
|||||||
|
|
||||||
const primaryWorkspace = visibleWorkspaces[0];
|
const primaryWorkspace = visibleWorkspaces[0];
|
||||||
if (primaryWorkspace?.id?.trim()) {
|
if (primaryWorkspace?.id?.trim()) {
|
||||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace(buildHostOpenProjectRoute(serverId) as any);
|
router.replace(buildHostOpenProjectRoute(serverId));
|
||||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|||||||
@@ -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,10 +48,8 @@ export default function Index() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetRoute = anyOnlineServerId
|
const targetRoute = anyOnlineServerId ? buildHostRootRoute(anyOnlineServerId) : WELCOME_ROUTE;
|
||||||
? buildHostRootRoute(anyOnlineServerId)
|
router.replace(targetRoute);
|
||||||
: WELCOME_ROUTE;
|
|
||||||
router.replace(targetRoute as any);
|
|
||||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||||
|
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||||
|
|||||||
@@ -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,33 +147,16 @@ 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) => {
|
||||||
if (source === "onboarding") {
|
if (source === "onboarding") {
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source === "editHost" && targetServerId) {
|
if (source === "editHost" && targetServerId) {
|
||||||
@@ -190,7 +172,7 @@ export default function PairScanScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
const settingsServerId = sourceServerId ?? serverId;
|
const settingsServerId = sourceServerId ?? serverId;
|
||||||
router.replace(buildHostSettingsRoute(settingsServerId) as any);
|
router.replace(buildHostSettingsRoute(settingsServerId));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[router, source, sourceServerId, targetServerId],
|
[router, source, sourceServerId, targetServerId],
|
||||||
@@ -209,7 +191,7 @@ export default function PairScanScreen() {
|
|||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
if (sourceServerId) {
|
if (sourceServerId) {
|
||||||
router.replace(buildHostSettingsRoute(sourceServerId) as any);
|
router.replace(buildHostSettingsRoute(sourceServerId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace("/" as any);
|
router.replace("/" as any);
|
||||||
@@ -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…
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
|
|||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.replace(buildHostSettingsRoute(targetServerId) as any);
|
router.replace(buildHostSettingsRoute(targetServerId));
|
||||||
}, [router, targetServerId]);
|
}, [router, targetServerId]);
|
||||||
|
|
||||||
if (!targetServerId) {
|
if (!targetServerId) {
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -28,6 +30,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 520,
|
maxWidth: 520,
|
||||||
maxHeight: "85%",
|
maxHeight: "85%",
|
||||||
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.xl,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -54,11 +58,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
desktopScroll: {
|
desktopScroll: {
|
||||||
flex: 1,
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
desktopContent: {
|
desktopContent: {
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
bottomSheetHandle: {
|
bottomSheetHandle: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
@@ -115,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]);
|
||||||
@@ -211,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());
|
||||||
}
|
}
|
||||||
@@ -235,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";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
DraftAgentStatusBar,
|
DraftAgentStatusBar,
|
||||||
type DraftAgentStatusBarProps,
|
type DraftAgentStatusBarProps,
|
||||||
} from "./agent-status-bar";
|
} from "./agent-status-bar";
|
||||||
|
import { ContextWindowMeter } from "./context-window-meter";
|
||||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -47,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;
|
||||||
@@ -115,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);
|
||||||
@@ -130,11 +134,15 @@ 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;
|
||||||
return {
|
return {
|
||||||
status: agent?.status ?? null,
|
status: agent?.status ?? null,
|
||||||
|
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
|
||||||
|
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -148,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;
|
||||||
@@ -212,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: () => {
|
||||||
@@ -425,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;
|
||||||
}
|
}
|
||||||
@@ -591,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 = (
|
||||||
@@ -636,6 +642,23 @@ export function AgentInputArea({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasContextWindowMeter =
|
||||||
|
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||||
|
typeof agentState.contextWindowUsedTokens === "number";
|
||||||
|
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
|
||||||
|
const contextWindowUsedTokens = hasContextWindowMeter ? agentState.contextWindowUsedTokens : null;
|
||||||
|
|
||||||
|
const beforeVoiceContent = (
|
||||||
|
<View style={styles.contextWindowMeterSlot}>
|
||||||
|
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
|
||||||
|
<ContextWindowMeter
|
||||||
|
maxTokens={contextWindowMaxTokens}
|
||||||
|
usedTokens={contextWindowUsedTokens}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
const leftContent =
|
const leftContent =
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
@@ -715,10 +738,12 @@ export function AgentInputArea({
|
|||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isInputActive={isInputActive}
|
isInputActive={isInputActive}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
|
beforeVoiceContent={beforeVoiceContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
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}
|
||||||
@@ -790,6 +815,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
contextWindowMeterSlot: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
realtimeVoiceButton: {
|
realtimeVoiceButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -240,7 +241,7 @@ export function AgentList({
|
|||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route);
|
||||||
},
|
},
|
||||||
[isActionSheetVisible, onAgentSelect],
|
[isActionSheetVisible, onAgentSelect],
|
||||||
);
|
);
|
||||||
@@ -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";
|
||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
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,7 +51,7 @@ import {
|
|||||||
getStatusSelectorHint,
|
getStatusSelectorHint,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/components/agent-status-bar.utils";
|
} from "@/components/agent-status-bar.utils";
|
||||||
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -60,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[];
|
||||||
@@ -75,12 +71,13 @@ type ControlledAgentStatusBarProps = {
|
|||||||
modelOptions?: StatusOption[];
|
modelOptions?: StatusOption[];
|
||||||
selectedModelId?: string;
|
selectedModelId?: string;
|
||||||
onSelectModel?: (modelId: string) => void;
|
onSelectModel?: (modelId: string) => void;
|
||||||
|
onSelectProviderAndModel?: (provider: string, modelId: string) => void;
|
||||||
thinkingOptions?: StatusOption[];
|
thinkingOptions?: StatusOption[];
|
||||||
selectedThinkingOptionId?: string;
|
selectedThinkingOptionId?: string;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +202,7 @@ function ControlledStatusBar({
|
|||||||
modelOptions,
|
modelOptions,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
|
onSelectProviderAndModel,
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
@@ -216,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);
|
||||||
|
|
||||||
@@ -248,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);
|
||||||
@@ -264,7 +266,7 @@ function ControlledStatusBar({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0;
|
const modelDisabled = disabled;
|
||||||
|
|
||||||
const SEARCH_THRESHOLD = 6;
|
const SEARCH_THRESHOLD = 6;
|
||||||
|
|
||||||
@@ -296,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[]>(
|
||||||
@@ -317,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
|
||||||
@@ -329,7 +330,7 @@ function ControlledStatusBar({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[provider, theme.colors.foreground],
|
[provider, providerDefinitions, theme.colors.foreground],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
@@ -351,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 ? (
|
||||||
<>
|
<>
|
||||||
@@ -410,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>
|
||||||
@@ -648,25 +650,28 @@ function ControlledStatusBar({
|
|||||||
selectedModel={selectedModelId ?? ""}
|
selectedModel={selectedModelId ?? ""}
|
||||||
canSelectProvider={canSelectProviderInModelMenu}
|
canSelectProvider={canSelectProviderInModelMenu}
|
||||||
onSelect={(selectedProviderId, modelId) => {
|
onSelect={(selectedProviderId, modelId) => {
|
||||||
if (selectedProviderId !== provider) {
|
if (onSelectProviderAndModel) {
|
||||||
onSelectProvider?.(selectedProviderId);
|
onSelectProviderAndModel(selectedProviderId, modelId);
|
||||||
|
} else {
|
||||||
|
if (selectedProviderId !== provider) {
|
||||||
|
onSelectProvider?.(selectedProviderId);
|
||||||
|
}
|
||||||
|
onSelectModel?.(modelId);
|
||||||
}
|
}
|
||||||
onSelectModel?.(modelId);
|
|
||||||
}}
|
}}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
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"
|
||||||
>
|
>
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
@@ -692,6 +697,7 @@ function ControlledStatusBar({
|
|||||||
accessibilityLabel="Select thinking option"
|
accessibilityLabel="Select thinking option"
|
||||||
testID="agent-preferences-thinking"
|
testID="agent-preferences-thinking"
|
||||||
>
|
>
|
||||||
|
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -735,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
|
||||||
@@ -838,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) => {
|
||||||
@@ -852,6 +862,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
model: currentAgent.model,
|
model: currentAgent.model,
|
||||||
features: currentAgent.features,
|
features: currentAgent.features,
|
||||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||||
|
lastUsage: currentAgent.lastUsage,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}),
|
}),
|
||||||
@@ -863,52 +874,37 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
|
|||||||
);
|
);
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
|
||||||
const modelsQuery = useQuery({
|
const {
|
||||||
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"],
|
entries: snapshotEntries,
|
||||||
enabled: Boolean(client && agent?.provider),
|
isLoading: snapshotIsLoading,
|
||||||
staleTime: 5 * 60 * 1000,
|
isFetching: snapshotIsFetching,
|
||||||
queryFn: async () => {
|
invalidate: invalidateSnapshot,
|
||||||
if (!client || !agent) {
|
} = useProvidersSnapshot(serverId);
|
||||||
throw new Error("Daemon client unavailable");
|
|
||||||
}
|
const snapshotModels = useMemo(() => {
|
||||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
if (payload.error) {
|
return null;
|
||||||
throw new Error(payload.error);
|
}
|
||||||
}
|
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||||
return payload.models ?? [];
|
return entry?.models ?? null;
|
||||||
},
|
}, [snapshotEntries, agent?.provider]);
|
||||||
});
|
|
||||||
|
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 agentProviderModelQuery = useQuery({
|
|
||||||
queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""],
|
|
||||||
enabled: Boolean(client && agent?.cwd && agent?.provider),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client || !agent) {
|
|
||||||
throw new Error("Daemon client unavailable");
|
|
||||||
}
|
|
||||||
const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd });
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
return payload.models ?? [];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const agentProviderModels = useMemo(() => {
|
const agentProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
if (agent?.provider && agentProviderModelQuery.data) {
|
if (agent?.provider && snapshotModels) {
|
||||||
map.set(agent.provider, agentProviderModelQuery.data);
|
map.set(agent.provider, snapshotModels);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [agent?.provider, agentProviderModelQuery.data]);
|
}, [agent?.provider, snapshotModels]);
|
||||||
|
|
||||||
const models = modelsQuery.data ?? null;
|
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
@@ -933,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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -952,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}
|
||||||
@@ -971,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,
|
||||||
@@ -988,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);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -1000,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,
|
||||||
@@ -1024,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={isProviderModelsQueryLoading(modelsQuery)}
|
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||||
|
onModelSelectorOpen={invalidateSnapshot}
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function DraftAgentStatusBar({
|
export function DraftAgentStatusBar({
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
@@ -1055,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[]>(() => {
|
||||||
@@ -1074,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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1082,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
|
||||||
@@ -1093,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}
|
||||||
@@ -1135,10 +1159,13 @@ export function DraftAgentStatusBar({
|
|||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
selectedModelId={selectedModel}
|
selectedModelId={selectedModel}
|
||||||
onSelectModel={(modelId) => onSelectModel(modelId)}
|
onSelectModel={(modelId) => onSelectModel(modelId)}
|
||||||
|
onSelectProviderAndModel={onSelectProviderAndModel}
|
||||||
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);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -1147,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, {
|
||||||
@@ -26,6 +27,7 @@ import { Check, ChevronDown, X } from "lucide-react-native";
|
|||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
|
SpeakMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
ActivityLog,
|
ActivityLog,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
@@ -38,7 +40,10 @@ import {
|
|||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
import type {
|
||||||
|
AgentPermissionAction,
|
||||||
|
AgentPermissionResponse,
|
||||||
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
@@ -68,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) =>
|
||||||
@@ -105,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({
|
||||||
@@ -178,7 +184,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
target: { kind: "file", path: normalized.file },
|
target: { kind: "file", path: normalized.file },
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,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]);
|
||||||
@@ -250,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)) {
|
||||||
@@ -323,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": {
|
||||||
@@ -356,6 +367,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
if (payload.source === "agent") {
|
if (payload.source === "agent") {
|
||||||
const data = payload.data;
|
const data = payload.data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.name === "speak" &&
|
||||||
|
data.detail.type === "unknown" &&
|
||||||
|
typeof data.detail.input === "string" &&
|
||||||
|
data.detail.input.trim()
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
toolName={data.name}
|
toolName={data.name}
|
||||||
@@ -688,12 +711,35 @@ 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";
|
||||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||||
const description = request.description ?? "";
|
const description = request.description ?? "";
|
||||||
|
const resolvedActions = useMemo((): AgentPermissionAction[] => {
|
||||||
|
if (request.kind === "question") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (Array.isArray(request.actions) && request.actions.length > 0) {
|
||||||
|
return request.actions;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "reject",
|
||||||
|
label: "Deny",
|
||||||
|
behavior: "deny",
|
||||||
|
variant: "danger",
|
||||||
|
intent: "dismiss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accept",
|
||||||
|
label: isPlanRequest ? "Implement" : "Accept",
|
||||||
|
behavior: "allow",
|
||||||
|
variant: "primary",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [isPlanRequest, request]);
|
||||||
|
|
||||||
const planMarkdown = useMemo(() => {
|
const planMarkdown = useMemo(() => {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -734,11 +780,11 @@ function PermissionRequestCard({
|
|||||||
isPending: isResponding,
|
isPending: isResponding,
|
||||||
} = permissionMutation;
|
} = permissionMutation;
|
||||||
|
|
||||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetPermissionMutation();
|
resetPermissionMutation();
|
||||||
setRespondingAction(null);
|
setRespondingActionId(null);
|
||||||
}, [permission.request.id, resetPermissionMutation]);
|
}, [permission.request.id, resetPermissionMutation]);
|
||||||
const handleResponse = useCallback(
|
const handleResponse = useCallback(
|
||||||
(response: AgentPermissionResponse) => {
|
(response: AgentPermissionResponse) => {
|
||||||
@@ -752,6 +798,24 @@ function PermissionRequestCard({
|
|||||||
},
|
},
|
||||||
[permission.agentId, permission.request.id, respondToPermission],
|
[permission.agentId, permission.request.id, respondToPermission],
|
||||||
);
|
);
|
||||||
|
const handleActionPress = useCallback(
|
||||||
|
(action: AgentPermissionAction) => {
|
||||||
|
setRespondingActionId(action.id);
|
||||||
|
if (action.behavior === "allow") {
|
||||||
|
handleResponse({
|
||||||
|
behavior: "allow",
|
||||||
|
selectedActionId: action.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleResponse({
|
||||||
|
behavior: "deny",
|
||||||
|
selectedActionId: action.id,
|
||||||
|
message: "Denied by user",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[handleResponse],
|
||||||
|
);
|
||||||
|
|
||||||
if (request.kind === "question") {
|
if (request.kind === "question") {
|
||||||
return (
|
return (
|
||||||
@@ -778,64 +842,48 @@ function PermissionRequestCard({
|
|||||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable
|
{resolvedActions.map((action) => {
|
||||||
testID="permission-request-deny"
|
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||||
style={({ pressed, hovered = false }) => [
|
const isPrimary = action.variant === "primary";
|
||||||
permissionStyles.optionButton,
|
const isRespondingAction = respondingActionId === action.id;
|
||||||
{
|
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
const iconColor = textColor;
|
||||||
borderColor: theme.colors.borderAccent,
|
const Icon = action.behavior === "allow" ? Check : X;
|
||||||
},
|
const testID =
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
action.behavior === "deny"
|
||||||
]}
|
? "permission-request-deny"
|
||||||
onPress={() => {
|
: action.id === "accept" || action.id === "implement"
|
||||||
setRespondingAction("deny");
|
? "permission-request-accept"
|
||||||
handleResponse({
|
: `permission-request-action-${action.id}`;
|
||||||
behavior: "deny",
|
|
||||||
message: "Denied by user",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={isResponding}
|
|
||||||
>
|
|
||||||
{respondingAction === "deny" ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<View style={permissionStyles.optionContent}>
|
|
||||||
<X size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
|
||||||
Deny
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
return (
|
||||||
testID="permission-request-accept"
|
<Pressable
|
||||||
style={({ pressed, hovered = false }) => [
|
key={action.id}
|
||||||
permissionStyles.optionButton,
|
testID={testID}
|
||||||
{
|
style={({ pressed, hovered = false }) => [
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
permissionStyles.optionButton,
|
||||||
borderColor: theme.colors.borderAccent,
|
{
|
||||||
},
|
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||||
]}
|
},
|
||||||
onPress={() => {
|
pressed ? permissionStyles.optionButtonPressed : null,
|
||||||
setRespondingAction("accept");
|
]}
|
||||||
handleResponse({ behavior: "allow" });
|
onPress={() => handleActionPress(action)}
|
||||||
}}
|
disabled={isResponding}
|
||||||
disabled={isResponding}
|
>
|
||||||
>
|
{isRespondingAction ? (
|
||||||
{respondingAction === "accept" ? (
|
<ActivityIndicator size="small" color={textColor} />
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
) : (
|
||||||
) : (
|
<View style={permissionStyles.optionContent}>
|
||||||
<View style={permissionStyles.optionContent}>
|
<Icon size={14} color={iconColor} />
|
||||||
<Check size={14} color={theme.colors.foreground} />
|
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
{action.label}
|
||||||
Accept
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
</Pressable>
|
||||||
</Pressable>
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -870,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,28 +4,19 @@ 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 { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +27,9 @@ import {
|
|||||||
type SelectorModelRow,
|
type SelectorModelRow,
|
||||||
} from "./combined-model-selector.utils";
|
} from "./combined-model-selector.utils";
|
||||||
|
|
||||||
|
// TODO: this should be configured per provider in the provider manifest
|
||||||
|
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||||
|
|
||||||
type SelectorView =
|
type SelectorView =
|
||||||
| { kind: "all" }
|
| { kind: "all" }
|
||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
@@ -56,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;
|
||||||
}
|
}
|
||||||
@@ -123,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);
|
||||||
@@ -161,7 +159,6 @@ function ModelRow({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const ProviderIcon = getProviderIcon(row.provider);
|
const ProviderIcon = getProviderIcon(row.provider);
|
||||||
const isWeb = Platform.OS === "web";
|
|
||||||
|
|
||||||
const handleToggleFavorite = useCallback(
|
const handleToggleFavorite = useCallback(
|
||||||
(event: GestureResponderEvent) => {
|
(event: GestureResponderEvent) => {
|
||||||
@@ -171,9 +168,12 @@ function ModelRow({
|
|||||||
[onToggleFavorite, row.modelId, row.provider],
|
[onToggleFavorite, row.modelId, row.provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const item = (
|
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
||||||
|
|
||||||
|
return (
|
||||||
<ComboboxItem
|
<ComboboxItem
|
||||||
label={row.modelLabel}
|
label={row.modelLabel}
|
||||||
|
description={showDescription ? row.description : undefined}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
elevated={elevated}
|
elevated={elevated}
|
||||||
@@ -211,21 +211,6 @@ function ModelRow({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isWeb || !row.description) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
|
||||||
<View>{item}</View>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" align="center" offset={4}>
|
|
||||||
<Text style={styles.tooltipText}>{row.description}</Text>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function FavoritesSection({
|
function FavoritesSection({
|
||||||
@@ -300,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";
|
||||||
|
|
||||||
@@ -358,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);
|
||||||
@@ -375,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}
|
||||||
@@ -525,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("");
|
||||||
|
|
||||||
@@ -549,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(
|
||||||
@@ -600,7 +590,7 @@ export function CombinedModelSelector({
|
|||||||
}, [selectedModelLabel, selectedProviderLabel]);
|
}, [selectedModelLabel, selectedProviderLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) {
|
if (platformIsWeb) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +604,7 @@ export function CombinedModelSelector({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [isOpen, isWeb]);
|
}, [isOpen, platformIsWeb]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -677,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
|
||||||
@@ -839,10 +829,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
favoriteButtonPressed: {
|
favoriteButtonPressed: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
tooltipText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
},
|
|
||||||
sheetLoadingState: {
|
sheetLoadingState: {
|
||||||
minHeight: 160,
|
minHeight: 160,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import Svg, { Circle } from "react-native-svg";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
type ContextWindowMeterProps = {
|
||||||
|
maxTokens: number;
|
||||||
|
usedTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SVG_SIZE = 20;
|
||||||
|
const CENTER = SVG_SIZE / 2;
|
||||||
|
const RADIUS = 7;
|
||||||
|
const STROKE_WIDTH = 2.25;
|
||||||
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||||
|
|
||||||
|
function isValidMaxTokens(value: number): boolean {
|
||||||
|
return Number.isFinite(value) && value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUsedTokens(value: number): boolean {
|
||||||
|
return Number.isFinite(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsagePercentage(maxTokens: number, usedTokens: number): number | null {
|
||||||
|
if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (usedTokens / maxTokens) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercentage(value: number): number {
|
||||||
|
return Math.max(0, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokenCount(value: number): string {
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
return `${Math.round(value / 1_000_000)}m`;
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${Math.round(value / 1_000)}k`;
|
||||||
|
}
|
||||||
|
return Math.round(value).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeterColors(
|
||||||
|
percentage: number,
|
||||||
|
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||||
|
): { progress: string; track: string } {
|
||||||
|
const track = theme.colors.surface3;
|
||||||
|
if (percentage > 90) {
|
||||||
|
return { progress: theme.colors.destructive, track };
|
||||||
|
}
|
||||||
|
if (percentage >= 70) {
|
||||||
|
return { progress: theme.colors.palette.amber[500], track };
|
||||||
|
}
|
||||||
|
return { progress: theme.colors.foregroundMuted, track };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||||
|
|
||||||
|
if (percentage === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedPercentage = clampPercentage(percentage);
|
||||||
|
const roundedPercentage = Math.round(percentage);
|
||||||
|
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||||
|
const colors = getMeterColors(clampedPercentage, theme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||||
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
|
<Pressable
|
||||||
|
style={styles.container}
|
||||||
|
accessibilityRole="image"
|
||||||
|
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||||
|
>
|
||||||
|
<Svg
|
||||||
|
width={SVG_SIZE}
|
||||||
|
height={SVG_SIZE}
|
||||||
|
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||||
|
style={styles.svg}
|
||||||
|
accessibilityElementsHidden
|
||||||
|
importantForAccessibility="no-hide-descendants"
|
||||||
|
>
|
||||||
|
<Circle
|
||||||
|
cx={CENTER}
|
||||||
|
cy={CENTER}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.track}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<Circle
|
||||||
|
cx={CENTER}
|
||||||
|
cy={CENTER}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={colors.progress}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={CIRCUMFERENCE}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</Pressable>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
|
<View style={styles.tooltipContent}>
|
||||||
|
<Text style={styles.tooltipTitle}>Context window</Text>
|
||||||
|
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.tooltipDetail}
|
||||||
|
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
|
||||||
|
</View>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
svg: {
|
||||||
|
transform: [{ rotate: "-90deg" }],
|
||||||
|
},
|
||||||
|
tooltipContent: {
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
tooltipTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
tooltipText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: theme.fontSize.sm * 1.4,
|
||||||
|
},
|
||||||
|
tooltipDetail: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
lineHeight: theme.fontSize.xs * 1.4,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
@@ -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,18 +118,22 @@ export function FileExplorerPane({
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
||||||
workspaceStateKey: actionsWorkspaceStateKey,
|
useFileExplorerActions({
|
||||||
requestDirectoryListing,
|
serverId,
|
||||||
requestFileDownloadToken,
|
workspaceId,
|
||||||
selectExplorerEntry,
|
workspaceRoot: normalizedWorkspaceRoot,
|
||||||
} = useFileExplorerActions({
|
});
|
||||||
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) =>
|
||||||
|
workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined,
|
||||||
|
);
|
||||||
|
const setExpandedPathsForWorkspace = usePanelStore((state) => state.setExpandedPathsForWorkspace);
|
||||||
|
const expandedPaths = useMemo(
|
||||||
|
() => new Set(expandedPathsArray && expandedPathsArray.length > 0 ? expandedPathsArray : ["."]),
|
||||||
|
[expandedPathsArray],
|
||||||
|
);
|
||||||
|
|
||||||
const directories = explorerState?.directories ?? new Map();
|
const directories = explorerState?.directories ?? new Map();
|
||||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||||
@@ -144,7 +149,6 @@ export function FileExplorerPane({
|
|||||||
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
[isExplorerLoading, pendingRequest?.mode, pendingRequest?.path],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(["."]));
|
|
||||||
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
const treeListRef = useRef<FlatList<TreeRow>>(null);
|
||||||
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
const scrollbar = useWebScrollViewScrollbar(treeListRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
@@ -154,8 +158,7 @@ export function FileExplorerPane({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasInitializedRef.current = false;
|
hasInitializedRef.current = false;
|
||||||
setExpandedPaths(new Set(["."]));
|
}, [workspaceStateKey]);
|
||||||
}, [actionsWorkspaceStateKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!hasWorkspaceScope) {
|
||||||
@@ -169,23 +172,33 @@ export function FileExplorerPane({
|
|||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
}, [hasWorkspaceScope, requestDirectoryListing]);
|
const persistedPaths =
|
||||||
|
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||||
|
if (persistedPaths) {
|
||||||
|
for (const path of persistedPaths) {
|
||||||
|
if (path !== ".") {
|
||||||
|
void requestDirectoryListing(path, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||||
|
|
||||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEntryPath || !hasWorkspaceScope) {
|
if (!selectedEntryPath || !workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parentDir = getParentDirectory(selectedEntryPath);
|
const parentDir = getParentDirectory(selectedEntryPath);
|
||||||
const ancestors = getAncestorDirectories(parentDir);
|
const ancestors = getAncestorDirectories(parentDir);
|
||||||
|
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||||
setExpandedPaths((prev) => {
|
if (newPaths.length === 0) {
|
||||||
const next = new Set(prev);
|
return;
|
||||||
ancestors.forEach((path) => next.add(path));
|
}
|
||||||
return next;
|
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
|
||||||
});
|
newPaths.forEach((path) => {
|
||||||
|
|
||||||
ancestors.forEach((path) => {
|
|
||||||
if (!directories.has(path)) {
|
if (!directories.has(path)) {
|
||||||
void requestDirectoryListing(path, {
|
void requestDirectoryListing(path, {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
@@ -193,34 +206,43 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]);
|
}, [
|
||||||
|
directories,
|
||||||
|
workspaceStateKey,
|
||||||
|
expandedPaths,
|
||||||
|
requestDirectoryListing,
|
||||||
|
selectedEntryPath,
|
||||||
|
setExpandedPathsForWorkspace,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleToggleDirectory = useCallback(
|
const handleToggleDirectory = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!hasWorkspaceScope) {
|
if (!workspaceStateKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpanded = expandedPaths.has(entry.path);
|
const isExpanded = expandedPaths.has(entry.path);
|
||||||
const nextExpanded = !isExpanded;
|
if (isExpanded) {
|
||||||
setExpandedPaths((prev) => {
|
setExpandedPathsForWorkspace(
|
||||||
const next = new Set(prev);
|
workspaceStateKey,
|
||||||
if (isExpanded) {
|
Array.from(expandedPaths).filter((path) => path !== entry.path),
|
||||||
next.delete(entry.path);
|
);
|
||||||
} else {
|
} else {
|
||||||
next.add(entry.path);
|
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]);
|
||||||
|
if (!directories.has(entry.path)) {
|
||||||
|
void requestDirectoryListing(entry.path, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextExpanded && !directories.has(entry.path)) {
|
|
||||||
void requestDirectoryListing(entry.path, {
|
|
||||||
recordHistory: false,
|
|
||||||
setCurrentPath: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
[
|
||||||
|
workspaceStateKey,
|
||||||
|
expandedPaths,
|
||||||
|
directories,
|
||||||
|
requestDirectoryListing,
|
||||||
|
setExpandedPathsForWorkspace,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenFile = useCallback(
|
const handleOpenFile = useCallback(
|
||||||
@@ -817,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>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { SquareTerminal } from "lucide-react-native";
|
||||||
|
import { Image, type ImageSourcePropType } from "react-native";
|
||||||
|
import {
|
||||||
|
isKnownEditorTargetId,
|
||||||
|
type EditorTargetId,
|
||||||
|
type KnownEditorTargetId,
|
||||||
|
} from "@server/shared/messages";
|
||||||
|
|
||||||
|
interface EditorAppIconProps {
|
||||||
|
editorId: EditorTargetId;
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||||
|
cursor: require("../../../assets/images/editor-apps/cursor.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"),
|
||||||
|
finder: require("../../../assets/images/editor-apps/finder.png"),
|
||||||
|
explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||||
|
"file-manager": require("../../../assets/images/editor-apps/file-explorer.png"),
|
||||||
|
};
|
||||||
|
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||||
|
|
||||||
|
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
|
||||||
|
return isKnownEditorTargetId(editorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
|
||||||
|
if (!hasBundledEditorAppIcon(editorId)) {
|
||||||
|
return <SquareTerminal size={size} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
source={EDITOR_APP_IMAGES[editorId]}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -229,21 +224,21 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeToAgent();
|
closeToAgent();
|
||||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
router.push(buildHostSettingsRoute(activeServerId));
|
||||||
}, [activeServerId, closeToAgent]);
|
}, [activeServerId, closeToAgent]);
|
||||||
|
|
||||||
const handleSettingsDesktop = useCallback(() => {
|
const handleSettingsDesktop = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(buildHostSettingsRoute(activeServerId) as any);
|
router.push(buildHostSettingsRoute(activeServerId));
|
||||||
}, [activeServerId]);
|
}, [activeServerId]);
|
||||||
|
|
||||||
const handleViewMoreNavigate = useCallback(() => {
|
const handleViewMoreNavigate = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(buildHostSessionsRoute(activeServerId) as any);
|
router.push(buildHostSessionsRoute(activeServerId));
|
||||||
}, [activeServerId]);
|
}, [activeServerId]);
|
||||||
|
|
||||||
const handleHostSelect = useCallback(
|
const handleHostSelect = useCallback(
|
||||||
@@ -253,7 +248,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
}
|
}
|
||||||
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
const nextPath = mapPathnameToServer(pathname, nextServerId);
|
||||||
setIsHostPickerOpen(false);
|
setIsHostPickerOpen(false);
|
||||||
router.push(nextPath as any);
|
router.push(nextPath);
|
||||||
},
|
},
|
||||||
[pathname],
|
[pathname],
|
||||||
);
|
);
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -79,12 +79,17 @@ export interface MessageInputProps {
|
|||||||
isInputActive?: boolean;
|
isInputActive?: boolean;
|
||||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||||
leftContent?: React.ReactNode;
|
leftContent?: React.ReactNode;
|
||||||
|
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||||
|
beforeVoiceContent?: React.ReactNode;
|
||||||
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
|
||||||
rightContent?: React.ReactNode;
|
rightContent?: React.ReactNode;
|
||||||
voiceServerId?: string;
|
voiceServerId?: string;
|
||||||
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. */
|
||||||
@@ -110,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;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -201,10 +208,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
isInputActive = true,
|
isInputActive = true,
|
||||||
leftContent,
|
leftContent,
|
||||||
|
beforeVoiceContent,
|
||||||
rightContent,
|
rightContent,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
isAgentRunning = false,
|
isAgentRunning = false,
|
||||||
|
defaultSendBehavior = "interrupt",
|
||||||
onQueue,
|
onQueue,
|
||||||
onSubmitLoadingPress,
|
onSubmitLoadingPress,
|
||||||
onKeyPress: onKeyPressCallback,
|
onKeyPress: onKeyPressCallback,
|
||||||
@@ -215,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();
|
||||||
@@ -286,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;
|
||||||
@@ -321,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: () => {
|
||||||
@@ -349,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(
|
||||||
@@ -575,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;
|
||||||
@@ -594,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 => {
|
||||||
@@ -614,7 +649,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !onAddImages) {
|
if (!isWeb || !onAddImages) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -667,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,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;
|
||||||
@@ -759,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;
|
||||||
@@ -813,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,
|
||||||
});
|
});
|
||||||
@@ -833,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(),
|
||||||
@@ -847,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({
|
||||||
@@ -869,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;
|
||||||
@@ -889,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,
|
||||||
@@ -930,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" />
|
||||||
@@ -962,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,
|
||||||
@@ -974,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>
|
||||||
@@ -1015,6 +1058,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
|
|
||||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||||
<View style={styles.rightButtonGroup}>
|
<View style={styles.rightButtonGroup}>
|
||||||
|
{beforeVoiceContent}
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
onPress={handleVoicePress}
|
onPress={handleVoicePress}
|
||||||
@@ -1067,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"
|
||||||
@@ -1095,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"
|
||||||
@@ -1109,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>
|
||||||
@@ -1169,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",
|
||||||
@@ -1188,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",
|
||||||
}
|
}
|
||||||
@@ -1213,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",
|
||||||
@@ -1232,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,
|
||||||
@@ -1253,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,
|
||||||
@@ -39,8 +40,10 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
Scissors,
|
Scissors,
|
||||||
|
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,
|
||||||
@@ -70,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;
|
||||||
@@ -123,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") {
|
||||||
@@ -330,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
|
||||||
@@ -350,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 ? (
|
||||||
@@ -397,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,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,
|
||||||
@@ -437,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}
|
||||||
@@ -559,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={
|
||||||
@@ -710,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();
|
||||||
@@ -751,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,
|
||||||
@@ -816,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}
|
||||||
@@ -866,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}
|
||||||
@@ -890,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
|
||||||
@@ -901,14 +1167,79 @@ 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SpeakMessageProps {
|
||||||
|
message: string;
|
||||||
|
timestamp: number;
|
||||||
|
disableOuterSpacing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
},
|
||||||
|
containerSpacing: {
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
headerLabel: {
|
||||||
|
fontFamily: Fonts.sans,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontFamily: Fonts.sans,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
lineHeight: 22,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const SpeakMessage = memo(function SpeakMessage({
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
disableOuterSpacing,
|
||||||
|
}: SpeakMessageProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
testID="speak-message"
|
||||||
|
style={[
|
||||||
|
speakMessageStylesheet.container,
|
||||||
|
!resolvedDisableOuterSpacing && speakMessageStylesheet.containerSpacing,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={speakMessageStylesheet.header}>
|
||||||
|
<MicVocal size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1323,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
|
||||||
@@ -1402,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1762,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) {
|
||||||
@@ -1877,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) {
|
||||||
|
|||||||
@@ -78,9 +78,17 @@ function createPlanMarkdownRules() {
|
|||||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
|
<View key={node.key} style={styles.list_item}>
|
||||||
<Text style={iconStyle}>{marker}</Text>
|
<Text style={iconStyle}>{marker}</Text>
|
||||||
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
|
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
||||||
|
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
||||||
|
return (
|
||||||
|
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
||||||
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -1113,7 +1107,7 @@ function WorkspaceRowWithMenu({
|
|||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
archivedWorkspaceId: workspace.workspaceId,
|
||||||
workspaces: sessionWorkspaces.values(),
|
workspaces: sessionWorkspaces.values(),
|
||||||
}) as any,
|
}),
|
||||||
);
|
);
|
||||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
testID="agent-chat-scroll"
|
testID="agent-chat-scroll"
|
||||||
nativeID="agent-chat-scroll-native-virtualized"
|
nativeID="agent-chat-scroll-native-virtualized"
|
||||||
ListHeaderComponent={liveHeaderContent ? () => liveHeaderContent : undefined}
|
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||||
contentContainerStyle={baseListContentContainerStyle}
|
contentContainerStyle={baseListContentContainerStyle}
|
||||||
style={listStyle}
|
style={listStyle}
|
||||||
onLayout={handleListLayout}
|
onLayout={handleListLayout}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -203,12 +207,12 @@ export function ComboboxItem({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{leadingContent}
|
{leadingContent}
|
||||||
<View style={styles.comboboxItemContent}>
|
<View style={[styles.comboboxItemContent, description && styles.comboboxItemContentInline]}>
|
||||||
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{description ? (
|
{description ? (
|
||||||
<Text numberOfLines={2} style={styles.comboboxItemDescription}>
|
<Text numberOfLines={1} style={styles.comboboxItemDescription}>
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -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,
|
||||||
@@ -847,6 +877,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
|
comboboxItemContentInline: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
comboboxItemLeadingSlot: {
|
comboboxItemLeadingSlot: {
|
||||||
width: 16,
|
width: 16,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -857,9 +892,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
comboboxItemDescription: {
|
comboboxItemDescription: {
|
||||||
marginTop: 2,
|
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -44,6 +46,7 @@ type TooltipContextValue = {
|
|||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
triggerRef: React.RefObject<View | null>;
|
triggerRef: React.RefObject<View | null>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
openOnPress: boolean;
|
||||||
delayDuration: number;
|
delayDuration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,12 +217,8 @@ export function Tooltip({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isWeb = Platform.OS === "web";
|
const isCompact = useIsCompactFormFactor();
|
||||||
const isMobileWeb =
|
const enabled = isCompact ? enabledOnMobile : enabledOnDesktop;
|
||||||
isWeb &&
|
|
||||||
typeof navigator !== "undefined" &&
|
|
||||||
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
|
|
||||||
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
|
|
||||||
|
|
||||||
const value = useMemo<TooltipContextValue>(
|
const value = useMemo<TooltipContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -227,9 +226,10 @@ export function Tooltip({
|
|||||||
setOpen: setIsOpen,
|
setOpen: setIsOpen,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
enabled,
|
enabled,
|
||||||
|
openOnPress: isCompact,
|
||||||
delayDuration,
|
delayDuration,
|
||||||
}),
|
}),
|
||||||
[isOpen, setIsOpen, enabled, delayDuration],
|
[isOpen, setIsOpen, enabled, isCompact, delayDuration],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
|
||||||
@@ -323,9 +323,17 @@ export function TooltipTrigger({
|
|||||||
const handlePress = useCallback(
|
const handlePress = useCallback(
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
onPress?.(e);
|
onPress?.(e);
|
||||||
|
if (!ctx.enabled || disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.openOnPress) {
|
||||||
|
clearOpenTimer();
|
||||||
|
ctx.setOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
},
|
},
|
||||||
[close, onPress],
|
[clearOpenTimer, close, ctx, disabled, onPress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const triggerProps = {
|
const triggerProps = {
|
||||||
@@ -336,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,
|
||||||
@@ -455,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}>
|
||||||
@@ -492,7 +500,7 @@ export function TooltipContent({
|
|||||||
statusBarTranslucent={Platform.OS === "android"}
|
statusBarTranslucent={Platform.OS === "android"}
|
||||||
onRequestClose={() => ctx.setOpen(false)}
|
onRequestClose={() => ctx.setOpen(false)}
|
||||||
>
|
>
|
||||||
<View pointerEvents="box-none" style={styles.overlay}>
|
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
entering={FadeIn.duration(80)}
|
entering={FadeIn.duration(80)}
|
||||||
@@ -513,7 +521,7 @@ export function TooltipContent({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -533,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,95 +235,72 @@ 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) {
|
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||||
return;
|
}, [anyOnlineServerId, router]);
|
||||||
}
|
|
||||||
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
|
||||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
|
||||||
|
|
||||||
const finishOnboarding = useCallback(
|
const finishOnboarding = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId));
|
||||||
},
|
},
|
||||||
[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;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user