mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3a5402557 | ||
|
|
7f8f42e92e | ||
|
|
426547bfcc | ||
|
|
9e9d0ee305 | ||
|
|
f930956e5c | ||
|
|
21d7f8cb2a | ||
|
|
3163e212fb | ||
|
|
c5da7505da | ||
|
|
d84d5f66cd | ||
|
|
e4c966b21e | ||
|
|
f05544ef8f | ||
|
|
fa800ae78e | ||
|
|
f242519623 | ||
|
|
75aa626df5 | ||
|
|
cd0aaed1e2 | ||
|
|
1b6f9300a7 | ||
|
|
62d9e656d7 | ||
|
|
c0614c3207 | ||
|
|
d802534e7b | ||
|
|
9fb64f5a0b | ||
|
|
f5a7055e7e | ||
|
|
d3a6bf80dd | ||
|
|
69f0fa07b4 | ||
|
|
fd341de848 | ||
|
|
33f77e335f | ||
|
|
a70b5c1e1d | ||
|
|
08ebe5e7e1 | ||
|
|
41cf070fd8 | ||
|
|
65342b338a | ||
|
|
f639c4cba6 | ||
|
|
a682476d11 | ||
|
|
b2cd61a6fe | ||
|
|
2828dce65f | ||
|
|
6db285b1d9 | ||
|
|
201f427f62 | ||
|
|
f7bef36606 | ||
|
|
5ea586a926 | ||
|
|
8e6455f1d6 | ||
|
|
7be9cc0d0b | ||
|
|
e446d9009f | ||
|
|
915cbda8a7 | ||
|
|
5631eb17ea | ||
|
|
49e09ee660 | ||
|
|
56b1def06e | ||
|
|
7512110e6f |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.94 - 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- **Attach pull request comments, reviews, threads, and failed check logs to chat from the PR panel** ([#1400](https://github.com/getpaseo/paseo/pull/1400))
|
||||
- **Use Paseo in Arabic, Chinese, English, French, Russian, and Spanish** ([#1478](https://github.com/getpaseo/paseo/pull/1478) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- **Create reusable terminal profiles from Host settings**
|
||||
- **Open workspaces in Antigravity** ([#1424](https://github.com/getpaseo/paseo/pull/1424) by [@krumpyzoid](https://github.com/krumpyzoid))
|
||||
|
||||
### Improved
|
||||
|
||||
- Claude skills appear in prompt autocomplete as you type ([#1464](https://github.com/getpaseo/paseo/pull/1464))
|
||||
- Copy file paths directly from file preview tab menus ([#1473](https://github.com/getpaseo/paseo/pull/1473))
|
||||
- PR status stays current after an agent merges a branch ([#1455](https://github.com/getpaseo/paseo/pull/1455))
|
||||
- Workspace tabs stay fast by retaining only the active workspace screens ([#1472](https://github.com/getpaseo/paseo/pull/1472))
|
||||
- iOS simulator previews can be launched from worktrees
|
||||
|
||||
### Fixed
|
||||
|
||||
- Composer send shortcuts no longer conflict with other keyboard shortcuts
|
||||
- Multi-question prompts advance one answer at a time ([#1462](https://github.com/getpaseo/paseo/pull/1462))
|
||||
- Imported Pi sessions keep their original model and thinking settings ([#1441](https://github.com/getpaseo/paseo/pull/1441) by [@thomasaull](https://github.com/thomasaull))
|
||||
- Reconnecting to a desktop host keeps the saved shell and workspace route
|
||||
- Worktree terminals no longer appear in parent workspaces
|
||||
- Mobile reconnects show the welcome screen correctly
|
||||
|
||||
## 0.1.93 - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude Fable 5 is available in the Claude model picker** ([#1443](https://github.com/getpaseo/paseo/pull/1443) by [@0-Captain](https://github.com/0-Captain))
|
||||
|
||||
## 0.1.92 - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
51
README.md
51
README.md
@@ -4,6 +4,11 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<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">
|
||||
@@ -149,52 +154,6 @@ npm run typecheck
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
|
||||
### Self-hosted relay TLS
|
||||
|
||||
Self-hosted relays use `ws://` unless TLS is opted in. For a relay behind nginx on 443, start the daemon with:
|
||||
|
||||
```bash
|
||||
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
|
||||
PASEO_RELAY_USE_TLS=true \
|
||||
paseo daemon start
|
||||
```
|
||||
|
||||
Equivalent config:
|
||||
|
||||
```json
|
||||
{
|
||||
"daemon": {
|
||||
"relay": {
|
||||
"enabled": true,
|
||||
"endpoint": "127.0.0.1:8080",
|
||||
"publicEndpoint": "relay.example.com:443",
|
||||
"useTls": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Minimal nginx WebSocket proxy:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name relay.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
|
||||
217
README.zh-CN.md
Normal file
217
README.zh-CN.md
Normal file
@@ -0,0 +1,217 @@
|
||||
<p align="center">
|
||||
<img src="packages/website/public/logo.svg" width="64" height="64" alt="Paseo logo">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<a href="https://www.reddit.com/r/PaseoAI/">
|
||||
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">Claude Code、Codex、Copilot、OpenCode 和 Pi agents 的统一界面。</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> 我是独立维护者,不一定每天都能及时处理 GitHub Issues。
|
||||
> 如果问题很紧急或阻塞了你,[Discord](https://discord.gg/jz8T2uahpH) 是最快联系到我的地方。
|
||||
|
||||
---
|
||||
|
||||
在你自己的机器上并行运行 agents。无论在手机上还是桌前,都能推进交付。
|
||||
|
||||
- **自托管:** Agents 在你的机器上运行,使用完整的本地开发环境、工具、配置和技能。
|
||||
- **多提供商:** 通过同一个界面使用 Claude Code、Codex、Copilot、OpenCode 和 Pi。为每个任务选择合适的模型。
|
||||
- **语音控制:** 在语音模式下口述任务或讨论问题。需要免手操作时很方便。
|
||||
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。
|
||||
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
|
||||
|
||||
## 快速开始
|
||||
|
||||
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
|
||||
|
||||
### 前置条件
|
||||
|
||||
你至少需要安装一个 agent CLI,并用你的凭据完成配置:
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||
- [Pi](https://pi.dev)
|
||||
|
||||
### 桌面 app(推荐)
|
||||
|
||||
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
|
||||
如果要从手机连接,在 Settings 中扫描显示的二维码。
|
||||
|
||||
### CLI / 无头模式
|
||||
|
||||
安装 CLI 并启动 Paseo:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
终端中会显示一个二维码。你可以从任意客户端连接。这个方式适合服务器和远程机器。
|
||||
|
||||
完整安装和配置见:
|
||||
|
||||
- [文档](https://paseo.sh/docs)
|
||||
- [配置参考](https://paseo.sh/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
你能在 app 中完成的事情,也都可以在终端中完成。
|
||||
|
||||
```bash
|
||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||
|
||||
paseo ls # 列出正在运行的 agents
|
||||
paseo attach abc123 # 实时流式查看输出
|
||||
paseo send abc123 "also add tests" # 发送后续任务
|
||||
|
||||
# 在远程 daemon 上运行
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
|
||||
|
||||
## Skills
|
||||
|
||||
Skills 会教你的 agent 使用 Paseo 来编排其他 agents。
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
```
|
||||
|
||||
然后在任意 agent 对话中使用:
|
||||
|
||||
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
|
||||
- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。
|
||||
- `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。
|
||||
- `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。
|
||||
|
||||
## 开发
|
||||
|
||||
Monorepo 包结构速览:
|
||||
|
||||
- `packages/server`:Paseo daemon(agent 进程编排、WebSocket API、MCP server)
|
||||
- `packages/app`:Expo 客户端(iOS、Android、Web)
|
||||
- `packages/cli`:用于 daemon 和 agent 工作流的 `paseo` CLI
|
||||
- `packages/desktop`:Electron 桌面 app
|
||||
- `packages/relay`:用于远程连接的 relay 包
|
||||
- `packages/website`:营销站点和文档(`paseo.sh`)
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
# 运行所有本地开发服务
|
||||
npm run dev
|
||||
|
||||
# 单独运行某个界面
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# 构建 server stack
|
||||
npm run build:server
|
||||
|
||||
# 全仓库检查
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## 社区
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 实现的自托管 relay
|
||||
|
||||
### 自托管 relay TLS
|
||||
|
||||
自托管 relay 默认使用 `ws://`,除非显式启用 TLS。对于 nginx 后面、监听 443 的 relay,可以这样启动 daemon:
|
||||
|
||||
```bash
|
||||
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
|
||||
PASEO_RELAY_USE_TLS=true \
|
||||
paseo daemon start
|
||||
```
|
||||
|
||||
等价配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"daemon": {
|
||||
"relay": {
|
||||
"enabled": true,
|
||||
"endpoint": "127.0.0.1:8080",
|
||||
"publicEndpoint": "relay.example.com:443",
|
||||
"useTls": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
最小 nginx WebSocket 代理配置:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name relay.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
@@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals:
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -48,6 +49,16 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
|
||||
### iOS simulator preview service
|
||||
|
||||
Paseo worktrees expose the native iOS dev app through the `ios-simulator` service in `paseo.json`. The service URL serves the simulator preview at `/.sim`, so the preview link is `${PASEO_URL}/.sim`.
|
||||
|
||||
The service is designed for concurrent worktrees: it derives a deterministic simulator identity from the worktree path, uses the worktree's assigned `PASEO_PORT`, pins `serve-sim` to that simulator UDID, and only tears down that worktree's helper/simulator state. It must not rely on the globally booted simulator or any fixed Metro port.
|
||||
|
||||
Worktree setup best-effort seeds the generated iOS project and newest native build cache from the source checkout before the service runs. The service still validates the native project by running Expo prebuild and Xcode; the seed only avoids paying all setup/build cost from a cold worktree every time.
|
||||
|
||||
Starting the service must not create, focus, reveal, or leave behind macOS Simulator.app windows. The browser preview is the user-visible simulator surface.
|
||||
|
||||
### Desktop renderer profiling
|
||||
|
||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
# Git Snapshot Startup Reshaping - 2026-05-27
|
||||
|
||||
## What changed
|
||||
|
||||
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
|
||||
|
||||
```text
|
||||
daemon startup / workspace subscription
|
||||
-> WorkspaceGitService.refreshSnapshot(cwd)
|
||||
-> getCheckoutSnapshotFacts(cwd)
|
||||
-> getCheckoutStatus(cwd, { facts })
|
||||
-> getCheckoutShortstat(cwd, { facts })
|
||||
-> getPullRequestStatus(cwd, github, ..., { facts })
|
||||
-> WorkspaceGitRuntimeSnapshot
|
||||
-> session workspace descriptor githubRuntime.pullRequest
|
||||
-> app useSidebarWorkspacesList()
|
||||
-> SidebarWorkspaceEntry.prHint
|
||||
-> Sidebar row badge + hover card checks
|
||||
```
|
||||
|
||||
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
|
||||
|
||||
## Shared Git Facts
|
||||
|
||||
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
|
||||
|
||||
- worktree root: `rev-parse --show-toplevel`
|
||||
- current branch: `rev-parse --abbrev-ref HEAD`
|
||||
- origin remote URL
|
||||
- Paseo worktree ownership and stored base ref
|
||||
- resolved base ref and best comparison base
|
||||
- main repo root
|
||||
- branch remote/merge config
|
||||
- tracked origin branch
|
||||
- pull request lookup target for fork/PR worktrees
|
||||
|
||||
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
|
||||
|
||||
## Current Data Flow
|
||||
|
||||
```text
|
||||
Workspace subscription / fetch_workspaces
|
||||
-> session workspace registry
|
||||
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
|
||||
-> refresh queue/throttle/dedupe per normalized cwd
|
||||
-> refreshGitSnapshot()
|
||||
-> getCheckoutSnapshotFacts()
|
||||
-> getCheckoutStatus({ facts })
|
||||
-> getCheckoutShortstat({ facts })
|
||||
-> refreshGitHubSnapshot()
|
||||
-> getPullRequestStatus({ facts })
|
||||
-> cached WorkspaceGitRuntimeSnapshot
|
||||
-> WorkspaceDescriptorPayload.gitRuntime
|
||||
-> WorkspaceDescriptorPayload.githubRuntime
|
||||
-> app session store
|
||||
-> useSidebarWorkspacesList()
|
||||
-> diffStat from descriptor
|
||||
-> prHint from descriptor.githubRuntime.pullRequest
|
||||
```
|
||||
|
||||
## Startup Benchmark
|
||||
|
||||
Added deterministic real-home benchmark:
|
||||
|
||||
`packages/server/scripts/benchmark-startup-git-real-home.ts`
|
||||
|
||||
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
|
||||
|
||||
The frozen home used for the comparison contained 22 workspaces.
|
||||
|
||||
### Before/After
|
||||
|
||||
| run | code shape | client shape | git commands | failures | elapsed |
|
||||
| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: |
|
||||
| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms |
|
||||
| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms |
|
||||
| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms |
|
||||
| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms |
|
||||
|
||||
The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran.
|
||||
|
||||
The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline).
|
||||
|
||||
### Baseline: before change + legacy PR fanout
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "legacyPrFanout",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 39039,
|
||||
"git": {
|
||||
"total": 529,
|
||||
"failed": 20,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 58 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
|
||||
{ "key": "rev-parse --git-dir", "count": 36 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
|
||||
{ "key": "config --get remote.origin.url", "count": 32 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 14 },
|
||||
{ "key": "status --porcelain", "count": 14 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 2009,
|
||||
"cpuSystemMs": 2428,
|
||||
"rssDeltaMb": -1.5,
|
||||
"heapUsedDeltaMb": 16.9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After: after change + snapshot-only sidebar
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "snapshotOnly",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 31273,
|
||||
"git": {
|
||||
"total": 372,
|
||||
"failed": 15,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "config --get remote.origin.url", "count": 35 },
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 31 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
|
||||
{ "key": "status --porcelain", "count": 22 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 17 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
|
||||
{ "key": "rev-list --count main..origin/main", "count": 7 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 1871,
|
||||
"cpuSystemMs": 2152,
|
||||
"rssDeltaMb": 4.4,
|
||||
"heapUsedDeltaMb": 8.8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After 2: shared service-level facts
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "snapshotOnly",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 31334,
|
||||
"git": {
|
||||
"total": 308,
|
||||
"failed": 15,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 31 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 26 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "status --porcelain", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "config --get remote.origin.url", "count": 13 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 13 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 13 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 13 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 },
|
||||
{ "key": "fetch origin --prune", "count": 5 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 1817,
|
||||
"cpuSystemMs": 1869,
|
||||
"rssDeltaMb": 16.7,
|
||||
"heapUsedDeltaMb": 10.4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshot Equivalence Guard
|
||||
|
||||
Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run:
|
||||
|
||||
- `rev-parse --show-toplevel`
|
||||
- `rev-parse --abbrev-ref HEAD`
|
||||
|
||||
Test:
|
||||
|
||||
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
|
||||
|
||||
## Remaining Waste Visible In Baseline
|
||||
|
||||
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
|
||||
|
||||
The benchmark still shows repeated per-workspace reads that are candidates for the next pass:
|
||||
|
||||
- base ref existence checks still repeat as `show-ref` probes.
|
||||
- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`.
|
||||
- repo common-dir lookup is lower, but still above the apparent git workspace count.
|
||||
- shortstat still runs its own merge-base/diff/untracked scan per workspace.
|
||||
|
||||
The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`.
|
||||
@@ -1,389 +0,0 @@
|
||||
# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27
|
||||
|
||||
## Answer
|
||||
|
||||
The startup timeout is real OpenCode provider snapshot work, not an agent resume path.
|
||||
|
||||
In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack:
|
||||
|
||||
1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records.
|
||||
2. Clients ask for provider snapshots for three cwd scopes at almost the same time:
|
||||
- `/Users/moboudra`
|
||||
- `/Users/moboudra/dev/paseo`
|
||||
- `/Users/moboudra/dev/blankpage/editor`
|
||||
3. Each OpenCode snapshot runs two OpenCode SDK calls:
|
||||
- `GET /provider?directory=...` through `client.provider.list()`
|
||||
- `GET /agent?directory=...` through `client.app.agents()`
|
||||
4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready.
|
||||
5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently.
|
||||
6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd.
|
||||
7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events.
|
||||
8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s.
|
||||
|
||||
So the root cause is:
|
||||
|
||||
```text
|
||||
Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget.
|
||||
```
|
||||
|
||||
More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total.
|
||||
|
||||
Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s.
|
||||
|
||||
The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading.
|
||||
|
||||
## Follow-up: Normal Copied-Home Startup Check
|
||||
|
||||
I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path:
|
||||
|
||||
```text
|
||||
fetchWorkspaces
|
||||
fetchAgents
|
||||
getProvidersSnapshot(home scope)
|
||||
getProvidersSnapshot(first workspace scope)
|
||||
```
|
||||
|
||||
That run did not reproduce the 30s OpenCode timeout.
|
||||
|
||||
```text
|
||||
home scope:
|
||||
OpenCode ready at ~8s
|
||||
availability: 1.6s
|
||||
fetch total: 5.2s
|
||||
|
||||
first workspace scope:
|
||||
OpenCode ready at ~26s
|
||||
availability: 2.0s
|
||||
fetch total: 15.4s
|
||||
```
|
||||
|
||||
The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure.
|
||||
|
||||
This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup.
|
||||
|
||||
## Reproduction Used
|
||||
|
||||
The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior.
|
||||
|
||||
Relevant scripts:
|
||||
|
||||
- `scripts/dev.sh`
|
||||
- `scripts/dev-daemon.sh`
|
||||
- `scripts/dev-home.sh`
|
||||
|
||||
`dev-home.sh` only seeds this metadata into the dev home:
|
||||
|
||||
```text
|
||||
agents/**/*.json
|
||||
projects/**/*.json
|
||||
config.json
|
||||
```
|
||||
|
||||
It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents.
|
||||
|
||||
I ran a separate daemon, not the main daemon:
|
||||
|
||||
```text
|
||||
PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi
|
||||
PASEO_LISTEN=127.0.0.1:51116
|
||||
PASEO_LOG_LEVEL=trace
|
||||
```
|
||||
|
||||
Startup facts:
|
||||
|
||||
```text
|
||||
18:13:39.552 Agent storage initialized: 712ms
|
||||
18:13:39.559 Workspace registries bootstrapped: 719ms
|
||||
18:13:39.961 Agent registry loaded: 4851 records
|
||||
18:13:39.972 Server listening: http://127.0.0.1:51116
|
||||
```
|
||||
|
||||
The probe then connected four client sessions and requested:
|
||||
|
||||
- workspaces
|
||||
- active agents
|
||||
- provider snapshots for home, paseo, and blankpage/editor
|
||||
|
||||
Client-visible result:
|
||||
|
||||
```text
|
||||
18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error:
|
||||
OpenCode app.agents timed out after 10s
|
||||
|
||||
18:14:41.687 /Users/moboudra/dev/paseo opencode error:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
|
||||
18:14:41.688 /Users/moboudra opencode error:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
```
|
||||
|
||||
## Exact OpenCode Timeline
|
||||
|
||||
OpenCode snapshot requests began at `18:14:10`.
|
||||
|
||||
Availability checks:
|
||||
|
||||
```text
|
||||
18:14:10.780 opencode availability start for /Users/moboudra
|
||||
18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo
|
||||
18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor
|
||||
|
||||
18:14:11.363 paseo availability complete: 576ms
|
||||
18:14:11.376 home availability complete: 597ms
|
||||
18:14:11.391 blankpage availability complete: 591ms
|
||||
```
|
||||
|
||||
OpenCode server acquisition:
|
||||
|
||||
```text
|
||||
18:14:11.364 OpenCode server spawn start: opencode serve --port 56376
|
||||
18:14:19.926 OpenCode server listening after 8562ms
|
||||
```
|
||||
|
||||
Six SDK calls were then issued:
|
||||
|
||||
```text
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo
|
||||
18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra
|
||||
18:14:19.931 GET /agent directory=/Users/moboudra
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor
|
||||
18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor
|
||||
```
|
||||
|
||||
Why six:
|
||||
|
||||
| Cwd | Why that scope exists | Model call | Mode call |
|
||||
| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- |
|
||||
| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
|
||||
Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro.
|
||||
|
||||
Headers arrived before the 30s timeout:
|
||||
|
||||
| Call | Cwd | Headers after request |
|
||||
| ----------- | ------------------------------- | --------------------- |
|
||||
| `/provider` | `/Users/moboudra` | 6.462s |
|
||||
| `/agent` | `/Users/moboudra` | 6.681s |
|
||||
| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s |
|
||||
| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s |
|
||||
| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s |
|
||||
| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s |
|
||||
|
||||
But body consumption and completion lagged:
|
||||
|
||||
```text
|
||||
18:14:29.380 /agent home complete, total app.agents duration 9450ms
|
||||
18:14:29.813 /agent paseo complete, total app.agents duration 9883ms
|
||||
18:14:30.263 /agent blankpage timed out at 10s
|
||||
18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout
|
||||
|
||||
18:14:41.687 paseo snapshot outer 30s timeout fires
|
||||
18:14:41.688 home snapshot outer 30s timeout fires
|
||||
|
||||
18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms
|
||||
18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms
|
||||
18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms
|
||||
```
|
||||
|
||||
The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed.
|
||||
|
||||
## Why Settings Refresh Works
|
||||
|
||||
After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`.
|
||||
|
||||
Results:
|
||||
|
||||
```text
|
||||
home refresh:
|
||||
total: 2165ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
|
||||
/Users/moboudra/dev/paseo refresh:
|
||||
total: 1675ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
|
||||
/Users/moboudra/dev/blankpage/editor refresh:
|
||||
total: 1794ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
```
|
||||
|
||||
Trace details for the manual-style refresh:
|
||||
|
||||
```text
|
||||
OpenCode server acquisition: 708ms-1291ms
|
||||
/agent completion: 433ms-592ms after request start
|
||||
/provider completion: 524ms-618ms after request start
|
||||
```
|
||||
|
||||
That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention.
|
||||
|
||||
## Minimal OpenCode-Only Repros
|
||||
|
||||
### OpenCode Only, No Daemon, No Artificial Load
|
||||
|
||||
I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently:
|
||||
|
||||
```text
|
||||
GET /provider?directory=/Users/moboudra
|
||||
GET /agent?directory=/Users/moboudra
|
||||
GET /provider?directory=/Users/moboudra/dev/paseo
|
||||
GET /agent?directory=/Users/moboudra/dev/paseo
|
||||
GET /provider?directory=/Users/moboudra/dev/blankpage/editor
|
||||
GET /agent?directory=/Users/moboudra/dev/blankpage/editor
|
||||
```
|
||||
|
||||
Three runs:
|
||||
|
||||
| Run | `opencode serve` ready | All six calls complete |
|
||||
| --- | ---------------------- | ---------------------- |
|
||||
| 1 | 1376ms | 1295ms |
|
||||
| 2 | 906ms | 1050ms |
|
||||
| 3 | 939ms | 898ms |
|
||||
|
||||
Slowest individual call in those runs:
|
||||
|
||||
```text
|
||||
/provider /Users/moboudra/dev/paseo: 1270ms total
|
||||
/agent /Users/moboudra/dev/blankpage/editor: 1251ms total
|
||||
```
|
||||
|
||||
So six concurrent OpenCode calls alone are not the bug.
|
||||
|
||||
### OpenCode Only Plus External Git Storm, No Daemon
|
||||
|
||||
I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon.
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
opencode serve ready: 15479ms
|
||||
all six OpenCode calls complete: 15176ms after server ready
|
||||
combined cold-start + calls: about 30655ms
|
||||
```
|
||||
|
||||
Individual calls under the external git storm:
|
||||
|
||||
| Call | Cwd | Total |
|
||||
| ----------- | -------------------------------------- | ------: |
|
||||
| `/provider` | `/Users/moboudra` | 10684ms |
|
||||
| `/agent` | `/Users/moboudra` | 10767ms |
|
||||
| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms |
|
||||
| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms |
|
||||
| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms |
|
||||
| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms |
|
||||
|
||||
This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary.
|
||||
|
||||
## Why It Does Not Retry
|
||||
|
||||
`ProviderSnapshotManager.getSnapshot()` only starts background warmup for:
|
||||
|
||||
- no existing snapshot
|
||||
- missing providers
|
||||
- entries still in `loading` with no active load
|
||||
|
||||
When refresh fails, `refreshProvider()` stores:
|
||||
|
||||
```text
|
||||
status: "error"
|
||||
error: "Timed out refreshing OpenCode after 30000ms"
|
||||
```
|
||||
|
||||
An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error.
|
||||
|
||||
Settings refresh calls `refresh_providers_snapshot_request`, which routes to:
|
||||
|
||||
```text
|
||||
refreshSettingsSnapshot()
|
||||
clearCachedProviders()
|
||||
resetSnapshotToLoading()
|
||||
refreshProviders(... force: true)
|
||||
```
|
||||
|
||||
That is why you have to force a manual refresh.
|
||||
|
||||
## Git Work During The Repro
|
||||
|
||||
This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling.
|
||||
|
||||
Total git commands in the dev-style copied-home daemon log:
|
||||
|
||||
```text
|
||||
632 spawned
|
||||
632 closed
|
||||
```
|
||||
|
||||
Top cwd counts:
|
||||
|
||||
| Count | Cwd |
|
||||
| ----: | ------------------------------------------------------------------------------------- |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` |
|
||||
| 24 | `/Users/moboudra/dev/paseo` |
|
||||
| 24 | `/Users/moboudra/dev/blankpage/editor` |
|
||||
| 24 | `/Users/moboudra/dev/faro/main` |
|
||||
| 24 | `/Users/moboudra/dev/konbert/web` |
|
||||
| 24 | `/Users/moboudra/dev/paseo-cloud` |
|
||||
|
||||
In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were:
|
||||
|
||||
```text
|
||||
142 git command spawns
|
||||
150 git command closes
|
||||
```
|
||||
|
||||
The main repeated command shapes were:
|
||||
|
||||
```text
|
||||
76 git rev-parse --show-toplevel
|
||||
72 git status --porcelain
|
||||
72 git show-ref --verify --quiet refs/remotes/origin/main
|
||||
72 git show-ref --verify --quiet refs/heads/main
|
||||
16 git config --get branch.main.remote
|
||||
16 git config --get branch.main.merge
|
||||
16 git rev-list --count main..origin/main
|
||||
16 git rev-list --count origin/main..main
|
||||
```
|
||||
|
||||
## Original `log.txt` Alignment
|
||||
|
||||
The original startup showed the same home and paseo outer timeout shape:
|
||||
|
||||
```text
|
||||
16:04:22.466 /Users/moboudra/dev/paseo:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
|
||||
16:04:22.482 /Users/moboudra:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
```
|
||||
|
||||
The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget.
|
||||
|
||||
## Files Instrumented For Diagnosis
|
||||
|
||||
Temporary trace instrumentation was added to:
|
||||
|
||||
- `packages/server/src/server/agent/provider-snapshot-manager.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode/runtime.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode/server-manager.ts`
|
||||
|
||||
The instrumentation is behavior-neutral and only emits trace logs.
|
||||
@@ -1,381 +0,0 @@
|
||||
# Daemon Startup Sequence Analysis - 2026-05-27
|
||||
|
||||
Source log: `log.txt` at repository root.
|
||||
|
||||
Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat.
|
||||
|
||||
This report is descriptive only. It does not propose optimizations.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration.
|
||||
|
||||
- Worker start: `16:03:46.678`, line 1.
|
||||
- Server listening: `16:03:48.285`, line 47, elapsed `602ms`.
|
||||
- First client hello: `16:03:50.285`, line 66.
|
||||
- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`.
|
||||
|
||||
The startup shape is therefore:
|
||||
|
||||
- Daemon listen readiness: about `0.6s`.
|
||||
- Client reconnect plus workspace/app/provider hydration: about `45s`.
|
||||
- No git commands after workspace registry reconciliation in this slice.
|
||||
|
||||
## Method
|
||||
|
||||
I parsed structured trace lines from `log.txt`, especially:
|
||||
|
||||
- `Git command closed`
|
||||
- `agent.session.inbound`
|
||||
- `agent.session.outbound`
|
||||
- `ws_slow_request`
|
||||
- provider snapshot warnings
|
||||
- provider resume events
|
||||
|
||||
Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log.
|
||||
|
||||
Relevant code paths checked:
|
||||
|
||||
- `packages/server/src/server/session.ts`
|
||||
- `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`.
|
||||
- `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`.
|
||||
- `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`.
|
||||
- `packages/server/src/server/workspace-git-service.ts`
|
||||
- checkout snapshot/root resolution uses `git rev-parse --show-toplevel`.
|
||||
- snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence.
|
||||
- `packages/app/src/contexts/session-context.tsx`
|
||||
- initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`.
|
||||
- `packages/app/src/hooks/use-sidebar-workspaces-list.ts`
|
||||
- sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`.
|
||||
|
||||
## Startup Timeline
|
||||
|
||||
| time | line | event |
|
||||
| -------------- | ---: | ------------------------------------------------------------------ |
|
||||
| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker |
|
||||
| `16:03:47.683` | 4 | worker spawned |
|
||||
| `16:03:47.684` | 6 | daemon keypair loaded |
|
||||
| `16:03:48.281` | 44 | bootstrap complete, ready to listen |
|
||||
| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` |
|
||||
| `16:03:50.274` | 60 | first websocket awaiting hello |
|
||||
| `16:03:50.285` | 66 | first client connected via hello |
|
||||
| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` |
|
||||
| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` |
|
||||
| `16:04:24.183` | 1201 | OpenCode provider subscribe starts |
|
||||
| `16:04:24.183` | 1202 | OpenCode provider subscribe ready |
|
||||
| `16:04:24.306` | 1214 | OpenCode server connected event |
|
||||
| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence |
|
||||
| `16:04:33.666` | 1777 | workspace registries reconciled |
|
||||
| `16:04:34.197` | 1783 | OpenCode heartbeat |
|
||||
| `16:04:44.200` | 1789 | OpenCode heartbeat |
|
||||
|
||||
## Git Command Totals
|
||||
|
||||
Total git commands in the sliced startup: `444`.
|
||||
|
||||
| phase | commands | failures | summed process time |
|
||||
| ------------------------------- | -------: | -------: | ------------------: |
|
||||
| daemon bootstrap before listen | 13 | 4 | 445ms |
|
||||
| post-listen before first client | 1 | 0 | 2020ms |
|
||||
| client reconnect + reconcile | 430 | 71 | 120813ms |
|
||||
| after reconcile | 0 | 0 | 0ms |
|
||||
| total | 444 | 75 | 123278ms |
|
||||
|
||||
Summed process time is not wall-clock time. Many commands overlap.
|
||||
|
||||
## Git Command Categories
|
||||
|
||||
| category | commands | failures | summed process time | max duration |
|
||||
| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: |
|
||||
| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms |
|
||||
| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms |
|
||||
| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms |
|
||||
| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms |
|
||||
| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms |
|
||||
| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms |
|
||||
|
||||
What those categories mean in the app:
|
||||
|
||||
- Root detection: determine whether a cwd is inside a git repo and find its checkout root.
|
||||
- Dirty status: show dirty/clean workspace state.
|
||||
- Upstream config and ahead/behind: show branch tracking and sync state.
|
||||
- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status.
|
||||
|
||||
## Per-Workspace Git Work
|
||||
|
||||
Columns:
|
||||
|
||||
- `phase`: `pre/warm/reconnect/after`
|
||||
- `cats`: `root/dirty/upstream/ahead/refs/base/other`
|
||||
- `total_ms`: summed process time for that workspace
|
||||
|
||||
| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes |
|
||||
| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` |
|
||||
| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` |
|
||||
| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` |
|
||||
| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | |
|
||||
| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` |
|
||||
| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` |
|
||||
| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | |
|
||||
| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | |
|
||||
| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | |
|
||||
| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | |
|
||||
| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | |
|
||||
| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | |
|
||||
| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | |
|
||||
| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | |
|
||||
| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | |
|
||||
| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` |
|
||||
| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` |
|
||||
| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` |
|
||||
|
||||
## Git Failure Shape
|
||||
|
||||
There were 75 nonzero git exits.
|
||||
|
||||
Most failures were not timeouts. They were expected probe failures:
|
||||
|
||||
- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories.
|
||||
- Missing upstream config: `config --get branch.<branch>.remote` fails for branches without configured upstream.
|
||||
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` fails when the remote branch/ref does not exist.
|
||||
- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist.
|
||||
|
||||
The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures.
|
||||
|
||||
## Inbound Client Work
|
||||
|
||||
Inbound session messages during the startup window:
|
||||
|
||||
| request | count |
|
||||
| --------------------------------- | ----: |
|
||||
| `client_heartbeat` | 19 |
|
||||
| `checkout_pr_status_request` | 18 |
|
||||
| `fetch_agents_request` | 11 |
|
||||
| `fetch_workspaces_request` | 9 |
|
||||
| `get_providers_snapshot_request` | 9 |
|
||||
| `project_icon_request` | 9 |
|
||||
| `fetch_agent_timeline_request` | 7 |
|
||||
| `clear_agent_attention` | 6 |
|
||||
| `list_terminals_request` | 5 |
|
||||
| `subscribe_terminals_request` | 5 |
|
||||
| `list_available_editors_request` | 2 |
|
||||
| `subscribe_checkout_diff_request` | 2 |
|
||||
| `checkout_status_request` | 1 |
|
||||
| `fetch_agent_request` | 1 |
|
||||
| `file_explorer_request` | 1 |
|
||||
| `read_project_config_request` | 1 |
|
||||
| `workspace_setup_status_request` | 1 |
|
||||
|
||||
Inbound by client:
|
||||
|
||||
| client | count | top work |
|
||||
| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` |
|
||||
| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` |
|
||||
|
||||
## Outbound Client Work
|
||||
|
||||
Outbound session messages during the startup window:
|
||||
|
||||
| message | count |
|
||||
| ---------------------------------- | ----: |
|
||||
| `providers_snapshot_update` | 129 |
|
||||
| `workspace_update` | 81 |
|
||||
| `checkout_status_update` | 76 |
|
||||
| `agent_update` | 47 |
|
||||
| `checkout_pr_status_response` | 18 |
|
||||
| `fetch_agents_response` | 11 |
|
||||
| `fetch_workspaces_response` | 9 |
|
||||
| `get_providers_snapshot_response` | 9 |
|
||||
| `project_icon_response` | 9 |
|
||||
| `fetch_agent_timeline_response` | 7 |
|
||||
| `list_terminals_response` | 5 |
|
||||
| `terminals_changed` | 5 |
|
||||
| `list_available_editors_response` | 2 |
|
||||
| `subscribe_checkout_diff_response` | 2 |
|
||||
| `checkout_status_response` | 1 |
|
||||
| `fetch_agent_response` | 1 |
|
||||
| `file_explorer_response` | 1 |
|
||||
| `read_project_config_response` | 1 |
|
||||
| `workspace_setup_status_response` | 1 |
|
||||
|
||||
Provider snapshot updates were large and repeated:
|
||||
|
||||
- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes.
|
||||
- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes.
|
||||
- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes.
|
||||
|
||||
## Slow Requests
|
||||
|
||||
Slow requests logged during startup:
|
||||
|
||||
| time | request | duration | client | line |
|
||||
| -------------- | --------------------------------: | -------: | --------------------- | ---: |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 |
|
||||
| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 |
|
||||
| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 |
|
||||
| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 |
|
||||
| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 |
|
||||
| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 |
|
||||
| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 |
|
||||
| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 |
|
||||
| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 |
|
||||
| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 |
|
||||
| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 |
|
||||
| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 |
|
||||
| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 |
|
||||
| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 |
|
||||
| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 |
|
||||
| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 |
|
||||
| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 |
|
||||
| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 |
|
||||
| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 |
|
||||
| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 |
|
||||
| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 |
|
||||
| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 |
|
||||
| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 |
|
||||
| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 |
|
||||
| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 |
|
||||
| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 |
|
||||
| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 |
|
||||
| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 |
|
||||
| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 |
|
||||
| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 |
|
||||
| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 |
|
||||
| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 |
|
||||
| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 |
|
||||
| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 |
|
||||
| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 |
|
||||
|
||||
The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0.
|
||||
|
||||
## Provider Findings
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode provider snapshot refresh had two timeouts:
|
||||
|
||||
| time | line | cwd | error |
|
||||
| -------------- | ---: | --------------------------- | --------------------------------------------- |
|
||||
| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` |
|
||||
| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` |
|
||||
|
||||
These are provider snapshot failures, not OpenCode agent resume failures.
|
||||
|
||||
The persisted OpenCode agent did resume:
|
||||
|
||||
| time | line | event |
|
||||
| -------------- | ---: | ----------------------------------------------------- |
|
||||
| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` |
|
||||
| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` |
|
||||
| `16:04:24.306` | 1214 | raw event `server.connected` |
|
||||
| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` |
|
||||
| `16:04:34.197` | 1783 | raw event `server.heartbeat` |
|
||||
| `16:04:44.200` | 1789 | raw event `server.heartbeat` |
|
||||
|
||||
There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice.
|
||||
|
||||
OpenCode-related git:
|
||||
|
||||
- `~/dev/opencode` had 22 git commands.
|
||||
- Four failed.
|
||||
- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`.
|
||||
- Those failures are git state/probe failures, not OpenCode provider process failures.
|
||||
|
||||
### Codex
|
||||
|
||||
Codex provider startup observations:
|
||||
|
||||
- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery.
|
||||
- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518.
|
||||
- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice.
|
||||
- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice.
|
||||
|
||||
### Claude
|
||||
|
||||
Claude agents resume successfully:
|
||||
|
||||
| time | line | client | agent |
|
||||
| -------------- | ---: | -------- | -------------------------------------- |
|
||||
| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` |
|
||||
| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` |
|
||||
|
||||
## What Work The App Asked For
|
||||
|
||||
The startup work visible in the app/server protocol is:
|
||||
|
||||
- Workspace list/sidebar hydration:
|
||||
- `fetch_workspaces_request`, 9 total.
|
||||
- This asks for the workspace list sorted by `activity_at desc`, usually page limit 200.
|
||||
- On the server this triggers workspace git observer sync and workspace update flushing.
|
||||
|
||||
- Agent list and agent detail hydration:
|
||||
- `fetch_agents_request`, 11 total.
|
||||
- `fetch_agent_request`, 1 total.
|
||||
- `fetch_agent_timeline_request`, 7 total.
|
||||
- Timeline requests are among the slowest requests in this slice.
|
||||
|
||||
- Checkout/PR status UI:
|
||||
- `checkout_pr_status_request`, 18 total, all Electron.
|
||||
- `checkout_status_request`, 1 total.
|
||||
- `subscribe_checkout_diff_request`, 2 total.
|
||||
- These correspond to git snapshot consumers and are clustered during Electron reconnect.
|
||||
|
||||
- Provider/model/mode UI:
|
||||
- `get_providers_snapshot_request`, 9 total.
|
||||
- `providers_snapshot_update`, 129 outbound updates.
|
||||
- OpenCode provider snapshot refresh times out twice during this flow.
|
||||
|
||||
- Workspace chrome:
|
||||
- `project_icon_request`, 9 total.
|
||||
- `file_explorer_request`, 1 total.
|
||||
|
||||
- Terminal panel:
|
||||
- `list_terminals_request`, 5 total.
|
||||
- `subscribe_terminals_request`, 5 total.
|
||||
- `terminals_changed`, 5 outbound updates.
|
||||
|
||||
- Attention state:
|
||||
- `clear_agent_attention`, 6 total.
|
||||
- Some failures appear while clearing attention for persisted agents, but these are not provider startup failures.
|
||||
|
||||
## Concrete Waste-Looking Work, Without Optimizing Yet
|
||||
|
||||
The log shows repeated work in these exact forms:
|
||||
|
||||
- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work.
|
||||
- Several workspaces get repeated full checkout snapshot patterns:
|
||||
- three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks.
|
||||
- three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks.
|
||||
- `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes.
|
||||
- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s.
|
||||
- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB.
|
||||
- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds.
|
||||
|
||||
Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed.
|
||||
@@ -5,7 +5,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
@@ -13,10 +13,12 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
|
||||
|
||||
79
docs/i18n.md
Normal file
79
docs/i18n.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# I18n
|
||||
|
||||
Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
|
||||
## Supported Locales
|
||||
|
||||
- `en`
|
||||
- `ar`
|
||||
- `es`
|
||||
- `fr`
|
||||
- `ru`
|
||||
- `zh-CN`
|
||||
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English.
|
||||
|
||||
## Translation Scope
|
||||
|
||||
Translate client-owned UI copy: labels, buttons, empty states, confirmation text, and local status/error wrappers.
|
||||
|
||||
Do not translate agent output, daemon output, terminal contents, file paths, provider names, model names, command names, user-authored text, code blocks, logs, or raw protocol/server error text.
|
||||
|
||||
## Adding Copy
|
||||
|
||||
English source strings live in `packages/app/src/i18n/resources/en.ts`. Simplified Chinese strings live in `packages/app/src/i18n/resources/zh-CN.ts`.
|
||||
|
||||
For migrated screens and components, use `useTranslation()` and pass translated text into UI primitives. Low-level primitives such as `<Button>` do not import translation state unless they own the text they render.
|
||||
|
||||
Keep resource keys grouped by product surface, not component mechanics.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
|
||||
```
|
||||
|
||||
The parity test catches missing keys between English and Simplified Chinese resources.
|
||||
|
||||
## Migration Order
|
||||
|
||||
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.
|
||||
|
||||
1. App shell and shared UI chrome: common actions, headers, sheets, command center, and client-owned toast status.
|
||||
2. Composer and agent workflow: composer input, agent controls, permission prompts, plan approval, and agent panel wrapper states.
|
||||
3. Settings expansion: Appearance, Shortcuts, Integrations, Permissions, Diagnostics, About, Project settings, Host settings, and provider diagnostics.
|
||||
4. Workspace and panels: setup/file/browser/terminal wrapper copy, file explorer local states, import-session flows, and remaining local toast/error wrapper text.
|
||||
|
||||
Within a migrated surface, do not leave mixed-language neighboring labels when those labels are owned by the client. Move the whole local copy cluster together.
|
||||
|
||||
### Progress
|
||||
|
||||
- Batch 2 migrated Composer and agent workflow chrome: Composer input and attachments, agent controls, stream permission prompts, agent panel wrapper states, and draft panel descriptors. Provider/model names, provider-defined option labels, agent output, and protocol/server diagnostics remain untranslated.
|
||||
- Batch 3A migrated Settings Diagnostics/About, Appearance, Shortcuts, Integrations, and Desktop Permissions chrome. Host settings and Project settings remain for Batch 3B; raw runtime status/error details remain untranslated.
|
||||
- Batch 3B migrated Host settings, Provider diagnostics, and Project settings chrome. Provider/model names, project/host labels, script commands, diagnostic output, file paths, and raw runtime/server error details remain untranslated.
|
||||
- Batch 4A migrated workspace wrapper chrome for import sessions, file explorer, setup, browser, terminal, and file panels. File paths, URLs, commands, logs, terminal output, provider labels, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4B migrated workspace tab shell, workspace scripts, Git actions/diff/PR chrome, worktree archive warnings, Open-in-editor controls, and inline review controls. Branch names, PR titles/bodies, check names, workflow names, file paths, diff contents, commands, terminal output, provider labels, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4C migrated Sidebar project/workspace menus, hide/remove/archive confirmations, workspace rename chrome, New workspace ref picker/create flow, and Open project home tiles. Workspace/project names, branch names, PR titles, paths, provider labels, daemon output, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4D migrated provider/model selector chrome, provider catalog install modal, add-connection method modal, and paste-pairing-link modal. Provider/model/catalog names, provider descriptions, pairing URLs, protocol parser errors, daemon connection details, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4E migrated onboarding welcome chrome, direct-connection modal fields/actions/local failure guidance, QR scan permission/unavailable states, and desktop pair-device card. Pairing URLs, host/port placeholders, endpoint values, transport details, protocol parser errors, and raw runtime/server errors remain untranslated.
|
||||
- Batch 4F migrated realtime voice overlay accessibility labels, rewind menu chrome and fallback toast, DiffViewer default empty state, and service URL chooser copy. Shortcut key names, raw daemon errors, diff contents, URLs, and caller-provided override labels remain untranslated.
|
||||
- Batch 4G migrated the keyboard shortcuts help dialog to render section titles, row labels, and row notes through translation keys. Shortcut key names, shortcut combos, binding IDs, action IDs, and fallback registry labels remain untranslated.
|
||||
- Batch 4H migrated Sessions screen chrome and AgentList local UI copy: date section headers, local status labels, fallback session titles, badges, load-more/empty states, and the archive action sheet. Agent titles, project paths, provider icons/labels, host labels, relative timestamps, and raw runtime data remain untranslated.
|
||||
- Batch 4I migrated message utility chrome: image lightbox labels/errors, code and turn copy accessibility labels, dictation controls, question form fallback placeholders/actions, PlanCard fallback title, assistant image fallback errors, and todo list labels. Message bodies, plan text, todo item text, attachment labels, runtime dictation errors, and agent/tool output remain untranslated.
|
||||
- Batch 4J migrated workspace tab toast/empty chrome: copy failure messages, copied labels, resume-command availability errors, reload-agent local status, host-disconnected wrapper reuse, and split-pane empty state. Agent IDs, generated resume commands, workspace paths, branch names, and raw reload errors remain untranslated.
|
||||
- Batch 4K migrated sidebar/project list chrome: host picker fallback/search/title, footer actions and tooltips, Sessions row labels, mobile close label, New workspace tooltip/accessibility label, project-list empty states, and project settings host-load wrapper text. Host names, project names, workspace names, paths, branch names, and raw host errors remain untranslated.
|
||||
- Batch 4L migrated picker/file/detail utility chrome: project picker states, branch switcher labels/placeholders, file pane loading/empty/fallback errors, tool-call details section/empty labels, and open-file accessibility. Directory paths, branch names, file contents, file sizes, tool details, and raw file-load errors remain untranslated.
|
||||
- Batch 4M migrated hook/modal utility chrome: image attachment permissions/dialog/errors, copied toast wrappers, rename modal local validation/fallback errors, branch switcher stash/switch prompts and fallback toasts, and workspace setup local fallback errors. Copied labels supplied by callers, branch names, selected paths, raw dialog/API errors, and raw server errors remain untranslated.
|
||||
- Batch 4N migrated pure view-model/policy utility chrome: import-session fallback titles/previews/empty states and the worktree setup callout. Provider labels and IDs remain runtime values interpolated into translated wrappers.
|
||||
- Batch 4O migrated remaining small utility chrome: workspace route gate states/actions, compaction markers, archived-agent callout, web browser fallback, desktop quitting overlay, and image drop overlay. Host names, host status values, browser IDs, token counts, and raw route errors remain runtime values.
|
||||
- Batch 4P migrated provider-selection pure view-model utility copy to direct `i18n.t(...)` calls and removed the local labels parameter path. Provider/model labels, provider IDs, and provider snapshot error messages remain runtime values.
|
||||
- Batch 4Q migrated desktop update utility chrome: app update status text, update callout titles/actions, generic update errors, and install-error wrappers. Version labels, installer messages, raw update errors, release-channel data, and logs remain runtime values.
|
||||
- Batch 4R migrated desktop permission utility chrome: permission status details, permission request fallback errors, empty permission statuses, and desktop notification test wrappers. Browser permission states, exception names, and raw browser API error messages remain runtime values.
|
||||
- Batch 4S migrated desktop daemon settings chrome: built-in daemon status rows, daemon lifecycle toggles, logs/status modals, clipboard alerts, daemon management confirmations/errors, daemon status load errors, and desktop CLI/skills install wrapper errors. PIDs, log paths, log contents, CLI status output, version values, and raw IPC errors remain runtime values.
|
||||
- Batch 4T migrated remaining attachment/autocomplete utility chrome: user and composer review attachment labels, workspace hover-card accessibility, branch stash restore prompts/toasts, agent autocomplete loading/empty/fallback error text, older-history fallback toast, draft panel labels, and agent-control fallback labels. PR/issue numbers, browser element tags, branch names, provider labels, model labels, agent prompts, command/file names, and raw server errors remain runtime values.
|
||||
- Batch 4U migrated shared default utility chrome: Combobox and Autocomplete default placeholders/empty/loading labels, drag-overlay and subagent-track loading labels, sub-agent activity fallback headers, and file-preview fallback errors. Caller-provided labels, tab titles, subagent descriptions, file paths, and raw file-load errors remain runtime values.
|
||||
- Batch 4V migrated Git policy action chrome to direct `i18n.t(...)` calls: commit/pull/push/sync/PR/merge/auto-merge/archive action labels, pending/success labels, and unavailable reasons. Branch/base refs, PR URLs, GitHub merge-state enum values, runtime statuses, and raw Git/GitHub errors remain runtime values.
|
||||
- Batch 4W migrated remaining local wrapper states: workspace copy unavailable toasts, startup daemon-log loading/empty/load-failed text, and file-explorer workspace/host unavailable fallbacks. Workspace paths, branch names, daemon log contents/paths, checkout query details, and raw file/daemon errors remain runtime values.
|
||||
- Batch 4X migrated descriptor and command chrome: Pair-device modal header, workspace setup sheet title, terminal panel fallback labels, command-center action titles, and file-pane host-disconnected fallback. Provider/catalog names, command-center search keywords, terminal runtime titles, file paths, and raw read errors remain runtime values.
|
||||
- Batch 4Y tightened the translation boundary so React components and custom hooks use `useTranslation()` while pure helpers keep direct `i18n.t(...)` fallbacks, and migrated remaining small UI/accessibility fallbacks across message details, menu backdrops, startup errors, sidebar PR badges, settings/project accessibility labels, composer send/create/download fallbacks, client slash-command descriptions, terminal subscribe errors, and desktop update completion text. Provider catalog metadata, shortcut registry fallbacks, agent/daemon/protocol reasons, terminal contents, raw runtime errors, and user/project/workspace names remain untranslated.
|
||||
- Batch 4Z expanded the supported locale set to the six UN official languages: Arabic, Chinese, English, French, Russian, and Spanish. Arabic, French, Russian, and Spanish now have full client-owned UI resource coverage, with key parity, fallback-ratio, and interpolation-placeholder tests guarding the generated translations. Arabic does not enable RTL layout direction in this batch.
|
||||
@@ -335,7 +335,7 @@ For paint-layer bugs, use high-contrast probes:
|
||||
3. Screenshot the simulator and sample pixels to see which color fills the area.
|
||||
4. Remove the probes before committing.
|
||||
|
||||
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
|
||||
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container.
|
||||
|
||||
## References
|
||||
|
||||
@@ -349,4 +349,3 @@ The welcome-screen investigation used this approach to prove the white layer was
|
||||
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
|
||||
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
|
||||
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
|
||||
- [Local research note: welcome theme split](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md)
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-1kkx6pimIhhqk/zSaUWknTL9NC1PZA271zmhktH4Sq4=
|
||||
sha256-62dqunclpCgWCsIyRV5DMjGdwUhPP4rbjEjSO9dJ7no=
|
||||
|
||||
277
package-lock.json
generated
277
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2567,9 +2567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -20529,6 +20529,19 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-localization": {
|
||||
"version": "17.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.9.tgz",
|
||||
"integrity": "sha512-k5eYHr7iLMob3M8cHH6bgDrDRmqnZG8xKGLhpx8ST+LsFXmSgYpJ/t0VwWm0gz64C/K669XhObdG3D6QwCGqhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rtl-detect": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-manifests": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz",
|
||||
@@ -23838,6 +23851,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -24016,6 +24038,34 @@
|
||||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-corefoundation": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
||||
@@ -24193,6 +24243,19 @@
|
||||
"css-in-js-utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inspect-webkit": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/inspect-webkit/-/inspect-webkit-0.0.5.tgz",
|
||||
"integrity": "sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"inspect-webkit": "dist/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
@@ -31257,6 +31320,33 @@
|
||||
"react": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
@@ -32637,6 +32727,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/rtl-detect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
|
||||
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -32923,6 +33019,22 @@
|
||||
"seroval": "^1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-sim": {
|
||||
"version": "0.1.40",
|
||||
"resolved": "https://registry.npmjs.org/serve-sim/-/serve-sim-0.1.40.tgz",
|
||||
"integrity": "sha512-32JSUriN/EnNR3zx6fmiOKjuFrbA8VHWR2J3dX8T51Z/xkSmrSLOwaz/3W9lPiCIaF2O3LT4qpAhLkp0f1kVaw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"inspect-webkit": "^0.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"serve-sim": "dist/serve-sim.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
@@ -35204,7 +35316,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -36089,6 +36201,15 @@
|
||||
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
@@ -36953,7 +37074,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -36997,6 +37118,7 @@
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
@@ -37004,12 +37126,15 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
@@ -37049,6 +37174,7 @@
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
"serve-sim": "^0.1.40",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.6",
|
||||
"wrangler": "^4.75.0",
|
||||
@@ -37156,6 +37282,85 @@
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"packages/app/node_modules/dom-serializer": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
|
||||
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/domelementtype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
|
||||
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/domhandler": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
|
||||
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/domutils": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
|
||||
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^3.0.0",
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/entities": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/expo-clipboard": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
||||
@@ -37167,6 +37372,28 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/htmlparser2": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
|
||||
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^3.0.0",
|
||||
"domhandler": "^6.0.0",
|
||||
"domutils": "^4.0.2",
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
@@ -37178,12 +37405,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.92",
|
||||
"@getpaseo/protocol": "0.1.92",
|
||||
"@getpaseo/server": "0.1.92",
|
||||
"@getpaseo/client": "0.1.94",
|
||||
"@getpaseo/protocol": "0.1.94",
|
||||
"@getpaseo/server": "0.1.94",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37429,10 +37656,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.92",
|
||||
"@getpaseo/relay": "0.1.92",
|
||||
"@getpaseo/protocol": "0.1.94",
|
||||
"@getpaseo/relay": "0.1.94",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37452,7 +37679,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37704,7 +37931,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37740,7 +37967,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37971,7 +38198,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37992,7 +38219,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38210,14 +38437,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.92",
|
||||
"@getpaseo/highlight": "0.1.92",
|
||||
"@getpaseo/protocol": "0.1.92",
|
||||
"@getpaseo/relay": "0.1.92",
|
||||
"@getpaseo/client": "0.1.94",
|
||||
"@getpaseo/highlight": "0.1.94",
|
||||
"@getpaseo/protocol": "0.1.94",
|
||||
"@getpaseo/relay": "0.1.94",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38989,7 +39216,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
BIN
packages/app/assets/images/editor-apps/antigravity.png
Normal file
BIN
packages/app/assets/images/editor-apps/antigravity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -533,13 +533,23 @@ async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
);
|
||||
}
|
||||
|
||||
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
||||
function startMetro(input: {
|
||||
metroPort: number;
|
||||
daemonPort: number;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
}): ChildProcess {
|
||||
const appDir = path.resolve(__dirname, "..");
|
||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(input.metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: "none",
|
||||
...(process.env.E2E_DESKTOP_RUNTIME === "1"
|
||||
? {
|
||||
PASEO_WEB_PLATFORM: "electron",
|
||||
EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${input.daemonPort}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
@@ -551,7 +561,7 @@ function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffe
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
buffer.add(`[stdout] ${line}`);
|
||||
input.buffer.add(`[stdout] ${line}`);
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
@@ -562,7 +572,7 @@ function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffe
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
buffer.add(`[stderr] ${line}`);
|
||||
input.buffer.add(`[stderr] ${line}`);
|
||||
console.error(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
@@ -685,7 +695,11 @@ export default async function globalSetup() {
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||
metroProcess = startMetro({
|
||||
metroPort,
|
||||
daemonPort: port,
|
||||
buffer: metroLineBuffer,
|
||||
});
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
relayPort,
|
||||
|
||||
@@ -165,14 +165,14 @@ export async function startRunningMockAgent(
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
model: opts.model,
|
||||
initialPrompt: opts.prompt,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expectComposerVisible(page);
|
||||
await client.sendAgentMessage(agent.id, opts.prompt);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
return { client, repo };
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,10 @@ export interface DesktopBridgeConfig {
|
||||
daemonLogPath?: string;
|
||||
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
||||
manageBuiltInDaemon?: boolean;
|
||||
/** Daemon listen address reported by desktop_daemon_status. Defaults to 127.0.0.1:6767. */
|
||||
daemonListen?: string;
|
||||
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
||||
hangDaemonStart?: boolean;
|
||||
/**
|
||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||
@@ -124,7 +128,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return {
|
||||
serverId: cfg.serverId,
|
||||
status: daemonRunning ? "running" : "stopped",
|
||||
listen: "127.0.0.1:6767",
|
||||
listen: cfg.daemonListen ?? "127.0.0.1:6767",
|
||||
hostname: null,
|
||||
pid: currentPid,
|
||||
home: "",
|
||||
@@ -134,6 +138,18 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
}
|
||||
|
||||
function startDesktopDaemon() {
|
||||
if (cfg.hangDaemonStart) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
startCount += 1;
|
||||
daemonRunning = true;
|
||||
// First start (bootstrap) returns the configured PID; subsequent starts
|
||||
// (after a stop) get a fresh PID so tests can observe the change.
|
||||
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
const desktopBridge: {
|
||||
platform: string;
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -218,12 +234,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
if (command === "start_desktop_daemon") {
|
||||
startCount += 1;
|
||||
daemonRunning = true;
|
||||
// First start (bootstrap) returns the configured PID; subsequent starts
|
||||
// (after a stop) get a fresh PID so tests can observe the change.
|
||||
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
||||
return buildDaemonStatus();
|
||||
return startDesktopDaemon();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -69,34 +69,46 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
/** Assert the inline new-agent plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the new terminal button is visible in the tab bar. */
|
||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||
/** Assert the new-tab dropdown trigger is visible in the tab bar. */
|
||||
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
|
||||
page.getByTestId("workspace-new-tab-menu-trigger").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Click the new agent tab button to create a draft/chat tab. */
|
||||
/** Click the inline plus button to create a draft/chat tab. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||
const button = page
|
||||
.getByTestId("workspace-new-agent-tab-inline")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the new terminal button to create a terminal tab. */
|
||||
/** Open the new-tab menu and click "New terminal". */
|
||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
const trigger = page
|
||||
.getByTestId("workspace-new-tab-menu-trigger")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = page
|
||||
.getByTestId("workspace-new-tab-menu-terminal")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(item).toBeVisible({ timeout: 10_000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||
@@ -117,9 +129,9 @@ export async function waitForTabWithTitle(
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
/** Assert the inline new-agent plus button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
|
||||
const buttons = page.getByTestId("workspace-new-agent-tab-inline").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pr-pane-data";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
|
||||
}
|
||||
|
||||
export async function clickSaveProjectSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
}
|
||||
|
||||
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
|
||||
@@ -79,7 +79,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise<void>
|
||||
}
|
||||
|
||||
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
@@ -44,6 +44,18 @@ export async function openQuestion(
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function expectQuestionNavigationEnabled(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function fillQuestionAnswer(
|
||||
page: Page,
|
||||
input: { question: string; answer: string },
|
||||
@@ -59,3 +71,32 @@ export async function submitQuestionAnswers(page: Page): Promise<void> {
|
||||
await page.getByTestId("question-form-primary-action").click();
|
||||
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectQuestionPrimaryActionEnabled(page: Page, label: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: label }),
|
||||
).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function expectQuestionPrimaryActionDisabled(
|
||||
page: Page,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: label }),
|
||||
).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectQuestionDismissEnabled(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: "Dismiss" }),
|
||||
).toBeEnabled();
|
||||
}
|
||||
|
||||
export async function continueToNextQuestion(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: "Next" })
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -139,17 +139,18 @@ class StartupAssertions {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async expectsReconnectWelcome(): Promise<this> {
|
||||
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
|
||||
async expectsSavedHostShell(input: { label: string }): Promise<this> {
|
||||
await expect(this.page.getByText(input.label, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(this.page.getByRole("button", { name: "Add project" })).toBeVisible();
|
||||
await expect(this.page.getByRole("button", { name: "Home" })).toBeVisible();
|
||||
await expect(this.page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
|
||||
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
|
||||
async expectsNoSavedHostErrorStatus(): Promise<this> {
|
||||
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
||||
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
||||
return this;
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "workspace-new-agent-tab-inline").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from "./fixtures";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertTerminalTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
clickNewChat,
|
||||
@@ -91,7 +91,7 @@ test.describe("Tab creation", () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertTerminalTileVisible(page);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,14 @@ import { test } from "./fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
chooseQuestionOption,
|
||||
continueToNextQuestion,
|
||||
expectCurrentQuestion,
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
fillQuestionAnswer,
|
||||
openQuestion,
|
||||
submitQuestionAnswers,
|
||||
@@ -15,6 +20,8 @@ const TOTAL_QUESTIONS = 3;
|
||||
const SURFACE_QUESTION = "Which surface should this apply to?";
|
||||
const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||
const SUCCESS_QUESTION = "What success criteria should we use?";
|
||||
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
|
||||
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
|
||||
|
||||
test.describe("Question prompt pagination", () => {
|
||||
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||
@@ -70,4 +77,51 @@ test.describe("Question prompt pagination", () => {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("free-write questions use Next before final Submit", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-free-write-",
|
||||
title: "Question free-write e2e",
|
||||
initialPrompt: "Emit synthetic questions: two free-write questions.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 1,
|
||||
total: 2,
|
||||
question: REPO_URL_QUESTION,
|
||||
});
|
||||
|
||||
await fillQuestionAnswer(page, {
|
||||
question: REPO_URL_QUESTION,
|
||||
answer: "git@github.com:user/private-repo.git",
|
||||
});
|
||||
|
||||
await expectQuestionPrimaryActionEnabled(page, "Next");
|
||||
await expectQuestionDismissEnabled(page);
|
||||
await expectQuestionNavigationEnabled(page, { index: 2, total: 2 });
|
||||
|
||||
await continueToNextQuestion(page);
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 2,
|
||||
total: 2,
|
||||
question: COMMIT_MESSAGE_QUESTION,
|
||||
});
|
||||
await expectQuestionPrimaryActionDisabled(page, "Submit");
|
||||
|
||||
await fillQuestionAnswer(page, {
|
||||
question: COMMIT_MESSAGE_QUESTION,
|
||||
answer: "Initialize private repo",
|
||||
});
|
||||
await expectQuestionPrimaryActionEnabled(page, "Submit");
|
||||
await submitQuestionAnswers(page);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
23
packages/app/e2e/settings-i18n.spec.ts
Normal file
23
packages/app/e2e/settings-i18n.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { openSettingsSection } from "./helpers/settings";
|
||||
|
||||
test("Settings language selector switches General labels", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "general");
|
||||
|
||||
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "System", exact: true }).click();
|
||||
await page.getByRole("button", { name: "简体中文 - Simplified Chinese", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("默认发送", { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "简体中文", exact: true }).click();
|
||||
await page.getByRole("button", { name: "English - 英语", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
61
packages/app/e2e/settings-sidebar-scroll.spec.ts
Normal file
61
packages/app/e2e/settings-sidebar-scroll.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
|
||||
test.describe("Settings sidebar scrolling", () => {
|
||||
test.use({ viewport: { width: 900, height: 260 } });
|
||||
|
||||
test("desktop drag region does not cover the scroll body", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.paseoDesktop = {
|
||||
platform: "darwin",
|
||||
events: { on: () => () => {} },
|
||||
invoke: async (command: string) => {
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
const geometry = await sidebar.evaluate((node) => {
|
||||
let scroller: HTMLElement | null = null;
|
||||
for (const element of node.querySelectorAll<HTMLElement>("*")) {
|
||||
if (element.scrollHeight > element.clientHeight) {
|
||||
scroller = element;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!scroller) return null;
|
||||
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const dragRegions = [];
|
||||
for (const element of node.querySelectorAll<HTMLElement>("*")) {
|
||||
if (getComputedStyle(element).getPropertyValue("-webkit-app-region") === "drag") {
|
||||
const rect = element.getBoundingClientRect();
|
||||
dragRegions.push({ bottom: rect.bottom });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scrollBodyTop: scrollerRect.top,
|
||||
dragRegions,
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry).not.toBeNull();
|
||||
expect(geometry!.dragRegions).not.toEqual([]);
|
||||
for (const dragRegion of geometry!.dragRegions) {
|
||||
expect(dragRegion.bottom).toBeLessThanOrEqual(geometry!.scrollBodyTop + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { getServerId } from "./helpers/server-id";
|
||||
import { startupScenario } from "./helpers/startup-dsl";
|
||||
|
||||
test.describe("Startup loading presentation", () => {
|
||||
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
||||
test("mobile reconnect preserves the saved host shell", async ({ page }) => {
|
||||
const startup = await startupScenario(page)
|
||||
.withMobileViewport()
|
||||
.withSavedHost({
|
||||
@@ -14,8 +14,8 @@ test.describe("Startup loading presentation", () => {
|
||||
})
|
||||
.openRoot();
|
||||
|
||||
await startup.expectsReconnectWelcome();
|
||||
await startup.expectsNoSavedHostStatus({ label: "Dev" });
|
||||
await startup.expectsSavedHostShell({ label: "Dev" });
|
||||
await startup.expectsNoSavedHostErrorStatus();
|
||||
await startup.expectsNoLocalServerStartupCopy();
|
||||
});
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ test.describe("Workspace agent title handoff", () => {
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").click();
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const promptTitle = "Investigate optimistic tab title handoff";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
getVisibleWorkspaceAgentTabIds,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
|
||||
@@ -79,6 +80,43 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
|
||||
await closeButton.first().click();
|
||||
}
|
||||
|
||||
async function openWorkspaceThroughApp(
|
||||
page: Page,
|
||||
input: {
|
||||
serverId: string;
|
||||
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: input.serverId,
|
||||
targetWorkspacePath: input.workspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceLocation(page, input);
|
||||
}
|
||||
|
||||
async function expectWorkspaceLocation(
|
||||
page: Page,
|
||||
input: {
|
||||
serverId: string;
|
||||
workspace: Awaited<ReturnType<typeof seedWorkspace>>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await expect(page).toHaveURL(
|
||||
buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: input.workspace.workspaceName,
|
||||
subtitle: input.workspace.projectDisplayName,
|
||||
});
|
||||
}
|
||||
|
||||
async function installDaemonWebSocketGate(page: Page) {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
@@ -260,6 +298,41 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const daemonGate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
title: `workspace-refresh-route-${Date.now()}`,
|
||||
});
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
hangDaemonStart: true,
|
||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
});
|
||||
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
|
||||
await daemonGate.drop();
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
||||
daemonGate.restore();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -78,4 +78,31 @@ config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
return resolveWithCustomWebOverlay(context, moduleName, platform);
|
||||
};
|
||||
|
||||
if (process.env.PASEO_SERVE_SIM_PREVIEW === "1") {
|
||||
const { simMiddleware } = require("serve-sim/middleware");
|
||||
const originalEnhanceMiddleware = config.server?.enhanceMiddleware;
|
||||
config.server = config.server ?? {};
|
||||
config.server.enhanceMiddleware = (metroMiddleware, server) => {
|
||||
const middleware = originalEnhanceMiddleware
|
||||
? originalEnhanceMiddleware(metroMiddleware, server)
|
||||
: metroMiddleware;
|
||||
const serveSimulator = simMiddleware({
|
||||
basePath: "/.sim",
|
||||
device: process.env.PASEO_SERVE_SIM_DEVICE_UDID,
|
||||
});
|
||||
return (req, res, next) => {
|
||||
serveSimulator(req, res, (error) => {
|
||||
if (error) {
|
||||
if (next) {
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
middleware(req, res, next);
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.92",
|
||||
"version": "0.1.94",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -73,6 +73,7 @@
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.9",
|
||||
"expo-notifications": "^0.32.16",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
@@ -80,12 +81,15 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"i18next": "^26.3.0",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"markdown-it": "^10.0.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.7.0",
|
||||
@@ -125,6 +129,7 @@
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
"serve-sim": "^0.1.40",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.6",
|
||||
"wrangler": "^4.75.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -157,6 +158,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
function renderListEmptyComponent(input: {
|
||||
renderModel: AgentStreamRenderModel;
|
||||
emptyStateStyle: StyleProp<ViewStyle>;
|
||||
emptyText: string;
|
||||
}): ReactNode {
|
||||
if (
|
||||
input.renderModel.boundary.hasVirtualizedHistory ||
|
||||
@@ -170,7 +172,7 @@ function renderListEmptyComponent(input: {
|
||||
|
||||
return (
|
||||
<View style={input.emptyStateStyle}>
|
||||
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
|
||||
<Text style={stylesheet.emptyStateText}>{input.emptyText}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -245,6 +247,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const streamRenderStrategy = useMemo(
|
||||
@@ -637,8 +640,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||
const listEmptyComponent = useMemo(
|
||||
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
|
||||
[renderModel, emptyStateStyle],
|
||||
() =>
|
||||
renderListEmptyComponent({
|
||||
renderModel,
|
||||
emptyStateStyle,
|
||||
emptyText: t("agentStream.empty"),
|
||||
}),
|
||||
[renderModel, emptyStateStyle, t],
|
||||
);
|
||||
|
||||
const { boundary, auxiliary } = renderModel;
|
||||
@@ -745,7 +753,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scroll to bottom"
|
||||
accessibilityLabel={t("agentStream.scrollToBottom")}
|
||||
testID="scroll-to-bottom-button"
|
||||
>
|
||||
<ChevronDown size={24} color={stylesheet.scrollToBottomIcon.color} />
|
||||
@@ -906,11 +914,14 @@ function PermissionRequestCard({
|
||||
permission: PendingPermission;
|
||||
client: DaemonClient | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const { request } = permission;
|
||||
const isPlanRequest = request.kind === "plan";
|
||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||
const title = isPlanRequest
|
||||
? t("agentStream.permission.plan")
|
||||
: (request.title ?? request.name ?? t("agentStream.permission.required"));
|
||||
const description = request.description ?? "";
|
||||
const resolvedToolCallDetail = useMemo(
|
||||
() =>
|
||||
@@ -931,19 +942,21 @@ function PermissionRequestCard({
|
||||
return [
|
||||
{
|
||||
id: "reject",
|
||||
label: "Deny",
|
||||
label: t("agentStream.permission.deny"),
|
||||
behavior: "deny",
|
||||
variant: "danger",
|
||||
intent: "dismiss",
|
||||
},
|
||||
{
|
||||
id: "accept",
|
||||
label: isPlanRequest ? "Implement" : "Accept",
|
||||
label: isPlanRequest
|
||||
? t("agentStream.permission.implement")
|
||||
: t("agentStream.permission.accept"),
|
||||
behavior: "allow",
|
||||
variant: "primary",
|
||||
},
|
||||
];
|
||||
}, [isPlanRequest, request]);
|
||||
}, [isPlanRequest, request, t]);
|
||||
|
||||
const planMarkdown = useMemo(() => {
|
||||
if (!request) {
|
||||
@@ -968,7 +981,7 @@ function PermissionRequestCard({
|
||||
response: AgentPermissionResponse;
|
||||
}) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return client.respondToPermissionAndWait(
|
||||
input.agentId,
|
||||
@@ -1042,7 +1055,7 @@ function PermissionRequestCard({
|
||||
const footer = (
|
||||
<>
|
||||
<Text testID="permission-request-question" style={permissionStyles.question}>
|
||||
How would you like to proceed?
|
||||
{t("agentStream.permission.question")}
|
||||
</Text>
|
||||
|
||||
<View style={optionsContainerStyle}>
|
||||
@@ -1094,7 +1107,7 @@ function PermissionRequestCard({
|
||||
|
||||
{planMarkdown ? (
|
||||
<PlanCard
|
||||
title="Proposed plan"
|
||||
title={t("agentStream.permission.proposedPlan")}
|
||||
text={planMarkdown}
|
||||
testID="permission-plan-card"
|
||||
disableOuterSpacing
|
||||
|
||||
@@ -41,15 +41,20 @@ import {
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||
SidebarAnimationProvider,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { startDaemonIfGateAllows, startHostRuntimeBootstrap } from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startDaemonIfGateAllows,
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -60,17 +65,16 @@ import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { I18nProvider } from "@/i18n/provider";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHosts,
|
||||
@@ -82,6 +86,7 @@ import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildHostRootRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
@@ -103,6 +108,7 @@ export interface HostRuntimeBootstrapState {
|
||||
retry: () => void;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
storeReady: boolean;
|
||||
startupBlocker: StartupBlocker;
|
||||
}
|
||||
|
||||
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
@@ -110,6 +116,7 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
|
||||
retry: () => {},
|
||||
hasGivenUpWaitingForHost: false,
|
||||
storeReady: false,
|
||||
startupBlocker: { kind: "none" },
|
||||
});
|
||||
|
||||
function PushNotificationRouter() {
|
||||
@@ -313,18 +320,26 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const daemonStartError = useDaemonStartLastError();
|
||||
const daemonStartIsRunning = useDaemonStartIsRunning();
|
||||
const waitForConfiguredLocalDaemon =
|
||||
hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon();
|
||||
|
||||
const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false);
|
||||
const isDesktopRuntime = shouldUseDesktopDaemon();
|
||||
const startupBlocker = useMemo(
|
||||
() =>
|
||||
resolveStartupBlocker({
|
||||
isDesktopRuntime,
|
||||
anyOnlineHostServerId,
|
||||
daemonStartIsRunning,
|
||||
daemonStartError,
|
||||
}),
|
||||
[anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, isDesktopRuntime],
|
||||
);
|
||||
const shouldRunGiveUpTimer = shouldRunStartupGiveUpTimer({
|
||||
startupBlocker,
|
||||
anyOnlineHostServerId,
|
||||
hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
anyOnlineHostServerId ||
|
||||
daemonStartError ||
|
||||
daemonStartIsRunning ||
|
||||
waitForConfiguredLocalDaemon ||
|
||||
hasGivenUpWaitingForHost
|
||||
) {
|
||||
if (!shouldRunGiveUpTimer) {
|
||||
return;
|
||||
}
|
||||
const handle = setTimeout(() => {
|
||||
@@ -333,13 +348,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [
|
||||
anyOnlineHostServerId,
|
||||
daemonStartError,
|
||||
daemonStartIsRunning,
|
||||
waitForConfiguredLocalDaemon,
|
||||
hasGivenUpWaitingForHost,
|
||||
]);
|
||||
}, [shouldRunGiveUpTimer]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
@@ -350,14 +359,13 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const splashError = !anyOnlineHostServerId ? daemonStartError : null;
|
||||
const isCurrentlyStoreReady =
|
||||
Boolean(anyOnlineHostServerId) || Boolean(splashError) || hasGivenUpWaitingForHost;
|
||||
const storeReady = useLatchedBoolean(isCurrentlyStoreReady);
|
||||
const splashError =
|
||||
startupBlocker.kind === "managed-daemon-error" ? startupBlocker.message : null;
|
||||
const storeReady = resolveStartupNavigationReady({ startupBlocker });
|
||||
|
||||
const state = useMemo<HostRuntimeBootstrapState>(
|
||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady }),
|
||||
[splashError, retry, hasGivenUpWaitingForHost, storeReady],
|
||||
() => ({ splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker }),
|
||||
[splashError, retry, hasGivenUpWaitingForHost, storeReady, startupBlocker],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -504,7 +512,7 @@ function MobileGestureWrapper({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
@@ -540,7 +548,7 @@ function MobileGestureWrapper({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
|
||||
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
@@ -586,11 +594,9 @@ function MobileGestureWrapper({
|
||||
isGesturing.value = false;
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (shouldOpen) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
animateToOpen();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
}
|
||||
})
|
||||
@@ -602,7 +608,7 @@ function MobileGestureWrapper({
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleGestureOpen,
|
||||
@@ -929,13 +935,15 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
<I18nProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</I18nProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { resolveKnownHostRoute } from "@/utils/host-routes";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
export default function HostRouteLayout() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<KnownHostRoute />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
return <KnownHostRoute />;
|
||||
}
|
||||
|
||||
function KnownHostRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
|
||||
const hosts = useHosts();
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
|
||||
const resolution = resolveKnownHostRoute({ routeServerId, hosts });
|
||||
const startupRoute = resolveStartupRoute({
|
||||
route: { kind: "host", serverId: routeServerId },
|
||||
startupBlocker: bootstrapState.startupBlocker,
|
||||
hostRegistryStatus,
|
||||
hosts,
|
||||
});
|
||||
|
||||
if (resolution.kind === "redirect") {
|
||||
return <Redirect href={resolution.href} />;
|
||||
if (startupRoute.kind === "redirect") {
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
}
|
||||
|
||||
// Keep the host Slot mounted while startup gates are active. React Navigation
|
||||
// web can reserialize a shallower tree and drop nested workspace URL segments
|
||||
// if the layout swaps Slot for a splash; leaf routes own the splash boundary.
|
||||
return <Slot />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
@@ -14,11 +15,13 @@ export default function HostNewWorkspaceRoute() {
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
|
||||
return (
|
||||
<NewWorkspaceScreen
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<HostRouteBootstrapBoundary>
|
||||
<NewWorkspaceScreen
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
@@ -7,9 +7,18 @@ import {
|
||||
type ActiveWorkspaceSelection,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
areWorkspaceSelectionListsEqual,
|
||||
areWorkspaceSelectionsEqual,
|
||||
getWorkspaceSelectionKey,
|
||||
pruneMountedWorkspaceSelections,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
} from "@/screens/workspace/workspace-deck-retention";
|
||||
import {
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
@@ -152,28 +161,33 @@ function HostWorkspaceRouteContent() {
|
||||
return <WorkspaceDeck />;
|
||||
}
|
||||
|
||||
function areWorkspaceSelectionsEqual(
|
||||
left: ActiveWorkspaceSelection | null,
|
||||
right: ActiveWorkspaceSelection | null,
|
||||
): boolean {
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
function WorkspaceDeck() {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
);
|
||||
const unmountWorkspaceSelection = useCallback((selection: ActiveWorkspaceSelection) => {
|
||||
setMountedSelections((current) =>
|
||||
current.filter(
|
||||
(mountedSelection) => !areWorkspaceSelectionsEqual(mountedSelection, selection),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
||||
const next = pruneMountedWorkspaceSelections({
|
||||
currentSelections: current,
|
||||
activeSelection,
|
||||
maxMountedWorkspaces: WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES,
|
||||
});
|
||||
if (areWorkspaceSelectionListsEqual(current, next)) {
|
||||
return current;
|
||||
}
|
||||
return [...current, activeSelection];
|
||||
return next;
|
||||
});
|
||||
}, [activeSelection]);
|
||||
|
||||
@@ -184,25 +198,61 @@ function WorkspaceDeck() {
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{mountedSelections.map((selection) => {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
return (
|
||||
<View
|
||||
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</View>
|
||||
<WorkspaceDeckEntry
|
||||
key={getWorkspaceSelectionKey(selection)}
|
||||
selection={selection}
|
||||
activeSelection={activeSelection}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceDeckEntry({
|
||||
selection,
|
||||
activeSelection,
|
||||
onUnmountInactive,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
activeSelection: ActiveWorkspaceSelection;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId);
|
||||
const workspaceExists = useWorkspaceExists(selection.serverId, selection.workspaceId);
|
||||
const shouldKeepMounted = shouldKeepWorkspaceDeckEntryMounted({
|
||||
isActive,
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldKeepMounted) {
|
||||
onUnmountInactive(selection);
|
||||
}
|
||||
}, [onUnmountInactive, selection, shouldKeepMounted]);
|
||||
|
||||
if (!shouldKeepMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveStartupRedirectRoute,
|
||||
resolveStartupWorkspaceSelection,
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
resolveStartupRoute,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startHostRuntimeBootstrap,
|
||||
WELCOME_ROUTE,
|
||||
} from "./host-runtime-bootstrap";
|
||||
|
||||
function createFakeStore() {
|
||||
@@ -117,131 +118,229 @@ describe("startHostRuntimeBootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStartupRedirectRoute", () => {
|
||||
const baseInput = {
|
||||
pathname: "/",
|
||||
describe("startup blocking policy", () => {
|
||||
const noBlockerInput = {
|
||||
isDesktopRuntime: false,
|
||||
anyOnlineHostServerId: null,
|
||||
daemonStartIsRunning: false,
|
||||
daemonStartError: null,
|
||||
};
|
||||
|
||||
it("runs the give-up timer when no startup blocker is active", () => {
|
||||
const blocker = resolveStartupBlocker(noBlockerInput);
|
||||
|
||||
expect(blocker).toEqual({ kind: "none" });
|
||||
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
|
||||
expect(
|
||||
shouldRunStartupGiveUpTimer({
|
||||
startupBlocker: blocker,
|
||||
anyOnlineHostServerId: null,
|
||||
hasGivenUpWaitingForHost: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks navigation while desktop is starting the managed daemon", () => {
|
||||
const blocker = resolveStartupBlocker({
|
||||
...noBlockerInput,
|
||||
isDesktopRuntime: true,
|
||||
daemonStartIsRunning: true,
|
||||
});
|
||||
|
||||
expect(blocker).toEqual({ kind: "managed-daemon-starting" });
|
||||
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(false);
|
||||
expect(
|
||||
shouldRunStartupGiveUpTimer({
|
||||
startupBlocker: blocker,
|
||||
anyOnlineHostServerId: null,
|
||||
hasGivenUpWaitingForHost: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("unblocks navigation when any host is online", () => {
|
||||
const blocker = resolveStartupBlocker({
|
||||
...noBlockerInput,
|
||||
isDesktopRuntime: true,
|
||||
anyOnlineHostServerId: "srv_desktop",
|
||||
daemonStartIsRunning: true,
|
||||
});
|
||||
|
||||
expect(blocker).toEqual({ kind: "none" });
|
||||
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps desktop daemon startup errors on the startup error surface", () => {
|
||||
const blocker = resolveStartupBlocker({
|
||||
...noBlockerInput,
|
||||
isDesktopRuntime: true,
|
||||
daemonStartError: "daemon failed to start",
|
||||
});
|
||||
|
||||
expect(blocker).toEqual({
|
||||
kind: "managed-daemon-error",
|
||||
message: "daemon failed to start",
|
||||
});
|
||||
expect(resolveStartupNavigationReady({ startupBlocker: blocker })).toBe(true);
|
||||
expect(
|
||||
shouldRunStartupGiveUpTimer({
|
||||
startupBlocker: blocker,
|
||||
anyOnlineHostServerId: null,
|
||||
hasGivenUpWaitingForHost: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStartupRoute", () => {
|
||||
const baseIndexInput = {
|
||||
route: { kind: "index" as const, pathname: "/" },
|
||||
startupBlocker: { kind: "none" as const },
|
||||
hostRegistryStatus: "ready" as const,
|
||||
hosts: [],
|
||||
anyOnlineHostServerId: null,
|
||||
workspaceSelection: null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: false,
|
||||
};
|
||||
const baseHostInput = {
|
||||
route: { kind: "host" as const, serverId: "server-saved" },
|
||||
startupBlocker: { kind: "none" as const },
|
||||
hostRegistryStatus: "ready" as const,
|
||||
hosts: [],
|
||||
};
|
||||
|
||||
it("returns null when the pathname is not the index route", () => {
|
||||
it("renders non-index routes instead of making an index startup decision", () => {
|
||||
expect(
|
||||
resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
pathname: "/h/server-1",
|
||||
anyOnlineHostServerId: "server-1",
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
route: { kind: "index", pathname: "/settings" },
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("waits while the persisted workspace selection has not finished loading", () => {
|
||||
it("keeps startup on the splash while the persisted workspace selection is loading", () => {
|
||||
expect(
|
||||
resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
anyOnlineHostServerId: "server-1",
|
||||
isWorkspaceSelectionLoaded: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
).toEqual({ kind: "splash" });
|
||||
});
|
||||
|
||||
it("waits while no host is online and the give-up timer has not fired", () => {
|
||||
expect(resolveStartupRedirectRoute(baseInput)).toBeNull();
|
||||
it("keeps startup on the splash while the host registry is loading", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hostRegistryStatus: "loading",
|
||||
}),
|
||||
).toEqual({ kind: "splash" });
|
||||
});
|
||||
|
||||
describe("scenario: saved-host-online", () => {
|
||||
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-1",
|
||||
it("does not treat loading hosts as an empty registry when a workspace is already restored", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hostRegistryStatus: "loading",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when the online host matches it", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-1",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when another host is first online", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("redirects to the host root when no persisted workspace exists", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/server-2");
|
||||
});
|
||||
hasGivenUpWaitingForHost: true,
|
||||
}),
|
||||
).toEqual({ kind: "splash" });
|
||||
});
|
||||
|
||||
describe("scenario: daemon-start-success-only (host comes online via daemon-start upsert)", () => {
|
||||
it("redirects to the host that came online", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "srv_desktop",
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/srv_desktop");
|
||||
});
|
||||
it("restores the saved workspace only after the host registry proves the host exists", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-1" }],
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
|
||||
});
|
||||
|
||||
describe("scenario: both-succeed", () => {
|
||||
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-saved",
|
||||
it("falls back to a saved host when the restored workspace host is no longer saved", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
hosts: [{ serverId: "server-next" }],
|
||||
hasGivenUpWaitingForHost: true,
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-next" });
|
||||
});
|
||||
|
||||
describe("scenario: both-fail (no host comes online, give-up timer fires)", () => {
|
||||
it("redirects to the welcome route", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
it("redirects to the online host when no saved workspace is selected", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
anyOnlineHostServerId: "srv-desktop",
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/srv-desktop" });
|
||||
});
|
||||
|
||||
it("keeps a known connecting host in app-owned routing instead of showing welcome", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-saved" }],
|
||||
hasGivenUpWaitingForHost: true,
|
||||
});
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-saved" });
|
||||
});
|
||||
|
||||
expect(route).toBe(WELCOME_ROUTE);
|
||||
});
|
||||
|
||||
it("still redirects to the host when one comes online before the timer expires", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-saved",
|
||||
it("shows welcome after root startup gives up and no host exists", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hasGivenUpWaitingForHost: true,
|
||||
});
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/welcome" });
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/server-saved");
|
||||
});
|
||||
it("keeps host routes mounted while the host registry is loading", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
hostRegistryStatus: "loading",
|
||||
}),
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("keeps host routes mounted while the managed daemon is starting", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
startupBlocker: { kind: "managed-daemon-starting" },
|
||||
}),
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("renders a host route once the route host is known", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
hosts: [{ serverId: "server-saved" }],
|
||||
}),
|
||||
).toEqual({ kind: "render" });
|
||||
});
|
||||
|
||||
it("sends removed host routes to a saved host instead of welcome", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
route: { kind: "host", serverId: "server-removed" },
|
||||
hosts: [{ serverId: "server-next" }],
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-next/open-project" });
|
||||
});
|
||||
|
||||
it("shows welcome from a host route only after the registry proves no hosts exist", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseHostInput,
|
||||
route: { kind: "host", serverId: "server-removed" },
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/welcome" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
||||
import type { Href } from "expo-router";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export interface HostRuntimeBootstrapStore {
|
||||
boot: () => void;
|
||||
@@ -56,53 +60,175 @@ export function startDaemonIfGateAllows(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export const WELCOME_ROUTE: Href = "/welcome";
|
||||
const WELCOME_ROUTE: Href = "/welcome";
|
||||
|
||||
export interface ResolveStartupRedirectInput {
|
||||
export type StartupBlocker =
|
||||
| { kind: "none" }
|
||||
| { kind: "managed-daemon-starting" }
|
||||
| { kind: "managed-daemon-error"; message: string };
|
||||
|
||||
export interface ResolveStartupBlockerInput {
|
||||
isDesktopRuntime: boolean;
|
||||
anyOnlineHostServerId: string | null;
|
||||
daemonStartIsRunning: boolean;
|
||||
daemonStartError: string | null;
|
||||
}
|
||||
|
||||
export function resolveStartupBlocker(input: ResolveStartupBlockerInput): StartupBlocker {
|
||||
if (!input.isDesktopRuntime) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
if (input.anyOnlineHostServerId) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
if (input.daemonStartError) {
|
||||
return { kind: "managed-daemon-error", message: input.daemonStartError };
|
||||
}
|
||||
|
||||
if (input.daemonStartIsRunning) {
|
||||
return { kind: "managed-daemon-starting" };
|
||||
}
|
||||
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
export function resolveStartupNavigationReady(input: { startupBlocker: StartupBlocker }): boolean {
|
||||
return input.startupBlocker.kind !== "managed-daemon-starting";
|
||||
}
|
||||
|
||||
export function shouldRunStartupGiveUpTimer(input: {
|
||||
startupBlocker: StartupBlocker;
|
||||
anyOnlineHostServerId: string | null;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
}): boolean {
|
||||
if (input.anyOnlineHostServerId) {
|
||||
return false;
|
||||
}
|
||||
if (input.hasGivenUpWaitingForHost) {
|
||||
return false;
|
||||
}
|
||||
return input.startupBlocker.kind === "none";
|
||||
}
|
||||
|
||||
export type StartupRegistryStatus = "loading" | "ready";
|
||||
|
||||
export interface IndexStartupRouteTarget {
|
||||
kind: "index";
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export interface HostStartupRouteTarget {
|
||||
kind: "host";
|
||||
serverId: string | null;
|
||||
}
|
||||
|
||||
export type StartupRouteTarget = IndexStartupRouteTarget | HostStartupRouteTarget;
|
||||
|
||||
interface ResolveStartupRouteBaseInput {
|
||||
startupBlocker: StartupBlocker;
|
||||
hostRegistryStatus: StartupRegistryStatus;
|
||||
hosts: readonly { serverId: string }[];
|
||||
}
|
||||
|
||||
export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseInput {
|
||||
route: IndexStartupRouteTarget;
|
||||
anyOnlineHostServerId: string | null;
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
isWorkspaceSelectionLoaded: boolean;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput {
|
||||
route: HostStartupRouteTarget;
|
||||
}
|
||||
|
||||
export type ResolveStartupRouteInput = ResolveIndexStartupRouteInput | ResolveHostStartupRouteInput;
|
||||
|
||||
export type StartupRouteDecision =
|
||||
| { kind: "render" }
|
||||
| { kind: "splash" }
|
||||
| { kind: "redirect"; href: Href };
|
||||
|
||||
function isIndexPathname(pathname: string) {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
|
||||
export function resolveStartupWorkspaceSelection(
|
||||
input: ResolveStartupRedirectInput,
|
||||
): ActiveWorkspaceSelection | null {
|
||||
if (!isIndexPathname(input.pathname)) {
|
||||
return null;
|
||||
function hostExists(hosts: readonly { serverId: string }[], serverId: string | null): boolean {
|
||||
if (!serverId) {
|
||||
return false;
|
||||
}
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
}
|
||||
if (!input.workspaceSelection) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaceSelection;
|
||||
return hosts.some((host) => host.serverId === serverId);
|
||||
}
|
||||
|
||||
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
|
||||
if (!isIndexPathname(input.pathname)) {
|
||||
return null;
|
||||
function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): StartupRouteDecision {
|
||||
if (!isIndexPathname(input.route.pathname)) {
|
||||
return { kind: "render" };
|
||||
}
|
||||
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
const workspaceSelection = input.workspaceSelection;
|
||||
if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) {
|
||||
return {
|
||||
kind: "redirect",
|
||||
href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.anyOnlineHostServerId) {
|
||||
if (resolveStartupWorkspaceSelection(input)) {
|
||||
return null;
|
||||
}
|
||||
return buildHostRootRoute(input.anyOnlineHostServerId);
|
||||
return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) };
|
||||
}
|
||||
|
||||
const savedHostServerId = input.hosts[0]?.serverId ?? null;
|
||||
if (savedHostServerId) {
|
||||
return { kind: "redirect", href: buildHostRootRoute(savedHostServerId) };
|
||||
}
|
||||
|
||||
if (input.hasGivenUpWaitingForHost) {
|
||||
return WELCOME_ROUTE;
|
||||
return { kind: "redirect", href: WELCOME_ROUTE };
|
||||
}
|
||||
|
||||
return null;
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): StartupRouteDecision {
|
||||
if (hostExists(input.hosts, input.route.serverId)) {
|
||||
return { kind: "render" };
|
||||
}
|
||||
|
||||
const fallbackServerId = input.hosts[0]?.serverId ?? null;
|
||||
if (fallbackServerId) {
|
||||
return { kind: "redirect", href: buildHostOpenProjectRoute(fallbackServerId) };
|
||||
}
|
||||
|
||||
return { kind: "redirect", href: WELCOME_ROUTE };
|
||||
}
|
||||
|
||||
function isHostStartupRouteInput(
|
||||
input: ResolveStartupRouteInput,
|
||||
): input is ResolveHostStartupRouteInput {
|
||||
return input.route.kind === "host";
|
||||
}
|
||||
|
||||
export function resolveStartupRoute(input: ResolveStartupRouteInput): StartupRouteDecision {
|
||||
if (isHostStartupRouteInput(input)) {
|
||||
if (input.startupBlocker.kind !== "none" || input.hostRegistryStatus === "loading") {
|
||||
return { kind: "render" };
|
||||
}
|
||||
return resolveReadyHostStartupRoute(input);
|
||||
}
|
||||
|
||||
if (input.startupBlocker.kind !== "none") {
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
if (input.hostRegistryStatus === "loading") {
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
return resolveReadyIndexStartupRoute(input);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,13 @@ import React from "react";
|
||||
import { Redirect, usePathname } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import {
|
||||
resolveStartupRedirectRoute,
|
||||
resolveStartupWorkspaceSelection,
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
|
||||
@@ -19,37 +16,24 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const hosts = useHosts();
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||
pathname,
|
||||
const startupRoute = resolveStartupRoute({
|
||||
route: { kind: "index", pathname },
|
||||
startupBlocker: bootstrapState.startupBlocker,
|
||||
hostRegistryStatus,
|
||||
hosts,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
if (startupWorkspaceSelection) {
|
||||
return (
|
||||
<Redirect
|
||||
href={buildHostWorkspaceRoute(
|
||||
startupWorkspaceSelection.serverId,
|
||||
startupWorkspaceSelection.workspaceId,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectRoute) {
|
||||
return <Redirect href={redirectRoute} />;
|
||||
if (startupRoute.kind === "redirect") {
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
}
|
||||
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -120,6 +121,7 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -190,13 +192,13 @@ export default function PairScanScreen() {
|
||||
navigateToPairedHost(profile.serverId);
|
||||
} catch (error) {
|
||||
lastScannedRef.current = null;
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
Alert.alert("Error", message);
|
||||
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
|
||||
Alert.alert(t("pairing.scan.errorTitle"), message);
|
||||
} finally {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
||||
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
@@ -216,15 +218,13 @@ export default function PairScanScreen() {
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={handleRouterBack} />
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
|
||||
<View style={bodyStyle}>
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||
<Text style={styles.permissionBody}>
|
||||
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
|
||||
</Text>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
|
||||
<Pressable style={styles.permissionButton} onPress={closeToSource}>
|
||||
<Text style={styles.permissionButtonText}>Back to Settings</Text>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -236,17 +236,15 @@ export default function PairScanScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={closeToSource} />
|
||||
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
|
||||
|
||||
<View style={bodyStyle}>
|
||||
{!granted ? (
|
||||
<View style={styles.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>Camera permission</Text>
|
||||
<Text style={styles.permissionBody}>
|
||||
Allow camera access to scan the pairing QR code from your daemon.
|
||||
</Text>
|
||||
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
|
||||
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
|
||||
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
|
||||
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
@@ -264,7 +262,7 @@ export default function PairScanScreen() {
|
||||
<View style={CORNER_BL_STYLE} />
|
||||
<View style={CORNER_BR_STYLE} />
|
||||
</View>
|
||||
{isPairing ? <Text style={helperTextStyle}>Pairing…</Text> : null}
|
||||
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Vendored ACP provider SVG assets.
|
||||
|
||||
export const ACP_PROVIDER_ICON_SVGS = {
|
||||
agy: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">\n <path d="M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z"/>\n</svg>\n',
|
||||
"agoragentic-acp":
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">\n <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>\n <path d="M5.5 10.5L8 4L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>\n <line x1="6.25" y1="8.75" x2="9.75" y2="8.75" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>\n</svg>\n',
|
||||
"amp-acp":
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -60,7 +61,7 @@ export interface FetchDaemonResolutionInput {
|
||||
|
||||
export class UnresolvedFileLinkError extends Error {
|
||||
constructor(readonly token: string) {
|
||||
super(`No file found for ${token}`);
|
||||
super(i18n.t("common.errors.noFileFound", { token }));
|
||||
this.name = "UnresolvedFileLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { OpenFileDisposition } from "@/workspace/file-open";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkResolution,
|
||||
type AssistantFileLinkSource,
|
||||
} from "./resolver";
|
||||
@@ -40,6 +40,7 @@ type AssistantFileLinkQueryKey = readonly [
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||
|
||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||
const { t } = useTranslation();
|
||||
const context = useAssistantFileLinkResolverContext();
|
||||
const queryClient = useQueryClient();
|
||||
const stableSource = useStableSource(source);
|
||||
@@ -91,6 +92,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
disposition,
|
||||
context,
|
||||
queryClient,
|
||||
formatNoFileFoundMessage: (token) => t("common.errors.noFileFound", { token }),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -163,6 +165,7 @@ function openAssistantFileLink(input: {
|
||||
disposition: OpenFileDisposition;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
queryClient: ReturnType<typeof useQueryClient>;
|
||||
formatNoFileFoundMessage: (token: string) => string;
|
||||
}): void {
|
||||
const capturedConfig = input.context.configRef.current;
|
||||
const capturedResolution = classifyForResolution(input.source, {
|
||||
@@ -211,7 +214,7 @@ function openAssistantFileLink(input: {
|
||||
} catch (error) {
|
||||
await dispatchUnresolvedError({
|
||||
error,
|
||||
fallbackToken: capturedResolution.token,
|
||||
noFileFoundMessage: input.formatNoFileFoundMessage(capturedResolution.token),
|
||||
capturedServerId: capturedConfig.serverId,
|
||||
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
|
||||
context: input.context,
|
||||
@@ -322,7 +325,7 @@ async function dispatchExternalUrl(input: {
|
||||
|
||||
async function dispatchUnresolvedError(input: {
|
||||
error: unknown;
|
||||
fallbackToken: string;
|
||||
noFileFoundMessage: string;
|
||||
capturedServerId?: string;
|
||||
capturedWorkspaceRoot?: string;
|
||||
context: AssistantFileLinkResolverContextValue;
|
||||
@@ -334,9 +337,7 @@ async function dispatchUnresolvedError(input: {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const token =
|
||||
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
|
||||
current.toast?.show(`No file found for ${token}`, {
|
||||
current.toast?.show(input.noFileFoundMessage, {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
|
||||
@@ -41,14 +41,35 @@ export interface BrowserElementAttachment {
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export type ComposerAttachment =
|
||||
export type PullRequestContextAttachmentKind =
|
||||
| "github.pull_request_comment"
|
||||
| "github.pull_request_review"
|
||||
| "github.pull_request_check";
|
||||
|
||||
interface PullRequestContextAttachmentFields {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
text: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export type PullRequestContextAttachment =
|
||||
| ({ kind: "github.pull_request_comment" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
|
||||
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
|
||||
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "github_issue"; item: GitHubSearchItem }
|
||||
| { kind: "github_pr"; item: GitHubSearchItem }
|
||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||
|
||||
export type WorkspaceComposerAttachment =
|
||||
| {
|
||||
kind: "browser_element";
|
||||
attachment: BrowserElementAttachment;
|
||||
}
|
||||
| PullRequestContextAttachment
|
||||
| {
|
||||
kind: "review";
|
||||
attachment: Extract<AgentAttachment, { type: "review" }>;
|
||||
@@ -56,15 +77,7 @@ export type ComposerAttachment =
|
||||
commentCount: number;
|
||||
};
|
||||
|
||||
export type UserComposerAttachment = Exclude<
|
||||
ComposerAttachment,
|
||||
{ kind: "review" } | { kind: "browser_element" }
|
||||
>;
|
||||
|
||||
export type WorkspaceComposerAttachment = Extract<
|
||||
ComposerAttachment,
|
||||
{ kind: "review" } | { kind: "browser_element" }
|
||||
>;
|
||||
export type ComposerAttachment = UserComposerAttachment | WorkspaceComposerAttachment;
|
||||
|
||||
export type AttachmentDataSource =
|
||||
| { kind: "bytes"; bytes: Uint8Array }
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ComposerAttachment, PullRequestContextAttachment } from "./types";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "./workspace-attachment-utils";
|
||||
|
||||
function contextAttachment(
|
||||
overrides: Partial<PullRequestContextAttachment> = {},
|
||||
): PullRequestContextAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id: "comment-1",
|
||||
title: "Comment · octocat",
|
||||
subtitle: "Fix flaky build",
|
||||
text: "GitHub pull request comment\n\nLooks good.",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace attachment utilities", () => {
|
||||
it("treats pull request context as a workspace attachment", () => {
|
||||
expect(isWorkspaceAttachment(contextAttachment())).toBe(true);
|
||||
});
|
||||
|
||||
it("strips context attachments from user draft attachments", () => {
|
||||
const normalAttachment: ComposerAttachment = {
|
||||
kind: "github_issue",
|
||||
item: {
|
||||
kind: "issue",
|
||||
number: 12,
|
||||
title: "Bug",
|
||||
url: "https://github.com/getpaseo/paseo/issues/12",
|
||||
state: "open",
|
||||
body: "Bug report",
|
||||
labels: [],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
},
|
||||
};
|
||||
|
||||
expect(userAttachmentsOnly([normalAttachment, contextAttachment()])).toEqual([
|
||||
normalAttachment,
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes context attachments as protocol text attachments", () => {
|
||||
expect(workspaceAttachmentToSubmitAttachment(contextAttachment())).toEqual({
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "Comment · octocat",
|
||||
text: "GitHub pull request comment\n\nLooks good.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,29 @@
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
PullRequestContextAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
|
||||
export function isPullRequestContextAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is PullRequestContextAttachment {
|
||||
return (
|
||||
attachment?.kind === "github.pull_request_comment" ||
|
||||
attachment?.kind === "github.pull_request_review" ||
|
||||
attachment?.kind === "github.pull_request_check"
|
||||
);
|
||||
}
|
||||
|
||||
export function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
return (
|
||||
attachment?.kind === "review" ||
|
||||
attachment?.kind === "browser_element" ||
|
||||
isPullRequestContextAttachment(attachment)
|
||||
);
|
||||
}
|
||||
|
||||
export function userAttachmentsOnly(
|
||||
@@ -16,7 +31,9 @@ export function userAttachmentsOnly(
|
||||
): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
attachment.kind !== "review" &&
|
||||
attachment.kind !== "browser_element" &&
|
||||
!isPullRequestContextAttachment(attachment),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,5 +48,13 @@ export function workspaceAttachmentToSubmitAttachment(
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: attachment.title,
|
||||
text: attachment.text,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceComposerAttachment } from "./types";
|
||||
import {
|
||||
appendWorkspaceAttachment,
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
resetWorkspaceAttachmentsStore,
|
||||
useWorkspaceAttachmentsStore,
|
||||
@@ -46,6 +47,16 @@ function reviewAttachment(body: string): WorkspaceComposerAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
function contextAttachment(id: string): WorkspaceComposerAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id,
|
||||
title: "Comment · octocat",
|
||||
text: "GitHub pull request comment\n\nLooks good.",
|
||||
url: `https://github.com/getpaseo/paseo/pull/42#${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace attachments store", () => {
|
||||
it("scopes workspace attachments by server and workspace before cwd fallback", () => {
|
||||
expect(
|
||||
@@ -86,4 +97,45 @@ describe("workspace attachments store", () => {
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends unique context attachments without dropping other workspace attachments", () => {
|
||||
const review = reviewAttachment("Please simplify this.");
|
||||
const context = contextAttachment("comment-1");
|
||||
|
||||
expect(appendWorkspaceAttachment([review], context)).toEqual([review, context]);
|
||||
});
|
||||
|
||||
it("dedupes repeated context attachments by provider, source, and id", () => {
|
||||
const original = contextAttachment("comment-1");
|
||||
const replacement = {
|
||||
...contextAttachment("comment-1"),
|
||||
title: "Comment · octocat updated",
|
||||
text: "Updated text",
|
||||
};
|
||||
|
||||
expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]);
|
||||
});
|
||||
|
||||
it("adds a workspace attachment against the current scope state", () => {
|
||||
resetWorkspaceAttachmentsStore();
|
||||
const scopeKey = buildWorkspaceAttachmentScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
cwd: "/repo",
|
||||
});
|
||||
const review = reviewAttachment("Please simplify this.");
|
||||
const context = contextAttachment("comment-1");
|
||||
|
||||
const addWorkspaceAttachment = useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment;
|
||||
useWorkspaceAttachmentsStore
|
||||
.getState()
|
||||
.setWorkspaceAttachments({ scopeKey, attachments: [review] });
|
||||
|
||||
addWorkspaceAttachment({ scopeKey, attachment: context });
|
||||
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toEqual([
|
||||
review,
|
||||
context,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,10 @@ interface WorkspaceAttachmentsStoreActions {
|
||||
scopeKey: string;
|
||||
attachments: readonly WorkspaceComposerAttachment[];
|
||||
}) => void;
|
||||
addWorkspaceAttachment: (input: {
|
||||
scopeKey: string;
|
||||
attachment: WorkspaceComposerAttachment;
|
||||
}) => void;
|
||||
clearWorkspaceAttachments: (input: { scopeKey: string }) => void;
|
||||
}
|
||||
|
||||
@@ -60,6 +64,35 @@ function areWorkspaceAttachmentsEqual(
|
||||
return left.every((attachment, index) => attachment === right[index]);
|
||||
}
|
||||
|
||||
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
|
||||
if (
|
||||
attachment.kind !== "github.pull_request_comment" &&
|
||||
attachment.kind !== "github.pull_request_review" &&
|
||||
attachment.kind !== "github.pull_request_check"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify({
|
||||
kind: attachment.kind,
|
||||
id: attachment.id,
|
||||
});
|
||||
}
|
||||
|
||||
export function appendWorkspaceAttachment(
|
||||
current: readonly WorkspaceComposerAttachment[],
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
): WorkspaceComposerAttachment[] {
|
||||
const contextKey = getContextAttachmentKey(attachment);
|
||||
if (contextKey === null) {
|
||||
return [...current, attachment];
|
||||
}
|
||||
|
||||
const next = current.filter(
|
||||
(currentAttachment) => getContextAttachmentKey(currentAttachment) !== contextKey,
|
||||
);
|
||||
return [...next, attachment];
|
||||
}
|
||||
|
||||
export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()((set) => ({
|
||||
attachmentsByScope: {},
|
||||
setWorkspaceAttachments: ({ scopeKey, attachments }) => {
|
||||
@@ -84,6 +117,21 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
addWorkspaceAttachment: ({ scopeKey, attachment }) => {
|
||||
set((state) => {
|
||||
const current = state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS;
|
||||
const attachments = appendWorkspaceAttachment(current, attachment);
|
||||
if (areWorkspaceAttachmentsEqual(current, attachments)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
attachmentsByScope: {
|
||||
...state.attachmentsByScope,
|
||||
[scopeKey]: attachments,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
clearWorkspaceAttachments: ({ scopeKey }) => {
|
||||
set((state) => {
|
||||
if (!state.attachmentsByScope[scopeKey]) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ClientSlashCommand {
|
||||
name: string;
|
||||
aliases: readonly string[];
|
||||
description: string;
|
||||
descriptionKey: "composer.clientCommands.archiveAgent" | "composer.clientCommands.freshDraft";
|
||||
argumentHint: string;
|
||||
kind: ClientSlashCommandKind;
|
||||
execution: ClientSlashCommandExecution;
|
||||
@@ -18,6 +19,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
name: "exit",
|
||||
aliases: ["quit", "q"],
|
||||
description: "Archive the current agent",
|
||||
descriptionKey: "composer.clientCommands.archiveAgent",
|
||||
argumentHint: "",
|
||||
kind: "archive-agent",
|
||||
execution: "immediate",
|
||||
@@ -26,6 +28,7 @@ export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [
|
||||
name: "clear",
|
||||
aliases: ["new"],
|
||||
description: "Archive this agent and start a fresh draft",
|
||||
descriptionKey: "composer.clientCommands.freshDraft",
|
||||
argumentHint: "",
|
||||
kind: "replace-agent-with-draft",
|
||||
execution: "immediate",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
import type { TextInputProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
@@ -304,6 +305,7 @@ export function SheetHeaderView({
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
@@ -327,7 +329,7 @@ export function SheetHeaderView({
|
||||
hitSlop={8}
|
||||
style={styles.headerBackButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")}
|
||||
testID="sheet-header-back"
|
||||
>
|
||||
{({ pressed }) => (
|
||||
@@ -347,7 +349,11 @@ export function SheetHeaderView({
|
||||
</View>
|
||||
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
|
||||
{showCloseButton ? (
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<Pressable
|
||||
accessibilityLabel={t("common.actions.close")}
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<X
|
||||
size={16}
|
||||
@@ -363,7 +369,7 @@ export function SheetHeaderView({
|
||||
<AdaptiveTextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={search.placeholder ?? "Search"}
|
||||
placeholder={search.placeholder ?? t("common.actions.search")}
|
||||
resetKey={search.resetKey}
|
||||
onChangeText={handleSearchChange}
|
||||
autoCapitalize="none"
|
||||
@@ -379,6 +385,7 @@ export function SheetHeaderView({
|
||||
|
||||
export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const back = header.back;
|
||||
const handleBackPress = back?.onPress;
|
||||
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
|
||||
@@ -393,7 +400,9 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
hitSlop={8}
|
||||
style={styles.headerBackButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
|
||||
accessibilityLabel={
|
||||
back?.accessibilityLabel ?? back?.label ?? t("common.actions.back")
|
||||
}
|
||||
testID="sheet-header-back"
|
||||
>
|
||||
{({ pressed }) => (
|
||||
@@ -417,7 +426,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
<AdaptiveTextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={header.search.placeholder ?? "Search"}
|
||||
placeholder={header.search.placeholder ?? t("common.actions.search")}
|
||||
resetKey={header.search.resetKey}
|
||||
onChangeText={header.search.onChange}
|
||||
autoCapitalize="none"
|
||||
@@ -462,6 +471,7 @@ export function AdaptiveModalSheet({
|
||||
presentation,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
@@ -585,7 +595,11 @@ export function AdaptiveModalSheet({
|
||||
|
||||
const desktopContent = (
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
<Pressable accessibilityLabel="Dismiss" style={ABSOLUTE_FILL_STYLE} onPress={onClose} />
|
||||
<Pressable
|
||||
accessibilityLabel={t("common.actions.dismiss")}
|
||||
style={ABSOLUTE_FILL_STYLE}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={desktopCardStyle}>
|
||||
{onFilesDropped ? (
|
||||
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
flexDirection: "row",
|
||||
@@ -49,6 +48,8 @@ export function AddHostMethodModal({
|
||||
onPasteLink,
|
||||
}: AddHostMethodModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.connectionMethods.title") }), [t]);
|
||||
|
||||
const handleDirect = useCallback(() => {
|
||||
onDirectConnection();
|
||||
@@ -64,7 +65,7 @@ export function AddHostMethodModal({
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={ADD_CONNECTION_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
@@ -73,13 +74,15 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Direct connection"
|
||||
accessibilityLabel={t("pairing.connectionMethods.direct.title")}
|
||||
testID="add-host-method-direct"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Direct connection</Text>
|
||||
<Text style={styles.optionSubtext}>Local network or VPN.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.direct.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.direct.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -88,12 +91,14 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handleScan}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scan QR code"
|
||||
accessibilityLabel={t("pairing.connectionMethods.scanQr.title")}
|
||||
>
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Scan QR code</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.scanQr.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.scanQr.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -102,13 +107,15 @@ export function AddHostMethodModal({
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Paste pairing link"
|
||||
accessibilityLabel={t("pairing.connectionMethods.pasteLink.title")}
|
||||
testID="add-host-method-pair-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Paste pairing link</Text>
|
||||
<Text style={styles.optionSubtext}>Encrypted relay connection.</Text>
|
||||
<Text style={styles.optionText}>{t("pairing.connectionMethods.pasteLink.title")}</Text>
|
||||
<Text style={styles.optionSubtext}>
|
||||
{t("pairing.connectionMethods.pasteLink.description")}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useReducer, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -15,7 +16,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
|
||||
|
||||
interface DirectConnectionDraft {
|
||||
host: string;
|
||||
@@ -31,6 +31,20 @@ interface PreparedDirectConnection {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface DirectConnectionLabels {
|
||||
hostRequired: string;
|
||||
invalidPort: string;
|
||||
invalidConnection: string;
|
||||
failedToConnect: (endpoint: string) => string;
|
||||
noAdditionalDetails: (detail: string) => string;
|
||||
timedOut: string;
|
||||
refused: string;
|
||||
hostNotFound: string;
|
||||
hostUnreachable: string;
|
||||
tlsError: string;
|
||||
unableToConnect: string;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
field: {
|
||||
gap: theme.spacing[2],
|
||||
@@ -128,14 +142,17 @@ function isIpv6Host(host: string): boolean {
|
||||
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||
}
|
||||
|
||||
function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
|
||||
function buildConnectionUriFromDraft(
|
||||
draft: DirectConnectionDraft,
|
||||
labels: DirectConnectionLabels,
|
||||
): string {
|
||||
const host = draft.host.trim();
|
||||
const port = Number(draft.port.trim());
|
||||
if (!host) {
|
||||
throw new Error("Host is required");
|
||||
throw new Error(labels.hostRequired);
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
throw new Error(labels.invalidPort);
|
||||
}
|
||||
|
||||
return serializeConnectionUriForStorage({
|
||||
@@ -147,8 +164,11 @@ function buildConnectionUriFromDraft(draft: DirectConnectionDraft): string {
|
||||
});
|
||||
}
|
||||
|
||||
function prepareDirectConnection(draft: DirectConnectionDraft): PreparedDirectConnection {
|
||||
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft));
|
||||
function prepareDirectConnection(
|
||||
draft: DirectConnectionDraft,
|
||||
labels: DirectConnectionLabels,
|
||||
): PreparedDirectConnection {
|
||||
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
|
||||
const endpoint = parsed.isIpv6
|
||||
? `[${parsed.host}]:${parsed.port}`
|
||||
: `${parsed.host}:${parsed.port}`;
|
||||
@@ -178,7 +198,10 @@ function normalizeTransportMessage(message: string | null | undefined): string |
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(details: (string | null)[]): string | null {
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: DirectConnectionLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
@@ -197,22 +220,24 @@ function formatTechnicalTransportDetails(details: (string | null)[]): string | n
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return `${unique[0]} (no additional details provided)`;
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
function buildConnectionFailureCopy(
|
||||
endpoint: string,
|
||||
error: unknown,
|
||||
): { title: string; detail: string | null; raw: string | null } {
|
||||
const title = `We failed to connect to ${endpoint}.`;
|
||||
function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: DirectConnectionLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError]) ??
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
@@ -228,26 +253,25 @@ function buildConnectionFailureCopy(
|
||||
if (raw === "Incorrect password" || raw === "Password required") {
|
||||
detail = raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = "Connection timed out. Check the host/port and your network.";
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = "Connection refused. Is the server running at this address?";
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = "Host not found. Check the hostname and try again.";
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = "Host is unreachable. Check your network and firewall.";
|
||||
detail = labels.hostUnreachable;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail =
|
||||
"TLS error. Direct connections use SSL only when a TLS terminator is in front of the daemon.";
|
||||
detail = labels.tlsError;
|
||||
} else {
|
||||
detail = "Unable to connect. Check the host/port and that the daemon is reachable.";
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
@@ -267,6 +291,7 @@ export interface AddHostModalProps {
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection } = useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -308,6 +333,23 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
() => ({ checked: useTls, disabled: isSaving }),
|
||||
[isSaving, useTls],
|
||||
);
|
||||
const directConnectionLabels = useMemo<DirectConnectionLabels>(
|
||||
() => ({
|
||||
hostRequired: t("pairing.direct.errors.hostRequired"),
|
||||
invalidPort: t("pairing.direct.errors.invalidPort"),
|
||||
invalidConnection: t("pairing.direct.errors.invalidConnection"),
|
||||
failedToConnect: (endpoint) => t("pairing.direct.errors.failedToConnect", { endpoint }),
|
||||
noAdditionalDetails: (detail) => t("pairing.direct.errors.noAdditionalDetails", { detail }),
|
||||
timedOut: t("pairing.direct.errors.timedOut"),
|
||||
refused: t("pairing.direct.errors.refused"),
|
||||
hostNotFound: t("pairing.direct.errors.hostNotFound"),
|
||||
hostUnreachable: t("pairing.direct.errors.hostUnreachable"),
|
||||
tlsError: t("pairing.direct.errors.tlsError"),
|
||||
unableToConnect: t("pairing.direct.errors.unableToConnect"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.direct.title") }), [t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
@@ -328,9 +370,13 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
|
||||
let connection: PreparedDirectConnection;
|
||||
try {
|
||||
connection = prepareDirectConnection({ host, port, useTls, password });
|
||||
connection = prepareDirectConnection(
|
||||
{ host, port, useTls, password },
|
||||
directConnectionLabels,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid connection";
|
||||
const message =
|
||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection;
|
||||
setErrorMessage(message);
|
||||
return;
|
||||
}
|
||||
@@ -349,10 +395,20 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(connection.uri, error);
|
||||
const {
|
||||
title,
|
||||
detail,
|
||||
raw: rawDetail,
|
||||
} = buildConnectionFailureCopy({
|
||||
endpoint: connection.uri,
|
||||
error,
|
||||
labels: directConnectionLabels,
|
||||
});
|
||||
let combined: string;
|
||||
if (rawDetail && detail && rawDetail !== detail) {
|
||||
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
|
||||
combined = `${title}\n${detail}\n${t("pairing.direct.errors.details", {
|
||||
detail: rawDetail,
|
||||
})}`;
|
||||
} else if (detail) {
|
||||
combined = `${title}\n${detail}`;
|
||||
} else {
|
||||
@@ -360,13 +416,14 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
}
|
||||
setErrorMessage(combined);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Connection failed", combined);
|
||||
Alert.alert(t("pairing.direct.errors.failedTitle"), combined);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
daemons,
|
||||
directConnectionLabels,
|
||||
handleClose,
|
||||
host,
|
||||
isMobile,
|
||||
@@ -375,6 +432,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
password,
|
||||
port,
|
||||
probeAndUpsertDirectConnection,
|
||||
t,
|
||||
useTls,
|
||||
]);
|
||||
|
||||
@@ -398,7 +456,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const handleToggleAdvanced = useCallback(() => {
|
||||
if (!isAdvancedOpen) {
|
||||
try {
|
||||
setAdvancedUri(buildConnectionUriFromDraft({ host, port, useTls, password }));
|
||||
setAdvancedUri(
|
||||
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||
);
|
||||
} catch {
|
||||
setAdvancedUri("");
|
||||
}
|
||||
@@ -419,27 +479,27 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setErrorMessage("");
|
||||
}
|
||||
setIsAdvancedOpen(false);
|
||||
}, [advancedUri, host, isAdvancedOpen, password, port, useTls]);
|
||||
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
|
||||
|
||||
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
|
||||
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={DIRECT_CONNECTION_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="add-host-modal"
|
||||
>
|
||||
<Text style={styles.helper}>Enter the address of a Paseo server.</Text>
|
||||
<Text style={styles.helper}>{t("pairing.direct.helper")}</Text>
|
||||
|
||||
<View style={styles.portRow}>
|
||||
<View style={hostFieldStyle}>
|
||||
<Text style={styles.label}>Host</Text>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel="Host"
|
||||
accessibilityLabel={t("pairing.direct.fields.host")}
|
||||
initialValue={host}
|
||||
resetKey={`direct-host-${inputResetKey}`}
|
||||
value={host}
|
||||
@@ -455,11 +515,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
/>
|
||||
</View>
|
||||
<View style={portFieldStyle}>
|
||||
<Text style={styles.label}>Port</Text>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-port-input"
|
||||
nativeID="direct-port-input"
|
||||
accessibilityLabel="Port"
|
||||
accessibilityLabel={t("pairing.direct.fields.port")}
|
||||
initialValue={port}
|
||||
resetKey={`direct-port-${inputResetKey}`}
|
||||
value={port}
|
||||
@@ -482,7 +542,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={handleToggleUseTls}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel="Use SSL"
|
||||
accessibilityLabel={t("pairing.direct.fields.useSsl")}
|
||||
accessibilityState={useTlsAccessibilityState}
|
||||
testID="direct-ssl-toggle"
|
||||
>
|
||||
@@ -493,21 +553,21 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.label}>Use SSL</Text>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
|
||||
<View style={styles.passwordRow}>
|
||||
<AdaptiveTextInput
|
||||
testID="direct-password-input"
|
||||
nativeID="direct-password-input"
|
||||
accessibilityLabel="Password"
|
||||
accessibilityLabel={t("pairing.direct.fields.password")}
|
||||
initialValue={password}
|
||||
resetKey={`direct-password-${inputResetKey}`}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Optional"
|
||||
placeholder={t("pairing.direct.fields.optional")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={passwordInputStyle}
|
||||
autoCapitalize="none"
|
||||
@@ -522,7 +582,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={handleTogglePasswordVisibility}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isPasswordVisible ? "Hide password" : "Show password"}
|
||||
accessibilityLabel={
|
||||
isPasswordVisible
|
||||
? t("pairing.direct.passwordVisibility.hide")
|
||||
: t("pairing.direct.passwordVisibility.show")
|
||||
}
|
||||
testID="direct-password-visibility-toggle"
|
||||
>
|
||||
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
|
||||
@@ -536,17 +600,19 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={handleToggleAdvanced}
|
||||
disabled={isSaving}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isAdvancedOpen ? "Hide advanced" : "Show advanced"}
|
||||
accessibilityLabel={
|
||||
isAdvancedOpen ? t("pairing.direct.advanced.hide") : t("pairing.direct.advanced.show")
|
||||
}
|
||||
testID="direct-host-advanced-toggle"
|
||||
>
|
||||
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.advancedText}>Advanced</Text>
|
||||
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
|
||||
</Pressable>
|
||||
{isAdvancedOpen ? (
|
||||
<AdaptiveTextInput
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel="Connection URI"
|
||||
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
@@ -572,7 +638,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
{t("pairing.direct.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
@@ -582,7 +648,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
leftIcon={connectIcon}
|
||||
testID="direct-host-submit"
|
||||
>
|
||||
{isSaving ? "Connecting..." : "Connect"}
|
||||
{isSaving ? t("pairing.direct.actions.connecting") : t("pairing.direct.actions.connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
@@ -33,8 +35,18 @@ interface AgentListProps {
|
||||
showAttentionIndicator?: boolean;
|
||||
}
|
||||
|
||||
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
|
||||
|
||||
const DATE_SECTION_ORDER = [
|
||||
"today",
|
||||
"yesterday",
|
||||
"thisWeek",
|
||||
"thisMonth",
|
||||
"older",
|
||||
] as const satisfies readonly DateSectionKey[];
|
||||
|
||||
type FlatListItem =
|
||||
| { type: "header"; key: string; title: string }
|
||||
| { type: "header"; key: string; section: DateSectionKey }
|
||||
| { type: "agent"; key: string; agent: AggregatedAgent };
|
||||
|
||||
function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
|
||||
@@ -94,7 +106,7 @@ function rememberArchivedAgentDetail(agent: AggregatedAgent) {
|
||||
});
|
||||
}
|
||||
|
||||
function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000);
|
||||
@@ -105,35 +117,50 @@ function deriveDateSectionLabel(lastActivityAt: Date): string {
|
||||
);
|
||||
|
||||
if (activityStart.getTime() >= todayStart.getTime()) {
|
||||
return "Today";
|
||||
return "today";
|
||||
}
|
||||
if (activityStart.getTime() >= yesterdayStart.getTime()) {
|
||||
return "Yesterday";
|
||||
return "yesterday";
|
||||
}
|
||||
|
||||
const diffTime = todayStart.getTime() - activityStart.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
if (diffDays <= 7) {
|
||||
return "This week";
|
||||
return "thisWeek";
|
||||
}
|
||||
if (diffDays <= 30) {
|
||||
return "This month";
|
||||
return "thisMonth";
|
||||
}
|
||||
return "Older";
|
||||
return "older";
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: AggregatedAgent["status"]): string {
|
||||
function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string {
|
||||
switch (section) {
|
||||
case "today":
|
||||
return t("agentList.dateSections.today");
|
||||
case "yesterday":
|
||||
return t("agentList.dateSections.yesterday");
|
||||
case "thisWeek":
|
||||
return t("agentList.dateSections.thisWeek");
|
||||
case "thisMonth":
|
||||
return t("agentList.dateSections.thisMonth");
|
||||
case "older":
|
||||
return t("agentList.dateSections.older");
|
||||
}
|
||||
}
|
||||
|
||||
function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string {
|
||||
switch (status) {
|
||||
case "initializing":
|
||||
return "Starting";
|
||||
return t("agentList.status.initializing");
|
||||
case "idle":
|
||||
return "Idle";
|
||||
return t("agentList.status.idle");
|
||||
case "running":
|
||||
return "Running";
|
||||
return t("agentList.status.running");
|
||||
case "error":
|
||||
return "Error";
|
||||
return t("agentList.status.error");
|
||||
case "closed":
|
||||
return "Closed";
|
||||
return t("agentList.status.closed");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
@@ -188,12 +215,14 @@ function SessionRow({
|
||||
onLongPress: (agent: AggregatedAgent) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const timeAgo = formatTimeAgo(agent.lastActivityAt);
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const statusLabel = formatStatusLabel(t, agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -231,14 +260,19 @@ function SessionRow({
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text style={sessionTitleStyle} numberOfLines={1}>
|
||||
{agent.title || "New session"}
|
||||
{agent.title || t("agentList.fallbackTitle")}
|
||||
</Text>
|
||||
{agent.archivedAt ? <SessionBadge label="Archived" icon={archivedIcon} /> : null}
|
||||
{(agent.pendingPermissionCount ?? 0) > 0 ? (
|
||||
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge label={t("agentList.badges.archived")} icon={archivedIcon} />
|
||||
) : null}
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<SessionBadge
|
||||
label={t("agentList.badges.pending", { count: pendingPermissionCount })}
|
||||
tone="warning"
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
|
||||
) : null}
|
||||
</View>
|
||||
{isMobile && (
|
||||
@@ -272,7 +306,7 @@ function SessionRow({
|
||||
)}
|
||||
{isMobile && showAttentionIndicator && agent.requiresAttention ? (
|
||||
<View style={styles.rowTrailing}>
|
||||
<SessionBadge label="Attention" tone="danger" />
|
||||
<SessionBadge label={t("agentList.badges.attention")} tone="danger" />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
@@ -289,6 +323,7 @@ export function AgentList({
|
||||
showAttentionIndicator = true,
|
||||
}: AgentListProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -354,22 +389,21 @@ export function AgentList({
|
||||
}, [actionAgent, actionClient, archiveAgent]);
|
||||
|
||||
const flatItems = useMemo((): FlatListItem[] => {
|
||||
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||
const buckets = new Map<string, AggregatedAgent[]>();
|
||||
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
|
||||
for (const agent of agents) {
|
||||
const label = deriveDateSectionLabel(agent.lastActivityAt);
|
||||
const existing = buckets.get(label) ?? [];
|
||||
const section = deriveDateSectionKey(agent.lastActivityAt);
|
||||
const existing = buckets.get(section) ?? [];
|
||||
existing.push(agent);
|
||||
buckets.set(label, existing);
|
||||
buckets.set(section, existing);
|
||||
}
|
||||
|
||||
const result: FlatListItem[] = [];
|
||||
for (const label of order) {
|
||||
const data = buckets.get(label);
|
||||
for (const section of DATE_SECTION_ORDER) {
|
||||
const data = buckets.get(section);
|
||||
if (!data || data.length === 0) {
|
||||
continue;
|
||||
}
|
||||
result.push({ type: "header", key: `header:${label}`, title: label });
|
||||
result.push({ type: "header", key: `header:${section}`, section });
|
||||
for (const agent of data) {
|
||||
result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent });
|
||||
}
|
||||
@@ -382,7 +416,7 @@ export function AgentList({
|
||||
if (item.type === "header") {
|
||||
return (
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionTitle}>{item.title}</Text>
|
||||
<Text style={styles.sectionTitle}>{formatDateSectionLabel(t, item.section)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -397,7 +431,7 @@ export function AgentList({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator],
|
||||
[handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
|
||||
@@ -454,8 +488,8 @@ export function AgentList({
|
||||
<View style={styles.sheetHandle} />
|
||||
<Text style={styles.sheetTitle}>
|
||||
{isActionDaemonUnavailable
|
||||
? "Host offline"
|
||||
: "This agent is still running. Archiving it will stop the agent."}
|
||||
? t("agentList.archiveSheet.hostOffline")
|
||||
: t("agentList.archiveSheet.runningAgent")}
|
||||
</Text>
|
||||
<View style={styles.sheetButtonRow}>
|
||||
<Pressable
|
||||
@@ -463,7 +497,7 @@ export function AgentList({
|
||||
onPress={handleCloseActionSheet}
|
||||
testID="agent-action-cancel"
|
||||
>
|
||||
<Text style={styles.sheetCancelText}>Cancel</Text>
|
||||
<Text style={styles.sheetCancelText}>{t("common.actions.cancel")}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
disabled={isActionDaemonUnavailable}
|
||||
@@ -471,7 +505,7 @@ export function AgentList({
|
||||
onPress={handleArchiveAgent}
|
||||
testID="agent-action-archive"
|
||||
>
|
||||
<Text style={sheetArchiveTextStyle}>Archive</Text>
|
||||
<Text style={sheetArchiveTextStyle}>{t("agentList.archiveSheet.archive")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -15,6 +16,7 @@ interface ArchivedAgentCalloutProps {
|
||||
}
|
||||
|
||||
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -43,14 +45,14 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>This agent is archived</Text>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
Unarchive
|
||||
{t("agentPanel.archived.unarchive")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -55,6 +55,17 @@ vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) =>
|
||||
({
|
||||
"message.attachments.closeImage": "Close image",
|
||||
"message.attachments.dismissImage": "Dismiss image",
|
||||
"message.attachments.imageLoadFailed": "Couldn't load image",
|
||||
})[key] ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Image as ExpoImage } from "expo-image";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -15,6 +16,7 @@ interface AttachmentLightboxProps {
|
||||
|
||||
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const url = useAttachmentPreviewUrl(metadata);
|
||||
const [errored, setErrored] = useState(false);
|
||||
@@ -63,14 +65,14 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss image"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View style={styles.contentLayer}>
|
||||
<View style={styles.imageArea}>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>Couldn't load image</Text>
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
<Pressable onPress={noopPress} style={styles.imagePressable}>
|
||||
<ExpoImage
|
||||
@@ -86,7 +88,7 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
|
||||
<Pressable
|
||||
testID="attachment-lightbox-close"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close image"
|
||||
accessibilityLabel={t("message.attachments.closeImage")}
|
||||
hitSlop={8}
|
||||
onPress={onClose}
|
||||
style={closeButtonStyle}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pressable, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
|
||||
import type { ComboboxProps } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -27,6 +28,7 @@ export function BranchSwitcher({
|
||||
isGitCheckout,
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
@@ -91,7 +93,7 @@ export function BranchSwitcher({
|
||||
onPress={handleOpen}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
accessibilityLabel={t("branchSwitcher.currentBranch", { branchName: currentBranchName })}
|
||||
>
|
||||
{titleContent}
|
||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||
@@ -101,10 +103,10 @@ export function BranchSwitcher({
|
||||
value={currentBranchName}
|
||||
onSelect={handleBranchSelect}
|
||||
searchable
|
||||
placeholder="Switch branch..."
|
||||
searchPlaceholder="Filter branches..."
|
||||
emptyText="No branches found."
|
||||
title="Switch branch"
|
||||
placeholder={t("branchSwitcher.placeholder")}
|
||||
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
|
||||
emptyText={t("branchSwitcher.empty")}
|
||||
title={t("branchSwitcher.title")}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
anchorRef={anchorRef}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Pressable, Text, TextInput, View } from "react-native";
|
||||
import { ArrowLeft, ArrowRight, MousePointer2, PencilRuler, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
buildWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachments,
|
||||
@@ -55,7 +56,7 @@ function truncateText(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
|
||||
}
|
||||
|
||||
function getWebviewLoadErrorMessage(event: Event): string | null {
|
||||
function getWebviewLoadErrorMessage(event: Event, failedToLoadLabel: string): string | null {
|
||||
const details = event as Event & {
|
||||
errorCode?: unknown;
|
||||
errorDescription?: unknown;
|
||||
@@ -69,7 +70,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
|
||||
const description =
|
||||
typeof details.errorDescription === "string" && details.errorDescription.trim()
|
||||
? details.errorDescription.trim()
|
||||
: "Failed to load page";
|
||||
: failedToLoadLabel;
|
||||
const url =
|
||||
typeof details.validatedURL === "string" && details.validatedURL.trim()
|
||||
? details.validatedURL.trim()
|
||||
@@ -78,7 +79,7 @@ function getWebviewLoadErrorMessage(event: Event): string | null {
|
||||
return url ? `${description}: ${url}` : description;
|
||||
}
|
||||
|
||||
function getLoadUrlRejectionMessage(error: unknown): string | null {
|
||||
function getLoadUrlRejectionMessage(error: unknown, failedToLoadLabel: string): string | null {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
if (error.message.includes("ERR_ABORTED") || error.message.includes("ERR_BLOCKED_BY_CLIENT")) {
|
||||
return null;
|
||||
@@ -91,18 +92,21 @@ function getLoadUrlRejectionMessage(error: unknown): string | null {
|
||||
}
|
||||
return error.trim();
|
||||
}
|
||||
return "Failed to load page";
|
||||
return failedToLoadLabel;
|
||||
}
|
||||
|
||||
function getUnsafeNavigationMessage(url: string): string | null {
|
||||
function getUnsafeNavigationMessage(
|
||||
url: string,
|
||||
labels: { invalidUrl: string; unsupportedProtocol: (protocol: string) => string },
|
||||
): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (ALLOWED_BROWSER_PROTOCOLS.has(parsed.protocol) || parsed.href === "about:blank") {
|
||||
return null;
|
||||
}
|
||||
return `Blocked unsupported browser URL: ${parsed.protocol}`;
|
||||
return labels.unsupportedProtocol(parsed.protocol);
|
||||
} catch {
|
||||
return "Invalid browser URL";
|
||||
return labels.invalidUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +287,7 @@ export function BrowserPane({
|
||||
onFocusPane?: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const browser = useBrowserStore((state) => state.browsersById[browserId] ?? null);
|
||||
const updateBrowser = useBrowserStore((state) => state.updateBrowser);
|
||||
const webviewRef = useRef<ElectronWebview | null>(null);
|
||||
@@ -327,6 +332,17 @@ export function BrowserPane({
|
||||
() => [styles.metaError, { color: theme.colors.palette.red[500] }],
|
||||
[theme.colors.palette.red],
|
||||
);
|
||||
const browserErrorLabels = useMemo(
|
||||
() => ({
|
||||
failedToLoad: t("workspace.browser.errors.failedToLoad"),
|
||||
invalidUrl: t("workspace.browser.errors.invalidUrl"),
|
||||
unsupportedProtocol: (protocol: string) =>
|
||||
t("workspace.browser.errors.unsupportedProtocol", { protocol }),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const browserErrorLabelsRef = useRef(browserErrorLabels);
|
||||
browserErrorLabelsRef.current = browserErrorLabels;
|
||||
|
||||
useEffect(() => {
|
||||
const nextUrl = browser?.url ?? "https://example.com";
|
||||
@@ -384,7 +400,10 @@ export function BrowserPane({
|
||||
|
||||
host.replaceChildren();
|
||||
|
||||
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(initialUrlRef.current);
|
||||
const initialUnsafeNavigationMessage = getUnsafeNavigationMessage(
|
||||
initialUrlRef.current,
|
||||
browserErrorLabelsRef.current,
|
||||
);
|
||||
const webview = document.createElement("webview") as ElectronWebview;
|
||||
webviewRef.current = webview;
|
||||
webview.setAttribute("partition", `persist:paseo-browser-${browserId}`);
|
||||
@@ -459,7 +478,7 @@ export function BrowserPane({
|
||||
updateBrowserRef.current(browserIdRef.current, { faviconUrl: favicons[0] ?? null });
|
||||
};
|
||||
const handleLoadFailed = (event: Event) => {
|
||||
const message = getWebviewLoadErrorMessage(event);
|
||||
const message = getWebviewLoadErrorMessage(event, browserErrorLabelsRef.current.failedToLoad);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
@@ -519,43 +538,46 @@ export function BrowserPane({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [browserId, onFocusPane]);
|
||||
|
||||
const navigate = useCallback((nextUrl: string) => {
|
||||
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
||||
const webview = webviewRef.current;
|
||||
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl);
|
||||
const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
|
||||
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
url: normalizedUrl,
|
||||
isLoading: unsafeNavigationMessage === null,
|
||||
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
|
||||
lastError: null,
|
||||
});
|
||||
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
|
||||
if (unsafeNavigationMessage) {
|
||||
const navigate = useCallback(
|
||||
(nextUrl: string) => {
|
||||
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
||||
const webview = webviewRef.current;
|
||||
const unsafeNavigationMessage = getUnsafeNavigationMessage(normalizedUrl, browserErrorLabels);
|
||||
const previousUrl = browserRef.current?.url ?? initialUrlRef.current;
|
||||
pendingNavigationUrlRef.current = unsafeNavigationMessage ? null : normalizedUrl;
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
isLoading: false,
|
||||
lastError: unsafeNavigationMessage,
|
||||
url: normalizedUrl,
|
||||
isLoading: unsafeNavigationMessage === null,
|
||||
...(normalizedUrl !== previousUrl ? { faviconUrl: null } : {}),
|
||||
lastError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (webview?.loadURL) {
|
||||
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
|
||||
const message = getLoadUrlRejectionMessage(error);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
setDraftUrl((current) => (current === normalizedUrl ? current : normalizedUrl));
|
||||
if (unsafeNavigationMessage) {
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
isLoading: false,
|
||||
lastError: message,
|
||||
lastError: unsafeNavigationMessage,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (webview) {
|
||||
webview.setAttribute("src", normalizedUrl);
|
||||
}
|
||||
}, []);
|
||||
return;
|
||||
}
|
||||
if (webview?.loadURL) {
|
||||
void webview.loadURL(normalizedUrl).catch((error: unknown) => {
|
||||
const message = getLoadUrlRejectionMessage(error, browserErrorLabels.failedToLoad);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
isLoading: false,
|
||||
lastError: message,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (webview) {
|
||||
webview.setAttribute("src", normalizedUrl);
|
||||
}
|
||||
},
|
||||
[browserErrorLabels],
|
||||
);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
webviewRef.current?.goBack?.();
|
||||
@@ -929,10 +951,8 @@ export function BrowserPane({
|
||||
if (!isElectronRuntime()) {
|
||||
return (
|
||||
<View style={styles.unavailableState}>
|
||||
<Text style={titleStyle}>Browser is desktop-only</Text>
|
||||
<Text style={subtitleStyle}>
|
||||
Open this workspace in Electron to use the built-in browser.
|
||||
</Text>
|
||||
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
|
||||
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -943,7 +963,7 @@ export function BrowserPane({
|
||||
<View style={styles.chromeLeft}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
accessibilityLabel={t("workspace.browser.controls.back")}
|
||||
disabled={!browser?.canGoBack}
|
||||
onPress={handleBack}
|
||||
style={backIconButtonStyle}
|
||||
@@ -952,7 +972,7 @@ export function BrowserPane({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Forward"
|
||||
accessibilityLabel={t("workspace.browser.controls.forward")}
|
||||
disabled={!browser?.canGoForward}
|
||||
onPress={handleForward}
|
||||
style={forwardIconButtonStyle}
|
||||
@@ -961,7 +981,11 @@ export function BrowserPane({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={browser?.isLoading ? "Stop loading" : "Refresh"}
|
||||
accessibilityLabel={
|
||||
browser?.isLoading
|
||||
? t("workspace.browser.controls.stopLoading")
|
||||
: t("workspace.browser.controls.refresh")
|
||||
}
|
||||
onPress={handleRefresh}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
@@ -970,13 +994,13 @@ export function BrowserPane({
|
||||
</View>
|
||||
<View style={styles.urlBarWrap}>
|
||||
<TextInput
|
||||
accessibilityLabel="Browser URL"
|
||||
accessibilityLabel={t("workspace.browser.controls.browserUrl")}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setDraftUrl}
|
||||
onFocus={handleUrlBarFocus}
|
||||
onSubmitEditing={handleNavigateDraftUrl}
|
||||
placeholder="Enter URL"
|
||||
placeholder={t("workspace.browser.controls.enterUrl")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
ref={urlInputRef}
|
||||
style={urlInputStyle}
|
||||
@@ -988,7 +1012,7 @@ export function BrowserPane({
|
||||
<>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open browser dev tools"
|
||||
accessibilityLabel={t("workspace.browser.controls.openDevTools")}
|
||||
onPress={handleOpenDevTools}
|
||||
style={baseIconButtonStyle}
|
||||
>
|
||||
@@ -996,7 +1020,11 @@ export function BrowserPane({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={selectorActive ? "Cancel element selector" : "Select element"}
|
||||
accessibilityLabel={
|
||||
selectorActive
|
||||
? t("workspace.browser.controls.cancelSelector")
|
||||
: t("workspace.browser.controls.selectElement")
|
||||
}
|
||||
onPress={handleToggleElementSelector}
|
||||
style={selectorIconButtonStyle}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface BrowserPaneProps {
|
||||
browserId: string;
|
||||
@@ -13,6 +14,7 @@ interface BrowserPaneProps {
|
||||
|
||||
export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
@@ -24,8 +26,8 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={titleStyle}>Browser is desktop-only</Text>
|
||||
<Text style={subtitleStyle}>Browser session {browserId}</Text>
|
||||
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
|
||||
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
interface BrowserPaneProps {
|
||||
@@ -12,6 +13,7 @@ interface BrowserPaneProps {
|
||||
}
|
||||
|
||||
export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
@@ -24,11 +26,9 @@ export function BrowserPane({ browserId }: BrowserPaneProps) {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={titleStyle}>Browser is desktop-only</Text>
|
||||
<Text style={subtitleStyle}>
|
||||
Open this workspace in Electron to use the built-in browser.
|
||||
</Text>
|
||||
<Text style={subtitleStyle}>Browser session {browserId}</Text>
|
||||
<Text style={titleStyle}>{t("workspace.browser.unavailable.title")}</Text>
|
||||
<Text style={subtitleStyle}>{t("workspace.browser.unavailable.subtitle")}</Text>
|
||||
<Text style={subtitleStyle}>{t("workspace.browser.session", { browserId })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -144,6 +145,7 @@ function ModelRow({
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const ProviderIcon = getProviderIcon(row.provider);
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
@@ -166,7 +168,9 @@ function ModelRow({
|
||||
hitSlop={8}
|
||||
style={favoriteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
|
||||
accessibilityLabel={
|
||||
isFavorite ? t("modelSelector.unfavoriteModel") : t("modelSelector.favoriteModel")
|
||||
}
|
||||
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
@@ -193,6 +197,7 @@ function ModelRow({
|
||||
theme.colors.palette.amber,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.border,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -256,6 +261,7 @@ function FavoritesSection({
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (favoriteRows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -263,7 +269,7 @@ function FavoritesSection({
|
||||
return (
|
||||
<View style={styles.favoritesContainer}>
|
||||
<View style={styles.sectionHeading}>
|
||||
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
||||
<Text style={styles.sectionHeadingText}>{t("modelSelector.favorites")}</Text>
|
||||
</View>
|
||||
{favoriteRows.map((row) => (
|
||||
<SelectableModelRow
|
||||
@@ -295,6 +301,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho
|
||||
|
||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const ProvIcon = getProviderIcon(provider.id);
|
||||
const selection = provider.modelSelection;
|
||||
|
||||
@@ -306,7 +313,11 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
||||
if (selection.kind === "models") {
|
||||
const count = selection.rows.length;
|
||||
stateNode = (
|
||||
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
|
||||
<Text style={styles.drillDownCount}>
|
||||
{t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", {
|
||||
count,
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
} else if (selection.kind === "loading") {
|
||||
stateNode = (
|
||||
@@ -316,14 +327,14 @@ function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.drillDownCount}>Loading</Text>
|
||||
<Text style={styles.drillDownCount}>{t("modelSelector.loadingShort")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownCount}>Error</Text>
|
||||
<Text style={styles.drillDownCount}>{t("modelSelector.error")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -431,6 +442,7 @@ function ProviderErrorEmptyState({
|
||||
isRetryingProvider: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const handleRetry = useCallback(() => {
|
||||
onRetryProvider?.(providerId);
|
||||
}, [onRetryProvider, providerId]);
|
||||
@@ -440,7 +452,7 @@ function ProviderErrorEmptyState({
|
||||
<Text style={styles.emptyStateText}>{message}</Text>
|
||||
{onRetryProvider ? (
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRetryingProvider}>
|
||||
{isRetryingProvider ? "Retrying…" : "Retry"}
|
||||
{isRetryingProvider ? t("modelSelector.retrying") : t("modelSelector.retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -461,6 +473,7 @@ function SelectorContent({
|
||||
isRetryingProvider,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
const selectedViewProvider = useMemo(
|
||||
() =>
|
||||
@@ -484,7 +497,7 @@ function SelectorContent({
|
||||
const emptyState = (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
<Text style={styles.emptyStateText}>{t("modelSelector.noMatches")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -501,7 +514,7 @@ function SelectorContent({
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.emptyStateText}>Loading</Text>
|
||||
<Text style={styles.emptyStateText}>{t("modelSelector.loadingShort")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -569,6 +582,7 @@ export function CombinedModelSelector({
|
||||
serverId = null,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
||||
@@ -653,12 +667,15 @@ export function CombinedModelSelector({
|
||||
}, [providers, view]);
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
|
||||
if (
|
||||
selectedModelLabel === t("modelSelector.loading") ||
|
||||
selectedModelLabel === t("modelSelector.selectModel")
|
||||
) {
|
||||
return selectedModelLabel;
|
||||
}
|
||||
|
||||
return buildSelectedTriggerLabel(selectedModelLabel);
|
||||
}, [selectedModelLabel]);
|
||||
}, [selectedModelLabel, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (platformIsWeb) {
|
||||
@@ -713,7 +730,7 @@ export function CombinedModelSelector({
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
return { title: "Select provider" };
|
||||
return { title: t("modelSelector.title") };
|
||||
}
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
@@ -723,7 +740,9 @@ export function CombinedModelSelector({
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${view.providerLabel} settings`}
|
||||
accessibilityLabel={t("modelSelector.openProviderSettings", {
|
||||
provider: view.providerLabel,
|
||||
})}
|
||||
testID={`selector-header-settings-${view.providerId}`}
|
||||
>
|
||||
<Settings
|
||||
@@ -742,7 +761,7 @@ export function CombinedModelSelector({
|
||||
search: {
|
||||
onChange: handleSearchQueryChange,
|
||||
resetKey: `${view.providerId}:${searchResetKey}`,
|
||||
placeholder: "Search models...",
|
||||
placeholder: t("modelSelector.searchPlaceholder"),
|
||||
autoFocus: platformIsWeb,
|
||||
testID: "model-search-input",
|
||||
},
|
||||
@@ -757,6 +776,7 @@ export function CombinedModelSelector({
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
searchResetKey,
|
||||
t,
|
||||
theme.iconSize.md,
|
||||
theme.iconSize.sm,
|
||||
theme.colors.foreground,
|
||||
@@ -771,7 +791,7 @@ export function CombinedModelSelector({
|
||||
onPress={handleTriggerPress}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
||||
accessibilityLabel={t("modelSelector.selectedModel", { model: selectedModelLabel })}
|
||||
testID="combined-model-selector"
|
||||
>
|
||||
{renderTrigger ? (
|
||||
@@ -823,7 +843,7 @@ export function CombinedModelSelector({
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
|
||||
<Text style={styles.sheetLoadingText}>{t("modelSelector.loadingSelector")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Combobox>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
@@ -202,6 +203,7 @@ interface CommandCenterAgentRowContentProps {
|
||||
|
||||
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
@@ -222,7 +224,7 @@ function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentPro
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<Text style={titleStyle} numberOfLines={1}>
|
||||
{agent.title || "New agent"}
|
||||
{agent.title || t("shell.commandCenter.newAgent")}
|
||||
</Text>
|
||||
<Text style={subtitleStyle} numberOfLines={1}>
|
||||
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
|
||||
@@ -256,10 +258,12 @@ function AgentItemsSection({
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
}: AgentItemsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null}
|
||||
<Text style={sectionLabelStyle}>Agents</Text>
|
||||
<Text style={sectionLabelStyle}>{t("shell.commandCenter.agents")}</Text>
|
||||
{agentItems.map((item, index) => {
|
||||
const rowIndex = actionItemsLength + index;
|
||||
const agent = item.agent;
|
||||
@@ -283,6 +287,7 @@ function AgentItemsSection({
|
||||
|
||||
export function CommandCenter() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
open,
|
||||
inputRef,
|
||||
@@ -442,12 +447,12 @@ export function CommandCenter() {
|
||||
|
||||
const resultList =
|
||||
items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>No matches</Text>
|
||||
<Text style={emptyTextStyle}>{t("shell.commandCenter.noMatches")}</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>Actions</Text>
|
||||
<Text style={sectionLabelStyle}>{t("shell.commandCenter.actions")}</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.action.id}`}
|
||||
@@ -501,7 +506,7 @@ export function CommandCenter() {
|
||||
onChangeText={setQuery}
|
||||
onKeyPress={handleKeyPress}
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
placeholder="Type a command or search agents..."
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -534,7 +539,7 @@ export function CommandCenter() {
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Type a command or search agents..."
|
||||
placeholder={t("shell.commandCenter.placeholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
@@ -81,6 +82,7 @@ export function ContextWindowMeter({
|
||||
showPercentage = false,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
|
||||
if (percentage === null) {
|
||||
@@ -106,7 +108,9 @@ export function ContextWindowMeter({
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
accessibilityLabel={t("contextWindow.accessibility", {
|
||||
percentage: roundedPercentage,
|
||||
})}
|
||||
>
|
||||
<Svg
|
||||
width={svgSize}
|
||||
@@ -143,13 +147,20 @@ export function ContextWindowMeter({
|
||||
</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>
|
||||
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
|
||||
<Text style={styles.tooltipText}>
|
||||
{t("contextWindow.used", { percentage: roundedPercentage })}
|
||||
</Text>
|
||||
<Text style={styles.tooltipDetail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(usedTokens),
|
||||
max: formatTokenCount(maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
{formattedSessionCost ? (
|
||||
<Text style={styles.tooltipDetail}>{`Session cost ${formattedSessionCost}`}</Text>
|
||||
<Text style={styles.tooltipDetail}>
|
||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { DictationStatus } from "@/hooks/use-dictation";
|
||||
@@ -43,6 +44,7 @@ export function DictationControls({
|
||||
disabled = false,
|
||||
}: DictationControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isFailed = status === "failed";
|
||||
const showActiveState = isRecording || isProcessing || isFailed;
|
||||
const actionsDisabled = isProcessing;
|
||||
@@ -71,7 +73,7 @@ export function DictationControls({
|
||||
onPress={onStart}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Start voice dictation"
|
||||
accessibilityLabel={t("message.dictation.start")}
|
||||
style={micButtonStyle}
|
||||
>
|
||||
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
@@ -89,7 +91,7 @@ export function DictationControls({
|
||||
<Pressable
|
||||
onPress={handleCancel}
|
||||
disabled={actionsDisabled && !isFailed}
|
||||
accessibilityLabel="Cancel dictation"
|
||||
accessibilityLabel={t("message.dictation.cancel")}
|
||||
style={cancelButtonStyle}
|
||||
>
|
||||
<X size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
@@ -102,7 +104,7 @@ export function DictationControls({
|
||||
{!actionsDisabled && isFailed ? (
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityLabel="Retry dictation"
|
||||
accessibilityLabel={t("message.dictation.retry")}
|
||||
style={ACTION_CONFIRM_STYLE}
|
||||
>
|
||||
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
|
||||
@@ -112,14 +114,14 @@ export function DictationControls({
|
||||
<>
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
accessibilityLabel="Insert transcription"
|
||||
accessibilityLabel={t("message.dictation.insert")}
|
||||
style={ACTION_SECONDARY_STYLE}
|
||||
>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onAcceptAndSend}
|
||||
accessibilityLabel="Insert transcription and send"
|
||||
accessibilityLabel={t("message.dictation.insertAndSend")}
|
||||
style={ACTION_CONFIRM_STYLE}
|
||||
>
|
||||
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
|
||||
@@ -149,6 +151,7 @@ export function DictationOverlay({
|
||||
onDiscard,
|
||||
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isFailed = status === "failed";
|
||||
const showActiveState = isRecording || isProcessing || isFailed;
|
||||
const actionsDisabled = isProcessing;
|
||||
@@ -189,7 +192,7 @@ export function DictationOverlay({
|
||||
onPress={handleCancel}
|
||||
disabled={actionsDisabled && !isFailed}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel dictation"
|
||||
accessibilityLabel={t("message.dictation.cancel")}
|
||||
style={overlayCancelButtonStyle}
|
||||
>
|
||||
<X size={theme.iconSize.lg} color={theme.colors.accentForeground} strokeWidth={2.5} />
|
||||
@@ -208,7 +211,9 @@ export function DictationOverlay({
|
||||
</View>
|
||||
{isFailed ? (
|
||||
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
|
||||
{errorText ? `Dictation failed: ${errorText}` : "Dictation failed. Tap retry."}
|
||||
{errorText
|
||||
? t("message.dictation.failed", { error: errorText })
|
||||
: t("message.dictation.failedRetry")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -223,7 +228,7 @@ export function DictationOverlay({
|
||||
<Pressable
|
||||
onPress={onRetry}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry dictation"
|
||||
accessibilityLabel={t("message.dictation.retry")}
|
||||
style={overlayRetryButtonStyle}
|
||||
>
|
||||
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||
@@ -234,7 +239,7 @@ export function DictationOverlay({
|
||||
<Pressable
|
||||
onPress={onAccept}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription"
|
||||
accessibilityLabel={t("message.dictation.insert")}
|
||||
style={OVERLAY_ACCEPT_BUTTON_STYLE}
|
||||
>
|
||||
<Pencil
|
||||
@@ -246,7 +251,7 @@ export function DictationOverlay({
|
||||
<Pressable
|
||||
onPress={onAcceptAndSend}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Insert transcription and send"
|
||||
accessibilityLabel={t("message.dictation.insertAndSend")}
|
||||
style={overlayConfirmButtonStyle}
|
||||
>
|
||||
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -120,10 +121,12 @@ function DiffSegment({
|
||||
export function DiffViewer({
|
||||
diffLines,
|
||||
maxHeight,
|
||||
emptyLabel = "No changes to display",
|
||||
emptyLabel,
|
||||
fillAvailableHeight = false,
|
||||
}: DiffViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||
const resolvedEmptyLabel = emptyLabel ?? t("diffViewer.empty");
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
const handleInnerLayout = React.useCallback(
|
||||
(e: { nativeEvent: { layout: { width: number } } }) =>
|
||||
@@ -159,7 +162,7 @@ export function DiffViewer({
|
||||
if (!diffLines.length) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>{emptyLabel}</Text>
|
||||
<Text style={styles.emptyText}>{resolvedEmptyLabel}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -7,19 +9,20 @@ import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/store
|
||||
|
||||
const AUTO_DISMISS_DELAY = 3000;
|
||||
|
||||
function getDownloadStatusText(download: Download): string {
|
||||
function getDownloadStatusText(download: Download, t: TFunction): string {
|
||||
if (download.status === "downloading") {
|
||||
if (download.progress) {
|
||||
return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`;
|
||||
}
|
||||
return "Starting...";
|
||||
return t("common.states.starting");
|
||||
}
|
||||
if (download.status === "complete") return "Download complete";
|
||||
return download.message ?? "Download failed";
|
||||
if (download.status === "complete") return t("common.states.downloadComplete");
|
||||
return download.message ?? t("common.states.downloadFailed");
|
||||
}
|
||||
|
||||
export function DownloadToast() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const downloads = useDownloadStore((state) => state.downloads);
|
||||
const activeDownloadId = useDownloadStore((state) => state.activeDownloadId);
|
||||
@@ -78,7 +81,7 @@ export function DownloadToast() {
|
||||
<Text style={styles.fileName} numberOfLines={1}>
|
||||
{activeDownload.fileName}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getDownloadStatusText(activeDownload)}</Text>
|
||||
<Text style={styles.status}>{getDownloadStatusText(activeDownload, t)}</Text>
|
||||
{activeDownload.status === "downloading" && activeDownload.progress && (
|
||||
<View style={styles.progressBar}>
|
||||
<ProgressFill percent={activeDownload.progress.percent} />
|
||||
|
||||
@@ -12,9 +12,13 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { PrPane } from "@/git/pr-pane";
|
||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatPrTabLabel,
|
||||
PullRequestPane,
|
||||
PullRequestTabIcon,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
@@ -23,11 +27,8 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
@@ -35,6 +36,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
@@ -68,7 +70,7 @@ export function ExplorerSidebar({
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
useSidebarAnimation();
|
||||
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
@@ -166,7 +168,7 @@ export function ExplorerSidebar({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) {
|
||||
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
@@ -206,11 +208,9 @@ export function ExplorerSidebar({
|
||||
windowWidth,
|
||||
});
|
||||
if (shouldClose) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
@@ -222,7 +222,7 @@ export function ExplorerSidebar({
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleCloseFromGesture,
|
||||
@@ -423,6 +423,7 @@ function SidebarContent({
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
@@ -436,7 +437,11 @@ function SidebarContent({
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab =
|
||||
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||
const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
);
|
||||
|
||||
const headerStyle = useMemo(
|
||||
() => [styles.header, { paddingRight: padding.right }],
|
||||
@@ -453,7 +458,7 @@ function SidebarContent({
|
||||
<ExplorerTabButton
|
||||
tab="changes"
|
||||
active={resolvedTab === "changes"}
|
||||
label="Changes"
|
||||
label={t("workspace.tabs.explorer.changes")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-changes"
|
||||
/>
|
||||
@@ -461,7 +466,7 @@ function SidebarContent({
|
||||
<ExplorerTabButton
|
||||
tab="files"
|
||||
active={resolvedTab === "files"}
|
||||
label="Files"
|
||||
label={t("workspace.tabs.explorer.files")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-files"
|
||||
/>
|
||||
@@ -473,7 +478,7 @@ function SidebarContent({
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-pr"
|
||||
>
|
||||
<GitHubIcon
|
||||
<PullRequestTabIcon
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
@@ -510,7 +515,14 @@ function SidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||
{resolvedTab === "pr" && prPane.data && (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceRoot}
|
||||
data={prPane.data}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Upload } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -16,6 +17,7 @@ interface FileDropZoneProps {
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { isDragging, containerRef } = useFileDropZone({
|
||||
onFilesDropped,
|
||||
@@ -58,7 +60,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
|
||||
{/* Content */}
|
||||
<View style={styles.overlayContent}>
|
||||
<Upload size={32} color={theme.colors.primary} />
|
||||
<Text style={styles.overlayText}>Drop images here</Text>
|
||||
<Text style={styles.overlayText}>{t("composer.attachments.dropImagesHere")}</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -45,10 +46,10 @@ import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "modified", label: "Modified" },
|
||||
{ value: "size", label: "Size" },
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "name" },
|
||||
{ value: "modified" },
|
||||
{ value: "size" },
|
||||
];
|
||||
|
||||
const INDENT_PER_LEVEL = 16;
|
||||
@@ -115,6 +116,7 @@ function TreeRowItem({
|
||||
onDownloadEntry,
|
||||
}: TreeRowItemProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isDirectory = entry.kind === "directory";
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
@@ -181,7 +183,7 @@ function TreeRowItem({
|
||||
<View style={styles.contextMetaBlock}>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
Size
|
||||
{t("workspace.fileExplorer.context.size")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatFileSize({ size: entry.size })}
|
||||
@@ -189,7 +191,7 @@ function TreeRowItem({
|
||||
</View>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
Modified
|
||||
{t("workspace.fileExplorer.context.modified")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
||||
@@ -198,11 +200,11 @@ function TreeRowItem({
|
||||
</View>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
|
||||
Copy path
|
||||
{t("workspace.fileExplorer.context.copyPath")}
|
||||
</DropdownMenuItem>
|
||||
{entry.kind === "file" ? (
|
||||
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
|
||||
Download
|
||||
{t("workspace.fileExplorer.context.download")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
@@ -229,6 +231,7 @@ export function FileExplorerPane({
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
@@ -395,7 +398,15 @@ export function FileExplorerPane({
|
||||
void refetchExplorer();
|
||||
}, [refetchExplorer]);
|
||||
|
||||
const currentSortLabel = resolveCurrentSortLabel(sortOption);
|
||||
const sortLabels = useMemo(
|
||||
() => ({
|
||||
name: t("workspace.fileExplorer.sort.name"),
|
||||
modified: t("workspace.fileExplorer.sort.modified"),
|
||||
size: t("workspace.fileExplorer.sort.size"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels);
|
||||
|
||||
const treeRows = useMemo(
|
||||
() => resolveTreeRows({ directories, expandedPaths, sortOption }),
|
||||
@@ -453,7 +464,7 @@ export function FileExplorerPane({
|
||||
if (!hasWorkspaceScope) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>Workspace is unavailable</Text>
|
||||
<Text style={styles.errorText}>{t("workspace.fileExplorer.states.unavailable")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -503,6 +514,7 @@ interface FileExplorerPaneContentProps {
|
||||
|
||||
function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
error,
|
||||
showInitialLoading,
|
||||
@@ -529,11 +541,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
<View style={styles.errorActions}>
|
||||
{showBackFromError ? (
|
||||
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
|
||||
<Text style={styles.retryButtonText}>Back</Text>
|
||||
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.back")}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable style={styles.retryButton} onPress={handleRetry}>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
<Text style={styles.retryButtonText}>{t("workspace.fileExplorer.actions.retry")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -544,7 +556,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading files…</Text>
|
||||
<Text style={styles.loadingText}>{t("workspace.fileExplorer.states.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -552,7 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
if (treeRows.length === 0) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No files</Text>
|
||||
<Text style={styles.emptyText}>{t("workspace.fileExplorer.empty.noFiles")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -570,7 +582,11 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
hitSlop={8}
|
||||
style={iconButtonStyleProp}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||
accessibilityLabel={
|
||||
isRefreshFetching
|
||||
? t("workspace.fileExplorer.actions.refreshing")
|
||||
: t("workspace.fileExplorer.actions.refresh")
|
||||
}
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
@@ -704,8 +720,11 @@ function resolveShowInitialLoading({
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCurrentSortLabel(sortOption: SortOption): string {
|
||||
return SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||
function resolveCurrentSortLabel(
|
||||
sortOption: SortOption,
|
||||
labels: Record<SortOption, string>,
|
||||
): string {
|
||||
return labels[sortOption] ?? labels.name;
|
||||
}
|
||||
|
||||
function resolveTreeRows({
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import Markdown, {
|
||||
type ASTNode,
|
||||
MarkdownIt,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -32,9 +22,6 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
@@ -59,8 +46,6 @@ interface FilePreviewBodyProps {
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -131,299 +116,6 @@ function clampLineSelection(input: {
|
||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface FilePreviewMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function FilePreviewMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: FilePreviewMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
function createFilePreviewMarkdownRules(): RenderRules {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<FilePreviewMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</FilePreviewMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
@@ -503,10 +195,8 @@ function FilePreviewBody({
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
const markdownRules = useMemo(() => createFilePreviewMarkdownRules(), []);
|
||||
const isMarkdownFile =
|
||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||
|
||||
@@ -562,7 +252,7 @@ function FilePreviewBody({
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -570,7 +260,7 @@ function FilePreviewBody({
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>No preview available</Text>
|
||||
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -589,11 +279,7 @@ function FilePreviewBody({
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
<AppearanceStyleBoundary>
|
||||
<Markdown style={markdownStyles} rules={markdownRules} markdownit={markdownParser}>
|
||||
{preview.content ?? ""}
|
||||
</Markdown>
|
||||
</AppearanceStyleBoundary>
|
||||
<MarkdownRenderer text={preview.content ?? ""} />
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
@@ -659,7 +345,7 @@ function FilePreviewBody({
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -689,7 +375,7 @@ function FilePreviewBody({
|
||||
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.emptyText}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
||||
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -704,6 +390,7 @@ export function FilePane({
|
||||
workspaceRoot: string;
|
||||
location: WorkspaceFileLocation;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
|
||||
@@ -726,7 +413,10 @@ export function FilePane({
|
||||
enabled: Boolean(client && readTarget),
|
||||
queryFn: async () => {
|
||||
if (!client || !readTarget) {
|
||||
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
||||
return {
|
||||
file: null as ExplorerFile | null,
|
||||
error: t("workspace.terminal.hostDisconnected"),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
||||
@@ -740,7 +430,7 @@ export function FilePane({
|
||||
return {
|
||||
file: null,
|
||||
imageAttachment: null,
|
||||
error: error instanceof Error ? error.message : "Failed to load file",
|
||||
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -19,6 +20,7 @@ function goBack(): void {
|
||||
|
||||
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const handleBack = useCallback(() => {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
@@ -35,7 +37,7 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
|
||||
onPress={handleBack}
|
||||
style={styles.backButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
accessibilityLabel={t("common.actions.back")}
|
||||
>
|
||||
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
@@ -48,6 +49,7 @@ export function SidebarMenuToggle({
|
||||
nativeID = "menu-button",
|
||||
}: SidebarMenuToggleProps = {}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||
@@ -68,7 +70,7 @@ export function SidebarMenuToggle({
|
||||
return (
|
||||
<HeaderToggleButton
|
||||
onPress={handlePress}
|
||||
tooltipLabel="Toggle sidebar"
|
||||
tooltipLabel={t("shell.menu.toggleSidebar")}
|
||||
tooltipKeys={toggleShortcutKeys}
|
||||
tooltipSide={tooltipSide}
|
||||
testID={testID}
|
||||
@@ -76,7 +78,7 @@ export function SidebarMenuToggle({
|
||||
style={style}
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
|
||||
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
|
||||
accessibilityState={accessibilityState}
|
||||
>
|
||||
{isMobile ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -153,6 +154,7 @@ interface CopyButtonProps {
|
||||
const COPIED_RESET_MS = 1500;
|
||||
|
||||
const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -189,7 +191,7 @@ const CopyButton = React.memo(function CopyButton({ getCode, visible }: CopyButt
|
||||
style={wrapperStyle}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={copied ? "Copied" : "Copy code"}
|
||||
accessibilityLabel={copied ? t("message.actions.copied") : t("message.actions.copyCode")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { useHostRegistryStatus } from "@/runtime/host-runtime";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
|
||||
export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }) {
|
||||
const storeReady = useStoreReady();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
|
||||
if (!storeReady) {
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
if (bootstrapState.startupBlocker.kind !== "none" || hostRegistryStatus === "loading") {
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
|
||||
@@ -15,6 +15,7 @@ const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
|
||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
|
||||
zed: require("../../../assets/images/editor-apps/zed.png"),
|
||||
antigravity: require("../../../assets/images/editor-apps/antigravity.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"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
export const ALL_FILTER_VALUE = "__all__";
|
||||
@@ -97,11 +98,15 @@ export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string
|
||||
if (firstPromptPreview) {
|
||||
return firstPromptPreview;
|
||||
}
|
||||
return "Untitled session";
|
||||
return i18n.t("importSession.preview.untitledSession");
|
||||
}
|
||||
|
||||
export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
|
||||
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
|
||||
return (
|
||||
entry.lastPromptPreview?.trim() ||
|
||||
entry.firstPromptPreview?.trim() ||
|
||||
i18n.t("importSession.preview.noPrompt")
|
||||
);
|
||||
}
|
||||
|
||||
export interface EmptyStateInputs {
|
||||
@@ -132,10 +137,16 @@ export function computeEmptyState(input: EmptyStateInputs): {
|
||||
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
|
||||
if (isFilteredEmpty) {
|
||||
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
||||
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
|
||||
return {
|
||||
showEmptyState,
|
||||
emptyStateTitle: i18n.t("importSession.empty.noProviderSessions", { provider: label }),
|
||||
};
|
||||
}
|
||||
if (input.totalAlreadyImportedCount > 0) {
|
||||
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
|
||||
return {
|
||||
showEmptyState,
|
||||
emptyStateTitle: i18n.t("importSession.empty.alreadyImported"),
|
||||
};
|
||||
}
|
||||
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
|
||||
return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noRecent") };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchRecentProviderSessionEntry,
|
||||
@@ -14,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
aggregateSessionEntries,
|
||||
ALL_FILTER_VALUE,
|
||||
@@ -63,8 +65,10 @@ function buildSessionsQueriesConfig(args: {
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
cwd: string | null | undefined;
|
||||
hostDisconnectedMessage?: string;
|
||||
}): SessionsQueryConfig[] {
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
|
||||
args;
|
||||
if (providersToFetch === null) return [];
|
||||
const enabled = visible && Boolean(client);
|
||||
return providersToFetch.map((provider) => ({
|
||||
@@ -72,7 +76,7 @@ function buildSessionsQueriesConfig(args: {
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return await client.fetchRecentProviderSessions({
|
||||
...(cwd ? { cwd } : {}),
|
||||
@@ -105,33 +109,36 @@ function SheetStatusMessages({
|
||||
importErrored,
|
||||
}: SheetStatusMessagesProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
if (!isClientReady) {
|
||||
return <Text style={styles.statusText}>Connect to a host to import sessions</Text>;
|
||||
return <Text style={styles.statusText}>{t("importSession.status.connectHost")}</Text>;
|
||||
}
|
||||
if (isSnapshotUnsupported) {
|
||||
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
|
||||
return <Text style={styles.statusText}>{t("importSession.status.updateHost")}</Text>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{hasNoImportableProviders ? (
|
||||
<Text style={styles.statusText}>No importable providers are enabled.</Text>
|
||||
<Text style={styles.statusText}>{t("importSession.status.noProviders")}</Text>
|
||||
) : null}
|
||||
{isLoadingSessions && !hasRows ? (
|
||||
<View style={styles.statusRow}>
|
||||
<LoadingSpinner color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.statusText}>Loading recent sessions...</Text>
|
||||
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{allQueriesErrored ? (
|
||||
<Text style={styles.statusText}>Could not load recent sessions.</Text>
|
||||
<Text style={styles.statusText}>{t("importSession.status.failedAll")}</Text>
|
||||
) : null}
|
||||
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
|
||||
<Text style={styles.statusText}>
|
||||
Could not load sessions for {erroredProviderLabels.join(", ")}.
|
||||
{t("importSession.status.failedProviders", {
|
||||
providers: erroredProviderLabels.join(", "),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{importErrored ? (
|
||||
<Text style={styles.statusText}>Could not import selected session.</Text>
|
||||
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -139,6 +146,7 @@ function SheetStatusMessages({
|
||||
|
||||
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.refreshButton,
|
||||
@@ -150,7 +158,7 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={isRefreshing}
|
||||
accessibilityLabel="Refresh sessions"
|
||||
accessibilityLabel={t("importSession.actions.refresh")}
|
||||
accessibilityRole="button"
|
||||
testID="import-session-refresh"
|
||||
style={pressableStyle}
|
||||
@@ -192,6 +200,7 @@ function ImportSessionSheetRow({
|
||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
|
||||
@@ -229,7 +238,9 @@ function ImportSessionSheetRow({
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.rowMeta}>{importing ? "Importing..." : lastActivity}</Text>
|
||||
<Text style={styles.rowMeta}>
|
||||
{importing ? t("importSession.row.importing") : lastActivity}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
@@ -253,6 +264,7 @@ export function ImportSessionSheet({
|
||||
onImportedAgent,
|
||||
onImported,
|
||||
}: ImportSessionSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -284,8 +296,9 @@ export function ImportSessionSheet({
|
||||
visible,
|
||||
client,
|
||||
cwd,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
|
||||
);
|
||||
|
||||
const queries = useQueries({ queries: queriesConfig });
|
||||
@@ -318,19 +331,20 @@ export function ImportSessionSheet({
|
||||
|
||||
const filterComboboxOptions = useMemo<ComboboxOption[]>(
|
||||
() => [
|
||||
{ id: ALL_FILTER_VALUE, label: "All providers" },
|
||||
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
|
||||
...filterProviders.map((provider) => ({
|
||||
id: provider,
|
||||
label: providerLabelById.get(provider) ?? provider,
|
||||
})),
|
||||
],
|
||||
[filterProviders, providerLabelById],
|
||||
[filterProviders, providerLabelById, t],
|
||||
);
|
||||
|
||||
const selectedProviderLabel = useMemo(
|
||||
() =>
|
||||
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers",
|
||||
[filterComboboxOptions, selectedProvider],
|
||||
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ??
|
||||
t("importSession.filters.all"),
|
||||
[filterComboboxOptions, selectedProvider, t],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
@@ -385,7 +399,7 @@ export function ImportSessionSheet({
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
if (!entry.cwd) {
|
||||
throw new Error("Session is missing a working directory");
|
||||
@@ -430,10 +444,10 @@ export function ImportSessionSheet({
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: "Import session",
|
||||
title: t("importSession.title"),
|
||||
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
|
||||
}),
|
||||
[isRefreshing, handleRefresh],
|
||||
[isRefreshing, handleRefresh, t],
|
||||
);
|
||||
|
||||
const isSnapshotUnsupported = !supportsSnapshot;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
@@ -9,9 +10,9 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||
|
||||
const SNAP_POINTS: string[] = ["70%", "92%"];
|
||||
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const { t } = useTranslation();
|
||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
|
||||
|
||||
@@ -23,10 +24,11 @@ export function KeyboardShortcutsDialog() {
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => setOpen(false), [setOpen]);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("settings.shortcuts.dialogTitle") }), [t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={SHORTCUTS_HEADER}
|
||||
header={header}
|
||||
visible={open}
|
||||
onClose={handleClose}
|
||||
testID="keyboard-shortcuts-dialog"
|
||||
@@ -35,13 +37,15 @@ export function KeyboardShortcutsDialog() {
|
||||
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
|
||||
{sections.map((section) => (
|
||||
<View key={section.title} style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
<Text style={styles.sectionTitle}>{t(section.titleKey)}</Text>
|
||||
<View style={styles.rows}>
|
||||
{section.rows.map((row) => (
|
||||
<View key={row.id} style={styles.row}>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
{row.note ? <Text style={styles.rowNote}>{row.note}</Text> : null}
|
||||
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
|
||||
{row.note ? (
|
||||
<Text style={styles.rowNote}>{row.noteKey ? t(row.noteKey) : row.note}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Shortcut keys={row.keys} style={styles.rowShortcut} />
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -40,11 +41,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
@@ -64,6 +61,7 @@ import {
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostNewWorkspaceRoute,
|
||||
@@ -107,6 +105,7 @@ interface SidebarSharedProps {
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: SidebarLabels;
|
||||
renderHostOption: (input: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
@@ -115,6 +114,16 @@ interface SidebarSharedProps {
|
||||
}) => ReactElement;
|
||||
}
|
||||
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
sessions: string;
|
||||
closeSidebar: string;
|
||||
}
|
||||
|
||||
interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
@@ -135,6 +144,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
void _selectedAgentId;
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) =>
|
||||
@@ -149,10 +159,10 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
);
|
||||
const activeServerId = activeDaemon?.serverId ?? null;
|
||||
const activeHostLabel = useMemo(() => {
|
||||
if (!activeDaemon) return "No host";
|
||||
if (!activeDaemon) return t("sidebar.host.noHost");
|
||||
const trimmed = activeDaemon.label?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId;
|
||||
}, [activeDaemon]);
|
||||
}, [activeDaemon, t]);
|
||||
const activeHostSnapshot = useHostRuntimeSnapshot(activeServerId ?? "");
|
||||
const activeHostStatus = activeServerId
|
||||
? (activeHostSnapshot?.connectionStatus ?? "connecting")
|
||||
@@ -274,6 +284,19 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
home: t("sidebar.actions.home"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
switchHost: t("sidebar.host.switchTitle"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
sessions: t("sidebar.sections.sessions"),
|
||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const sharedProps = {
|
||||
theme,
|
||||
activeServerId,
|
||||
@@ -294,6 +317,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
labels,
|
||||
};
|
||||
|
||||
if (isCompactLayout) {
|
||||
@@ -428,12 +452,14 @@ function FooterIconButton({
|
||||
|
||||
function AddProjectTooltipContent({
|
||||
newAgentKeys,
|
||||
label,
|
||||
}: {
|
||||
newAgentKeys: ReturnType<typeof useShortcutKeys>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
</View>
|
||||
);
|
||||
@@ -468,6 +494,7 @@ function SidebarFooter({
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
activeServerId: string | null;
|
||||
@@ -482,6 +509,13 @@ function SidebarFooter({
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
addProject: string;
|
||||
home: string;
|
||||
settings: string;
|
||||
switchHost: string;
|
||||
searchHosts: string;
|
||||
};
|
||||
}) {
|
||||
const newAgentKeys = useShortcutKeys("new-agent");
|
||||
return (
|
||||
@@ -501,26 +535,26 @@ function SidebarFooter({
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityLabel={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
theme={theme}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<AddProjectTooltipContent newAgentKeys={newAgentKeys} />
|
||||
<AddProjectTooltipContent newAgentKeys={newAgentKeys} label={labels.addProject} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<FooterIconButton
|
||||
onPress={handleHome}
|
||||
testID="sidebar-home"
|
||||
accessibilityLabel="Home"
|
||||
accessibilityLabel={labels.home}
|
||||
icon={Home}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
testID="sidebar-settings"
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityLabel={labels.settings}
|
||||
icon={Settings}
|
||||
theme={theme}
|
||||
/>
|
||||
@@ -531,8 +565,8 @@ function SidebarFooter({
|
||||
onSelect={handleHostSelect}
|
||||
renderOption={renderHostOption}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
searchPlaceholder="Search hosts..."
|
||||
title={labels.switchHost}
|
||||
searchPlaceholder={labels.searchHosts}
|
||||
desktopMinWidth={280}
|
||||
open={isHostPickerOpen}
|
||||
onOpenChange={setIsHostPickerOpen}
|
||||
@@ -566,6 +600,7 @@ function MobileSidebar({
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
@@ -581,7 +616,7 @@ function MobileSidebar({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
@@ -645,7 +680,7 @@ function MobileSidebar({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) {
|
||||
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
@@ -679,11 +714,9 @@ function MobileSidebar({
|
||||
isGesturing.value = false;
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldClose) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
@@ -695,7 +728,7 @@ function MobileSidebar({
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
mobilePanelState,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -753,7 +786,7 @@ function MobileSidebar({
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
@@ -770,7 +803,7 @@ function MobileSidebar({
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close sidebar"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
@@ -815,6 +848,7 @@ function MobileSidebar({
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
@@ -847,6 +881,7 @@ function DesktopSidebar({
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
labels,
|
||||
insetsTop,
|
||||
isOpen,
|
||||
handleViewMore,
|
||||
@@ -924,7 +959,7 @@ function DesktopSidebar({
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
label={labels.sessions}
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
@@ -968,6 +1003,7 @@ function DesktopSidebar({
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
/>
|
||||
|
||||
{/* Resize handle - absolutely positioned over right border */}
|
||||
|
||||
@@ -39,7 +39,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Paragraph stays a <View>, not a <Text>, for layout fidelity. RN Android's
|
||||
// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan
|
||||
|
||||
@@ -49,7 +49,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// iOS-only: paragraph wraps in UITextView so the entire paragraph is one
|
||||
// native text view. That's what unlocks cross-inline drag selection — handles
|
||||
|
||||
@@ -50,7 +50,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Same shape as Android — paragraph is a View so block-level children (images)
|
||||
// keep their natural layout. Web text selection already spans nested inline
|
||||
|
||||
332
packages/app/src/components/markdown/html-ish.test.ts
Normal file
332
packages/app/src/components/markdown/html-ish.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
|
||||
|
||||
describe("splitHtmlishMarkdown", () => {
|
||||
const inlineImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
|
||||
"",
|
||||
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
|
||||
"",
|
||||
"```ts",
|
||||
'const isBrowser = userAgent.includes("Mozilla");',
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
const multiDetailsBody = [
|
||||
"### Bot Review",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Important Files Changed</h3></summary>",
|
||||
"",
|
||||
"- `packages/server/src/server/session.ts`",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Security Findings</h3></summary>",
|
||||
"",
|
||||
"No blocking findings.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<!-- bot_other_comments_section -->",
|
||||
'<sub>Reviews (8): Last reviewed commit: “revert: undo parser” | <a href="https://app.greptile.com">Re-trigger Greptile</a></sub>',
|
||||
].join("\n");
|
||||
|
||||
it("classifies linked HTML images as generic inline images, not block markdown images", () => {
|
||||
const [image, text] = splitHtmlishMarkdown(inlineImageBody);
|
||||
|
||||
expect(image).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/assets/priority.svg?v=9",
|
||||
flowsWithText: true,
|
||||
});
|
||||
expect(text).toEqual({
|
||||
kind: "markdown",
|
||||
text: [
|
||||
" Spoofed browser User-Agent allows access control bypass",
|
||||
"",
|
||||
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
|
||||
"",
|
||||
"```ts",
|
||||
'const isBrowser = userAgent.includes("Mozilla");',
|
||||
"```",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("flags both images as flowsWithText when two inline images precede title text on the same line", () => {
|
||||
const twoImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/priority.svg" align="top"></a> <a href="#"><img alt="Security" src="https://example.com/security.svg" align="top"></a> **Title text here**',
|
||||
"",
|
||||
"Body paragraph.",
|
||||
].join("\n");
|
||||
|
||||
const parts = splitHtmlishMarkdown(twoImageBody);
|
||||
const [first, second, third] = parts;
|
||||
|
||||
expect(first).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
});
|
||||
expect(second).toEqual({ kind: "markdown", text: " " });
|
||||
expect(third).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Security",
|
||||
src: "https://example.com/security.svg",
|
||||
flowsWithText: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps safe width and height attributes on inline images", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<img alt="Small" src="https://example.com/small.svg" width="18" height="12">',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
width: 18,
|
||||
height: 12,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps safe non-empty image links", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<a href="https://example.com/details"><img alt="Small" src="https://example.com/small.svg"></a>',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
href: "https://example.com/details",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves inline image parts inside details bodies", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<details><summary>Images</summary><a href="https://example.com/page"><img alt="Icon" src="https://example.com/icon.svg"></a> Inline text</details>',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "details",
|
||||
summary: "Images",
|
||||
body: "Inline text",
|
||||
bodyParts: [
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.svg",
|
||||
href: "https://example.com/page",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: " Inline text" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag standalone inline images as flowing with text", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown('<img alt="Shot" src="https://example.com/shot.png">\n\nCaption below'),
|
||||
).toEqual([
|
||||
{ kind: "inlineImage", alt: "Shot", src: "https://example.com/shot.png" },
|
||||
{ kind: "markdown", text: "\n\nCaption below" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag mid-line inline images as flowing with text", () => {
|
||||
const [, image] = splitHtmlishMarkdown(
|
||||
'Before <img alt="Icon" src="https://example.com/icon.png"> after',
|
||||
);
|
||||
|
||||
expect(image).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves ordinary markdown images on the markdown path", () => {
|
||||
const source = "";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("leaves unsafe HTML image sources inert", () => {
|
||||
const source = '<img alt="Bad" src="javascript:alert(1)">';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("removes raw image anchor and image tags from rendered markdown text", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).not.toContain("<a ");
|
||||
expect(text).not.toContain("<img ");
|
||||
expect(text).not.toContain("</a>");
|
||||
});
|
||||
|
||||
it("unwraps sub text and strips HTML comments", () => {
|
||||
const parts = splitHtmlishMarkdown(multiDetailsBody);
|
||||
const tail = parts.at(-1);
|
||||
|
||||
expect(tail).toEqual({
|
||||
kind: "markdown",
|
||||
text: "\n\nReviews (8): Last reviewed commit: “revert: undo parser” | [Re-trigger Greptile](https://app.greptile.com)",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak stray closing details tags across multiple details blocks", () => {
|
||||
const renderedText = splitHtmlishMarkdown(multiDetailsBody)
|
||||
.map((part) => {
|
||||
if (part.kind === "markdown") return part.text;
|
||||
if (part.kind === "details") return `${part.summary}\n${part.body}`;
|
||||
return part.alt;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
expect(renderedText).not.toContain("</details>");
|
||||
expect(renderedText).not.toContain("<!--");
|
||||
expect(splitHtmlishMarkdown(multiDetailsBody).slice(0, 4)).toMatchObject([
|
||||
{ kind: "markdown", text: "### Bot Review\n\n" },
|
||||
{ kind: "details", summary: "Important Files Changed" },
|
||||
{ kind: "markdown", text: "\n\n" },
|
||||
{ kind: "details", summary: "Security Findings" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves paragraph boundaries around title, prose, and code block", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).toContain(
|
||||
"Spoofed browser User-Agent allows access control bypass\n\nThe middleware now trusts",
|
||||
);
|
||||
expect(text).toContain("unauthenticated requests.\n\n```ts\nconst isBrowser");
|
||||
});
|
||||
|
||||
it("keeps product-specific image language out of parser and renderer logic", () => {
|
||||
const parserSource = readFileSync(new URL("./html-ish.ts", import.meta.url), "utf8");
|
||||
const rendererSource = readFileSync(new URL("./renderer.tsx", import.meta.url), "utf8");
|
||||
const productionSource = `${parserSource}\n${rendererSource}`;
|
||||
|
||||
expect(productionSource).not.toMatch(/Greptile|badge|P0|P1|P2|greptile-static-assets/i);
|
||||
});
|
||||
|
||||
it("parses Greptile-style details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary><h3>Greptile Summary</h3></summary>Body markdown</details>",
|
||||
),
|
||||
).toEqual([{ kind: "details", summary: "Greptile Summary", body: "Body markdown" }]);
|
||||
});
|
||||
|
||||
it("keeps details inside fenced code as literal markdown", () => {
|
||||
const source = "```html\n<details><summary>Example</summary>Body</details>\n```";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("keeps details inside inline code on native runtimes without copied array sorting", () => {
|
||||
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
|
||||
|
||||
expect(withoutArrayToSorted(() => splitHtmlishMarkdown(source))).toEqual([
|
||||
{ kind: "markdown", text: source },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps details inside inline code as literal markdown", () => {
|
||||
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("still parses normal details outside code", () => {
|
||||
expect(splitHtmlishMarkdown("`code`\n<details><summary>Real</summary>Body</details>")).toEqual([
|
||||
{ kind: "markdown", text: "`code`\n" },
|
||||
{ kind: "details", summary: "Real", body: "Body" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves text before and after details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown("Before\n<details><summary>More</summary>Hidden</details>\nAfter"),
|
||||
).toEqual([
|
||||
{ kind: "markdown", text: "Before\n" },
|
||||
{ kind: "details", summary: "More", body: "Hidden" },
|
||||
{ kind: "markdown", text: "\nAfter" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary>One</summary>A</details><details><summary>Two</summary>B</details>",
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "details", summary: "One", body: "A" },
|
||||
{ kind: "details", summary: "Two", body: "B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes br and simple code tags into markdown", () => {
|
||||
expect(normalizeHtmlishMarkdown("Line 1<br/>Line 2 <code>safe-value</code>")).toBe(
|
||||
"Line 1\nLine 2 `safe-value`",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves complex code tags inert instead of parsing HTML", () => {
|
||||
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
|
||||
'<code onclick="evil()"><script>x</script></code>',
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when details are unclosed", () => {
|
||||
const source = "<details><summary>Open</summary>Still open";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when summary is missing", () => {
|
||||
const source = "<details>Hidden</details>";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("does not execute or render unknown HTML as structured content", () => {
|
||||
const source =
|
||||
'<script>alert(1)</script><details onclick="evil()"><summary>Safe</summary><iframe src="x"></iframe></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "<script>alert(1)</script>" },
|
||||
{ kind: "details", summary: "Safe", body: '<iframe src="x"></iframe>' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function withoutArrayToSorted<T>(callback: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Array.prototype, "toSorted");
|
||||
Reflect.deleteProperty(Array.prototype, "toSorted");
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
if (descriptor) {
|
||||
Reflect.defineProperty(Array.prototype, "toSorted", descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
705
packages/app/src/components/markdown/html-ish.ts
Normal file
705
packages/app/src/components/markdown/html-ish.ts
Normal file
@@ -0,0 +1,705 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
|
||||
export interface MarkdownTextPart {
|
||||
kind: "markdown";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkdownDetailsPart {
|
||||
kind: "details";
|
||||
summary: string;
|
||||
body: string;
|
||||
bodyParts?: MarkdownDisplayPart[];
|
||||
}
|
||||
|
||||
export interface MarkdownInlineImagePart {
|
||||
kind: "inlineImage";
|
||||
alt: string;
|
||||
src: string;
|
||||
href?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** The image starts a line and text continues on that same line. */
|
||||
flowsWithText?: boolean;
|
||||
}
|
||||
|
||||
export type MarkdownDisplayPart = MarkdownTextPart | MarkdownDetailsPart | MarkdownInlineImagePart;
|
||||
|
||||
const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
||||
const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
const VOID_HTML_TAGS = new Set(["br", "img"]);
|
||||
|
||||
interface ProtectedMarkdownRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownImageDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface HtmlTextToken {
|
||||
kind: "text";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HtmlCommentToken {
|
||||
kind: "comment";
|
||||
}
|
||||
|
||||
interface HtmlTagToken {
|
||||
kind: "tag";
|
||||
name: string;
|
||||
closing: boolean;
|
||||
selfClosing: boolean;
|
||||
attributes: Record<string, string>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
type HtmlToken = HtmlTextToken | HtmlCommentToken | HtmlTagToken;
|
||||
|
||||
interface InlineImageParseResult {
|
||||
part: MarkdownInlineImagePart;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownDelimiterMatch {
|
||||
index: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface HtmlishTokenParser {
|
||||
write(chunk: string): void;
|
||||
skip(length: number): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
export function splitHtmlishMarkdown(source: string): MarkdownDisplayPart[] {
|
||||
return splitHtmlishTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
|
||||
function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
const parts: MarkdownDisplayPart[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (isOpenTag(token, "details")) {
|
||||
const closeIndex = findMatchingClose(tokens, cursor, "details");
|
||||
if (closeIndex !== null) {
|
||||
const details = parseDetailsTokens(tokens.slice(cursor + 1, closeIndex));
|
||||
if (details) {
|
||||
parts.push(details);
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineImage = parseInlineImageAt(tokens, cursor);
|
||||
if (inlineImage) {
|
||||
parts.push(
|
||||
flowsWithFollowingText(tokens, cursor, inlineImage.end)
|
||||
? { ...inlineImage.part, flowsWithText: true }
|
||||
: inlineImage.part,
|
||||
);
|
||||
cursor = inlineImage.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
|
||||
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
|
||||
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
|
||||
const token = tokens[start];
|
||||
if (token?.kind !== "tag" || token.closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
return image ? { part: image, end: start + 1 } : null;
|
||||
}
|
||||
|
||||
if (token.name !== "a") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeIndex = findMatchingClose(tokens, start, "a");
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = getSingleImageChild(tokens.slice(start + 1, closeIndex));
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlineImage = imageTokenToInlineImage(image, safeHref(token.attributes.href));
|
||||
return inlineImage ? { part: inlineImage, end: closeIndex + 1 } : null;
|
||||
}
|
||||
|
||||
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
|
||||
const previous = tokens[start - 1];
|
||||
// At line start if nothing precedes this image, or the preceding token ends with only
|
||||
// whitespace since the last newline (covers a bare space between two images on the same line).
|
||||
const atLineStart =
|
||||
previous === undefined ||
|
||||
(previous.kind === "text" && /(?:^|[\n\r])[ \t]*$/.test(previous.value));
|
||||
if (!atLineStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan forward past same-line whitespace-only text tokens and inline images to find
|
||||
// the first substantive text token, without crossing a newline.
|
||||
let cursor = end;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (token === undefined) {
|
||||
break;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
const sameLine = token.value.split(/\r?\n/, 1)[0] ?? "";
|
||||
if (sameLine.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
// Whitespace-only on this line — keep scanning only if no newline was crossed.
|
||||
if (token.value.includes("\n") || token.value.includes("\r")) {
|
||||
return false;
|
||||
}
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
|
||||
if (token.kind === "tag") {
|
||||
const imageResult = parseInlineImageAt(tokens, cursor);
|
||||
if (imageResult) {
|
||||
cursor = imageResult.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const previous = parts.at(-1);
|
||||
if (previous?.kind === "markdown") {
|
||||
previous.text += text;
|
||||
return;
|
||||
}
|
||||
parts.push({ kind: "markdown", text });
|
||||
}
|
||||
|
||||
function parseDetailsTokens(tokens: HtmlToken[]): MarkdownDetailsPart | null {
|
||||
const summaryOpenIndex = findNextOpenTag(tokens, 0, "summary");
|
||||
if (summaryOpenIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryCloseIndex = findMatchingClose(tokens, summaryOpenIndex, "summary");
|
||||
if (summaryCloseIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryTokens = tokens.slice(summaryOpenIndex + 1, summaryCloseIndex);
|
||||
const bodyTokens = [...tokens.slice(0, summaryOpenIndex), ...tokens.slice(summaryCloseIndex + 1)];
|
||||
const summary = renderSummaryTokens(summaryTokens).trim();
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bodyParts = splitHtmlishTokens(bodyTokens);
|
||||
const body = renderBodyText(bodyParts);
|
||||
|
||||
return {
|
||||
kind: "details",
|
||||
summary,
|
||||
body: body.trim(),
|
||||
...(bodyParts.some((part) => part.kind !== "markdown")
|
||||
? { bodyParts: trimBodyParts(bodyParts) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderBodyText(parts: MarkdownDisplayPart[]): string {
|
||||
return parts.map((part) => (part.kind === "markdown" ? part.text : "")).join("");
|
||||
}
|
||||
|
||||
function trimBodyParts(parts: MarkdownDisplayPart[]): MarkdownDisplayPart[] {
|
||||
const trimmed = [...parts];
|
||||
const first = trimmed[0];
|
||||
if (first?.kind === "markdown") {
|
||||
first.text = first.text.trimStart();
|
||||
}
|
||||
const last = trimmed.at(-1);
|
||||
if (last?.kind === "markdown") {
|
||||
last.text = last.text.trimEnd();
|
||||
}
|
||||
return trimmed.filter((part) => part.kind !== "markdown" || part.text.length > 0);
|
||||
}
|
||||
|
||||
function renderSummaryTokens(tokens: HtmlToken[]): string {
|
||||
return stripSingleHeadingWrapper(renderInlineTokens(tokens));
|
||||
}
|
||||
|
||||
function stripSingleHeadingWrapper(text: string): string {
|
||||
const tokens = tokenizeHtmlishMarkdown(text.trim());
|
||||
if (tokens.length < 3) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const first = tokens[0];
|
||||
const last = tokens.at(-1);
|
||||
if (!isHeadingTag(first) || !isClosingTag(last, first.name)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return renderInlineTokens(tokens.slice(1, -1));
|
||||
}
|
||||
|
||||
function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
let output = "";
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
output += token.value;
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "comment" || token.closing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "br") {
|
||||
output += "\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
output += renderImageToken(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeIndex = token.selfClosing ? null : findMatchingClose(tokens, index, token.name);
|
||||
if (closeIndex === null) {
|
||||
output += renderUnknownTag(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
if (token.name === "a") {
|
||||
output += renderLinkToken(token, children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "sub") {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
output += `\`${renderInlineTokens(children)}\``;
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
const rawTag = token.raw;
|
||||
const tagName = token.name;
|
||||
if (isHeadingTag(token)) {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderImageToken(token: HtmlTagToken): string {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
if (!image) {
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
return ``;
|
||||
}
|
||||
|
||||
function renderLinkToken(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
const imageOnly = getSingleImageChild(children);
|
||||
if (imageOnly) {
|
||||
return renderImageToken(imageOnly);
|
||||
}
|
||||
|
||||
const label = renderInlineTokens(children);
|
||||
const href = token.attributes.href ?? "";
|
||||
if (!label || !SAFE_LINK_HREF_RE.test(href) || href === "#") {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `[${label}](${href})`;
|
||||
}
|
||||
|
||||
function imageTokenToInlineImage(
|
||||
token: HtmlTagToken,
|
||||
href: string | undefined,
|
||||
): MarkdownInlineImagePart | null {
|
||||
const src = token.attributes.src ?? "";
|
||||
if (!SAFE_IMAGE_SRC_RE.test(src)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "inlineImage",
|
||||
alt: token.attributes.alt ?? "",
|
||||
src,
|
||||
...(href ? { href } : {}),
|
||||
...parseImageDimensions(token.attributes),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimensions(attributes: Record<string, string>): MarkdownImageDimensions {
|
||||
return {
|
||||
...parseImageDimension("width", attributes.width),
|
||||
...parseImageDimension("height", attributes.height),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimension(key: "width" | "height", value: string | undefined) {
|
||||
if (!value || !/^\d+(?:\.\d+)?$/.test(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? { [key]: parsed } : {};
|
||||
}
|
||||
|
||||
function safeHref(href: string | undefined): string | undefined {
|
||||
if (!href || href === "#" || !SAFE_LINK_HREF_RE.test(href)) {
|
||||
return undefined;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
function getSingleImageChild(tokens: HtmlToken[]): HtmlTagToken | null {
|
||||
const visible = tokens.filter((token) => token.kind !== "comment" && !isWhitespaceText(token));
|
||||
return visible.length === 1 && isOpenTag(visible[0], "img") ? visible[0] : null;
|
||||
}
|
||||
|
||||
function renderUnknownTag(token: HtmlTagToken): string {
|
||||
if (token.name === "script" || token.name === "style") {
|
||||
return "";
|
||||
}
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
function escapeMarkdownImageAlt(value: string): string {
|
||||
return value.replace(/]/g, "\\]");
|
||||
}
|
||||
|
||||
function findNextOpenTag(tokens: HtmlToken[], start: number, name: string): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (isOpenTag(tokens[index], name)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingClose(tokens: HtmlToken[], openIndex: number, name: string): number | null {
|
||||
let depth = 1;
|
||||
for (let index = openIndex + 1; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (isOpenTag(token, name) && !token.selfClosing) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (isClosingTag(token, name)) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOpenTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && !token.closing;
|
||||
}
|
||||
|
||||
function isClosingTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTag(token: HtmlToken | undefined): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && isHeadingTagName(token.name) && !token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTagName(name: string): boolean {
|
||||
return (
|
||||
name === "h1" ||
|
||||
name === "h2" ||
|
||||
name === "h3" ||
|
||||
name === "h4" ||
|
||||
name === "h5" ||
|
||||
name === "h6"
|
||||
);
|
||||
}
|
||||
|
||||
function isWhitespaceText(token: HtmlToken): boolean {
|
||||
return token.kind === "text" && token.value.trim() === "";
|
||||
}
|
||||
|
||||
function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
|
||||
const protectedRanges = getProtectedMarkdownRanges(source);
|
||||
const tokens: HtmlToken[] = [];
|
||||
const parser = createHtmlishTokenParser(source, tokens);
|
||||
let cursor = 0;
|
||||
|
||||
for (const range of protectedRanges) {
|
||||
parser.write(source.slice(cursor, range.start));
|
||||
tokens.push({ kind: "text", value: source.slice(range.start, range.end) });
|
||||
parser.skip(range.end - range.start);
|
||||
cursor = range.end;
|
||||
}
|
||||
|
||||
parser.write(source.slice(cursor));
|
||||
parser.end();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
||||
let stripNextLeadingLineBreak = false;
|
||||
let skippedLength = 0;
|
||||
let parser!: Parser;
|
||||
parser = new Parser(
|
||||
{
|
||||
onopentag(name, attributes, isImplied) {
|
||||
if (isImplied) {
|
||||
return;
|
||||
}
|
||||
const selfClosing = VOID_HTML_TAGS.has(name);
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: false,
|
||||
selfClosing,
|
||||
attributes,
|
||||
raw: renderStartTag(name, attributes),
|
||||
});
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
if (isImplied || VOID_HTML_TAGS.has(name)) {
|
||||
return;
|
||||
}
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: true,
|
||||
selfClosing: false,
|
||||
attributes: {},
|
||||
raw: `</${name}>`,
|
||||
});
|
||||
},
|
||||
ontext(value) {
|
||||
if (stripNextLeadingLineBreak) {
|
||||
stripNextLeadingLineBreak = false;
|
||||
value = stripLeadingLineBreak(value);
|
||||
}
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
tokens.push({ kind: "text", value });
|
||||
},
|
||||
oncomment() {
|
||||
stripNextLeadingLineBreak = isLineStart(source, parser.startIndex + skippedLength);
|
||||
tokens.push({ kind: "comment" });
|
||||
},
|
||||
},
|
||||
{
|
||||
decodeEntities: false,
|
||||
lowerCaseAttributeNames: true,
|
||||
lowerCaseTags: true,
|
||||
recognizeSelfClosing: true,
|
||||
},
|
||||
);
|
||||
return {
|
||||
write(chunk) {
|
||||
parser.write(chunk);
|
||||
},
|
||||
skip(length) {
|
||||
skippedLength += length;
|
||||
},
|
||||
end() {
|
||||
parser.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLineStart(source: string, index: number): boolean {
|
||||
return index === 0 || source[index - 1] === "\n" || source[index - 1] === "\r";
|
||||
}
|
||||
|
||||
function stripLeadingLineBreak(value: string): string {
|
||||
if (value.startsWith("\r\n")) {
|
||||
return value.slice(2);
|
||||
}
|
||||
if (value.startsWith("\n") || value.startsWith("\r")) {
|
||||
return value.slice(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderStartTag(name: string, attributes: Record<string, string>): string {
|
||||
const renderedAttributes = Object.entries(attributes)
|
||||
.map(([key, value]) => (value === "" ? ` ${key}` : ` ${key}="${escapeAttribute(value)}"`))
|
||||
.join("");
|
||||
return `<${name}${renderedAttributes}>`;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const fencedRanges = getFencedCodeRanges(source);
|
||||
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
|
||||
}
|
||||
|
||||
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
FENCE_LINE_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = FENCE_LINE_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const marker = open[1];
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const close = findClosingFence(source, FENCE_LINE_RE.lastIndex, marker);
|
||||
if (!close) {
|
||||
ranges.push({ start: open.index, end: source.length });
|
||||
return ranges;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
FENCE_LINE_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingFence(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
): MarkdownDelimiterMatch | null {
|
||||
const closeRe = new RegExp(
|
||||
`^ {0,3}[${marker[0]}]{${marker.length},}[^\\n\\r]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
);
|
||||
closeRe.lastIndex = start;
|
||||
const close = closeRe.exec(source);
|
||||
return close ? { index: close.index, end: closeRe.lastIndex } : null;
|
||||
}
|
||||
|
||||
function getInlineCodeRanges(
|
||||
source: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
BACKTICK_RUN_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = BACKTICK_RUN_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
if (isProtectedIndex(open.index, fencedRanges)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = open[0];
|
||||
const close = findClosingBacktickRun(source, BACKTICK_RUN_RE.lastIndex, marker, fencedRanges);
|
||||
if (!close) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
BACKTICK_RUN_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingBacktickRun(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): MarkdownDelimiterMatch | null {
|
||||
BACKTICK_RUN_RE.lastIndex = start;
|
||||
|
||||
while (true) {
|
||||
const close = BACKTICK_RUN_RE.exec(source);
|
||||
if (!close) {
|
||||
return null;
|
||||
}
|
||||
if (close[0] === marker && !isProtectedIndex(close.index, fencedRanges)) {
|
||||
return { index: close.index, end: BACKTICK_RUN_RE.lastIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProtectedRanges(ranges: ProtectedMarkdownRange[]): ProtectedMarkdownRange[] {
|
||||
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
||||
const merged: ProtectedMarkdownRange[] = [];
|
||||
|
||||
for (const range of sorted) {
|
||||
const previous = merged.at(-1);
|
||||
if (!previous || range.start > previous.end) {
|
||||
merged.push({ ...range });
|
||||
continue;
|
||||
}
|
||||
previous.end = Math.max(previous.end, range.end);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isProtectedIndex(index: number, ranges: ProtectedMarkdownRange[]): boolean {
|
||||
return ranges.some((range) => index >= range.start && index < range.end);
|
||||
}
|
||||
|
||||
export function normalizeHtmlishMarkdown(source: string): string {
|
||||
return renderInlineTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
63
packages/app/src/components/markdown/inline-image-size.ts
Normal file
63
packages/app/src/components/markdown/inline-image-size.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface InlineImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface InlineImageExplicitDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const INLINE_IMAGE_FALLBACK_SIZE = 16;
|
||||
const INLINE_IMAGE_MAX_WIDTH = 240;
|
||||
const INLINE_IMAGE_MAX_HEIGHT = 160;
|
||||
|
||||
export function resolveInlineImageSize(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
const dimensions = resolveInlineImageDimensions(input);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
INLINE_IMAGE_MAX_WIDTH / dimensions.width,
|
||||
INLINE_IMAGE_MAX_HEIGHT / dimensions.height,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.round(dimensions.width * scale),
|
||||
height: Math.round(dimensions.height * scale),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInlineImageDimensions(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
if (input.explicit.width && input.explicit.height) {
|
||||
return { width: input.explicit.width, height: input.explicit.height };
|
||||
}
|
||||
|
||||
if (input.explicit.width && input.natural) {
|
||||
return {
|
||||
width: input.explicit.width,
|
||||
height: input.explicit.width * (input.natural.height / input.natural.width),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.height && input.natural) {
|
||||
return {
|
||||
width: input.explicit.height * (input.natural.width / input.natural.height),
|
||||
height: input.explicit.height,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.width) {
|
||||
return { width: input.explicit.width, height: input.explicit.width };
|
||||
}
|
||||
|
||||
if (input.explicit.height) {
|
||||
return { width: input.explicit.height, height: input.explicit.height };
|
||||
}
|
||||
|
||||
return input.natural ?? { width: INLINE_IMAGE_FALLBACK_SIZE, height: INLINE_IMAGE_FALLBACK_SIZE };
|
||||
}
|
||||
92
packages/app/src/components/markdown/part-groups.test.ts
Normal file
92
packages/app/src/components/markdown/part-groups.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarkdownDisplayPart } from "./html-ish";
|
||||
import { groupMarkdownParts } from "./part-groups";
|
||||
|
||||
describe("groupMarkdownParts", () => {
|
||||
const flowImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
it("flows an image with the lead paragraph and keeps later paragraphs full width", () => {
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " **Title line**\n\nSecond paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage],
|
||||
lead: "**Title line**",
|
||||
rest: "Second paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flows an image with a single-paragraph text", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: " Only line" }])).toEqual([
|
||||
{ kind: "imageText", images: [flowImage], lead: "Only line", rest: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups two consecutive flowing images with the lead paragraph", () => {
|
||||
const secondImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Severity",
|
||||
src: "https://example.com/severity.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " " },
|
||||
secondImage,
|
||||
{ kind: "markdown", text: " **Title text here**\n\nBody paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage, secondImage],
|
||||
lead: "**Title text here**",
|
||||
rest: "Body paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps standalone images as plain parts", () => {
|
||||
const image: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Shot",
|
||||
src: "https://example.com/shot.png",
|
||||
};
|
||||
|
||||
expect(groupMarkdownParts([image, { kind: "markdown", text: "\n\nCaption" }])).toEqual([
|
||||
{ kind: "part", part: image },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nCaption" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when no markdown follows", () => {
|
||||
expect(groupMarkdownParts([flowImage])).toEqual([{ kind: "part", part: flowImage }]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when the following markdown starts with a blank line", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: "\n\nBelow" }])).toEqual([
|
||||
{ kind: "part", part: flowImage },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nBelow" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes other part kinds through unchanged", () => {
|
||||
const details: MarkdownDisplayPart = { kind: "details", summary: "More", body: "Body" };
|
||||
|
||||
expect(groupMarkdownParts([{ kind: "markdown", text: "Intro" }, details])).toEqual([
|
||||
{ kind: "part", part: { kind: "markdown", text: "Intro" } },
|
||||
{ kind: "part", part: details },
|
||||
]);
|
||||
});
|
||||
});
|
||||
89
packages/app/src/components/markdown/part-groups.ts
Normal file
89
packages/app/src/components/markdown/part-groups.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { MarkdownDisplayPart, MarkdownInlineImagePart } from "./html-ish";
|
||||
|
||||
export type MarkdownPartGroup =
|
||||
| { kind: "part"; part: MarkdownDisplayPart }
|
||||
| { kind: "imageText"; images: MarkdownInlineImagePart[]; lead: string; rest: string };
|
||||
|
||||
/**
|
||||
* Pairs a run of inline images that flow with text (small status images at the
|
||||
* start of a line, GitHub-style) with the lead paragraph of the following
|
||||
* markdown part, so the renderer can lay them out side by side instead of
|
||||
* stacking the images as their own blocks.
|
||||
*
|
||||
* Between consecutive flowing images the parser may emit whitespace-only
|
||||
* markdown parts — these are consumed and discarded when building the group.
|
||||
*/
|
||||
export function groupMarkdownParts(parts: readonly MarkdownDisplayPart[]): MarkdownPartGroup[] {
|
||||
const groups: MarkdownPartGroup[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < parts.length) {
|
||||
const part = parts[index];
|
||||
|
||||
if (part.kind === "inlineImage" && part.flowsWithText) {
|
||||
// Collect the maximal run of flowing images, skipping whitespace-only markdown between them.
|
||||
const images: MarkdownInlineImagePart[] = [part];
|
||||
let lookahead = index + 1;
|
||||
|
||||
while (lookahead < parts.length) {
|
||||
const candidate = parts[lookahead];
|
||||
if (candidate === undefined) {
|
||||
break;
|
||||
}
|
||||
if (isWhitespaceOnlyMarkdown(candidate)) {
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.kind === "inlineImage" && candidate.flowsWithText) {
|
||||
images.push(candidate);
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const trailing = parts[lookahead];
|
||||
if (trailing?.kind === "markdown") {
|
||||
const { lead, rest } = splitLeadParagraph(trailing.text);
|
||||
if (lead) {
|
||||
groups.push({ kind: "imageText", images, lead, rest });
|
||||
index = lookahead + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No usable lead — emit the images (and any whitespace parts) as plain parts.
|
||||
for (let i = index; i < lookahead; i += 1) {
|
||||
const p = parts[i];
|
||||
if (p !== undefined) {
|
||||
groups.push({ kind: "part", part: p });
|
||||
}
|
||||
}
|
||||
index = lookahead;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({ kind: "part", part });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isWhitespaceOnlyMarkdown(part: MarkdownDisplayPart): boolean {
|
||||
return part.kind === "markdown" && part.text.trim() === "";
|
||||
}
|
||||
|
||||
function splitLeadParagraph(text: string): { lead: string; rest: string } {
|
||||
if (/^[ \t]*\r?\n/.test(text)) {
|
||||
return { lead: "", rest: text };
|
||||
}
|
||||
const boundary = /\r?\n[ \t]*\r?\n/.exec(text);
|
||||
if (!boundary) {
|
||||
return { lead: text.trim(), rest: "" };
|
||||
}
|
||||
return {
|
||||
lead: text.slice(0, boundary.index).trim(),
|
||||
rest: text.slice(boundary.index + boundary[0].length),
|
||||
};
|
||||
}
|
||||
29
packages/app/src/components/markdown/renderer.test.ts
Normal file
29
packages/app/src/components/markdown/renderer.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveInlineImageSize } from "./inline-image-size";
|
||||
|
||||
describe("resolveInlineImageSize", () => {
|
||||
it("respects a one-sided explicit width using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { width: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 18,
|
||||
height: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects a one-sided explicit height using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { height: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 36,
|
||||
height: 18,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a generic small fallback when no dimensions are known", () => {
|
||||
expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({
|
||||
width: 16,
|
||||
height: 16,
|
||||
});
|
||||
});
|
||||
});
|
||||
758
packages/app/src/components/markdown/renderer.tsx
Normal file
758
packages/app/src/components/markdown/renderer.tsx
Normal file
@@ -0,0 +1,758 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
|
||||
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles> | ReturnType<typeof createCompactMarkdownStyles>;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
|
||||
function markdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createCompactMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
const defaultMarkdownParser = MarkdownIt({ typographer: true, linkify: true });
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
export interface MarkdownRendererProps {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
enableHtmlish?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
rules,
|
||||
markdownit = defaultMarkdownParser,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
enableHtmlish = true,
|
||||
}: MarkdownRendererProps) {
|
||||
const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]);
|
||||
const parts = useMemo(
|
||||
() => (enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]),
|
||||
[enableHtmlish, text],
|
||||
);
|
||||
const rendererProps = useMemo(
|
||||
() => ({
|
||||
compact,
|
||||
rules: markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}),
|
||||
[
|
||||
allowedImageHandlers,
|
||||
compact,
|
||||
markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
topLevelMaxExceededItem,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<MarkdownPartList parts={parts} rendererProps={rendererProps} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
rules: RenderRules;
|
||||
};
|
||||
|
||||
function MarkdownPartList({
|
||||
parts,
|
||||
rendererProps,
|
||||
}: {
|
||||
parts: MarkdownDisplayPart[];
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const keyedGroups = useMemo(() => keyMarkdownGroups(groupMarkdownParts(parts)), [parts]);
|
||||
return (
|
||||
<>
|
||||
{keyedGroups.map(({ key, group }) =>
|
||||
group.kind === "part" ? (
|
||||
<MarkdownPart key={key} part={group.part} rendererProps={rendererProps} />
|
||||
) : (
|
||||
<MarkdownImageTextGroup key={key} group={group} rendererProps={rendererProps} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function keyMarkdownGroups(
|
||||
groups: MarkdownPartGroup[],
|
||||
): { key: string; group: MarkdownPartGroup }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return groups.map((group) => {
|
||||
const identity =
|
||||
group.kind === "part"
|
||||
? getMarkdownPartIdentity(group.part)
|
||||
: `imageText:${group.images.map((i) => i.src).join(",")}:${group.lead.slice(0, 80)}`;
|
||||
const seenCount = seen.get(identity) ?? 0;
|
||||
seen.set(identity, seenCount + 1);
|
||||
return { key: `${identity}:${seenCount}`, group };
|
||||
});
|
||||
}
|
||||
|
||||
function getMarkdownPartIdentity(part: MarkdownDisplayPart): string {
|
||||
if (part.kind === "markdown") {
|
||||
return `markdown:${part.text.slice(0, 80)}`;
|
||||
}
|
||||
if (part.kind === "inlineImage") {
|
||||
return `inlineImage:${part.src}:${part.alt}`;
|
||||
}
|
||||
return `details:${part.summary.slice(0, 80)}:${part.body.slice(0, 80)}`;
|
||||
}
|
||||
|
||||
function MarkdownPart({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: MarkdownDisplayPart;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
if (part.kind === "details") {
|
||||
return <MarkdownDetails part={part} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
if (part.kind === "inlineImage") {
|
||||
return <MarkdownInlineImage part={part} onLinkPress={rendererProps.onLinkPress} />;
|
||||
}
|
||||
|
||||
if (part.text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownFragment text={part.text} {...rendererProps} />;
|
||||
}
|
||||
|
||||
function MarkdownFragment({
|
||||
text,
|
||||
compact,
|
||||
rules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}: MarkdownRendererProps & { rules: RenderRules }) {
|
||||
const uniProps = compact ? compactMarkdownStyleMapping : markdownStyleMapping;
|
||||
return (
|
||||
<ThemedMarkdown
|
||||
uniProps={uniProps}
|
||||
rules={rules}
|
||||
markdownit={markdownit}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={allowedImageHandlers}
|
||||
topLevelMaxExceededItem={topLevelMaxExceededItem}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
natural: InlineImageDimensions | null;
|
||||
failed: boolean;
|
||||
setFailed: (failed: boolean) => void;
|
||||
} {
|
||||
const [natural, setNatural] = useState<InlineImageDimensions | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (part.width && part.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Image.getSize(
|
||||
part.src,
|
||||
(width, height) => {
|
||||
if (!cancelled) {
|
||||
setNatural({ width, height });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [part.height, part.src, part.width]);
|
||||
|
||||
return { natural, failed, setFailed };
|
||||
}
|
||||
|
||||
function MarkdownInlineImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "inlineImage" }>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural: naturalDimensions } = useNaturalImageDimensions(part);
|
||||
const explicitDimensions = useMemo(
|
||||
() => ({ width: part.width, height: part.height }),
|
||||
[part.height, part.width],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(
|
||||
() => resolveInlineImageSize({ explicit: explicitDimensions, natural: naturalDimensions }),
|
||||
[explicitDimensions, naturalDimensions],
|
||||
);
|
||||
const imageStyle = useMemo(() => [detailsStyles.inlineImage, imageSize], [imageSize]);
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return <View style={detailsStyles.inlineImageWrap}>{image}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={detailsStyles.inlineImageWrap} onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const FLOW_IMAGE_MAX_HEIGHT = 18;
|
||||
|
||||
function MarkdownFlowImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: MarkdownInlineImagePart;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural, failed, setFailed } = useNaturalImageDimensions(part);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const handleError = useCallback(() => setFailed(true), [setFailed]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(() => {
|
||||
const size = resolveInlineImageSize({
|
||||
explicit: { width: part.width, height: part.height },
|
||||
natural,
|
||||
});
|
||||
const scale = Math.min(1, FLOW_IMAGE_MAX_HEIGHT / size.height);
|
||||
return { width: Math.round(size.width * scale), height: Math.round(size.height * scale) };
|
||||
}, [natural, part.height, part.width]);
|
||||
const imageStyle = useMemo(() => [detailsStyles.flowImage, imageSize], [imageSize]);
|
||||
|
||||
if (failed) {
|
||||
if (!part.alt) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={detailsStyles.flowImageFallback}>
|
||||
<Text style={detailsStyles.flowImageFallbackText}>{part.alt}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return image;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownImageTextGroup({
|
||||
group,
|
||||
rendererProps,
|
||||
}: {
|
||||
group: Extract<MarkdownPartGroup, { kind: "imageText" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={detailsStyles.imageTextRow}>
|
||||
{group.images.map((image) => (
|
||||
<MarkdownFlowImage key={image.src} part={image} onLinkPress={rendererProps.onLinkPress} />
|
||||
))}
|
||||
<View style={detailsStyles.imageTextRowContent}>
|
||||
<MarkdownFragment text={group.lead} {...rendererProps} />
|
||||
</View>
|
||||
</View>
|
||||
{group.rest ? <MarkdownFragment text={group.rest} {...rendererProps} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownDetails({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "details" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = useCallback(() => setOpen((current) => !current), []);
|
||||
const bodyParts = useMemo(
|
||||
() => part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }],
|
||||
[part.body, part.bodyParts],
|
||||
);
|
||||
return (
|
||||
<View style={detailsStyles.container}>
|
||||
<Pressable style={detailsStyles.summaryRow} onPress={toggle} accessibilityRole="button">
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={detailsStyles.summaryIcon.color} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={detailsStyles.summaryIcon.color} />
|
||||
)}
|
||||
<Text style={detailsStyles.summaryText}>{part.summary}</Text>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View style={detailsStyles.body}>
|
||||
<MarkdownPartList parts={bodyParts} rendererProps={rendererProps} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface SharedMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SharedMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: SharedMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
export function createSharedMarkdownRules(): RenderRules {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<SharedMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</SharedMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const detailsStyles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: "hidden",
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
summaryIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
},
|
||||
inlineImageWrap: {
|
||||
alignSelf: "flex-start",
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
inlineImage: {},
|
||||
imageTextRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
imageTextRowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
flowImage: {
|
||||
marginTop: 2,
|
||||
},
|
||||
flowImageFallback: {
|
||||
marginTop: 2,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
flowImageFallbackText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
|
||||
describe("getCompactionMarkerLabel", () => {
|
||||
@@ -16,4 +17,13 @@ describe("getCompactionMarkerLabel", () => {
|
||||
);
|
||||
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted");
|
||||
});
|
||||
|
||||
it("renders labels in the active app language", async () => {
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
try {
|
||||
expect(getCompactionMarkerLabel({ status: "loading" })).toBe("正在压缩...");
|
||||
} finally {
|
||||
await i18n.changeLanguage("en");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
export interface CompactionMarkerLabelInput {
|
||||
status: "loading" | "completed";
|
||||
trigger?: "auto" | "manual";
|
||||
@@ -9,9 +11,13 @@ export function getCompactionMarkerLabel({
|
||||
trigger,
|
||||
preTokens,
|
||||
}: CompactionMarkerLabelInput): string {
|
||||
if (status === "loading") return "Compacting...";
|
||||
if (trigger === "auto") return "Context automatically compacted";
|
||||
if (trigger === "manual") return "Context manually compacted";
|
||||
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
|
||||
return "Context compacted";
|
||||
if (status === "loading") return i18n.t("message.compaction.loading");
|
||||
if (trigger === "auto") return i18n.t("message.compaction.auto");
|
||||
if (trigger === "manual") return i18n.t("message.compaction.manual");
|
||||
if (preTokens) {
|
||||
return i18n.t("message.compaction.withTokens", {
|
||||
tokens: Math.round(preTokens / 1000),
|
||||
});
|
||||
}
|
||||
return i18n.t("message.compaction.completed");
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
ViewStyle,
|
||||
type TextStyle,
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
@@ -26,12 +26,8 @@ import {
|
||||
Children,
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
@@ -51,7 +47,7 @@ import {
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -62,8 +58,8 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -112,8 +108,6 @@ import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface UserMessageProps {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -161,22 +155,6 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||
] as const;
|
||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles>;
|
||||
rules: RenderRules;
|
||||
markdownit: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
allowedImageHandlers: readonly string[];
|
||||
topLevelMaxExceededItem: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
const markdownStyleMapping = (theme: Theme): Partial<MarkdownWithStableRendererProps> => ({
|
||||
style: createMarkdownStyles(theme),
|
||||
});
|
||||
|
||||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
@@ -448,18 +426,23 @@ function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAtta
|
||||
return <Image source={imageSource} style={userMessageStylesheet.imageThumbnail} />;
|
||||
}
|
||||
|
||||
function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
||||
function getUserMessageAttachmentLabel(
|
||||
attachment: AgentAttachment,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
switch (attachment.type) {
|
||||
case "review": {
|
||||
const count = attachment.comments.length;
|
||||
return count === 1 ? "Review · 1 comment" : `Review · ${count} comments`;
|
||||
return count === 1
|
||||
? t("message.attachments.reviewOne")
|
||||
: t("message.attachments.reviewMany", { count });
|
||||
}
|
||||
case "github_pr":
|
||||
return `PR #${attachment.number}`;
|
||||
case "github_issue":
|
||||
return `Issue #${attachment.number}`;
|
||||
case "text":
|
||||
return attachment.title ?? "Text attachment";
|
||||
return attachment.title ?? t("message.attachments.textAttachment");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -480,6 +463,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
@@ -562,7 +546,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
style={userMessageStylesheet.structuredAttachmentPill}
|
||||
>
|
||||
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
|
||||
{getUserMessageAttachmentLabel(attachment)}
|
||||
{getUserMessageAttachmentLabel(attachment, t)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -588,7 +572,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
<TurnCopyButton
|
||||
getContent={getMessageContent}
|
||||
containerStyle={userMessageStylesheet.copyButton}
|
||||
accessibilityLabel="Copy message"
|
||||
accessibilityLabel={t("message.actions.copyMessage")}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -862,6 +846,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
const handleImageError = useCallback(() => {
|
||||
setLoadState({ status: "error" });
|
||||
}, []);
|
||||
const { t } = useTranslation();
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
assistantMessageStylesheet.imageSurface,
|
||||
@@ -887,7 +872,9 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
<View style={stateSurfaceStyle}>
|
||||
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
|
||||
{loadState.status === "error" ? (
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>Image unavailable</Text>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||
{t("message.attachments.imageUnavailable")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
@@ -924,6 +911,7 @@ function AssistantMarkdownImage({
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolution = useMemo(
|
||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||
[source, workspaceRoot],
|
||||
@@ -953,7 +941,7 @@ function AssistantMarkdownImage({
|
||||
|
||||
const file = await client.readFile(resolution.cwd, resolution.path);
|
||||
if (file.kind !== "image") {
|
||||
throw new Error("Image preview unavailable.");
|
||||
throw new Error(t("message.attachments.imagePreviewUnavailable"));
|
||||
}
|
||||
|
||||
return await persistAttachmentFromBytes({
|
||||
@@ -1026,7 +1014,11 @@ function AssistantMarkdownImage({
|
||||
);
|
||||
}
|
||||
|
||||
const errorText = resolveAssistantImageErrorText(query.error, dataImageQuery.error);
|
||||
const errorText = resolveAssistantImageErrorText(
|
||||
query.error,
|
||||
dataImageQuery.error,
|
||||
t("message.attachments.imagePreviewLoadFailed"),
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={stateFrameStyle}>
|
||||
@@ -1035,10 +1027,14 @@ function AssistantMarkdownImage({
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown): string {
|
||||
function resolveAssistantImageErrorText(
|
||||
fileError: unknown,
|
||||
dataError: unknown,
|
||||
fallbackText: string,
|
||||
): string {
|
||||
if (fileError instanceof Error) return fileError.message;
|
||||
if (dataError instanceof Error) return dataError.message;
|
||||
return "Unable to load image preview.";
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkUrl(
|
||||
@@ -1144,6 +1140,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
accessibilityLabel,
|
||||
copiedAccessibilityLabel,
|
||||
}: TurnCopyButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -1185,7 +1182,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn")
|
||||
copied
|
||||
? (copiedAccessibilityLabel ?? t("message.actions.copied"))
|
||||
: (accessibilityLabel ?? t("message.actions.copyTurn"))
|
||||
}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
@@ -1499,18 +1498,15 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<ThemedMarkdown
|
||||
uniProps={markdownStyleMapping}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
</AppearanceStyleBoundary>
|
||||
<MarkdownRenderer
|
||||
text={text}
|
||||
enableHtmlish={false}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1966,6 +1962,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
timestamp: _timestamp,
|
||||
disableOuterSpacing,
|
||||
}: SpeakMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
@@ -1979,7 +1976,7 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
<View testID="speak-message" style={containerStyle}>
|
||||
<View style={speakMessageStylesheet.header}>
|
||||
<ThemedMicVocal size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={speakMessageStylesheet.headerLabel}>Spoke</Text>
|
||||
<Text style={speakMessageStylesheet.headerLabel}>{t("message.speak.header")}</Text>
|
||||
</View>
|
||||
<Text style={speakMessageStylesheet.text}>{message}</Text>
|
||||
</View>
|
||||
@@ -2080,6 +2077,7 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
onArtifactClick,
|
||||
disableOuterSpacing,
|
||||
}: ActivityLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -2149,7 +2147,9 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
</Text>
|
||||
{metadata && (
|
||||
<View style={activityLogStylesheet.detailsRow}>
|
||||
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
||||
<Text style={activityLogStylesheet.detailsText}>
|
||||
{t("message.activity.details")}
|
||||
</Text>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} color="#71717a" />
|
||||
) : (
|
||||
@@ -2306,6 +2306,7 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
items,
|
||||
disableOuterSpacing,
|
||||
}: TodoListCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const nextTask = useMemo(() => items.find((item) => !item.completed)?.text, [items]);
|
||||
@@ -2319,7 +2320,7 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
<View style={todoListCardStylesheet.detailsWrapper}>
|
||||
<View style={todoListCardStylesheet.list}>
|
||||
{items.length === 0 ? (
|
||||
<Text style={todoListCardStylesheet.emptyText}>No tasks yet.</Text>
|
||||
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<TodoListItemRow key={item.text} text={item.text} completed={item.completed} />
|
||||
@@ -2328,11 +2329,11 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [items]);
|
||||
}, [items, t]);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
label="Tasks"
|
||||
label={t("message.todo.title")}
|
||||
secondaryLabel={nextTask}
|
||||
icon={CheckSquare}
|
||||
isExpanded={isExpanded}
|
||||
@@ -2475,6 +2476,7 @@ function ExpandableBadgeLabelRow({
|
||||
onOpenFileHoverIn,
|
||||
onOpenFileHoverOut,
|
||||
}: ExpandableBadgeLabelRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={expandableBadgeStylesheet.labelRow}
|
||||
@@ -2499,7 +2501,7 @@ function ExpandableBadgeLabelRow({
|
||||
onHoverIn={onOpenFileHoverIn}
|
||||
onHoverOut={onOpenFileHoverOut}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open file"
|
||||
accessibilityLabel={t("message.actions.openFile")}
|
||||
testID="tool-call-open-file"
|
||||
style={expandableBadgeStylesheet.openFileButton}
|
||||
hitSlop={6}
|
||||
@@ -3169,7 +3171,6 @@ export const ToolCall = memo(function ToolCall({
|
||||
if (presentation.isPlan && effectiveDetail?.type === "plan") {
|
||||
return (
|
||||
<PlanCard
|
||||
title="Plan"
|
||||
text={effectiveDetail.text}
|
||||
testID="timeline-plan-card"
|
||||
disableOuterSpacing={disableOuterSpacing}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -12,7 +13,6 @@ import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adapt
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
@@ -61,6 +61,7 @@ export interface PairLinkModalProps {
|
||||
|
||||
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
@@ -98,11 +99,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
if (isSaving) return;
|
||||
const raw = offerUrlRef.current.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
||||
setErrorMessage(t("pairing.link.errors.required"));
|
||||
return;
|
||||
}
|
||||
if (!raw.includes("#offer=")) {
|
||||
setErrorMessage("Link must include #offer=...");
|
||||
setErrorMessage(t("pairing.link.errors.missingOffer"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,15 +112,15 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
const idx = raw.indexOf("#offer=");
|
||||
const encoded = raw.slice(idx + "#offer=".length).trim();
|
||||
if (!encoded) {
|
||||
throw new Error("Offer payload is empty");
|
||||
throw new Error(t("pairing.link.errors.emptyOffer"));
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
return ConnectionOfferSchema.parse(payload);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid pairing link";
|
||||
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Pairing failed", message);
|
||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -150,15 +151,16 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||
setErrorMessage(message);
|
||||
if (!isMobile) {
|
||||
Alert.alert("Pairing failed", message);
|
||||
Alert.alert(t("pairing.link.alert.failedTitle"), message);
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
|
||||
|
||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||
offerUrlRef.current = next;
|
||||
@@ -168,22 +170,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
void handleSave();
|
||||
}, [handleSave]);
|
||||
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.link.title") }), [t]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={PAIR_LINK_HEADER}
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="pair-link-modal"
|
||||
>
|
||||
<Text style={styles.helper}>Paste the pairing link from your server.</Text>
|
||||
<Text style={styles.helper}>{t("pairing.link.helper")}</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Pairing link</Text>
|
||||
<Text style={styles.label}>{t("pairing.link.label")}</Text>
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
testID="pair-link-input"
|
||||
nativeID="pair-link-input"
|
||||
accessibilityLabel="pair-link-input"
|
||||
accessibilityLabel={t("pairing.link.label")}
|
||||
onChangeText={handleChangeOfferUrl}
|
||||
placeholder="https://app.paseo.sh/#offer=..."
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
@@ -204,9 +208,9 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
disabled={isSaving}
|
||||
testID="pair-link-cancel"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
accessibilityLabel={t("pairing.link.actions.cancel")}
|
||||
>
|
||||
Cancel
|
||||
{t("pairing.link.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
style={FLEX_ONE_STYLE}
|
||||
@@ -215,10 +219,10 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
disabled={isSaving}
|
||||
testID="pair-link-submit"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Pair"
|
||||
accessibilityLabel={t("pairing.link.actions.pair")}
|
||||
leftIcon={pairIcon}
|
||||
>
|
||||
{isSaving ? "Pairing..." : "Pair"}
|
||||
{isSaving ? t("pairing.link.actions.pairing") : t("pairing.link.actions.pair")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import Markdown, { type ASTNode } from "react-native-markdown-display";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
|
||||
@@ -194,7 +195,7 @@ function createPlanMarkdownRules() {
|
||||
}
|
||||
|
||||
export function PlanCard({
|
||||
title = "Plan",
|
||||
title,
|
||||
description,
|
||||
text,
|
||||
footer,
|
||||
@@ -209,8 +210,10 @@ export function PlanCard({
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const markdownStyles = createMarkdownStyles(theme);
|
||||
const markdownRules = createPlanMarkdownRules();
|
||||
const resolvedTitle = title ?? t("agentStream.permission.plan");
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
@@ -234,7 +237,7 @@ export function PlanCard({
|
||||
|
||||
return (
|
||||
<View testID={testID} style={containerStyle}>
|
||||
<Text style={titleStyle}>{title}</Text>
|
||||
<Text style={titleStyle}>{resolvedTitle}</Text>
|
||||
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{text}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
@@ -60,6 +61,7 @@ function PathRow({ path, active, onSelect }: PathRowProps) {
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const serverId = useActiveServerId();
|
||||
|
||||
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
|
||||
@@ -235,7 +237,7 @@ export function ProjectPickerModal() {
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChangeText={handleChangeQuery}
|
||||
placeholder="Type a directory path..."
|
||||
placeholder={t("projectPicker.placeholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
@@ -253,9 +255,9 @@ export function ProjectPickerModal() {
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{isSubmitting ? <Text style={emptyTextStyle}>Opening project...</Text> : null}
|
||||
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
|
||||
{!isSubmitting && options.length === 0 && !query.trim() ? (
|
||||
<Text style={emptyTextStyle}>Start typing a path</Text>
|
||||
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
|
||||
) : null}
|
||||
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
@@ -48,6 +49,10 @@ interface CatalogRowProps {
|
||||
}
|
||||
|
||||
function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const actionLabel = installing
|
||||
? t("providerCatalog.actions.adding")
|
||||
: t("providerCatalog.actions.add");
|
||||
const handleInstall = useCallback(() => {
|
||||
onInstall(entry);
|
||||
}, [entry, onInstall]);
|
||||
@@ -84,12 +89,14 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${entry.title} install instructions`}
|
||||
accessibilityLabel={t("providerCatalog.actions.installInstructionsFor", {
|
||||
provider: entry.title,
|
||||
})}
|
||||
onPress={handleOpenInstallLink}
|
||||
style={styles.installLink}
|
||||
>
|
||||
<Text style={styles.installLinkText} numberOfLines={1}>
|
||||
Install instructions
|
||||
{t("providerCatalog.actions.installInstructions")}
|
||||
</Text>
|
||||
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
|
||||
</Pressable>
|
||||
@@ -103,7 +110,7 @@ function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) {
|
||||
style={styles.actionButton}
|
||||
testID={`install-provider-${entry.id}`}
|
||||
>
|
||||
{installing ? "Adding" : "Add"}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -114,6 +121,7 @@ export function ProviderCatalogList({
|
||||
installingProviderId,
|
||||
onInstall,
|
||||
}: ProviderCatalogListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { entries: catalogEntries } = useAcpProviderCatalog();
|
||||
const { entries: providerEntries } = useProvidersSnapshot(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -139,10 +147,10 @@ export function ProviderCatalogList({
|
||||
</View>
|
||||
<AdaptiveTextInput
|
||||
testID="provider-catalog-search"
|
||||
accessibilityLabel="Search providers"
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search providers"
|
||||
accessibilityLabel={t("providerCatalog.search")}
|
||||
placeholder={t("providerCatalog.search")}
|
||||
style={styles.searchInput}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -151,9 +159,7 @@ export function ProviderCatalogList({
|
||||
|
||||
{availableEntries.length === 0 ? (
|
||||
<View style={styles.stateBox}>
|
||||
<Text style={styles.stateText}>
|
||||
{search.trim().length > 0 ? "No providers found" : "All providers are installed"}
|
||||
</Text>
|
||||
<Text style={styles.stateText}>{t("providerCatalog.noProviders")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -79,6 +81,7 @@ function CustomModelRow({
|
||||
deleting: boolean;
|
||||
onDelete: (modelId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
|
||||
const deleteButtonStyle = useCallback(
|
||||
@@ -110,7 +113,7 @@ function CustomModelRow({
|
||||
hitSlop={8}
|
||||
style={deleteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Remove ${model.id}`}
|
||||
accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
|
||||
>
|
||||
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
|
||||
</Pressable>
|
||||
@@ -148,6 +151,7 @@ function AddCustomModelSubSheet({
|
||||
onClose: () => void;
|
||||
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [input, setInput] = useState("");
|
||||
@@ -182,12 +186,15 @@ function AddCustomModelSubSheet({
|
||||
.then(() => refresh([provider]))
|
||||
.then(() => onClose())
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to save model");
|
||||
setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave"));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, trimmed]);
|
||||
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]);
|
||||
|
||||
const header = useMemo<SheetHeader>(() => ({ title: "Add custom model" }), []);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({ title: t("settings.providers.models.addCustomTitle") }),
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -199,14 +206,14 @@ function AddCustomModelSubSheet({
|
||||
testID="add-custom-model-sheet"
|
||||
>
|
||||
<View style={sheetStyles.formGroup}>
|
||||
<Text style={sheetStyles.formLabel}>Model ID</Text>
|
||||
<Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
|
||||
<AdaptiveTextInput
|
||||
initialValue={input}
|
||||
resetKey={`add-custom-${visible}`}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={handleAdd}
|
||||
placeholder="e.g. openai/gpt-5"
|
||||
placeholder={t("settings.providers.models.modelIdPlaceholder")}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -217,10 +224,10 @@ function AddCustomModelSubSheet({
|
||||
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
|
||||
<View style={sheetStyles.formActions}>
|
||||
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
|
||||
Cancel
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}>
|
||||
{saving ? "Adding…" : "Add"}
|
||||
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -239,6 +246,7 @@ function DiagnosticSubSheet({
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
@@ -251,11 +259,13 @@ function DiagnosticSubSheet({
|
||||
const result = await client.getProviderDiagnostic(provider);
|
||||
setDiagnostic(result.diagnostic);
|
||||
} catch (err) {
|
||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||
setDiagnostic(
|
||||
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, provider]);
|
||||
}, [client, provider, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -280,7 +290,7 @@ function DiagnosticSubSheet({
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: "Diagnostic",
|
||||
title: t("settings.providers.diagnostic.title"),
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={handleRefreshPress}
|
||||
@@ -288,7 +298,11 @@ function DiagnosticSubSheet({
|
||||
hitSlop={8}
|
||||
style={refreshButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={loading ? "Refreshing diagnostic" : "Refresh diagnostic"}
|
||||
accessibilityLabel={
|
||||
loading
|
||||
? t("settings.providers.diagnostic.refreshingAccessibility")
|
||||
: t("settings.providers.diagnostic.refreshAccessibility")
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -302,6 +316,7 @@ function DiagnosticSubSheet({
|
||||
handleRefreshPress,
|
||||
loading,
|
||||
refreshButtonStyle,
|
||||
t,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.iconSize.sm,
|
||||
],
|
||||
@@ -312,7 +327,7 @@ function DiagnosticSubSheet({
|
||||
body = (
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>Running diagnostic…</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (diagnostic) {
|
||||
@@ -328,7 +343,7 @@ function DiagnosticSubSheet({
|
||||
} else {
|
||||
body = (
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<Text style={sheetStyles.mutedText}>No diagnostic available</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -366,6 +381,7 @@ interface ProviderSheetFooterInput {
|
||||
fetchedAtLabel: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
t: TFunction;
|
||||
onOpenAddSheet: () => void;
|
||||
onOpenDiagSheet: () => void;
|
||||
onRefreshModels: () => void;
|
||||
@@ -375,6 +391,7 @@ function renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
onOpenAddSheet,
|
||||
onOpenDiagSheet,
|
||||
onRefreshModels,
|
||||
@@ -388,7 +405,7 @@ function renderProviderSheetFooter({
|
||||
<View style={contentStyle}>
|
||||
{fetchedAtLabel || !isCompact ? (
|
||||
<Text style={metaStyle} numberOfLines={1}>
|
||||
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
|
||||
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
@@ -399,7 +416,7 @@ function renderProviderSheetFooter({
|
||||
onPress={onOpenAddSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
Add model
|
||||
{t("settings.providers.models.addModel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -408,7 +425,7 @@ function renderProviderSheetFooter({
|
||||
onPress={onOpenDiagSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
Diagnostic
|
||||
{t("settings.providers.diagnostic.button")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -418,7 +435,9 @@ function renderProviderSheetFooter({
|
||||
disabled={modelsRefreshing}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{modelsRefreshing ? "Refreshing…" : "Refresh"}
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.diagnostic.refreshing")
|
||||
: t("settings.providers.diagnostic.refresh")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -426,6 +445,7 @@ function renderProviderSheetFooter({
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
discoveredCount,
|
||||
additionalCount,
|
||||
@@ -445,7 +465,7 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>Loading models…</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -455,7 +475,9 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
|
||||
{modelsRefreshing ? "Retrying…" : "Retry"}
|
||||
{modelsRefreshing
|
||||
? t("settings.providers.models.retrying")
|
||||
: t("settings.providers.models.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
@@ -463,14 +485,14 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>No models match your search</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noSearchMatches")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (discoveredCount === 0 && additionalCount === 0) {
|
||||
return (
|
||||
<View style={sheetStyles.emptyState}>
|
||||
<Text style={sheetStyles.mutedText}>No models detected</Text>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -478,7 +500,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
<>
|
||||
{filteredDiscovered.length > 0 ? (
|
||||
<View style={sheetStyles.section}>
|
||||
<SectionHeader title="Discovered" count={filteredDiscovered.length} />
|
||||
<SectionHeader
|
||||
title={t("settings.providers.models.discovered")}
|
||||
count={filteredDiscovered.length}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
{filteredDiscovered.map((model) => (
|
||||
<DiscoveredModelRow key={model.id} model={model} />
|
||||
@@ -488,7 +513,10 @@ function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
) : null}
|
||||
{filteredCustom.length > 0 ? (
|
||||
<View style={sheetStyles.section}>
|
||||
<SectionHeader title="Custom models" count={filteredCustom.length} />
|
||||
<SectionHeader
|
||||
title={t("settings.providers.models.custom")}
|
||||
count={filteredCustom.length}
|
||||
/>
|
||||
<View style={settingsStyles.card}>
|
||||
{filteredCustom.map((model) => (
|
||||
<CustomModelRow
|
||||
@@ -511,6 +539,7 @@ export function ProviderDiagnosticSheet({
|
||||
onClose,
|
||||
serverId,
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
@@ -531,7 +560,9 @@ export function ProviderDiagnosticSheet({
|
||||
);
|
||||
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||
const providerErrorMessage =
|
||||
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
|
||||
providerEntry?.status === "error"
|
||||
? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError"))
|
||||
: null;
|
||||
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
|
||||
|
||||
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
|
||||
@@ -546,7 +577,7 @@ export function ProviderDiagnosticSheet({
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
|
||||
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
const fetchedAtLabel = useMemo(() => {
|
||||
@@ -605,11 +636,11 @@ export function ProviderDiagnosticSheet({
|
||||
title: providerLabel,
|
||||
search: {
|
||||
onChange: setQuery,
|
||||
placeholder: "Search models",
|
||||
placeholder: t("settings.providers.models.searchPlaceholder"),
|
||||
testID: "provider-settings-search",
|
||||
},
|
||||
}),
|
||||
[providerLabel],
|
||||
[providerLabel, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -623,6 +654,7 @@ export function ProviderDiagnosticSheet({
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
t,
|
||||
onOpenAddSheet: handleOpenAddSheet,
|
||||
onOpenDiagSheet: handleOpenDiagSheet,
|
||||
onRefreshModels: handleRefreshModels,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BUILTIN_PROVIDER_ICON_NAMES,
|
||||
KNOWN_PROVIDER_ICON_NAMES,
|
||||
TERMINAL_PROFILE_ICON_NAMES,
|
||||
} from "@getpaseo/protocol/provider-icon-names";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import { resolveProviderIconName } from "./provider-icon-name";
|
||||
|
||||
describe("resolveProviderIconName", () => {
|
||||
@@ -10,9 +16,34 @@ describe("resolveProviderIconName", () => {
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
||||
});
|
||||
|
||||
it("falls back to the bot icon for unknown custom providers", () => {
|
||||
expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("known provider icon names", () => {
|
||||
it("includes every ACP catalog entry that ships an icon", () => {
|
||||
const known = new Set(KNOWN_PROVIDER_ICON_NAMES);
|
||||
for (const entry of ACP_PROVIDER_CATALOG) {
|
||||
if (entry.iconSvg) {
|
||||
expect(known).toContain(entry.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("only lists ACP icon ids that have a catalog entry with an icon", () => {
|
||||
const builtin = new Set(BUILTIN_PROVIDER_ICON_NAMES);
|
||||
const terminalOnly = new Set(TERMINAL_PROFILE_ICON_NAMES);
|
||||
const catalogIdsWithIcons = new Set(
|
||||
ACP_PROVIDER_CATALOG.filter((entry) => entry.iconSvg).map((entry) => entry.id),
|
||||
);
|
||||
for (const name of KNOWN_PROVIDER_ICON_NAMES) {
|
||||
if (!builtin.has(name) && !terminalOnly.has(name)) {
|
||||
expect(catalogIdsWithIcons).toContain(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,21 @@
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
|
||||
export type BuiltinProviderIconName =
|
||||
| "claude"
|
||||
| "codex"
|
||||
| "copilot"
|
||||
| "kiro"
|
||||
| "omp"
|
||||
| "opencode"
|
||||
| "pi";
|
||||
import {
|
||||
BUILTIN_PROVIDER_ICON_NAMES,
|
||||
KNOWN_PROVIDER_ICON_NAMES,
|
||||
} from "@getpaseo/protocol/provider-icon-names";
|
||||
|
||||
export type ProviderIconName =
|
||||
| { kind: "builtin"; id: BuiltinProviderIconName }
|
||||
| { kind: "builtin"; id: string }
|
||||
| { kind: "catalog"; id: string }
|
||||
| { kind: "bot" };
|
||||
|
||||
const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"kiro",
|
||||
"omp",
|
||||
"opencode",
|
||||
"pi",
|
||||
]);
|
||||
|
||||
const CATALOG_ICON_PROVIDER_IDS: ReadonlySet<string> = new Set(
|
||||
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])),
|
||||
);
|
||||
const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES);
|
||||
const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES);
|
||||
|
||||
export function resolveProviderIconName(provider: string): ProviderIconName {
|
||||
if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) {
|
||||
return { kind: "builtin", id: provider as BuiltinProviderIconName };
|
||||
if (BUILTIN_PROVIDER_IDS.has(provider)) {
|
||||
return { kind: "builtin", id: provider };
|
||||
}
|
||||
if (CATALOG_ICON_PROVIDER_IDS.has(provider)) {
|
||||
if (KNOWN_PROVIDER_IDS.has(provider)) {
|
||||
return { kind: "catalog", id: provider };
|
||||
}
|
||||
return { kind: "bot" };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user