mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e529c3601f |
@@ -1,88 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.101 - 2026-06-26
|
||||
|
||||
### Added
|
||||
|
||||
- Copy a troubleshooting report from Settings when support needs host, daemon, provider, and log details ([#1728](https://github.com/getpaseo/paseo/pull/1728))
|
||||
- Claude image tool results now render as images in chat ([#1717](https://github.com/getpaseo/paseo/pull/1717))
|
||||
- Added Japanese ([#1694](https://github.com/getpaseo/paseo/pull/1694) by [@sysCat64](https://github.com/sysCat64))
|
||||
- Added Brazilian Portuguese ([#1653](https://github.com/getpaseo/paseo/pull/1653) by [@Alcimerio](https://github.com/Alcimerio))
|
||||
|
||||
### Improved
|
||||
|
||||
- Provider diagnostics stay useful even when model discovery is slow ([#1724](https://github.com/getpaseo/paseo/pull/1724))
|
||||
- Slow provider requests no longer make the app look disconnected ([#1723](https://github.com/getpaseo/paseo/pull/1723))
|
||||
- Worktrees linked to differently named tracked branches find their PRs correctly ([#1718](https://github.com/getpaseo/paseo/pull/1718))
|
||||
- Workspaces started from slash-command prompts get clearer names ([#1709](https://github.com/getpaseo/paseo/pull/1709))
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi no longer creates empty sessions while loading new-agent options ([#1727](https://github.com/getpaseo/paseo/pull/1727))
|
||||
- Windows daemon status finds the daemon process more reliably ([#1725](https://github.com/getpaseo/paseo/pull/1725))
|
||||
- OpenAI voice credentials no longer affect other OpenAI-backed tools
|
||||
- Provider model lists no longer disappear during refresh
|
||||
|
||||
## 0.1.100 - 2026-06-24
|
||||
|
||||
### Added
|
||||
|
||||
- Cycle agent modes with Shift+Tab
|
||||
- Select a custom Copilot agent when starting or mid-session ([#1700](https://github.com/getpaseo/paseo/pull/1700))
|
||||
|
||||
### Improved
|
||||
|
||||
- ACP provider catalog updated to the latest registry versions
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude no longer sends an extra API request after each message ([#1701](https://github.com/getpaseo/paseo/pull/1701))
|
||||
- OpenCode no longer leaves stray background servers running after sessions end ([#1697](https://github.com/getpaseo/paseo/pull/1697))
|
||||
- Slash commands and skills now load in OMP agents ([#1698](https://github.com/getpaseo/paseo/pull/1698))
|
||||
|
||||
## 0.1.99 - 2026-06-23
|
||||
|
||||
### Improved
|
||||
|
||||
- The PR panel now has a refresh button and clearer loading states ([#1664](https://github.com/getpaseo/paseo/pull/1664))
|
||||
- Provider diagnostics and model lists now stay in sync ([#1660](https://github.com/getpaseo/paseo/pull/1660))
|
||||
|
||||
### Fixed
|
||||
|
||||
- ACP providers like Grok no longer show duplicate user messages
|
||||
- Saved composer modes no longer reset while provider data is loading ([#1658](https://github.com/getpaseo/paseo/pull/1658))
|
||||
- The right sidebar no longer gets stuck on mobile ([#1661](https://github.com/getpaseo/paseo/pull/1661))
|
||||
|
||||
## 0.1.98 - 2026-06-21
|
||||
|
||||
### Added
|
||||
|
||||
- See plan usage in-app for Claude, Codex, Copilot, Cursor, Z.AI, Grok, and Kimi ([#1278](https://github.com/getpaseo/paseo/pull/1278) by [@ABorakati](https://github.com/ABorakati))
|
||||
- Added Ultracode for Claude ([#1625](https://github.com/getpaseo/paseo/pull/1625))
|
||||
- Detach a subagent to run it on its own ([#1612](https://github.com/getpaseo/paseo/pull/1612))
|
||||
- Add a project without creating a workspace
|
||||
- Add a setting to show branch names instead of titles in the sidebar
|
||||
|
||||
### Improved
|
||||
|
||||
- Mid-turn thinking and mode changes now say they apply next turn
|
||||
- PR merge options name their method: squash, merge, or rebase ([#1608](https://github.com/getpaseo/paseo/pull/1608) by [@mcowger](https://github.com/mcowger))
|
||||
- A running agent's mode change is remembered for new agents
|
||||
- Copy a provider's launch diagnostic in one tap ([#1611](https://github.com/getpaseo/paseo/pull/1611))
|
||||
|
||||
### Fixed
|
||||
|
||||
- OpenCode no longer scans your whole disk on macOS desktop ([#1626](https://github.com/getpaseo/paseo/pull/1626))
|
||||
- Daemon no longer crashes when OpenAI speech has no API key ([#1368](https://github.com/getpaseo/paseo/pull/1368) by [@mcowger](https://github.com/mcowger))
|
||||
- Reopening an archived Codex agent no longer hangs
|
||||
- Claude's context meter no longer jumps to subagent usage
|
||||
- Claude's context meter fills from the first message in a new session
|
||||
- OpenCode's mode picker now respects your disabled modes ([#1366](https://github.com/getpaseo/paseo/pull/1366) by [@mcowger](https://github.com/mcowger))
|
||||
- File links and @-mentions find files in dot-folders and deep paths ([#1609](https://github.com/getpaseo/paseo/pull/1609))
|
||||
- Archiving a project's last workspace no longer makes it vanish ([#1631](https://github.com/getpaseo/paseo/pull/1631))
|
||||
- Collapsed sidebar projects stay collapsed
|
||||
|
||||
## 0.1.97 - 2026-06-18
|
||||
|
||||
### Added
|
||||
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
<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> ·
|
||||
<a href="README.ja.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 のエージェントを、ひとつのインターフェースで。</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo アプリのスクリーンショット" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo モバイルアプリ" width="100%">
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> 私はひとりでメンテナンスしているため、GitHub Issues を毎日確認できるとは限りません。
|
||||
> 急ぎの問題や作業がブロックされている場合は、[Discord](https://discord.gg/jz8T2uahpH) から連絡するのが一番早いです。
|
||||
|
||||
---
|
||||
|
||||
自分のマシンでエージェントを並列実行。スマートフォンからでもデスクからでも、開発を進めてリリースできます。
|
||||
|
||||
- **セルフホスト:** エージェントはあなたのマシン上で動作し、完全な開発環境を使用します。自分のツール・設定・スキルをそのまま活用できます。
|
||||
- **マルチプロバイダー:** Claude Code、Codex、Copilot、OpenCode、Pi を同一のインターフェースで利用。タスクに合ったモデルを選べます。
|
||||
- **音声コントロール:** 音声モードでタスクを口述したり問題を話し合ったりできます。ハンズフリーが必要なときに便利です。
|
||||
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
|
||||
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
|
||||
|
||||
## はじめかた
|
||||
|
||||
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
|
||||
|
||||
### 前提条件
|
||||
|
||||
エージェント 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)
|
||||
|
||||
### デスクトップアプリ(推奨)
|
||||
|
||||
[paseo.sh/download](https://paseo.sh/download) または [GitHub のリリースページ](https://github.com/getpaseo/paseo/releases)からダウンロードしてください。アプリを開くとデーモンが自動的に起動します。追加のインストールは不要です。
|
||||
|
||||
スマートフォンから接続するには、Settings 画面に表示される QR コードをスキャンしてください。
|
||||
|
||||
### CLI / ヘッドレス
|
||||
|
||||
CLI をインストールして Paseo を起動します。
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
ターミナルに QR コードが表示されます。どのクライアントからでも接続できます。サーバーやリモートマシンでの利用に適しています。
|
||||
|
||||
詳しいセットアップと設定については以下を参照してください。
|
||||
|
||||
- [ドキュメント](https://paseo.sh/docs)
|
||||
- [設定リファレンス](https://paseo.sh/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
アプリでできることはすべてターミナルからも実行できます。
|
||||
|
||||
```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 # 実行中のエージェントを一覧表示
|
||||
paseo attach abc123 # ライブ出力をストリーミング
|
||||
paseo send abc123 "also add tests" # 追加タスクを送信
|
||||
|
||||
# リモートデーモンで実行
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
詳細は[完全な CLI リファレンス](https://paseo.sh/docs/cli)を参照してください。
|
||||
|
||||
## スキル
|
||||
|
||||
スキルはエージェントに Paseo を使って他のエージェントをオーケストレーションする方法を教えます。
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
```
|
||||
|
||||
どのエージェントとの会話でも使用できます。
|
||||
|
||||
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
|
||||
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
|
||||
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
|
||||
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
|
||||
|
||||
## 開発
|
||||
|
||||
モノレポのパッケージ構成:
|
||||
|
||||
- `packages/server`: Paseo デーモン(エージェントプロセスのオーケストレーション、WebSocket API、MCP サーバー)
|
||||
- `packages/app`: Expo クライアント(iOS、Android、Web)
|
||||
- `packages/cli`: デーモンおよびエージェントワークフロー向け `paseo` CLI
|
||||
- `packages/desktop`: Electron デスクトップアプリ
|
||||
- `packages/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
|
||||
|
||||
# サーバースタックをビルド
|
||||
npm run build:server
|
||||
|
||||
# リポジトリ全体のチェック
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## コミュニティ
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — Go 実装のセルフホスト型リレー
|
||||
|
||||
---
|
||||
|
||||
<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="getpaseo/paseo のスター履歴チャート" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## ライセンス
|
||||
|
||||
AGPL-3.0
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a>
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+1
-2
@@ -6,8 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja.md">日本語</a>
|
||||
<a href="README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+11
-18
@@ -14,23 +14,16 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. `relationship` and `workspace` are separate decisions:
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
|
||||
|
||||
- `relationship` decides whether the new agent belongs under the caller.
|
||||
- `workspace` decides where the new agent lives and whether a new workspace/worktree is created.
|
||||
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
|
||||
|
||||
`relationship: { kind: "subagent" }` stamps the created agent with `paseo.parent-agent-id`, pointing back at the creating agent. The client surfaces that as `agent.parentAgentId`. This requires an agent-scoped MCP session.
|
||||
|
||||
`relationship: { kind: "detached" }` creates a sibling/root agent (e.g. handoffs, fire-and-forget delegations). The daemon may still use the creating agent for cwd/config inheritance, but it does not write `paseo.parent-agent-id`.
|
||||
|
||||
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — stand on their own, do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
|
||||
`workspace: { kind: "current" }` uses the caller's workspace and can optionally override the runtime cwd. It requires an agent-scoped MCP session. `workspace: { kind: "create", source: { kind: "directory" | "worktree", ... } }` creates a new workspace for the new agent; worktree creation goes through the Paseo worktree workflow and stamps the agent with that fresh workspace id.
|
||||
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
|
||||
|
||||
## Archive
|
||||
|
||||
@@ -113,11 +106,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
@@ -48,7 +48,7 @@ The heart of Paseo. A Node.js process that:
|
||||
- Listens for WebSocket connections from clients
|
||||
- Manages agent lifecycle (create, run, stop, resume, archive)
|
||||
- Streams agent output in real time via a timeline model
|
||||
- Provides agent-to-agent tools through a transport-neutral tool catalog, with MCP as one adapter
|
||||
- Exposes an MCP server for agent-to-agent control
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
@@ -62,8 +62,7 @@ All paths are under `packages/server/src/`.
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral Paseo tool catalog for subagents, permissions, worktrees |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
@@ -290,8 +289,6 @@ All providers:
|
||||
- Map tool calls to a normalized `ToolCallDetail` type
|
||||
- Expose provider-specific modes (plan, default, full-access)
|
||||
|
||||
Providers that can accept native tool definitions should set `supportsNativePaseoTools` and read `launchContext.paseoTools`. The daemon then passes the shared Paseo tool catalog directly and removes the internal Paseo MCP server from that provider launch config. Providers that only support MCP continue to receive the same tools through the MCP fallback at `/mcp/agents`.
|
||||
|
||||
## Data flow: running an agent
|
||||
|
||||
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
|
||||
|
||||
@@ -438,7 +438,7 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
|
||||
By default, Paseo injects its internal MCP server into ACP providers so agents can use Paseo tools such as subagent creation. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
+2
-36
@@ -27,9 +27,6 @@ $PASEO_HOME/
|
||||
├── projects/
|
||||
│ ├── projects.json # Project registry
|
||||
│ └── workspaces.json # Workspace registry
|
||||
├── runtime/
|
||||
│ └── managed-processes/
|
||||
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
@@ -54,7 +51,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for `create_agent` subagent relationships — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
@@ -163,7 +160,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { voice: { apiKey: string, baseUrl: string } },
|
||||
openai: { apiKey: string },
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
@@ -193,37 +190,6 @@ All fields are optional with sensible defaults.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
Set these to select OpenAI instead of local speech:
|
||||
|
||||
| Env var | Applies to |
|
||||
| ------------------------------ | ------------------------------- |
|
||||
| `PASEO_VOICE_STT_PROVIDER` | Voice mode STT provider |
|
||||
| `PASEO_DICTATION_STT_PROVIDER` | Composer dictation STT provider |
|
||||
| `PASEO_VOICE_TTS_PROVIDER` | Voice mode TTS provider |
|
||||
|
||||
OpenAI voice can be configured under `providers.openai`:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": {
|
||||
"voice": {
|
||||
"apiKey": "sk-...",
|
||||
"baseUrl": "https://api.openai.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`providers.openai.voice.apiKey` and `providers.openai.voice.baseUrl` apply only to Paseo OpenAI voice features.
|
||||
|
||||
Paseo uses these paths under the configured OpenAI base URL:
|
||||
|
||||
- dictation STT: `/v1/audio/transcriptions`
|
||||
- voice mode STT: `/v1/audio/transcriptions`
|
||||
- voice mode TTS: `/v1/audio/speech`
|
||||
|
||||
---
|
||||
|
||||
## 3. Schedule
|
||||
|
||||
@@ -49,38 +49,6 @@ 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.
|
||||
|
||||
### Expo Router layout ownership
|
||||
|
||||
Each layout owns only the routes directly inside its directory. In the root
|
||||
layout, register `h/[serverId]`; do not register host leaf routes such as
|
||||
`h/[serverId]/workspace/[workspaceId]`, `h/[serverId]/open-project`, or
|
||||
`h/[serverId]/index` there. The `h/[serverId]/_layout.tsx` file owns those leaf
|
||||
routes with its own nested stack and relative screen names:
|
||||
`workspace/[workspaceId]/index`, `open-project`, `index`, and so on. Expo Router
|
||||
warns with `[Layout children]: No route named ...` when a layout registers
|
||||
grandchildren. Treat that warning as a route-tree bug: on native, this shape can
|
||||
leave a nested index route mounted without its local dynamic params and render a
|
||||
blank screen.
|
||||
|
||||
Do not paper over missing required route params by reading global params in the
|
||||
leaf. Required dynamic params belong to the matched route. If
|
||||
`useLocalSearchParams()` misses one, fix the layout ownership.
|
||||
|
||||
Keep non-route modules out of `src/app`. Expo Router treats ordinary `.ts` and
|
||||
`.tsx` files there as routes, which produces `missing the required default
|
||||
export` warnings and pollutes the route tree. Put shared route policy in
|
||||
`src/navigation`, `src/utils`, or another non-route directory.
|
||||
|
||||
Treat `/h/[serverId]` as the host home route. It resolves to the last remembered
|
||||
workspace for that host after the workspace-selection store hydrates unless the
|
||||
host's hydrated workspace list proves that workspace is gone; hosts without a
|
||||
remembered workspace go to `open-project`.
|
||||
|
||||
Keep workspace identity and retention outside native-stack `getId`/
|
||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||
reordering an already-mounted workspace screen.
|
||||
|
||||
### 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`.
|
||||
|
||||
+2
-5
@@ -8,12 +8,10 @@ Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
- `ar`
|
||||
- `es`
|
||||
- `fr`
|
||||
- `ja`
|
||||
- `pt-BR`
|
||||
- `ru`
|
||||
- `zh-CN`
|
||||
|
||||
The persisted app language setting is `"system" | "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN"`. `"system"` follows the device or browser locale when it maps to a supported locale; unsupported system locales fall back to English. Japanese maps from system locales `ja` and Japanese regional locales. Brazilian Portuguese maps from system locales `pt-BR` and bare `pt`; other Portuguese regional locales remain unsupported until explicitly added.
|
||||
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
|
||||
|
||||
@@ -35,7 +33,7 @@ Run:
|
||||
npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
|
||||
```
|
||||
|
||||
The parity test catches missing keys across English and every supported locale resource.
|
||||
The parity test catches missing keys between English and Simplified Chinese resources.
|
||||
|
||||
## Migration Order
|
||||
|
||||
@@ -79,4 +77,3 @@ Within a migrated surface, do not leave mixed-language neighboring labels when t
|
||||
- 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.
|
||||
- Batch 5A added Brazilian Portuguese (`pt-BR`) resource coverage, language selector labels, i18next registration, and system-locale mapping for `pt-BR` and bare `pt`. Non-Brazilian Portuguese regional locales remain unsupported until a matching resource is added.
|
||||
|
||||
+6
-21
@@ -10,16 +10,12 @@ Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-age
|
||||
|
||||
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
|
||||
|
||||
Copilot custom agents are exposed through ACP session config, not the slash-command list. When custom agents are available, Copilot returns a select config option with `id: "agent"` and `category: "_agent"`; Paseo maps that to the `agent` provider feature. Copilot uses the agent display name as the option value, and the blank value means the default Copilot agent.
|
||||
|
||||
### Direct
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
@@ -30,28 +26,18 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
|
||||
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
## Provider Helper Processes
|
||||
|
||||
Provider-owned helper processes that can outlive an individual agent session must be recorded in the daemon's managed-process registry. Store provider/kind metadata, the PID, launch command/args, and process identity captured from the platform process table. Remove the record on normal exit or shutdown.
|
||||
|
||||
If a helper process has a readiness phase, the provider's lifecycle model must own the process immediately after `spawn`, before readiness succeeds. Startup timeout, startup exit, and daemon shutdown must all clean up through that owned generation. Do not keep a spawned helper only inside a readiness promise; that creates a live process outside the manager/reaper contract.
|
||||
|
||||
Daemon bootstrap reconciles that ledger in the background, without blocking startup: dead PIDs are deleted, PID identity mismatches are deleted without killing anything, only positively matched Paseo-owned leftovers are terminated, and a record whose process cannot be inspected is left in place for the next reconcile rather than deleted. Do not add broad process-name sweepers for provider cleanup; cleanup starts from records Paseo previously wrote.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
@@ -81,8 +67,6 @@ To add plan usage for a provider, add `packages/server/src/services/quota-fetche
|
||||
|
||||
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
|
||||
|
||||
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
|
||||
|
||||
---
|
||||
|
||||
## ACP Provider Checklist
|
||||
@@ -344,13 +328,14 @@ interface AgentClient {
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
|
||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listImportableSessions(
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listImportableSessions?(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
importSession(
|
||||
importSession?(
|
||||
input: ImportProviderSessionInput,
|
||||
context: ImportProviderSessionContext,
|
||||
): Promise<ImportedProviderSession>;
|
||||
|
||||
@@ -13,15 +13,6 @@ Each domain becomes a controller class in its own file with the **exact** contra
|
||||
|
||||
Session shrinks to a connection/dispatch shell: it keeps `handleMessage`, the `??` chain (1739-1751), `emit`/`emitBinary`, `sessionLogger`, connection identity, inflight metrics, lifecycle intents, and the **ordered** `cleanup()`. Each `dispatchXMessage` collapses to `return this.xController.dispatch(msg)`.
|
||||
|
||||
## Progress (shipped — diverged from the original filenames)
|
||||
|
||||
The first carves shipped as **deep modules with a narrow Host seam**, not the `dispatch(msg)`-owned-set controllers sketched below: `session.ts` keeps each `dispatchXMessage` switch and delegates per case to the subsystem. Home convention that emerged: session subsystems live at **`session/<domain>/`**, with `session.ts` as the orchestrator shell.
|
||||
|
||||
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
|
||||
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
|
||||
|
||||
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
|
||||
|
||||
### Why this is safe at the dispatch seam (verified)
|
||||
|
||||
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
|
||||
@@ -126,7 +117,7 @@ In-place, separately reviewable. Split the `audio_output` TTS-debug branch out o
|
||||
|
||||
## Slice 7 — VoiceSessionController (XL)
|
||||
|
||||
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → the existing `packages/server/src/server/session/voice/voice-session.ts` (see Progress above). Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
|
||||
**Move:** voice handlers + ~25 voice fields + the TTS-debug hook (Slice 6) + `voiceModeAgentId`/`isVoiceMode` + the `shouldAutoAllowVoicePermission` predicate (Slice 3) → `packages/server/src/server/voice/voice-session-controller.ts`. Carve voice types out of `dispatchVoiceAndControlMessage`, leaving infra (restart/shutdown/heartbeat/ping/abort) on the shell.
|
||||
|
||||
**SessionContext surface:** pure `emit`, `emitBinary`, `hasBinaryChannel`, `sessionLogger`/`sessionId`/`paseoHome`, `getSpeechReadiness`, agent-control port `{ loadAgent, reloadWithSystemPrompt, interruptIfRunning, isRunning, sendSpokenText, buildAgentPrompt }`, `getSignal`/`abortCurrent` (Slice 6).
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ Rules that apply to both steps:
|
||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
||||
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
- If the user asks for a release preview, show the prospective changelog/release contents and answer questions, but do not commit, tag, publish, or run release commands until they explicitly authorize the release.
|
||||
|
||||
## Two paths
|
||||
|
||||
|
||||
@@ -130,7 +130,6 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- Never re-run a suite another agent already reported green.
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-riRET+7Y8oJnMrydvzliE7/pe8T3/75EcIwGXw8vnCM=
|
||||
sha256-oJSIAxDUwa/MXkCfKuQR6Owb6/YykAYP/mRKVCGK+fQ=
|
||||
|
||||
Generated
+46
-72
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -11207,8 +11207,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11221,8 +11220,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11235,8 +11233,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11249,8 +11246,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11263,8 +11259,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11277,8 +11272,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.59.0",
|
||||
@@ -11291,8 +11285,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.59.0",
|
||||
@@ -11305,8 +11298,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11319,8 +11311,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11333,8 +11324,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11347,8 +11337,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11361,8 +11350,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11375,8 +11363,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11389,8 +11376,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11403,8 +11389,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11417,8 +11402,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11431,8 +11415,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11445,8 +11428,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.59.0",
|
||||
@@ -11459,8 +11441,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11473,8 +11454,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
@@ -11487,8 +11467,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11501,8 +11480,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11515,8 +11493,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
@@ -11529,8 +11506,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.59.0",
|
||||
@@ -11543,8 +11519,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
@@ -18272,7 +18247,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -35243,7 +35217,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -35566,12 +35540,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.101",
|
||||
"@getpaseo/protocol": "0.1.101",
|
||||
"@getpaseo/server": "0.1.101",
|
||||
"@getpaseo/client": "0.1.97",
|
||||
"@getpaseo/protocol": "0.1.97",
|
||||
"@getpaseo/server": "0.1.97",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -35817,10 +35791,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.101",
|
||||
"@getpaseo/relay": "0.1.101",
|
||||
"@getpaseo/protocol": "0.1.97",
|
||||
"@getpaseo/relay": "0.1.97",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35831,7 +35805,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -36074,7 +36048,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -36970,7 +36944,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -37201,7 +37175,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37213,7 +37187,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -37431,15 +37405,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.181",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.1.101",
|
||||
"@getpaseo/highlight": "0.1.101",
|
||||
"@getpaseo/protocol": "0.1.101",
|
||||
"@getpaseo/relay": "0.1.101",
|
||||
"@getpaseo/client": "0.1.97",
|
||||
"@getpaseo/highlight": "0.1.97",
|
||||
"@getpaseo/protocol": "0.1.97",
|
||||
"@getpaseo/relay": "0.1.97",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -37848,7 +37822,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
"development",
|
||||
"mcp",
|
||||
"openai",
|
||||
"realtime",
|
||||
"voice",
|
||||
"voice-assistant"
|
||||
],
|
||||
|
||||
@@ -12,7 +12,7 @@ import { startRunningMockAgent } from "./helpers/composer";
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-scroll-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for auto-scroll test.",
|
||||
@@ -21,13 +21,14 @@ test.describe("Agent stream UI", () => {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectScrollFollowsNewContent(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-indicator-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stream briefly for indicator transition test.",
|
||||
@@ -38,7 +39,8 @@ test.describe("Agent stream UI", () => {
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -26,26 +26,15 @@ import {
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let projectId: string;
|
||||
let workspaceId: string;
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
client = await connectSeedClient();
|
||||
const created = await client.createWorkspace({
|
||||
source: { kind: "directory", path: tempRepo.path },
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${tempRepo.path}`);
|
||||
}
|
||||
projectId = created.workspace.projectId;
|
||||
workspaceId = created.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.removeProject(projectId).catch(() => undefined);
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
@@ -53,12 +42,10 @@ test.describe("Archive tab reconciliation", () => {
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `cli-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `cli-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
@@ -94,12 +81,10 @@ test.describe("Archive tab reconciliation", () => {
|
||||
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `ui-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `ui-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
@@ -126,12 +111,10 @@ test.describe("Archive tab reconciliation", () => {
|
||||
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
title: `unarchive-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ test.describe("Composer attachments", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-queue-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running for queue test.",
|
||||
@@ -205,7 +205,8 @@ test.describe("Composer attachments", () => {
|
||||
await expectQueuedMessageButton(page);
|
||||
await expectComposerDraft(page, "");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -213,7 +214,7 @@ test.describe("Composer attachments", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-interrupt-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stay running for interrupt test.",
|
||||
@@ -225,7 +226,8 @@ test.describe("Composer attachments", () => {
|
||||
await expectAgentIdle(page, 15_000);
|
||||
await expectComposerDraft(page, "preserve me");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -228,7 +228,6 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
|
||||
|
||||
await changeCodeFontSizeFromSettings(page, 18);
|
||||
await returnToWorkspaceChanges(page);
|
||||
await expectStoredCodeFontSize(page, 18);
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
|
||||
await expectDiffCodeFontSize(page, 18);
|
||||
@@ -239,9 +238,6 @@ test("changes diff keeps unwrapped gutter and code rows aligned after code size
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
@@ -274,13 +270,10 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
})
|
||||
.toBe(fontSize);
|
||||
const actualFontSize = await page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
expect(actualFontSize).toBe(fontSize);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
|
||||
@@ -407,22 +400,6 @@ async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number):
|
||||
await page.getByLabel("Code font size").fill(String(codeFontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
|
||||
await expectStoredCodeFontSize(page, codeFontSize);
|
||||
}
|
||||
|
||||
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(
|
||||
(settingsKey) => localStorage.getItem(settingsKey),
|
||||
APP_SETTINGS_KEY,
|
||||
);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return (JSON.parse(raw) as { codeFontSize?: number }).codeFontSize ?? null;
|
||||
})
|
||||
.toBe(codeFontSize);
|
||||
}
|
||||
|
||||
async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { gotoAppShell } from "./helpers/app";
|
||||
import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
@@ -66,13 +66,6 @@ async function addProjectFromPicker(page: Page, projectPath: string): Promise<st
|
||||
return testId!.replace("sidebar-project-row-", "");
|
||||
}
|
||||
|
||||
async function waitForSidebarProjectListReady(page: Page): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid="sidebar-project-empty-state"], [data-testid^="sidebar-project-row-"]')
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
|
||||
// Projects are parents in the sidebar. Archiving the last workspace leaves the
|
||||
// project row in place with a ghost "+ New workspace" child row.
|
||||
test.describe("Project with no workspaces persists", () => {
|
||||
@@ -83,7 +76,7 @@ test.describe("Project with no workspaces persists", () => {
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await waitForNoProjectsInSidebar(page);
|
||||
|
||||
projectId = await addProjectFromPicker(page, repo.path);
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
||||
@@ -99,7 +92,7 @@ test.describe("Project with no workspaces persists", () => {
|
||||
expect(workspaces.entries).toEqual([]);
|
||||
} finally {
|
||||
if (projectId) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
await removeProjectFromSidebar(page, projectId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
@@ -170,7 +163,7 @@ test.describe("Project remove", () => {
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarProjectListReady(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
const readded = await workspace.client.addProject(workspace.repoPath);
|
||||
@@ -186,6 +179,7 @@ test.describe("Project remove", () => {
|
||||
page.getByTestId(`sidebar-project-new-workspace-row-${workspace.projectId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await removeProjectFromSidebar(page, workspace.projectId).catch(() => undefined);
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,6 +35,10 @@ function buildSeededStoragePayload() {
|
||||
* idle agent from the same client it uses for everything else.
|
||||
*/
|
||||
export interface IdleAgentSeedClient {
|
||||
createWorkspace(input: { source: { kind: "directory"; path: string } }): Promise<{
|
||||
workspace: { id: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -52,14 +56,20 @@ export interface IdleAgentSeedClient {
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; workspaceId: string; title: string },
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: input.cwd },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${input.cwd}`);
|
||||
}
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: input.title,
|
||||
});
|
||||
const snapshot = await client.waitForAgentUpsert(
|
||||
@@ -74,7 +84,7 @@ export async function createIdleAgent(
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,6 @@ export async function selectGithubOption(
|
||||
export interface MockAgentSetup {
|
||||
client: SeedDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
||||
@@ -167,29 +166,20 @@ export async function startRunningMockAgent(
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspace = createdWorkspace.workspace;
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
workspaceId: workspace.id,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
model: opts.model,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, workspace.id)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, createdWorkspace.workspace.id)}?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,
|
||||
});
|
||||
return {
|
||||
client,
|
||||
repo,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
return { client, repo };
|
||||
}
|
||||
|
||||
export interface GithubWorkspaceHandle {
|
||||
@@ -208,14 +198,8 @@ export async function openGithubWorkspace(
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
const workspace = createdWorkspace.workspace;
|
||||
await gotoAppShell(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.id);
|
||||
await selectWorkspaceInSidebar(page, createdWorkspace.workspace.id);
|
||||
await waitForTabBar(page);
|
||||
return {
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
return { cleanup: () => client.close().catch(() => undefined) };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface AgentHandle {
|
||||
page: Page;
|
||||
client: SeedDaemonClient;
|
||||
agentId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
@@ -188,7 +187,6 @@ export async function launchAgent(input: {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
projectId: createdWorkspace.workspace.projectId,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
@@ -198,7 +196,6 @@ export async function launchAgent(input: {
|
||||
}
|
||||
|
||||
export async function closeAgent(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.removeProject(handle.projectId).catch(() => undefined);
|
||||
await handle.client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface SeedDaemonClient {
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
@@ -200,17 +199,15 @@ export async function seedWorkspace(options: {
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
||||
}
|
||||
const workspace = created.workspace;
|
||||
return {
|
||||
client,
|
||||
repoPath: project.path,
|
||||
workspaceId: workspace.id,
|
||||
workspaceName: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceId: created.workspace.id,
|
||||
workspaceName: created.workspace.name,
|
||||
workspaceDirectory: created.workspace.workspaceDirectory,
|
||||
projectId: created.workspace.projectId,
|
||||
projectDisplayName: created.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.removeProject(workspace.projectId).catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
},
|
||||
|
||||
@@ -249,7 +249,6 @@ export async function expectDirectHostUriHidden(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectDiagnosticsContent(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Run" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,18 +28,15 @@ function sleep(ms: number): Promise<void> {
|
||||
export class TerminalE2EHarness {
|
||||
readonly client: SeedDaemonClient;
|
||||
readonly tempRepo: TempRepo;
|
||||
readonly projectId: string;
|
||||
readonly workspaceId: string;
|
||||
|
||||
private constructor(input: {
|
||||
client: SeedDaemonClient;
|
||||
tempRepo: TempRepo;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
this.client = input.client;
|
||||
this.tempRepo = input.tempRepo;
|
||||
this.projectId = input.projectId;
|
||||
this.workspaceId = input.workspaceId;
|
||||
}
|
||||
|
||||
@@ -57,13 +54,11 @@ export class TerminalE2EHarness {
|
||||
return new TerminalE2EHarness({
|
||||
client,
|
||||
tempRepo,
|
||||
projectId: seedResult.workspace.projectId,
|
||||
workspaceId: seedResult.workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.client.removeProject(this.projectId).catch(() => {});
|
||||
await this.client.close().catch(() => {});
|
||||
await this.tempRepo.cleanup().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
let client: WorkspaceSetupDaemonClient | null = null;
|
||||
const repos: Array<{ cleanup: () => Promise<void> }> = [];
|
||||
const worktrees: WorktreeRecord[] = [];
|
||||
const projectIds = new Set<string>();
|
||||
|
||||
const withWorkspace: WithWorkspace = async (options) => {
|
||||
if (!client) {
|
||||
@@ -65,7 +64,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
if (!added.project) {
|
||||
throw new Error(added.error ?? `Failed to add project ${repo.path}`);
|
||||
}
|
||||
projectIds.add(added.project.projectId);
|
||||
}
|
||||
|
||||
const created = await client.createWorkspace({
|
||||
@@ -75,7 +73,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${workspacePath}`);
|
||||
}
|
||||
const workspaceId = created.workspace.id;
|
||||
projectIds.add(created.workspace.projectId);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
@@ -91,11 +88,6 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
return {
|
||||
withWorkspace,
|
||||
cleanup: async () => {
|
||||
if (client) {
|
||||
for (const projectId of projectIds) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
for (const { repoPath, worktreePath } of worktrees) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
|
||||
@@ -19,7 +19,6 @@ type WorkspaceSetupDaemonClient = Pick<
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "listTerminals"
|
||||
| "removeProject"
|
||||
| "subscribeRawMessages"
|
||||
>;
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEMP_CLEANUP_RETRIES = 5;
|
||||
const TEMP_CLEANUP_RETRY_DELAY_MS = 100;
|
||||
|
||||
interface TempRepo {
|
||||
path: string;
|
||||
branchHeads: Record<string, string>;
|
||||
@@ -129,12 +126,7 @@ export const createTempGitRepo = async (
|
||||
path: repoPath,
|
||||
branchHeads,
|
||||
cleanup: async () => {
|
||||
await rm(repoPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: TEMP_CLEANUP_RETRIES,
|
||||
retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
|
||||
});
|
||||
await rm(repoPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -149,12 +141,7 @@ export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<Te
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: async () => {
|
||||
await rm(dirPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: TEMP_CLEANUP_RETRIES,
|
||||
retryDelay: TEMP_CLEANUP_RETRY_DELAY_MS,
|
||||
});
|
||||
await rm(dirPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
import { openAgentRoute } from "./helpers/mock-agent";
|
||||
import {
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { escapeRegex } from "./helpers/regex";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface CreateAgentRequestMessage {
|
||||
type: "create_agent_request";
|
||||
config?: {
|
||||
provider?: unknown;
|
||||
modeId?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey, serverId: seededServerId }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({
|
||||
serverId: seededServerId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.4-mini",
|
||||
mode: "auto",
|
||||
thinkingByModel: {
|
||||
"gpt-5.4-mini": "low",
|
||||
},
|
||||
},
|
||||
mock: {
|
||||
model: "ten-second-stream",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
|
||||
);
|
||||
}
|
||||
|
||||
async function readCodexModePreference(page: Page): Promise<unknown> {
|
||||
return page.evaluate((preferencesKey) => {
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providerPreferences?: Record<string, { mode?: unknown }>;
|
||||
};
|
||||
return parsed.providerPreferences?.codex?.mode ?? null;
|
||||
}, CREATE_AGENT_PREFERENCES_KEY);
|
||||
}
|
||||
|
||||
async function selectMode(page: Page, label: string): Promise<void> {
|
||||
const modeControl = page.getByTestId("mode-control").first();
|
||||
await expect(modeControl).toBeVisible({ timeout: 30_000 });
|
||||
await modeControl.click();
|
||||
|
||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchInput.fill(label);
|
||||
|
||||
const option = page
|
||||
.getByRole("dialog")
|
||||
.last()
|
||||
.getByText(new RegExp(`^${escapeRegex(label)}$`, "i"))
|
||||
.first();
|
||||
await expect(option).toBeVisible({ timeout: 10_000 });
|
||||
await option.click({ force: true });
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function recordAndBlockCreateAgentRequests(page: Page): Promise<{
|
||||
waitForCreateAgentRequest(): Promise<CreateAgentRequestMessage>;
|
||||
}> {
|
||||
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
|
||||
const createAgentSeen = new Promise<CreateAgentRequestMessage>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForCreateAgentRequest: () => createAgentSeen,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("New workspace Codex mode preferences", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
|
||||
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await selectMode(page, "Full access");
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access");
|
||||
|
||||
await submitNewWorkspacePrompt(page, "Keep Codex full access selected globally.");
|
||||
const createAgentRequest = await createAgentRecorder.waitForCreateAgentRequest();
|
||||
|
||||
expect(createAgentRequest.config).toMatchObject({
|
||||
provider: "codex",
|
||||
modeId: "full-access",
|
||||
});
|
||||
await expect
|
||||
.poll(() => readCodexModePreference(page), { timeout: 10_000 })
|
||||
.toBe("full-access");
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
|
||||
await seedCodexDefaultPermissionPreferences(page, serverId);
|
||||
|
||||
try {
|
||||
const agent = await seeded.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Codex live mode preference e2e",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: seeded.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Default permissions", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await selectMode(page, "Full access");
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("mode-control").first()).toContainText("Full access", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ interface WorkspaceStatusGroupEvent {
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
@@ -54,12 +54,10 @@ test.describe("Settings toggle tab regression", () => {
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-toggle-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-toggle-b-${Date.now()}`,
|
||||
});
|
||||
|
||||
@@ -97,12 +95,10 @@ test.describe("Settings toggle tab regression", () => {
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `agent-route-refresh-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `agent-route-refresh-b-${Date.now()}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ test.describe("Model B sidebar shape", () => {
|
||||
await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Switch to status grouping.
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
|
||||
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
|
||||
|
||||
@@ -34,7 +34,6 @@ test.describe("Workspace agent tab rename", () => {
|
||||
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -390,7 +390,7 @@ async function expectWorkspaceRowInStatusBucket(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; bucket: string },
|
||||
) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -204,7 +204,6 @@ test.describe("Workspace navigation regression", () => {
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `workspace-reconnect-${Date.now()}`,
|
||||
});
|
||||
|
||||
@@ -307,7 +306,6 @@ test.describe("Workspace navigation regression", () => {
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `workspace-refresh-route-${Date.now()}`,
|
||||
});
|
||||
await injectDesktopBridge(page, {
|
||||
@@ -346,12 +344,10 @@ test.describe("Workspace navigation regression", () => {
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
||||
cwd: firstWorkspace.repoPath,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
title: `workspace-nav-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
||||
cwd: secondWorkspace.repoPath,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
title: `workspace-nav-b-${Date.now()}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ test.describe("Workspace pane mounting", () => {
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `pane-remount-${Date.now()}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
const createdProjectIds = new Set<string>();
|
||||
|
||||
test.describe.configure({ retries: 0, timeout: 180_000 });
|
||||
|
||||
@@ -35,10 +34,6 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
for (const projectId of createdProjectIds) {
|
||||
await worktreeClient.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
createdProjectIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
@@ -54,18 +49,15 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
// the History table cells must show after restore — never "main".
|
||||
const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: worktreeSlug,
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `restart-restore-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
@@ -25,7 +25,6 @@ test.describe("Worktree restore", () => {
|
||||
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
const createdProjectIds = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
@@ -40,10 +39,6 @@ test.describe("Worktree restore", () => {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
}
|
||||
createdWorktreeDirectories.clear();
|
||||
for (const projectId of createdProjectIds) {
|
||||
await worktreeClient.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
createdProjectIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
await worktreeClient?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
@@ -53,18 +48,15 @@ test.describe("Worktree restore", () => {
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-inplace-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `restore-inplace-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
@@ -98,18 +90,15 @@ test.describe("Worktree restore", () => {
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.101",
|
||||
"version": "0.1.97",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -28,7 +28,6 @@ import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import {
|
||||
SidebarAnimationProvider,
|
||||
useSidebarAnimation,
|
||||
@@ -56,7 +54,7 @@ import {
|
||||
startDaemonIfGateAllows,
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
@@ -467,26 +465,14 @@ function AppContainer({
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
{isCompactLayout && chromeEnabled ? (
|
||||
<ExplorerSidebarAnimationProvider>
|
||||
<CompactExplorerSidebarHost enabled={chromeEnabled}>
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</CompactExplorerSidebarHost>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
) : (
|
||||
<View style={flexStyle}>{children}</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
{workspaceChrome}
|
||||
<View style={rowStyle}>
|
||||
{!isCompactLayout && chromeEnabled && !isFocusModeEnabled && (
|
||||
<LeftSidebar selectedAgentId={selectedAgentId} />
|
||||
)}
|
||||
<View style={flexStyle}>{children}</View>
|
||||
</View>
|
||||
<FloatingPanelPortalHost />
|
||||
{isCompactLayout && chromeEnabled && <LeftSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
@@ -877,6 +863,8 @@ function FaviconStatusSync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
@@ -901,7 +889,19 @@ function RootStack() {
|
||||
<Stack.Screen name="settings/projects/[projectKey]" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="h/[serverId]" />
|
||||
{/*
|
||||
Do not add getId or dangerouslySingular back to the workspace route.
|
||||
Expo Router maps dangerouslySingular to React Navigation getId, and
|
||||
getId repeatedly breaks Android native-stack/Fabric by reordering an
|
||||
already-mounted workspace screen. Keep workspace identity/retention
|
||||
outside this route-level native-stack API.
|
||||
*/}
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</Stack>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Redirect, Slot, useLocalSearchParams } from "expo-router";
|
||||
import { useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import { HostRouteProvider } from "@/navigation/host-route-context";
|
||||
import { resolveStartupRoute } from "@/navigation/host-runtime-bootstrap";
|
||||
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
const HOST_STACK_SCREEN_OPTIONS = {
|
||||
headerShown: false,
|
||||
animation: "none" as const,
|
||||
};
|
||||
|
||||
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
|
||||
|
||||
export default function HostRouteLayout() {
|
||||
return <KnownHostRoute />;
|
||||
}
|
||||
@@ -32,21 +24,8 @@ function KnownHostRoute() {
|
||||
return <Redirect href={startupRoute.href} />;
|
||||
}
|
||||
|
||||
const stack = (
|
||||
<Stack screenOptions={HOST_STACK_SCREEN_OPTIONS}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="workspace/[workspaceId]/index" />
|
||||
<Stack.Screen name="agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
|
||||
<Stack.Screen name="sessions" />
|
||||
<Stack.Screen name="open-project" />
|
||||
<Stack.Screen name="new" />
|
||||
<Stack.Screen name="settings" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
if (!routeServerId) {
|
||||
return stack;
|
||||
}
|
||||
|
||||
return <HostRouteProvider serverId={routeServerId}>{stack}</HostRouteProvider>;
|
||||
// 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,39 +1,9 @@
|
||||
import { Redirect } from "expo-router";
|
||||
import { useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
import {
|
||||
resolveHostIndexRoute,
|
||||
resolveWorkspaceSelectionStatus,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const serverId = useHostRouteServerId();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelectionWorkspaceId =
|
||||
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
|
||||
|
||||
if (!serverId || !isWorkspaceSelectionLoaded) {
|
||||
return <StartupSplashScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Redirect
|
||||
href={resolveHostIndexRoute({
|
||||
serverId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
if (!serverId) return null;
|
||||
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
|
||||
}
|
||||
|
||||
-56
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveHostIndexRoute,
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
resolveStartupRoute,
|
||||
@@ -201,7 +200,6 @@ describe("resolveStartupRoute", () => {
|
||||
hosts: [],
|
||||
anyOnlineHostServerId: null,
|
||||
workspaceSelection: null,
|
||||
workspaceSelectionStatus: "unknown" as const,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
hasGivenUpWaitingForHost: false,
|
||||
};
|
||||
@@ -257,22 +255,10 @@ describe("resolveStartupRoute", () => {
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-1" }],
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "exists",
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-1/workspace/workspace-a" });
|
||||
});
|
||||
|
||||
it("does not restore a saved workspace after workspace hydration proves it is missing", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
...baseIndexInput,
|
||||
hosts: [{ serverId: "server-1" }],
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "missing",
|
||||
}),
|
||||
).toEqual({ kind: "redirect", href: "/h/server-1" });
|
||||
});
|
||||
|
||||
it("falls back to a saved host when the restored workspace host is no longer saved", () => {
|
||||
expect(
|
||||
resolveStartupRoute({
|
||||
@@ -358,45 +344,3 @@ describe("resolveStartupRoute", () => {
|
||||
).toEqual({ kind: "redirect", href: "/welcome" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHostIndexRoute", () => {
|
||||
it("restores the remembered workspace when the host index is reopened for the same host", () => {
|
||||
expect(
|
||||
resolveHostIndexRoute({
|
||||
serverId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "exists",
|
||||
}),
|
||||
).toEqual("/h/server-saved/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("keeps restoring a remembered workspace before the host workspace list hydrates", () => {
|
||||
expect(
|
||||
resolveHostIndexRoute({
|
||||
serverId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "unknown",
|
||||
}),
|
||||
).toEqual("/h/server-saved/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("opens project selection when the remembered workspace is proven missing", () => {
|
||||
expect(
|
||||
resolveHostIndexRoute({
|
||||
serverId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "missing",
|
||||
}),
|
||||
).toEqual("/h/server-saved/open-project");
|
||||
});
|
||||
|
||||
it("opens project selection when the remembered workspace belongs to another host", () => {
|
||||
expect(
|
||||
resolveHostIndexRoute({
|
||||
serverId: "server-saved",
|
||||
workspaceSelection: { serverId: "server-other", workspaceId: "workspace-a" },
|
||||
workspaceSelectionStatus: "exists",
|
||||
}),
|
||||
).toEqual("/h/server-saved/open-project");
|
||||
});
|
||||
});
|
||||
+3
-45
@@ -136,7 +136,6 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn
|
||||
route: IndexStartupRouteTarget;
|
||||
anyOnlineHostServerId: string | null;
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceSelectionStatus: WorkspaceSelectionStatus;
|
||||
isWorkspaceSelectionLoaded: boolean;
|
||||
hasGivenUpWaitingForHost: boolean;
|
||||
}
|
||||
@@ -152,42 +151,6 @@ export type StartupRouteDecision =
|
||||
| { kind: "splash" }
|
||||
| { kind: "redirect"; href: Href };
|
||||
|
||||
export type WorkspaceSelectionStatus = "unknown" | "exists" | "missing";
|
||||
|
||||
function shouldRestoreWorkspaceSelection(input: {
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceSelectionStatus: WorkspaceSelectionStatus;
|
||||
}): input is {
|
||||
workspaceSelection: ActiveWorkspaceSelection;
|
||||
workspaceSelectionStatus: Exclude<WorkspaceSelectionStatus, "missing">;
|
||||
} {
|
||||
return input.workspaceSelection !== null && input.workspaceSelectionStatus !== "missing";
|
||||
}
|
||||
|
||||
export function resolveWorkspaceSelectionStatus(input: {
|
||||
hasHydratedWorkspaces: boolean;
|
||||
workspaceExists: boolean;
|
||||
}): WorkspaceSelectionStatus {
|
||||
if (input.workspaceExists) {
|
||||
return "exists";
|
||||
}
|
||||
return input.hasHydratedWorkspaces ? "missing" : "unknown";
|
||||
}
|
||||
|
||||
export function resolveHostIndexRoute(input: {
|
||||
serverId: string;
|
||||
workspaceSelection: ActiveWorkspaceSelection | null;
|
||||
workspaceSelectionStatus: WorkspaceSelectionStatus;
|
||||
}): Href {
|
||||
if (
|
||||
input.workspaceSelection?.serverId === input.serverId &&
|
||||
shouldRestoreWorkspaceSelection(input)
|
||||
) {
|
||||
return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
|
||||
}
|
||||
return buildHostOpenProjectRoute(input.serverId);
|
||||
}
|
||||
|
||||
function isIndexPathname(pathname: string) {
|
||||
return pathname === "/" || pathname === "";
|
||||
}
|
||||
@@ -208,16 +171,11 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
|
||||
return { kind: "splash" };
|
||||
}
|
||||
|
||||
if (
|
||||
shouldRestoreWorkspaceSelection(input) &&
|
||||
hostExists(input.hosts, input.workspaceSelection.serverId)
|
||||
) {
|
||||
const workspaceSelection = input.workspaceSelection;
|
||||
if (workspaceSelection && hostExists(input.hosts, workspaceSelection.serverId)) {
|
||||
return {
|
||||
kind: "redirect",
|
||||
href: buildHostWorkspaceRoute(
|
||||
input.workspaceSelection.serverId,
|
||||
input.workspaceSelection.workspaceId,
|
||||
),
|
||||
href: buildHostWorkspaceRoute(workspaceSelection.serverId, workspaceSelection.workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,12 +2,8 @@ import React from "react";
|
||||
import { Redirect, usePathname } from "expo-router";
|
||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||
import {
|
||||
resolveStartupRoute,
|
||||
resolveWorkspaceSelectionStatus,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { resolveStartupRoute } from "@/app/host-runtime-bootstrap";
|
||||
import { useHostRegistryStatus, useHosts } from "@/runtime/host-runtime";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
@@ -24,13 +20,6 @@ export default function Index() {
|
||||
const hostRegistryStatus = useHostRegistryStatus();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceSelectionServerId = workspaceSelection?.serverId ?? null;
|
||||
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
|
||||
const hasHydratedWorkspaceSelectionHost = useHasHydratedWorkspaces(workspaceSelectionServerId);
|
||||
const workspaceSelectionExists = useWorkspaceExists(
|
||||
workspaceSelectionServerId,
|
||||
workspaceSelectionWorkspaceId,
|
||||
);
|
||||
|
||||
const startupRoute = resolveStartupRoute({
|
||||
route: { kind: "index", pathname },
|
||||
@@ -39,10 +28,6 @@ export default function Index() {
|
||||
hosts,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
|
||||
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
|
||||
workspaceExists: workspaceSelectionExists,
|
||||
}),
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Platform, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Copy, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import {
|
||||
formatAppDiagnosticHeader,
|
||||
formatDiagnosticSection,
|
||||
formatHostRuntimeSection,
|
||||
formatServerInfoSection,
|
||||
redactAppDiagnosticReport,
|
||||
} from "@/diagnostics/app-diagnostic-report";
|
||||
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
interface AppDiagnosticSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
appVersion: string | null;
|
||||
isDesktopApp: boolean;
|
||||
}
|
||||
|
||||
type ProgressStatus = "pending" | "running" | "done" | "failed";
|
||||
|
||||
interface ProgressRow {
|
||||
id: string;
|
||||
label: string;
|
||||
status: ProgressStatus;
|
||||
}
|
||||
|
||||
interface DiagnosticCollectionResult {
|
||||
sections: string[];
|
||||
status: ProgressStatus;
|
||||
}
|
||||
|
||||
const SNAP_POINTS = ["55%", "88%"];
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
export function AppDiagnosticSheet({
|
||||
visible,
|
||||
onClose,
|
||||
appVersion,
|
||||
isDesktopApp,
|
||||
}: AppDiagnosticSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const hosts = useHosts();
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState<ProgressRow[]>([]);
|
||||
const runIdRef = useRef(0);
|
||||
|
||||
const updateProgress = useCallback((id: string, label: string, status: ProgressStatus) => {
|
||||
setProgress((current) => {
|
||||
const existing = current.find((row) => row.id === id);
|
||||
if (existing) {
|
||||
return current.map((row) => (row.id === id ? { ...row, label, status } : row));
|
||||
}
|
||||
return [...current, { id, label, status }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const runDiagnostics = useCallback(async () => {
|
||||
const runId = runIdRef.current + 1;
|
||||
runIdRef.current = runId;
|
||||
const isCurrentRun = () => runIdRef.current === runId;
|
||||
const updateRunProgress = (id: string, label: string, status: ProgressStatus) => {
|
||||
if (isCurrentRun()) {
|
||||
updateProgress(id, label, status);
|
||||
}
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
setDiagnostic(null);
|
||||
setProgress([]);
|
||||
|
||||
const sections: string[] = [];
|
||||
try {
|
||||
updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "running");
|
||||
sections.push(
|
||||
formatAppDiagnosticHeader({
|
||||
appVersion,
|
||||
platform: Platform.OS,
|
||||
isDesktopApp,
|
||||
hostCount: hosts.length,
|
||||
}),
|
||||
);
|
||||
updateRunProgress("client", t("settings.diagnostics.app.progress.client"), "done");
|
||||
|
||||
if (isDesktopApp) {
|
||||
const desktopLabel = t("settings.diagnostics.app.progress.desktop");
|
||||
updateRunProgress("desktop", desktopLabel, "running");
|
||||
const desktopResult = await collectDesktopDiagnosticSections();
|
||||
sections.push(...desktopResult.sections);
|
||||
updateRunProgress("desktop", desktopLabel, desktopResult.status);
|
||||
}
|
||||
|
||||
const store = getHostRuntimeStore();
|
||||
for (const host of hosts) {
|
||||
const hostProgressId = `host:${host.serverId}`;
|
||||
updateRunProgress(hostProgressId, host.label, "running");
|
||||
const snapshot = store.getSnapshot(host.serverId);
|
||||
const hostResult = await collectHostDiagnosticSections(host, snapshot);
|
||||
sections.push(...hostResult.sections);
|
||||
updateRunProgress(hostProgressId, host.label, hostResult.status);
|
||||
}
|
||||
|
||||
if (isCurrentRun()) {
|
||||
setDiagnostic(redactAppDiagnosticReport(sections.join("\n\n"), hosts));
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentRun()) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [appVersion, hosts, isDesktopApp, t, updateProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
void runDiagnostics();
|
||||
} else {
|
||||
runIdRef.current += 1;
|
||||
setLoading(false);
|
||||
setDiagnostic(null);
|
||||
setProgress([]);
|
||||
}
|
||||
}, [visible, runDiagnostics]);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
void runDiagnostics();
|
||||
}, [runDiagnostics]);
|
||||
|
||||
const handleCopyPress = useCallback(() => {
|
||||
if (!diagnostic) return;
|
||||
void Clipboard.setStringAsync(diagnostic)
|
||||
.then(() => toast.copied(t("settings.diagnostics.app.copyLabel")))
|
||||
.catch(() => toast.error(t("settings.diagnostics.app.copyFailed")));
|
||||
}, [diagnostic, t, toast]);
|
||||
|
||||
const iconButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.iconButton,
|
||||
(Boolean(hovered) || pressed) && styles.iconButtonHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const disabledIconButtonStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.iconButton,
|
||||
(Boolean(hovered) || pressed) && Boolean(diagnostic) && styles.iconButtonHovered,
|
||||
diagnostic ? null : styles.disabled,
|
||||
],
|
||||
[diagnostic],
|
||||
);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("settings.diagnostics.app.title"),
|
||||
actions: (
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
onPress={handleCopyPress}
|
||||
disabled={!diagnostic}
|
||||
hitSlop={8}
|
||||
style={disabledIconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.diagnostics.app.copyAccessibility")}
|
||||
>
|
||||
<ThemedCopy size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleRefreshPress}
|
||||
disabled={loading}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
loading
|
||||
? t("settings.diagnostics.app.refreshingAccessibility")
|
||||
: t("settings.diagnostics.app.refreshAccessibility")
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
),
|
||||
}),
|
||||
[
|
||||
diagnostic,
|
||||
disabledIconButtonStyle,
|
||||
handleCopyPress,
|
||||
handleRefreshPress,
|
||||
iconButtonStyle,
|
||||
loading,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
scrollable={false}
|
||||
testID="app-diagnostic-sheet"
|
||||
>
|
||||
{diagnostic ? (
|
||||
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={520}>
|
||||
{diagnostic}
|
||||
</ScrollableCodeSurface>
|
||||
) : (
|
||||
<SurfaceCard key={visible ? "visible" : "hidden"}>
|
||||
<View style={styles.progressContent}>
|
||||
{progress.length === 0 ? (
|
||||
<View style={styles.progressRow}>
|
||||
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
<Text style={styles.mutedText}>{t("settings.diagnostics.app.running")}</Text>
|
||||
</View>
|
||||
) : (
|
||||
progress.map((row) => (
|
||||
<View key={row.id} style={styles.progressRow}>
|
||||
{row.status === "running" || row.status === "pending" ? (
|
||||
<ThemedLoadingSpinner
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={row.status === "failed" ? styles.statusDotFailed : styles.statusDot}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={styles.mutedText}
|
||||
>{`${row.label}: ${formatProgressStatus(row.status)}`}</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</SurfaceCard>
|
||||
)}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
async function collectDesktopDiagnosticSections(): Promise<DiagnosticCollectionResult> {
|
||||
try {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
return {
|
||||
status: "done",
|
||||
sections: [
|
||||
formatDiagnosticSection("Desktop", [
|
||||
{ label: "Daemon status", value: status.status },
|
||||
{ label: "Desktop managed", value: String(status.desktopManaged) },
|
||||
{ label: "Daemon PID", value: status.pid === null ? "none" : String(status.pid) },
|
||||
{ label: "Daemon version", value: status.version ?? "unknown" },
|
||||
{ label: "Daemon home", value: status.home || "unknown" },
|
||||
{ label: "Log path", value: logs.logPath || "unknown" },
|
||||
{ label: "Error", value: status.error ?? "none" },
|
||||
]),
|
||||
[
|
||||
"Desktop daemon log tail",
|
||||
logs.contents ? indentBlock(logs.contents) : " No log lines found",
|
||||
].join("\n"),
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
sections: [formatDiagnosticSection("Desktop", [{ label: "Error", value: toMessage(error) }])],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function collectHostDiagnosticSections(
|
||||
host: HostProfile,
|
||||
snapshot: HostRuntimeSnapshot | null,
|
||||
): Promise<DiagnosticCollectionResult> {
|
||||
const sections = [formatHostRuntimeSection({ host, snapshot })];
|
||||
const client = snapshot?.client ?? null;
|
||||
if (snapshot?.connectionStatus !== "online" || !client) {
|
||||
sections.push(
|
||||
formatDiagnosticSection(`Host diagnostics: ${host.label}`, [
|
||||
{ label: "Status", value: "host is not connected" },
|
||||
]),
|
||||
);
|
||||
return { sections, status: "done" };
|
||||
}
|
||||
|
||||
try {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
sections.push(formatServerInfoSection(serverInfo));
|
||||
|
||||
const rttMs = await client.measureLatency({ timeoutMs: 5000 });
|
||||
sections.push(
|
||||
formatDiagnosticSection(`Host latency: ${host.label}`, [
|
||||
{ label: "Active RTT", value: `${Math.round(rttMs)}ms` },
|
||||
]),
|
||||
);
|
||||
|
||||
if (serverInfo?.features?.daemonDiagnostics === true) {
|
||||
const result = await client.collectDiagnostics();
|
||||
sections.push(result.diagnostic);
|
||||
} else {
|
||||
sections.push(
|
||||
formatDiagnosticSection(`Daemon diagnostics: ${host.label}`, [
|
||||
{ label: "Status", value: "unsupported by this daemon" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return { sections, status: "done" };
|
||||
} catch (error) {
|
||||
sections.push(
|
||||
formatDiagnosticSection(`Host diagnostics: ${host.label}`, [
|
||||
{ label: "Error", value: toMessage(error) },
|
||||
]),
|
||||
);
|
||||
return { sections, status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
function indentBlock(value: string): string {
|
||||
return value
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function toMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function formatProgressStatus(status: ProgressStatus): string {
|
||||
switch (status) {
|
||||
case "done":
|
||||
return "done";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "running":
|
||||
case "pending":
|
||||
return "running";
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
progressContent: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
progressRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 24,
|
||||
},
|
||||
mutedText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.success,
|
||||
},
|
||||
statusDotFailed: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.destructive,
|
||||
},
|
||||
}));
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
archivingAt: input.archivingAt ?? null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(
|
||||
overrides: Partial<CompactExplorerSidebarHostModel> = {},
|
||||
): CompactExplorerSidebarHostModel {
|
||||
return {
|
||||
serverId: overrides.serverId ?? "server-1",
|
||||
workspaceId: overrides.workspaceId ?? "workspace-a",
|
||||
persistenceKey: overrides.persistenceKey ?? "server-1:workspace-a",
|
||||
workspaceRoot: overrides.workspaceRoot ?? "/repo/a",
|
||||
isGit: overrides.isGit ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveCompactExplorerSidebarHostModel", () => {
|
||||
it("retains the last workspace root for the same active selection while the workspace reloads", () => {
|
||||
const previous = createModel();
|
||||
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
persistenceKey: "server-1:workspace-a",
|
||||
workspaceRoot: "/repo/a",
|
||||
isGit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("switches ownership to the active workspace instead of leaking the previous one", () => {
|
||||
const previous = createModel();
|
||||
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-b" },
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-b",
|
||||
persistenceKey: "server-1:workspace-b",
|
||||
workspaceRoot: "",
|
||||
isGit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retain a previous owner when there is no active workspace selection", () => {
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous: createModel(),
|
||||
selection: null,
|
||||
workspace: null,
|
||||
isGit: false,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the current workspace directory when it is available", () => {
|
||||
const result = resolveCompactExplorerSidebarHostModel({
|
||||
previous: null,
|
||||
selection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
workspace: createWorkspace({ id: "workspace-a", workspaceDirectory: "/repo/current" }),
|
||||
isGit: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-a",
|
||||
persistenceKey: "server-1:workspace-a",
|
||||
workspaceRoot: "/repo/current",
|
||||
isGit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export interface CompactExplorerSidebarHostModel {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
persistenceKey: string;
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
interface ResolveCompactExplorerSidebarHostModelInput {
|
||||
previous: CompactExplorerSidebarHostModel | null;
|
||||
selection: ActiveWorkspaceSelection | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function resolveCompactExplorerSidebarHostModel(
|
||||
input: ResolveCompactExplorerSidebarHostModelInput,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const serverId = trimNonEmpty(input.selection?.serverId);
|
||||
const workspaceId = trimNonEmpty(input.selection?.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistenceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!persistenceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousForSelection =
|
||||
input.previous &&
|
||||
input.previous.serverId === serverId &&
|
||||
input.previous.workspaceId === workspaceId
|
||||
? input.previous
|
||||
: null;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
workspaceId,
|
||||
persistenceKey,
|
||||
workspaceRoot:
|
||||
trimNonEmpty(input.workspace?.workspaceDirectory) ??
|
||||
previousForSelection?.workspaceRoot ??
|
||||
"",
|
||||
isGit: input.workspace ? input.isGit : (previousForSelection?.isGit ?? input.isGit),
|
||||
};
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
resolveCompactExplorerSidebarHostModel,
|
||||
type CompactExplorerSidebarHostModel,
|
||||
} from "@/components/compact-explorer-sidebar-host-state";
|
||||
|
||||
interface CompactExplorerOpenGestureSurfaceProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
onOpenExplorer: () => void;
|
||||
}
|
||||
|
||||
const COMPACT_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
|
||||
|
||||
function CompactExplorerOpenGestureSurface({
|
||||
children,
|
||||
enabled,
|
||||
onOpenExplorer,
|
||||
}: CompactExplorerOpenGestureSurfaceProps) {
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled,
|
||||
onOpen: onOpenExplorer,
|
||||
});
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
|
||||
<View style={styles.fill}>{children}</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveCompactExplorerSidebarModel(
|
||||
enabled: boolean,
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: true }),
|
||||
);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
const retainedModelRef = useRef<CompactExplorerSidebarHostModel | null>(null);
|
||||
const { checkoutQuery } = useWorkspaceCheckoutStatus({
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused: enabled && selection !== null,
|
||||
normalizedServerId: selection?.serverId ?? "",
|
||||
normalizedWorkspaceId: selection?.workspaceId ?? "",
|
||||
workspaceDirectory: workspace?.workspaceDirectory || null,
|
||||
});
|
||||
const resolvedModel = useMemo(
|
||||
() =>
|
||||
resolveCompactExplorerSidebarHostModel({
|
||||
previous: isExplorerOpen ? retainedModelRef.current : null,
|
||||
selection,
|
||||
workspace,
|
||||
isGit: checkoutQuery.data?.isGit ?? false,
|
||||
}),
|
||||
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
retainedModelRef.current = null;
|
||||
if (enabled && isExplorerOpen) {
|
||||
showMobileAgent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isExplorerOpen) {
|
||||
retainedModelRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (resolvedModel) {
|
||||
retainedModelRef.current = resolvedModel;
|
||||
}
|
||||
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
|
||||
|
||||
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
|
||||
}
|
||||
|
||||
interface CompactExplorerSidebarHostProps {
|
||||
children: ReactNode;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
|
||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
|
||||
const handleOpenExplorer = useCallback(() => {
|
||||
if (!model?.workspaceRoot) {
|
||||
return;
|
||||
}
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: true,
|
||||
checkout: {
|
||||
serverId: model.serverId,
|
||||
cwd: model.workspaceRoot,
|
||||
isGit: model.isGit,
|
||||
},
|
||||
});
|
||||
}, [model, openFileExplorerForCheckout]);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceFileFromExplorer({
|
||||
filePath,
|
||||
persistenceKey: model.persistenceKey,
|
||||
showMobileAgent,
|
||||
openWorkspaceTabFocused,
|
||||
focusWorkspaceTab,
|
||||
});
|
||||
},
|
||||
[focusWorkspaceTab, model, openWorkspaceTabFocused, showMobileAgent],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CompactExplorerOpenGestureSurface
|
||||
enabled={enabled && Boolean(model?.workspaceRoot)}
|
||||
onOpenExplorer={handleOpenExplorer}
|
||||
>
|
||||
{children}
|
||||
</CompactExplorerOpenGestureSurface>
|
||||
{enabled && model ? (
|
||||
<CompactExplorerSidebar
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
workspaceRoot={model.workspaceRoot}
|
||||
isGit={model.isGit}
|
||||
onOpenFile={handleOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
fill: {
|
||||
flex: 1,
|
||||
},
|
||||
} as const;
|
||||
@@ -9,25 +9,23 @@ import { useProviderUsage } from "@/provider-usage/use-provider-usage";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
maxTokens: number | null;
|
||||
usedTokens: number | null;
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
serverId?: string;
|
||||
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
|
||||
provider?: string | null;
|
||||
/** Reserve the meter footprint and show a loading ring while usage is pending. */
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
const SVG_SIZE = 14;
|
||||
const COMPACT_SVG_SIZE = 12;
|
||||
const SVG_SIZE = 16;
|
||||
const COMPACT_SVG_SIZE = 14;
|
||||
const CENTER = SVG_SIZE / 2;
|
||||
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
|
||||
const RADIUS = 6;
|
||||
const COMPACT_RADIUS = 5;
|
||||
const STROKE_WIDTH = 2;
|
||||
const COMPACT_STROKE_WIDTH = 1.75;
|
||||
const RADIUS = 7;
|
||||
const COMPACT_RADIUS = 6;
|
||||
const STROKE_WIDTH = 2.25;
|
||||
const COMPACT_STROKE_WIDTH = 2;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
|
||||
|
||||
@@ -102,7 +100,6 @@ export function ContextWindowMeter({
|
||||
showPercentage = false,
|
||||
serverId,
|
||||
provider,
|
||||
pending = false,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -111,8 +108,7 @@ export function ContextWindowMeter({
|
||||
serverId ?? null,
|
||||
{ enabled: isTooltipOpen },
|
||||
);
|
||||
const percentage =
|
||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
const handleTooltipOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setIsTooltipOpen(nextOpen);
|
||||
@@ -123,42 +119,14 @@ export function ContextWindowMeter({
|
||||
[refreshProviderUsage],
|
||||
);
|
||||
|
||||
const geometry = getMeterGeometry(showPercentage);
|
||||
|
||||
// No usage yet: reserve the footprint with a track-only ring while a session is
|
||||
// active so the real ring fades in without shifting siblings. Render nothing when
|
||||
// no usage is expected.
|
||||
if (percentage === null || maxTokens === null || usedTokens === null) {
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={geometry.containerStyle}>
|
||||
<Svg
|
||||
width={geometry.svgSize}
|
||||
height={geometry.svgSize}
|
||||
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={geometry.center}
|
||||
cy={geometry.center}
|
||||
r={geometry.radius}
|
||||
fill="none"
|
||||
stroke={theme.colors.surface3}
|
||||
strokeWidth={geometry.strokeWidth}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? <View style={styles.skeletonLabel} /> : null}
|
||||
</View>
|
||||
);
|
||||
if (percentage === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } = geometry;
|
||||
const { svgSize, center, radius, strokeWidth, circumference, containerStyle } =
|
||||
getMeterGeometry(showPercentage);
|
||||
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
const formattedSessionCost =
|
||||
@@ -262,14 +230,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
skeletonLabel: {
|
||||
width: 22,
|
||||
height: theme.fontSize.sm,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1.5],
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 200,
|
||||
},
|
||||
tooltipTitle: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -15,13 +16,9 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatPrTabLabel,
|
||||
PullRequestPane,
|
||||
PullRequestPaneError,
|
||||
PullRequestPaneSkeleton,
|
||||
PullRequestTabIcon,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
@@ -31,9 +28,8 @@ import {
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -53,29 +49,7 @@ interface ExplorerSidebarProps {
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface ExplorerSidebarSharedState {
|
||||
explorerTab: ExplorerTab;
|
||||
handleTabPress: (tab: ExplorerTab) => void;
|
||||
}
|
||||
|
||||
function useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
}: Pick<ExplorerSidebarProps, "serverId" | "workspaceRoot" | "isGit">): ExplorerSidebarSharedState {
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||
},
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
||||
);
|
||||
|
||||
return { explorerTab, handleTabPress };
|
||||
}
|
||||
|
||||
export function CompactExplorerSidebar({
|
||||
export function ExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
@@ -83,22 +57,40 @@ export function CompactExplorerSidebar({
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
useSidebarAnimation();
|
||||
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: true,
|
||||
enabled: isMobile,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
return;
|
||||
}
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
if (explorerWidth > maxWidth) {
|
||||
setExplorerWidth(maxWidth);
|
||||
}
|
||||
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
||||
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -111,15 +103,23 @@ export function CompactExplorerSidebar({
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
|
||||
// For resize drag, track the starting width
|
||||
const startWidthRef = useRef(explorerWidth);
|
||||
const resizeWidth = useSharedValue(explorerWidth);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(reason: string) => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen,
|
||||
});
|
||||
showMobileAgent();
|
||||
if (isMobile) {
|
||||
showMobileAgent();
|
||||
return;
|
||||
}
|
||||
closeDesktopFileExplorer();
|
||||
},
|
||||
[isOpen, showMobileAgent],
|
||||
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
@@ -128,14 +128,24 @@ export function CompactExplorerSidebar({
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile;
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab });
|
||||
},
|
||||
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
|
||||
);
|
||||
|
||||
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
|
||||
const handleDesktopClose = useCallback(() => handleClose("desktop-close-button"), [handleClose]);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(true)
|
||||
.enabled(enableSidebarCloseGesture)
|
||||
// Use manual activation so child views keep touch streams
|
||||
// unless we detect an intentional right-swipe close.
|
||||
.manualActivation(true)
|
||||
@@ -209,6 +219,7 @@ export function CompactExplorerSidebar({
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
enableSidebarCloseGesture,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -223,6 +234,32 @@ export function CompactExplorerSidebar({
|
||||
],
|
||||
);
|
||||
|
||||
// Desktop resize gesture (drag left edge)
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(!isMobile)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = explorerWidth;
|
||||
resizeWidth.value = explorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Dragging left (negative translationX) increases width
|
||||
const newWidth = startWidthRef.current - event.translationX;
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||
resizeWidth.value = clampedWidth;
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
}),
|
||||
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
@@ -231,6 +268,10 @@ export function CompactExplorerSidebar({
|
||||
opacity: backdropOpacity.value,
|
||||
}));
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
|
||||
const backdropCombinedStyle = useMemo(
|
||||
() => [
|
||||
explorerStaticStyles.backdrop,
|
||||
@@ -271,6 +312,10 @@ export function CompactExplorerSidebar({
|
||||
],
|
||||
[overlayVisible],
|
||||
);
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
|
||||
[resizeAnimatedStyle, insets.top],
|
||||
);
|
||||
|
||||
// Mobile: full-screen overlay with gesture.
|
||||
// On web, keep it interactive only while open so closed sidebars don't eat taps.
|
||||
@@ -279,101 +324,39 @@ export function CompactExplorerSidebar({
|
||||
else if (isOpen) overlayPointerEvents = "auto";
|
||||
else overlayPointerEvents = "none";
|
||||
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
<Animated.View style={backdropCombinedStyle} />
|
||||
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
|
||||
// screens so only the active screen exposes explorer/terminal surfaces.
|
||||
if (!isScreenFocused) {
|
||||
return null;
|
||||
}
|
||||
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isMobile) {
|
||||
return (
|
||||
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
|
||||
{/* Backdrop */}
|
||||
<Animated.View style={backdropCombinedStyle} />
|
||||
|
||||
export function ExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false }));
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const startWidthRef = useRef(explorerWidth);
|
||||
const resizeWidth = useSharedValue(explorerWidth);
|
||||
|
||||
useEffect(() => {
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<SidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleHeaderClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
if (explorerWidth > maxWidth) {
|
||||
setExplorerWidth(maxWidth);
|
||||
}
|
||||
}, [explorerWidth, setExplorerWidth, viewportWidth]);
|
||||
|
||||
const handleDesktopClose = useCallback(() => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason: "desktop-close-button",
|
||||
isOpen,
|
||||
});
|
||||
closeDesktopFileExplorer();
|
||||
}, [closeDesktopFileExplorer, isOpen]);
|
||||
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = explorerWidth;
|
||||
resizeWidth.value = explorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newWidth = startWidthRef.current - event.translationX;
|
||||
const maxWidth = Math.max(
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
|
||||
);
|
||||
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
|
||||
resizeWidth.value = clampedWidth;
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
}),
|
||||
[explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
|
||||
[resizeAnimatedStyle, insets.top],
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: fixed width sidebar with resize handle
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -381,11 +364,12 @@ export function ExplorerSidebar({
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View style={RESIZE_HANDLE_STYLE} />
|
||||
</GestureDetector>
|
||||
|
||||
<ExplorerSidebarContent
|
||||
<SidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleDesktopClose}
|
||||
@@ -443,7 +427,7 @@ interface SidebarContentProps {
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
function ExplorerSidebarContent({
|
||||
function SidebarContent({
|
||||
activeTab,
|
||||
onTabPress,
|
||||
onClose,
|
||||
@@ -457,7 +441,6 @@ function ExplorerSidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
@@ -467,17 +450,11 @@ function ExplorerSidebarContent({
|
||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||
});
|
||||
const hasPullRequest = prPane.prNumber !== null;
|
||||
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
|
||||
const resolvedTab: ExplorerTab =
|
||||
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const handlePrRetry = useCallback(() => {
|
||||
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
@@ -510,7 +487,7 @@ function ExplorerSidebarContent({
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-files"
|
||||
/>
|
||||
{isGit && showPrTab && (
|
||||
{isGit && hasPullRequest && (
|
||||
<ExplorerTabButton
|
||||
tab="pr"
|
||||
active={resolvedTab === "pr"}
|
||||
@@ -554,13 +531,12 @@ function ExplorerSidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && (
|
||||
<PrTabContent
|
||||
{resolvedTab === "pr" && prPane.data && (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceRoot}
|
||||
prPane={prPane}
|
||||
data={prPane.data}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
onRetry={handlePrRetry}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -568,38 +544,6 @@ function ExplorerSidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
interface PrTabContentProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prPane: UsePrPaneDataResult;
|
||||
workspaceAttachmentScopeKey: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function PrTabContent({
|
||||
serverId,
|
||||
cwd,
|
||||
prPane,
|
||||
workspaceAttachmentScopeKey,
|
||||
onRetry,
|
||||
}: PrTabContentProps) {
|
||||
if (prPane.data) {
|
||||
return (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
data={prPane.data}
|
||||
activityLoading={prPane.activityLoading}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prPane.error) {
|
||||
return <PullRequestPaneError onRetry={onRetry} />;
|
||||
}
|
||||
return <PullRequestPaneSkeleton />;
|
||||
}
|
||||
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
|
||||
@@ -35,7 +35,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
|
||||
import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -213,7 +213,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
enabled: isCompactLayout || isOpen,
|
||||
});
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel({ projects });
|
||||
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
activeServerId ? state.getGroupMode(activeServerId) : "project",
|
||||
@@ -1096,7 +1096,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<SidebarDisplayPreferencesMenu serverId={serverId} />
|
||||
<SidebarGroupingSelector serverId={serverId} />
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
resolveProviderDiscoveredModels,
|
||||
type ProviderDiscoveredModelsCache,
|
||||
} from "./provider-diagnostic-models";
|
||||
|
||||
const piModel: AgentModelDefinition = {
|
||||
provider: "pi",
|
||||
id: "pi/model",
|
||||
label: "Pi Model",
|
||||
};
|
||||
|
||||
const grokModel: AgentModelDefinition = {
|
||||
provider: "grok",
|
||||
id: "grok-build",
|
||||
label: "Grok Build",
|
||||
};
|
||||
|
||||
function resolveModels(input: {
|
||||
serverId?: string;
|
||||
provider: string;
|
||||
currentModels?: AgentModelDefinition[];
|
||||
loading?: boolean;
|
||||
cache?: ProviderDiscoveredModelsCache | null;
|
||||
}) {
|
||||
return resolveProviderDiscoveredModels({
|
||||
serverId: input.serverId ?? "local",
|
||||
provider: input.provider,
|
||||
currentModels: input.currentModels,
|
||||
providerSnapshotRefreshing: input.loading === true,
|
||||
previousCache: input.cache ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveProviderDiscoveredModels", () => {
|
||||
it("keeps a provider's cached discovered models visible while that provider refreshes", () => {
|
||||
const ready = resolveModels({ provider: "grok", currentModels: [grokModel] });
|
||||
|
||||
const refreshing = resolveModels({ provider: "grok", loading: true, cache: ready.cache });
|
||||
|
||||
expect(refreshing.models).toEqual([grokModel]);
|
||||
});
|
||||
|
||||
it("does not show one provider's cached models while another provider loads", () => {
|
||||
const ready = resolveModels({ provider: "pi", currentModels: [piModel] });
|
||||
|
||||
const refreshing = resolveModels({ provider: "grok", loading: true, cache: ready.cache });
|
||||
|
||||
expect(refreshing.models).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not show another server's cached models while the same provider loads", () => {
|
||||
const ready = resolveModels({
|
||||
serverId: "server-a",
|
||||
provider: "grok",
|
||||
currentModels: [grokModel],
|
||||
});
|
||||
|
||||
const refreshing = resolveModels({
|
||||
serverId: "server-b",
|
||||
provider: "grok",
|
||||
loading: true,
|
||||
cache: ready.cache,
|
||||
});
|
||||
|
||||
expect(refreshing.models).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface ProviderDiscoveredModelsCache {
|
||||
serverId: string;
|
||||
provider: string;
|
||||
models: AgentModelDefinition[];
|
||||
}
|
||||
|
||||
export interface ResolveProviderDiscoveredModelsInput {
|
||||
serverId: string;
|
||||
provider: string;
|
||||
currentModels: AgentModelDefinition[] | undefined;
|
||||
providerSnapshotRefreshing: boolean;
|
||||
previousCache: ProviderDiscoveredModelsCache | null;
|
||||
}
|
||||
|
||||
export interface ResolveProviderDiscoveredModelsResult {
|
||||
models: AgentModelDefinition[];
|
||||
cache: ProviderDiscoveredModelsCache | null;
|
||||
}
|
||||
|
||||
export function resolveProviderDiscoveredModels({
|
||||
serverId,
|
||||
provider,
|
||||
currentModels,
|
||||
providerSnapshotRefreshing,
|
||||
previousCache,
|
||||
}: ResolveProviderDiscoveredModelsInput): ResolveProviderDiscoveredModelsResult {
|
||||
if (currentModels && currentModels.length > 0) {
|
||||
const cache = { serverId, provider, models: currentModels };
|
||||
return { models: currentModels, cache };
|
||||
}
|
||||
|
||||
if (
|
||||
providerSnapshotRefreshing &&
|
||||
previousCache?.serverId === serverId &&
|
||||
previousCache.provider === provider
|
||||
) {
|
||||
return { models: previousCache.models, cache: previousCache };
|
||||
}
|
||||
|
||||
return { models: [], cache: previousCache };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
type PressableStateCallbackType,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -32,10 +32,6 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
||||
import {
|
||||
resolveProviderDiscoveredModels,
|
||||
type ProviderDiscoveredModelsCache,
|
||||
} from "./provider-diagnostic-models";
|
||||
|
||||
interface ProviderDiagnosticSheetProps {
|
||||
provider: string;
|
||||
@@ -363,26 +359,26 @@ function DiagnosticSubSheet({
|
||||
let body: React.ReactNode;
|
||||
if (loading && !diagnostic) {
|
||||
body = (
|
||||
<SurfaceCard key={visible ? "visible" : "hidden"}>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
|
||||
</View>
|
||||
</SurfaceCard>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (diagnostic) {
|
||||
body = (
|
||||
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={480}>
|
||||
{diagnostic}
|
||||
</ScrollableCodeSurface>
|
||||
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator>
|
||||
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<SurfaceCard key={visible ? "visible" : "hidden"}>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
|
||||
</View>
|
||||
</SurfaceCard>
|
||||
<View style={sheetStyles.codeBlockLoading}>
|
||||
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,7 +391,7 @@ function DiagnosticSubSheet({
|
||||
scrollable={false}
|
||||
testID="provider-diagnostic-sheet"
|
||||
>
|
||||
{body}
|
||||
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -603,16 +599,14 @@ export function ProviderDiagnosticSheet({
|
||||
: null;
|
||||
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
|
||||
|
||||
const stableDiscoveredRef = useRef<ProviderDiscoveredModelsCache | null>(null);
|
||||
const currentModels = providerEntry?.models;
|
||||
const { models: discoveredModels, cache: nextDiscoveredCache } = resolveProviderDiscoveredModels({
|
||||
serverId,
|
||||
provider,
|
||||
currentModels,
|
||||
providerSnapshotRefreshing,
|
||||
previousCache: stableDiscoveredRef.current,
|
||||
});
|
||||
stableDiscoveredRef.current = nextDiscoveredCache;
|
||||
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
|
||||
if (providerEntry?.models && providerEntry.models.length > 0) {
|
||||
stableDiscoveredRef.current = providerEntry.models;
|
||||
}
|
||||
const discoveredModels =
|
||||
providerEntry?.models && providerEntry.models.length > 0
|
||||
? providerEntry.models
|
||||
: stableDiscoveredRef.current;
|
||||
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -862,6 +856,22 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
diagnosticCard: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
codeScroll: {
|
||||
maxHeight: 480,
|
||||
},
|
||||
codeContent: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
},
|
||||
codeBlockLoading: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
@@ -877,3 +887,4 @@ const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFo
|
||||
const MAIN_SNAP_POINTS = ["65%", "92%"];
|
||||
const ADD_SNAP_POINTS = ["40%"];
|
||||
const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"];
|
||||
const DIAGNOSTIC_CARD_STYLE = [settingsStyles.card, sheetStyles.diagnosticCard];
|
||||
|
||||
@@ -18,7 +18,6 @@ export function ProviderSettingsHost() {
|
||||
|
||||
return (
|
||||
<ProviderDiagnosticSheet
|
||||
key={`${serverId}:${provider}`}
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={visible}
|
||||
|
||||
+10
-52
@@ -8,11 +8,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
|
||||
const ThemedSettings2 = withUnistyles(Settings2);
|
||||
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -22,25 +20,11 @@ const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
|
||||
{ value: "status", label: "Status" },
|
||||
];
|
||||
|
||||
const WORKSPACE_TITLE_SOURCE_ITEMS: Array<{ value: WorkspaceTitleSource; label: string }> = [
|
||||
{ value: "title", label: "Title" },
|
||||
{ value: "branch", label: "Branch name" },
|
||||
];
|
||||
|
||||
interface DisplayPreferenceOption<Value extends string> {
|
||||
value: Value;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) {
|
||||
export function SidebarGroupingSelector({ serverId }: { serverId: string | null }) {
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
serverId ? state.getGroupMode(serverId) : "project",
|
||||
);
|
||||
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
updateSettings,
|
||||
} = useAppSettings();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(mode: SidebarGroupMode) => {
|
||||
@@ -50,13 +34,6 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
|
||||
[serverId, setGroupMode],
|
||||
);
|
||||
|
||||
const handleWorkspaceTitleSourceSelect = useCallback(
|
||||
(source: WorkspaceTitleSource) => {
|
||||
void updateSettings({ workspaceTitleSource: source });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
@@ -70,61 +47,45 @@ export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string |
|
||||
<DropdownMenuTrigger
|
||||
style={triggerStyle}
|
||||
accessibilityRole={platformIsWeb ? undefined : "button"}
|
||||
accessibilityLabel="Display preferences"
|
||||
testID="sidebar-display-preferences-menu"
|
||||
accessibilityLabel="Sidebar grouping"
|
||||
testID="sidebar-grouping-selector"
|
||||
>
|
||||
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
|
||||
<DropdownMenuContent align="start" width={180} testID="sidebar-grouping-menu">
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Group by</Text>
|
||||
</View>
|
||||
{GROUP_MODE_ITEMS.map((item) => (
|
||||
<DisplayPreferenceMenuItem
|
||||
<GroupModeMenuItem
|
||||
key={item.value}
|
||||
item={item}
|
||||
isSelected={groupMode === item.value}
|
||||
testIDPrefix="sidebar-grouping"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<View style={styles.menuHeader}>
|
||||
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
|
||||
</View>
|
||||
{WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => (
|
||||
<DisplayPreferenceMenuItem
|
||||
key={item.value}
|
||||
item={item}
|
||||
isSelected={workspaceTitleSource === item.value}
|
||||
testIDPrefix="sidebar-workspace-title-source"
|
||||
onSelect={handleWorkspaceTitleSourceSelect}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayPreferenceMenuItem<Value extends string>({
|
||||
function GroupModeMenuItem({
|
||||
item,
|
||||
isSelected,
|
||||
testIDPrefix,
|
||||
onSelect,
|
||||
}: {
|
||||
item: DisplayPreferenceOption<Value>;
|
||||
item: { value: SidebarGroupMode; label: string };
|
||||
isSelected: boolean;
|
||||
testIDPrefix: string;
|
||||
onSelect: (value: Value) => void;
|
||||
onSelect: (mode: SidebarGroupMode) => void;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`${testIDPrefix}-${item.value}`}
|
||||
testID={`sidebar-grouping-${item.value}`}
|
||||
selected={isSelected}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
<Text style={styles.optionLabel}>{item.label}</Text>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +110,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -22,14 +22,12 @@ import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { SyncedLoader } from "@/components/synced-loader";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -112,10 +110,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
showShortcutBadge?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
settings: { workspaceTitleSource },
|
||||
} = useAppSettings();
|
||||
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
|
||||
const workspaceBranchTextStyle = useMemo(
|
||||
() => [
|
||||
styles.workspaceBranchText,
|
||||
@@ -138,7 +132,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
<View style={styles.workspaceTitleRow}>
|
||||
<View style={styles.workspaceTitleLeft}>
|
||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
||||
{workspaceLabel}
|
||||
{workspace.name}
|
||||
</Text>
|
||||
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
|
||||
</View>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
|
||||
describe("resolveSidebarWorkspacePrimaryLabel", () => {
|
||||
it("uses the workspace name in title mode", () => {
|
||||
const label = resolveSidebarWorkspacePrimaryLabel({
|
||||
workspace: { name: "Investigate search", currentBranch: "fix/search" },
|
||||
workspaceTitleSource: "title",
|
||||
});
|
||||
|
||||
expect(label).toBe("Investigate search");
|
||||
});
|
||||
|
||||
it("uses the branch name in branch mode", () => {
|
||||
const label = resolveSidebarWorkspacePrimaryLabel({
|
||||
workspace: { name: "Investigate search", currentBranch: "fix/search" },
|
||||
workspaceTitleSource: "branch",
|
||||
});
|
||||
|
||||
expect(label).toBe("fix/search");
|
||||
});
|
||||
|
||||
it("falls back to the workspace name in branch mode without a branch", () => {
|
||||
const label = resolveSidebarWorkspacePrimaryLabel({
|
||||
workspace: { name: "Local folder", currentBranch: null },
|
||||
workspaceTitleSource: "branch",
|
||||
});
|
||||
|
||||
expect(label).toBe("Local folder");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
|
||||
|
||||
export function resolveSidebarWorkspacePrimaryLabel(input: {
|
||||
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
|
||||
workspaceTitleSource: WorkspaceTitleSource;
|
||||
}): string {
|
||||
if (input.workspaceTitleSource === "branch") {
|
||||
return input.workspace.currentBranch ?? input.workspace.name;
|
||||
}
|
||||
return input.workspace.name;
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
type CodeSurfaceTone = "surface0" | "surface1" | "surface2";
|
||||
|
||||
interface ScrollableCodeSurfaceProps {
|
||||
children: ReactNode;
|
||||
maxHeight?: number;
|
||||
horizontal?: boolean;
|
||||
selectable?: boolean;
|
||||
tone?: CodeSurfaceTone;
|
||||
bordered?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
scrollStyle?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
interface SurfaceCardProps {
|
||||
children: ReactNode;
|
||||
tone?: CodeSurfaceTone;
|
||||
bordered?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
accessibilityLabel?: string;
|
||||
codeSurface?: boolean;
|
||||
}
|
||||
|
||||
export function SurfaceCard({
|
||||
children,
|
||||
tone = "surface1",
|
||||
bordered = true,
|
||||
style,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
codeSurface = false,
|
||||
}: SurfaceCardProps) {
|
||||
const surfaceStyle = getSurfaceStyle(tone);
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, surfaceStyle, bordered && styles.bordered, style],
|
||||
[bordered, style, surfaceStyle],
|
||||
);
|
||||
|
||||
const surface = (
|
||||
<View
|
||||
style={containerStyle}
|
||||
dataSet={codeSurface ? CODE_SURFACE_DATASET : undefined}
|
||||
testID={testID}
|
||||
accessible={accessibilityLabel != null}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return <AppearanceStyleBoundary>{surface}</AppearanceStyleBoundary>;
|
||||
}
|
||||
|
||||
export function ScrollableCodeSurface({
|
||||
children,
|
||||
maxHeight,
|
||||
horizontal = true,
|
||||
selectable = true,
|
||||
tone = "surface1",
|
||||
bordered = true,
|
||||
style,
|
||||
scrollStyle,
|
||||
contentContainerStyle,
|
||||
textStyle,
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
}: ScrollableCodeSurfaceProps) {
|
||||
const maxHeightStyle = useMemo(
|
||||
() => (maxHeight == null ? null : inlineUnistylesStyle({ maxHeight })),
|
||||
[maxHeight],
|
||||
);
|
||||
const outerScrollStyle = useMemo(
|
||||
() => [maxHeightStyle, scrollStyle],
|
||||
[maxHeightStyle, scrollStyle],
|
||||
);
|
||||
const contentStyle = useMemo(
|
||||
() => [styles.content, contentContainerStyle],
|
||||
[contentContainerStyle],
|
||||
);
|
||||
const codeTextStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
|
||||
const codeContent =
|
||||
typeof children === "string" ? (
|
||||
<PlainCodeText text={children} selectable={selectable} textStyle={codeTextStyle} />
|
||||
) : (
|
||||
<Text selectable={selectable} style={codeTextStyle} dataSet={CODE_SURFACE_DATASET}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return (
|
||||
<SurfaceCard
|
||||
tone={tone}
|
||||
bordered={bordered}
|
||||
style={style}
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
codeSurface
|
||||
>
|
||||
<ScrollView
|
||||
style={outerScrollStyle}
|
||||
contentContainerStyle={contentStyle}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{horizontal ? (
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
{codeContent}
|
||||
</ScrollView>
|
||||
) : (
|
||||
codeContent
|
||||
)}
|
||||
</ScrollView>
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
function PlainCodeText({
|
||||
text,
|
||||
selectable,
|
||||
textStyle,
|
||||
}: {
|
||||
text: string;
|
||||
selectable: boolean;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
}) {
|
||||
const lines = useMemo(() => {
|
||||
let offset = 0;
|
||||
return text.split("\n").map((line) => {
|
||||
const key = `${offset}:${line.length}`;
|
||||
offset += line.length + 1;
|
||||
return { key, line };
|
||||
});
|
||||
}, [text]);
|
||||
return (
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{lines.map(({ key, line }) => (
|
||||
<Text key={key} selectable={selectable} style={textStyle} dataSet={CODE_SURFACE_DATASET}>
|
||||
{line.length === 0 ? "\u200B" : line}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp<ViewStyle> {
|
||||
switch (tone) {
|
||||
case "surface0":
|
||||
return styles.surface0;
|
||||
case "surface2":
|
||||
return styles.surface2;
|
||||
case "surface1":
|
||||
return styles.surface1;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
bordered: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
surface0: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
surface1: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
surface2: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
text: {
|
||||
backgroundColor: "transparent",
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
...(isWeb
|
||||
? {
|
||||
whiteSpace: "pre",
|
||||
overflowWrap: "normal",
|
||||
}
|
||||
: null),
|
||||
},
|
||||
}));
|
||||
@@ -16,22 +16,14 @@ import { Bot, ShieldAlert, ShieldCheck, ShieldOff, ShieldQuestionMark } from "lu
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
|
||||
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
@@ -113,10 +105,7 @@ function AgentModeControlView({
|
||||
}: AgentModeControlViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
|
||||
const anchorRef = useRef<View>(null);
|
||||
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -156,26 +145,6 @@ function AgentModeControlView({
|
||||
[onSelectMode, handleOpenChange],
|
||||
);
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (action.id !== "message-input.mode-cycle") return false;
|
||||
if (disabled || !isActiveComposer) return false;
|
||||
const nextModeId = resolveNextAgentModeId({ modeOptions, selectedMode: selectedModeId });
|
||||
if (!nextModeId) return false;
|
||||
onSelectMode(nextModeId);
|
||||
return true;
|
||||
},
|
||||
[disabled, isActiveComposer, modeOptions, onSelectMode, selectedModeId],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: keyboardHandlerIdRef.current,
|
||||
actions: ["message-input.mode-cycle"],
|
||||
enabled: isActiveComposer && !disabled && modeOptions.length > 1,
|
||||
priority: 200,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
const renderOption = useCallback(
|
||||
(args: {
|
||||
option: ComboboxOption;
|
||||
@@ -224,31 +193,21 @@ function AgentModeControlView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("mode"))}</Text>
|
||||
{isActiveComposer && cycleShortcutKeys ? <Shortcut chord={cycleShortcutKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedMode.id}
|
||||
@@ -302,7 +261,6 @@ export const AgentModeControl = memo(function AgentModeControl({
|
||||
compareAvailableModes,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const { updatePreferences } = useFormPreferences();
|
||||
const toast = useToast();
|
||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
|
||||
|
||||
@@ -314,18 +272,7 @@ export const AgentModeControl = memo(function AgentModeControl({
|
||||
|
||||
const handleSelectMode = useCallback(
|
||||
(modeId: string) => {
|
||||
if (!client || !slice?.provider) return;
|
||||
void updatePreferences((current) =>
|
||||
mergeProviderPreferences({
|
||||
preferences: current,
|
||||
provider: slice.provider,
|
||||
updates: {
|
||||
mode: modeId || undefined,
|
||||
},
|
||||
}),
|
||||
).catch((error) => {
|
||||
console.warn("[AgentModeControl] persist mode preference failed", error);
|
||||
});
|
||||
if (!client) return;
|
||||
void client
|
||||
.setAgentMode(agentId, modeId)
|
||||
.then((notice) => showProviderNoticeToast(toast, notice))
|
||||
@@ -334,7 +281,7 @@ export const AgentModeControl = memo(function AgentModeControl({
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
},
|
||||
[agentId, client, slice?.provider, toast, updatePreferences],
|
||||
[agentId, client, toast],
|
||||
);
|
||||
|
||||
if (!slice || availableModes.length === 0) return null;
|
||||
@@ -413,13 +360,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
import { resolveAgentControlsMode, resolveNextAgentModeId } from "./mode";
|
||||
|
||||
const PLAN_MODE = { id: "plan", label: "Plan" } satisfies AgentMode;
|
||||
|
||||
const MODES = [
|
||||
PLAN_MODE,
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "full-access", label: "Full Access" },
|
||||
] satisfies AgentMode[];
|
||||
import { resolveAgentControlsMode } from "./mode";
|
||||
|
||||
describe("resolveAgentControlsMode", () => {
|
||||
it("uses ready mode when no controlled agent controls are provided", () => {
|
||||
@@ -38,32 +29,3 @@ describe("resolveAgentControlsMode", () => {
|
||||
).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNextAgentModeId", () => {
|
||||
it("cycles from the selected mode to the next mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "build" })).toBe(
|
||||
"full-access",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps from the last mode to the first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "full-access" })).toBe(
|
||||
"plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an empty selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "" })).toBe("build");
|
||||
});
|
||||
|
||||
it("treats a stale selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "deleted-mode" })).toBe(
|
||||
"build",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when there are fewer than two modes", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: [], selectedMode: "" })).toBeNull();
|
||||
expect(resolveNextAgentModeId({ modeOptions: [PLAN_MODE], selectedMode: "plan" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export function resolveNextAgentModeId({
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
}: {
|
||||
modeOptions: readonly AgentMode[];
|
||||
selectedMode: string | null | undefined;
|
||||
}): string | null {
|
||||
if (modeOptions.length < 2) return null;
|
||||
|
||||
const selectedIndex = modeOptions.findIndex((mode) => mode.id === selectedMode);
|
||||
const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
const nextIndex = (currentIndex + 1) % modeOptions.length;
|
||||
return modeOptions[nextIndex]?.id ?? null;
|
||||
}
|
||||
|
||||
export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) {
|
||||
return agentControls ? "draft" : "ready";
|
||||
|
||||
@@ -89,7 +89,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
@@ -210,10 +209,8 @@ function renderContextWindowMeter(
|
||||
showPercentage: boolean,
|
||||
serverId: string,
|
||||
provider: string | null,
|
||||
pending: boolean,
|
||||
): ReactElement | null {
|
||||
const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null;
|
||||
if (!hasData && !pending) {
|
||||
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
@@ -224,7 +221,6 @@ function renderContextWindowMeter(
|
||||
showPercentage={showPercentage}
|
||||
serverId={serverId}
|
||||
provider={provider}
|
||||
pending={pending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1646,9 +1642,6 @@ export function Composer({
|
||||
agentState.contextWindowUsedTokens,
|
||||
);
|
||||
|
||||
const contextWindowPending =
|
||||
agentState.status === "initializing" || agentState.status === "running";
|
||||
|
||||
const contextWindowMeter = useMemo(
|
||||
() =>
|
||||
renderContextWindowMeter(
|
||||
@@ -1658,7 +1651,6 @@ export function Composer({
|
||||
isCompactLayout,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
),
|
||||
[
|
||||
contextWindowMaxTokens,
|
||||
@@ -1667,7 +1659,6 @@ export function Composer({
|
||||
isCompactLayout,
|
||||
serverId,
|
||||
agentState.provider,
|
||||
contextWindowPending,
|
||||
],
|
||||
);
|
||||
const { beforeVoiceContent, footerInlineContent } = useMemo(
|
||||
@@ -1747,15 +1738,8 @@ export function Composer({
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() =>
|
||||
renderLeftContent({
|
||||
agentControls,
|
||||
agentId,
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, serverId],
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
|
||||
[agentId, focusInput, serverId, agentControls, isCompactLayout],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -1868,92 +1852,90 @@ export function Composer({
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
|
||||
interface ComposerKeyboardScopeValue {
|
||||
isActiveComposer: boolean;
|
||||
}
|
||||
|
||||
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
|
||||
isActiveComposer: false,
|
||||
});
|
||||
|
||||
export function ComposerKeyboardScopeProvider({
|
||||
isActiveComposer,
|
||||
children,
|
||||
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
|
||||
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
|
||||
return (
|
||||
<ComposerKeyboardScopeContext.Provider value={value}>
|
||||
{children}
|
||||
</ComposerKeyboardScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposerKeyboardScope(): ComposerKeyboardScopeValue {
|
||||
return useContext(ComposerKeyboardScopeContext);
|
||||
}
|
||||
@@ -81,39 +81,6 @@ describe("create agent preferences", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not erase a saved mode when a later partial update has no mode", () => {
|
||||
expect(
|
||||
mergeProviderPreferences({
|
||||
preferences: {
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.5",
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.5": "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "codex",
|
||||
updates: {
|
||||
model: "gpt-5.6",
|
||||
mode: undefined,
|
||||
thinkingByModel: undefined,
|
||||
featureValues: undefined,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
model: "gpt-5.6",
|
||||
mode: "full-access",
|
||||
thinkingByModel: { "gpt-5.5": "high" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads invalid stored preferences as empty preferences", () => {
|
||||
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
|
||||
});
|
||||
|
||||
@@ -46,43 +46,6 @@ export function parseFormPreferences(value: unknown): FormPreferences {
|
||||
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
|
||||
}
|
||||
|
||||
function mergeDefinedRecord<T>(
|
||||
existing: Record<string, T> | undefined,
|
||||
updates: Record<string, T> | undefined,
|
||||
): Record<string, T> | undefined {
|
||||
if (updates === undefined) {
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
};
|
||||
}
|
||||
|
||||
function applyProviderPreferenceUpdates(
|
||||
existing: ProviderPreferences,
|
||||
updates: Partial<ProviderPreferences>,
|
||||
): ProviderPreferences {
|
||||
const next: ProviderPreferences = { ...existing };
|
||||
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
|
||||
const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues);
|
||||
|
||||
if (updates.model !== undefined) {
|
||||
next.model = updates.model;
|
||||
}
|
||||
if (updates.mode !== undefined) {
|
||||
next.mode = updates.mode;
|
||||
}
|
||||
if (nextThinkingByModel !== undefined) {
|
||||
next.thinkingByModel = nextThinkingByModel;
|
||||
}
|
||||
if (nextFeatureValues !== undefined) {
|
||||
next.featureValues = nextFeatureValues;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function mergeProviderPreferences(args: {
|
||||
preferences: FormPreferences;
|
||||
provider: AgentProvider;
|
||||
@@ -91,13 +54,32 @@ export function mergeProviderPreferences(args: {
|
||||
const { preferences, provider, updates } = args;
|
||||
const existingProviderPreferences = preferences.providerPreferences ?? {};
|
||||
const existing = existingProviderPreferences[provider] ?? {};
|
||||
const nextThinkingByModel =
|
||||
updates.thinkingByModel === undefined
|
||||
? existing.thinkingByModel
|
||||
: {
|
||||
...existing.thinkingByModel,
|
||||
...updates.thinkingByModel,
|
||||
};
|
||||
const nextFeatureValues =
|
||||
updates.featureValues === undefined
|
||||
? existing.featureValues
|
||||
: {
|
||||
...existing.featureValues,
|
||||
...updates.featureValues,
|
||||
};
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
provider,
|
||||
providerPreferences: {
|
||||
...existingProviderPreferences,
|
||||
[provider]: applyProviderPreferenceUpdates(existing, updates),
|
||||
[provider]: {
|
||||
...existing,
|
||||
...updates,
|
||||
...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}),
|
||||
...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
|
||||
title: "Auggie CLI",
|
||||
description:
|
||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||
version: "0.30.0",
|
||||
version: "0.29.0",
|
||||
iconId: "auggie",
|
||||
installLink: "https://www.augmentcode.com/",
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.30.0", "--acp"],
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.29.0", "--acp"],
|
||||
env: {
|
||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.29",
|
||||
version: "3.0.27",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.29", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.27", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
title: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
version: "2.110.0",
|
||||
version: "2.108.2",
|
||||
iconId: "codebuddy-code",
|
||||
installLink: "https://www.codebuddy.cn/cli/",
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.110.0", "--acp"],
|
||||
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.108.2", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codewhale",
|
||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.11",
|
||||
version: "0.2.7",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.11", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.7", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.4.9",
|
||||
version: "0.4.1",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.9", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.1", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.158.0",
|
||||
version: "0.151.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.158.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.151.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.7.22",
|
||||
version: "0.7.20",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.7.22", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.7.20", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.21",
|
||||
version: "1.1.18",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.21", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.18", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.0.30",
|
||||
version: "1.0.24",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.30", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.24", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.19.2",
|
||||
version: "0.18.3",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.2", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.3", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatHostRuntimeSection, redactAppDiagnosticReport } from "./app-diagnostic-report";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
function makeHost(): HostProfile {
|
||||
return {
|
||||
serverId: "srv-secret",
|
||||
label: "Secret host",
|
||||
lifecycle: {},
|
||||
preferredConnectionId: "direct:secret.example.test:6767",
|
||||
createdAt: "2026-06-25T00:00:00.000Z",
|
||||
updatedAt: "2026-06-25T00:00:00.000Z",
|
||||
connections: [
|
||||
{
|
||||
id: "direct:secret.example.test:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "secret.example.test:6767",
|
||||
useTls: true,
|
||||
password: "tcp-password",
|
||||
},
|
||||
{
|
||||
id: "relay:relay.secret.test:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.secret.test:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "daemon-public-key-secret",
|
||||
},
|
||||
{
|
||||
id: "socket:/tmp/paseo-secret.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo-secret.sock",
|
||||
},
|
||||
{
|
||||
id: "pipe:\\\\.\\pipe\\paseo-secret",
|
||||
type: "directPipe",
|
||||
path: "\\\\.\\pipe\\paseo-secret",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("app diagnostics report", () => {
|
||||
test("formats connection rows without raw connection details", () => {
|
||||
const host = makeHost();
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
serverId: host.serverId,
|
||||
activeConnectionId: "relay:relay.secret.test:443",
|
||||
activeConnection: {
|
||||
type: "relay",
|
||||
endpoint: "relay.secret.test:443",
|
||||
display: "relay",
|
||||
},
|
||||
connectionStatus: "online",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: "2026-06-25T00:00:00.000Z",
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
probeByConnectionId: new Map([
|
||||
["direct:secret.example.test:6767", { status: "available", latencyMs: 42 }],
|
||||
["relay:relay.secret.test:443", { status: "available", latencyMs: 8 }],
|
||||
]),
|
||||
clientGeneration: 1,
|
||||
};
|
||||
|
||||
const report = formatHostRuntimeSection({ host, snapshot });
|
||||
|
||||
expect(report).toContain("direct TCP");
|
||||
expect(report).toContain("relay");
|
||||
expect(report).toContain("local socket");
|
||||
expect(report).toContain("local pipe");
|
||||
expect(report).not.toContain("secret.example.test");
|
||||
expect(report).not.toContain("relay.secret.test");
|
||||
expect(report).not.toContain("daemon-public-key-secret");
|
||||
expect(report).not.toContain("/tmp/paseo-secret.sock");
|
||||
expect(report).not.toContain("tcp-password");
|
||||
});
|
||||
|
||||
test("redacts saved connection secrets from collected daemon and desktop text", () => {
|
||||
const host = makeHost();
|
||||
const redacted = redactAppDiagnosticReport(
|
||||
[
|
||||
"secret.example.test:6767",
|
||||
"relay.secret.test:443",
|
||||
"daemon-public-key-secret",
|
||||
"/tmp/paseo-secret.sock",
|
||||
"\\\\.\\pipe\\paseo-secret",
|
||||
"password=tcp-password",
|
||||
"paseo://pairing-secret",
|
||||
].join("\n"),
|
||||
[host],
|
||||
);
|
||||
|
||||
expect(redacted).not.toContain("secret.example.test");
|
||||
expect(redacted).not.toContain("relay.secret.test");
|
||||
expect(redacted).not.toContain("daemon-public-key-secret");
|
||||
expect(redacted).not.toContain("/tmp/paseo-secret.sock");
|
||||
expect(redacted).not.toContain("\\\\.\\pipe\\paseo-secret");
|
||||
expect(redacted).not.toContain("tcp-password");
|
||||
expect(redacted).not.toContain("pairing-secret");
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
|
||||
interface DiagnosticEntry {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function formatDiagnosticSection(title: string, entries: DiagnosticEntry[]): string {
|
||||
return [title, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n");
|
||||
}
|
||||
|
||||
export function formatAppDiagnosticHeader(input: {
|
||||
appVersion: string | null;
|
||||
platform: string;
|
||||
isDesktopApp: boolean;
|
||||
hostCount: number;
|
||||
}): string {
|
||||
return formatDiagnosticSection("Paseo app diagnostics", [
|
||||
{ label: "Collected at", value: new Date().toISOString() },
|
||||
{ label: "App version", value: input.appVersion ?? "unknown" },
|
||||
{ label: "Platform", value: input.platform },
|
||||
{ label: "Desktop app", value: String(input.isDesktopApp) },
|
||||
{ label: "Saved hosts", value: String(input.hostCount) },
|
||||
]);
|
||||
}
|
||||
|
||||
export function formatHostRuntimeSection(input: {
|
||||
host: HostProfile;
|
||||
snapshot: HostRuntimeSnapshot | null;
|
||||
}): string {
|
||||
const { host, snapshot } = input;
|
||||
const entries: DiagnosticEntry[] = [
|
||||
{ label: "Server ID", value: host.serverId },
|
||||
{ label: "Status", value: snapshot?.connectionStatus ?? "not started" },
|
||||
{
|
||||
label: "Active connection",
|
||||
value: snapshot?.activeConnection
|
||||
? describeConnectionKind(snapshot.activeConnection.type)
|
||||
: "none",
|
||||
},
|
||||
{ label: "Last online", value: snapshot?.lastOnlineAt ?? "never" },
|
||||
{ label: "Last error", value: snapshot?.lastError ?? "none" },
|
||||
{
|
||||
label: "Agent directory",
|
||||
value: snapshot?.agentDirectoryStatus ?? "unknown",
|
||||
},
|
||||
];
|
||||
|
||||
const connectionRows = host.connections.map((connection, index) => {
|
||||
const probe = snapshot?.probeByConnectionId.get(connection.id) ?? null;
|
||||
const isActive = snapshot?.activeConnectionId === connection.id;
|
||||
return {
|
||||
label: `Connection ${index + 1}`,
|
||||
value: [
|
||||
describeConnectionKind(connection.type),
|
||||
isActive ? "active" : "inactive",
|
||||
probe ? `probe=${probe.status}` : "probe=unknown",
|
||||
probe?.status === "available" ? `latency=${Math.round(probe.latencyMs)}ms` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
};
|
||||
});
|
||||
|
||||
return formatDiagnosticSection(`Host: ${host.label}`, [...entries, ...connectionRows]);
|
||||
}
|
||||
|
||||
export function formatServerInfoSection(serverInfo: ServerInfoStatusPayload | null): string {
|
||||
if (!serverInfo) {
|
||||
return formatDiagnosticSection("Server info", [{ label: "Status", value: "not received" }]);
|
||||
}
|
||||
|
||||
const features = serverInfo.features ? Object.keys(serverInfo.features).sort() : [];
|
||||
return formatDiagnosticSection("Server info", [
|
||||
{ label: "Server ID", value: serverInfo.serverId },
|
||||
{ label: "Hostname", value: serverInfo.hostname ?? "unknown" },
|
||||
{ label: "Version", value: serverInfo.version ?? "unknown" },
|
||||
{ label: "Features", value: features.length > 0 ? features.join(", ") : "none" },
|
||||
]);
|
||||
}
|
||||
|
||||
export function describeConnectionKind(type: HostConnection["type"] | string): string {
|
||||
switch (type) {
|
||||
case "directTcp":
|
||||
return "direct TCP";
|
||||
case "directSocket":
|
||||
return "local socket";
|
||||
case "directPipe":
|
||||
return "local pipe";
|
||||
case "relay":
|
||||
return "relay";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]): string {
|
||||
let redacted = report;
|
||||
for (const value of collectSensitiveHostValues(hosts)) {
|
||||
redacted = redacted.split(value).join("[redacted]");
|
||||
}
|
||||
return redacted
|
||||
.replace(/paseo:\/\/\S+/gi, "paseo://[redacted]")
|
||||
.replace(
|
||||
/([?&](?:password|token|secret|key|publicKey|daemonPublicKeyB64)=)[^&\s"']+/gi,
|
||||
"$1[redacted]",
|
||||
)
|
||||
.replace(
|
||||
/((?:password|token|secret|authorization|api[_-]?key|daemonPublicKeyB64|relayKey)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)/gi,
|
||||
"$1[redacted]",
|
||||
);
|
||||
}
|
||||
|
||||
function collectSensitiveHostValues(hosts: HostProfile[]): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
for (const connection of host.connections) {
|
||||
values.add(connection.id);
|
||||
if (connection.type === "directTcp") {
|
||||
values.add(connection.endpoint);
|
||||
if (connection.password) values.add(connection.password);
|
||||
} else if (connection.type === "relay") {
|
||||
values.add(connection.relayEndpoint);
|
||||
values.add(connection.daemonPublicKeyB64);
|
||||
} else if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
values.add(connection.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...values].filter((value) => value.trim().length > 0);
|
||||
}
|
||||
@@ -122,7 +122,6 @@ function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
|
||||
|
||||
interface HighlightedTextProps {
|
||||
tokens: HighlightToken[];
|
||||
textMetricsStyle: TextStyle;
|
||||
wrapLines?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
@@ -141,37 +140,14 @@ function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefine
|
||||
: { whiteSpace: "pre", overflowWrap: "normal" };
|
||||
}
|
||||
|
||||
function getNumericLineHeight(textMetricsStyle: TextStyle): number | undefined {
|
||||
const { lineHeight } = textMetricsStyle;
|
||||
return typeof lineHeight === "number" && Number.isFinite(lineHeight) ? lineHeight : undefined;
|
||||
}
|
||||
|
||||
function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp<ViewStyle> {
|
||||
const lineHeight = getNumericLineHeight(textMetricsStyle);
|
||||
return useMemo(
|
||||
() => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null),
|
||||
[lineHeight],
|
||||
);
|
||||
}
|
||||
|
||||
function HighlightedToken({ token }: { token: HighlightToken }) {
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
}
|
||||
|
||||
function HighlightedText({
|
||||
tokens,
|
||||
textMetricsStyle,
|
||||
wrapLines = false,
|
||||
testID,
|
||||
}: HighlightedTextProps) {
|
||||
function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) {
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
],
|
||||
[textMetricsStyle, wrapLines],
|
||||
() => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)],
|
||||
[wrapLines],
|
||||
);
|
||||
|
||||
const keyedTokens = useMemo(
|
||||
@@ -270,7 +246,6 @@ function DiffGutterCell({
|
||||
lineNumber,
|
||||
type,
|
||||
gutterWidth,
|
||||
textMetricsStyle,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
isLineHovered,
|
||||
@@ -281,7 +256,6 @@ function DiffGutterCell({
|
||||
lineNumber: number | null;
|
||||
type: DiffLine["type"] | undefined | null;
|
||||
gutterWidth: number;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
isLineHovered?: boolean;
|
||||
@@ -289,27 +263,23 @@ function DiffGutterCell({
|
||||
textTestID?: string;
|
||||
actionTestID?: string;
|
||||
}) {
|
||||
const lineHeight = getNumericLineHeight(textMetricsStyle);
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
styles.gutterCell,
|
||||
lineTypeBackground(type),
|
||||
rowMetricsStyle,
|
||||
inlineUnistylesStyle({ width: gutterWidth }),
|
||||
style,
|
||||
],
|
||||
[type, rowMetricsStyle, gutterWidth, style],
|
||||
[type, gutterWidth, style],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.lineNumberText,
|
||||
type === "add" && styles.addLineNumberText,
|
||||
type === "remove" && styles.removeLineNumberText,
|
||||
],
|
||||
[textMetricsStyle, type],
|
||||
[type],
|
||||
);
|
||||
const comments = useMemo(
|
||||
() =>
|
||||
@@ -327,7 +297,6 @@ function DiffGutterCell({
|
||||
comments={comments}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isLineHovered={isLineHovered}
|
||||
lineHeight={lineHeight}
|
||||
onStartComment={onStartComment}
|
||||
style={containerStyle}
|
||||
actionTestID={actionTestID}
|
||||
@@ -342,7 +311,6 @@ function DiffGutterCell({
|
||||
function DiffTextLine({
|
||||
line,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
@@ -352,7 +320,6 @@ function DiffTextLine({
|
||||
}: {
|
||||
line: DiffLine;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
@@ -361,16 +328,14 @@ function DiffTextLine({
|
||||
textTestID?: string;
|
||||
}) {
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
|
||||
[line.type, rowMetricsStyle],
|
||||
() => [styles.textLineContainer, lineTypeBackground(line.type)],
|
||||
[line.type],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line.type === "add" && styles.addLineText,
|
||||
@@ -378,7 +343,7 @@ function DiffTextLine({
|
||||
line.type === "header" && styles.headerLineText,
|
||||
line.type === "context" && styles.contextLineText,
|
||||
],
|
||||
[line.type, textMetricsStyle, wrapLines],
|
||||
[line.type, wrapLines],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -391,12 +356,7 @@ function DiffTextLine({
|
||||
style={containerStyle}
|
||||
>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
<HighlightedText
|
||||
tokens={visibleTokens}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
wrapLines={wrapLines}
|
||||
testID={textTestID}
|
||||
/>
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} testID={textTestID} />
|
||||
) : (
|
||||
<Text style={textStyle} testID={textTestID}>
|
||||
{formatDiffContentText(line.content)}
|
||||
@@ -409,7 +369,6 @@ function DiffTextLine({
|
||||
function SplitTextLine({
|
||||
line,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewActions,
|
||||
onHoverChange,
|
||||
hoverTargetKey,
|
||||
@@ -417,23 +376,20 @@ function SplitTextLine({
|
||||
}: {
|
||||
line: SplitDiffDisplayLine | null;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
hoverTargetKey?: string | null;
|
||||
onHoverTargetChange?: (key: string | null) => void;
|
||||
}) {
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
|
||||
[line?.type, rowMetricsStyle],
|
||||
() => [styles.textLineContainer, lineTypeBackground(line?.type)],
|
||||
[line?.type],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line?.type === "add" && styles.addLineText,
|
||||
@@ -441,7 +397,7 @@ function SplitTextLine({
|
||||
line?.type === "context" && styles.contextLineText,
|
||||
!line && styles.emptySplitCellText,
|
||||
],
|
||||
[line, textMetricsStyle, wrapLines],
|
||||
[line, wrapLines],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -454,11 +410,7 @@ function SplitTextLine({
|
||||
style={containerStyle}
|
||||
>
|
||||
{visibleTokens ? (
|
||||
<HighlightedText
|
||||
tokens={visibleTokens}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
||||
)}
|
||||
@@ -471,7 +423,6 @@ function DiffLineView({
|
||||
lineNumber,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewTarget,
|
||||
reviewActions,
|
||||
}: {
|
||||
@@ -479,22 +430,19 @@ function DiffLineView({
|
||||
lineNumber: number | null;
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewTarget?: ReviewableDiffTarget | null;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}) {
|
||||
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
|
||||
[line.type, rowMetricsStyle],
|
||||
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
|
||||
[line.type],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line.type === "add" && styles.addLineText,
|
||||
@@ -502,7 +450,7 @@ function DiffLineView({
|
||||
line.type === "header" && styles.headerLineText,
|
||||
line.type === "context" && styles.contextLineText,
|
||||
],
|
||||
[line.type, textMetricsStyle, wrapLines],
|
||||
[line.type, wrapLines],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -516,18 +464,13 @@ function DiffLineView({
|
||||
lineNumber={lineNumber}
|
||||
type={line.type}
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={isLineHovered}
|
||||
style={styles.lineNumberGutter}
|
||||
/>
|
||||
{line.type !== "header" && visibleTokens ? (
|
||||
<HighlightedText
|
||||
tokens={visibleTokens}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
|
||||
)}
|
||||
@@ -539,27 +482,23 @@ function SplitDiffLine({
|
||||
line,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewActions,
|
||||
}: {
|
||||
line: SplitDiffDisplayLine | null;
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewActions?: InlineReviewActions;
|
||||
}) {
|
||||
const [isLineHovered, setIsLineHovered] = useState(false);
|
||||
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
||||
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
|
||||
[line?.type, rowMetricsStyle],
|
||||
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
|
||||
[line?.type],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.diffTextMetrics,
|
||||
textMetricsStyle,
|
||||
styles.diffLineText,
|
||||
getWrappedTextStyle(wrapLines),
|
||||
line?.type === "add" && styles.addLineText,
|
||||
@@ -567,7 +506,7 @@ function SplitDiffLine({
|
||||
line?.type === "context" && styles.contextLineText,
|
||||
!line && styles.emptySplitCellText,
|
||||
],
|
||||
[line, textMetricsStyle, wrapLines],
|
||||
[line, wrapLines],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -581,18 +520,13 @@ function SplitDiffLine({
|
||||
lineNumber={line?.lineNumber ?? null}
|
||||
type={line?.type}
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={isLineHovered}
|
||||
style={styles.lineNumberGutter}
|
||||
/>
|
||||
{visibleTokens ? (
|
||||
<HighlightedText
|
||||
tokens={visibleTokens}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
||||
) : (
|
||||
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
||||
)}
|
||||
@@ -715,7 +649,6 @@ function SplitDiffColumn({
|
||||
side,
|
||||
gutterWidth,
|
||||
wrapLines,
|
||||
textMetricsStyle,
|
||||
reviewActions,
|
||||
showDivider = false,
|
||||
}: {
|
||||
@@ -723,7 +656,6 @@ function SplitDiffColumn({
|
||||
side: "left" | "right";
|
||||
gutterWidth: number;
|
||||
wrapLines: boolean;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewActions?: InlineReviewActions;
|
||||
showDivider?: boolean;
|
||||
}) {
|
||||
@@ -745,10 +677,6 @@ function SplitDiffColumn({
|
||||
],
|
||||
[scrollWidth],
|
||||
);
|
||||
const headerLineTextStyle = useMemo(
|
||||
() => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText],
|
||||
[textMetricsStyle],
|
||||
);
|
||||
|
||||
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
|
||||
|
||||
@@ -760,7 +688,7 @@ function SplitDiffColumn({
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<View key={key} style={styles.splitHeaderRow}>
|
||||
<Text style={headerLineTextStyle}>{row.content}</Text>
|
||||
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -776,7 +704,6 @@ function SplitDiffColumn({
|
||||
line={line}
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
/>
|
||||
<InlineReviewRow
|
||||
@@ -799,13 +726,7 @@ function SplitDiffColumn({
|
||||
{keyedRows.map(({ key, row }) => {
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<DiffGutterCell
|
||||
key={key}
|
||||
lineNumber={null}
|
||||
type="header"
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
/>
|
||||
<DiffGutterCell key={key} lineNumber={null} type="header" gutterWidth={gutterWidth} />
|
||||
);
|
||||
}
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
@@ -821,7 +742,6 @@ function SplitDiffColumn({
|
||||
lineNumber={line?.lineNumber ?? null}
|
||||
type={line?.type}
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={line?.reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={
|
||||
@@ -849,7 +769,7 @@ function SplitDiffColumn({
|
||||
if (row.kind === "header") {
|
||||
return (
|
||||
<View key={key} style={styles.splitHeaderRow}>
|
||||
<Text style={headerLineTextStyle}>{row.content}</Text>
|
||||
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -865,7 +785,6 @@ function SplitDiffColumn({
|
||||
<SplitTextLine
|
||||
line={line}
|
||||
wrapLines={false}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
hoverTargetKey={reviewTargetKey}
|
||||
onHoverTargetChange={setHoveredReviewTargetKey}
|
||||
@@ -991,7 +910,6 @@ function DiffFileBody({
|
||||
layout,
|
||||
wrapLines,
|
||||
codeFontSize,
|
||||
textMetricsStyle,
|
||||
reviewActions,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
@@ -1000,7 +918,6 @@ function DiffFileBody({
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
codeFontSize: number;
|
||||
textMetricsStyle: TextStyle;
|
||||
reviewActions?: InlineReviewActions;
|
||||
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
|
||||
testID?: string;
|
||||
@@ -1061,7 +978,6 @@ function DiffFileBody({
|
||||
side="left"
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
/>
|
||||
<SplitDiffColumn
|
||||
@@ -1069,7 +985,6 @@ function DiffFileBody({
|
||||
side="right"
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
showDivider
|
||||
/>
|
||||
@@ -1090,7 +1005,6 @@ function DiffFileBody({
|
||||
lineNumber={lineNumber}
|
||||
gutterWidth={gutterWidth}
|
||||
wrapLines={wrapLines}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
/>
|
||||
@@ -1117,7 +1031,6 @@ function DiffFileBody({
|
||||
lineNumber={lineNumber}
|
||||
type={line.type}
|
||||
gutterWidth={gutterWidth}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
isLineHovered={
|
||||
@@ -1146,7 +1059,6 @@ function DiffFileBody({
|
||||
<DiffTextLine
|
||||
line={line}
|
||||
wrapLines={false}
|
||||
textMetricsStyle={textMetricsStyle}
|
||||
reviewTarget={reviewTarget}
|
||||
reviewActions={reviewActions}
|
||||
hoverTargetKey={reviewTarget?.key ?? null}
|
||||
@@ -1709,14 +1621,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
|
||||
":",
|
||||
);
|
||||
const diffTextMetricsStyle = useMemo<TextStyle>(() => {
|
||||
const monoFontFamily = appSettings.monoFontFamily.trim();
|
||||
return {
|
||||
fontSize: codeFontSize,
|
||||
lineHeight: diffBodyLineHeight,
|
||||
...(monoFontFamily ? { fontFamily: monoFontFamily } : null),
|
||||
};
|
||||
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
|
||||
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
|
||||
|
||||
const unifiedToggleStyle = useMemo(
|
||||
@@ -2105,7 +2009,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
layout={effectiveLayout}
|
||||
wrapLines={wrapLines}
|
||||
codeFontSize={codeFontSize}
|
||||
textMetricsStyle={diffTextMetricsStyle}
|
||||
reviewActions={reviewActions}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}-body`}
|
||||
@@ -2114,7 +2017,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
|
||||
},
|
||||
[
|
||||
codeFontSize,
|
||||
diffTextMetricsStyle,
|
||||
effectiveLayout,
|
||||
handleBodyHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
@@ -2778,6 +2680,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const HEADER_LINE_TEXT_STYLE = [styles.diffTextMetrics, styles.diffLineText, styles.headerLineText];
|
||||
const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
|
||||
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
|
||||
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
const ROW_KEYS = [0, 1, 2].map((i) => `pr-activity-skeleton-row-${i}`);
|
||||
|
||||
export function useSkeletonPulse(): Animated.Value {
|
||||
const pulse = useRef(new Animated.Value(0)).current;
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [pulse]);
|
||||
return pulse;
|
||||
}
|
||||
|
||||
export function SkeletonPulse({
|
||||
pulse,
|
||||
style,
|
||||
}: {
|
||||
pulse: Animated.Value;
|
||||
style: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const opacity = pulse.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.4, 0.8],
|
||||
});
|
||||
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
|
||||
return <Animated.View style={pulseStyle} />;
|
||||
}
|
||||
|
||||
export function PrActivitySkeleton() {
|
||||
const pulse = useSkeletonPulse();
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="pr-pane-activity-skeleton">
|
||||
{ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.row}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.avatar} />
|
||||
<View style={styles.lines}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.lineWide} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.lineNarrow} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
avatar: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
lines: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
lineWide: {
|
||||
width: "70%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
lineNarrow: {
|
||||
width: "45%",
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,4 @@
|
||||
export { PullRequestPane } from "./pane";
|
||||
export { PullRequestPaneError } from "./pane-error";
|
||||
export { PullRequestPaneSkeleton } from "./pane-skeleton";
|
||||
export { PullRequestTabIcon } from "./tab-icon";
|
||||
export { formatPrTabLabel } from "./tab-label";
|
||||
export { prPaneTimelineQueryKind } from "./query-keys";
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function PullRequestPaneError({
|
||||
onRetry,
|
||||
message,
|
||||
}: {
|
||||
onRetry: () => void;
|
||||
message?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane-error">
|
||||
<Text style={styles.message}>{message ?? t("workspace.git.diff.failedRefresh")}</Text>
|
||||
<Button variant="ghost" size="xs" leftIcon={RotateCw} onPress={onRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
message: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
|
||||
|
||||
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
|
||||
|
||||
export function PullRequestPaneSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
const pulse = useSkeletonPulse();
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane-skeleton">
|
||||
<View style={styles.header}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.title} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
|
||||
</View>
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
|
||||
<View style={styles.checks}>
|
||||
{CHECK_ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.checkRow}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.checkDot} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.checkName} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<PrActivitySkeleton />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
header: {
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
},
|
||||
title: {
|
||||
width: "75%",
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
subtitle: {
|
||||
width: "40%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
toolbarButton: {
|
||||
width: 96,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
section: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
checks: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
checkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
minHeight: 32,
|
||||
},
|
||||
checkDot: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
checkName: {
|
||||
width: "60%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -25,13 +25,10 @@ import {
|
||||
MessageSquare,
|
||||
MessageSquarePlus,
|
||||
MoreHorizontal,
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
import type { PressableStateCallbackType } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -45,12 +42,9 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { PrActivitySkeleton } from "./activity-skeleton";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
collapseActivity,
|
||||
expandActivity,
|
||||
@@ -91,8 +85,6 @@ const ThemedGitPullRequestDraft = withUnistyles(GitPullRequestDraft);
|
||||
const ThemedMessageSquare = withUnistyles(MessageSquare);
|
||||
const ThemedMessageSquarePlus = withUnistyles(MessageSquarePlus);
|
||||
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -146,13 +138,6 @@ function kebabTriggerStyle({
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function refreshButtonStyle({
|
||||
hovered = false,
|
||||
pressed = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.refreshButton, (hovered || pressed) && styles.refreshButtonHovered];
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreHorizontal
|
||||
@@ -191,17 +176,13 @@ export function PullRequestPane({
|
||||
serverId,
|
||||
cwd,
|
||||
data,
|
||||
activityLoading,
|
||||
workspaceAttachmentScopeKey,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
data: PrPaneData;
|
||||
activityLoading: boolean;
|
||||
workspaceAttachmentScopeKey?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const daemonClient = useHostRuntimeClient(serverId);
|
||||
const canFetchGitHubCheckDetails = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
|
||||
@@ -218,24 +199,6 @@ export function PullRequestPane({
|
||||
void openExternalUrl(data.url);
|
||||
}, [data.url]);
|
||||
|
||||
const refreshSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({ serverId, cwd, actionId: "refresh" }),
|
||||
) === "pending";
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (isRefreshing) {
|
||||
return;
|
||||
}
|
||||
void runRefresh({ serverId, cwd }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [cwd, isRefreshing, runRefresh, serverId, t, toast]);
|
||||
|
||||
const handleToggleChecks = useCallback(() => {
|
||||
setChecksOpen((open) => !open);
|
||||
}, []);
|
||||
@@ -432,47 +395,6 @@ export function PullRequestPane({
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.toolbar} testID="pr-pane-toolbar">
|
||||
<View style={styles.toolbarActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
leftIcon={ExternalLink}
|
||||
onPress={handleOpenPrUrl}
|
||||
style={styles.viewButton}
|
||||
testID="pr-pane-view-pr"
|
||||
>
|
||||
{t("workspace.git.pr.actions.viewPullRequest")}
|
||||
</Button>
|
||||
</View>
|
||||
{refreshSupported ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isRefreshing
|
||||
? t("workspace.git.diff.refreshing")
|
||||
: t("workspace.git.diff.refreshState")
|
||||
}
|
||||
testID="pr-pane-refresh"
|
||||
style={refreshButtonStyle}
|
||||
hitSlop={8}
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshing ? (
|
||||
<ThemedLoadingSpinner
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -498,6 +420,8 @@ export function PullRequestPane({
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title="Checks"
|
||||
open={checksOpen}
|
||||
@@ -564,30 +488,27 @@ export function PullRequestPane({
|
||||
size="xs"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
Add all to chat
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
{visibleEntries.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No activity yet</Text>
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
) : (
|
||||
visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -1315,48 +1236,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
toolbar: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[3],
|
||||
},
|
||||
toolbarActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
viewButton: {
|
||||
gap: theme.spacing[1],
|
||||
minHeight: 24,
|
||||
height: 24,
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.base,
|
||||
},
|
||||
refreshButton: {
|
||||
marginLeft: "auto",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
refreshButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
refreshIcon: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -435,37 +435,6 @@ describe("selectPrPaneState", () => {
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("reports activityLoading while the timeline is pending its first response", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: true,
|
||||
timelineIsLoading: true,
|
||||
});
|
||||
expect(state.activityLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("does not report activityLoading when no timeline fetch was scheduled", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: false,
|
||||
timelineIsLoading: true,
|
||||
});
|
||||
expect(state.activityLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report activityLoading once the timeline payload resolves", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: true,
|
||||
timelineIsLoading: false,
|
||||
timelinePayload: timelinePayload(),
|
||||
});
|
||||
expect(state.activityLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("reports refreshing during background revalidation of either query", () => {
|
||||
expect(
|
||||
selectPrPaneState({
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface UsePrPaneDataResult {
|
||||
data: PrPaneData | null;
|
||||
prNumber: number | null;
|
||||
isLoading: boolean;
|
||||
activityLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
error: Error | null;
|
||||
githubFeaturesEnabled: boolean;
|
||||
@@ -168,14 +167,13 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
|
||||
: mapPrPaneData(input.status, input.timelinePayload);
|
||||
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
|
||||
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
|
||||
const timelinePending =
|
||||
input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined;
|
||||
|
||||
return {
|
||||
data,
|
||||
prNumber: identity.prNumber,
|
||||
isLoading: input.statusIsLoading || timelinePending,
|
||||
activityLoading: timelinePending,
|
||||
isLoading:
|
||||
input.statusIsLoading ||
|
||||
(input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined),
|
||||
isRefreshing: statusRefreshing || timelineRefreshing,
|
||||
error: firstNonSuppressedError({
|
||||
statusPayloadError: input.statusPayloadError,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/us
|
||||
import {
|
||||
buildGitActions,
|
||||
narrowPullRequestState,
|
||||
type BuildGitActionsInput,
|
||||
type GitAction,
|
||||
type GitActions,
|
||||
} from "@/git/policy";
|
||||
@@ -569,8 +568,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
}, [prStatus?.url, handleCreatePr]);
|
||||
|
||||
// Build actions
|
||||
const gitActionsInput = useMemo<BuildGitActionsInput>(
|
||||
() => ({
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
const actions = buildGitActions({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
@@ -685,66 +684,61 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[
|
||||
isGit,
|
||||
hasRemote,
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
prStatus?.state,
|
||||
prStatus?.isDraft,
|
||||
prStatus?.isMerged,
|
||||
prStatus?.mergeable,
|
||||
prStatus?.github,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shipDefault,
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
actionsDisabled,
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergePrStatuses.squash,
|
||||
mergePrStatuses.merge,
|
||||
mergePrStatuses.rebase,
|
||||
enablePrAutoMergeStatuses.squash,
|
||||
enablePrAutoMergeStatuses.merge,
|
||||
enablePrAutoMergeStatuses.rebase,
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergePr,
|
||||
handleEnablePrAutoMerge,
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
icons,
|
||||
baseRef,
|
||||
],
|
||||
);
|
||||
|
||||
const gitActions: GitActions = useMemo(
|
||||
() =>
|
||||
translateGitActions(buildGitActions(gitActionsInput), { baseRefLabel, hasPullRequest, t }),
|
||||
[gitActionsInput, baseRefLabel, hasPullRequest, t],
|
||||
);
|
||||
});
|
||||
return translateGitActions(actions, { baseRefLabel, hasPullRequest, t });
|
||||
}, [
|
||||
t,
|
||||
isGit,
|
||||
hasRemote,
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
prStatus?.state,
|
||||
prStatus?.isDraft,
|
||||
prStatus?.isMerged,
|
||||
prStatus?.mergeable,
|
||||
prStatus?.github,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shipDefault,
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
actionsDisabled,
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergePrStatuses.squash,
|
||||
mergePrStatuses.merge,
|
||||
mergePrStatuses.rebase,
|
||||
enablePrAutoMergeStatuses.squash,
|
||||
enablePrAutoMergeStatuses.merge,
|
||||
enablePrAutoMergeStatuses.rebase,
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergePr,
|
||||
handleEnablePrAutoMerge,
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
icons,
|
||||
baseRef,
|
||||
]);
|
||||
|
||||
return { gitActions, branchLabel, isGit };
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
type ServiceUrlBehavior,
|
||||
type Settings,
|
||||
type SettingsDeps,
|
||||
type WorkspaceTitleSource,
|
||||
} from "./storage";
|
||||
|
||||
export {
|
||||
@@ -68,7 +67,6 @@ export type {
|
||||
ServiceUrlBehavior,
|
||||
Settings,
|
||||
SettingsDeps,
|
||||
WorkspaceTitleSource,
|
||||
};
|
||||
|
||||
const productionDeps: SettingsDeps = {
|
||||
@@ -174,9 +172,6 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (updates.syntaxTheme !== undefined) {
|
||||
appUpdates.syntaxTheme = updates.syntaxTheme;
|
||||
}
|
||||
if (updates.workspaceTitleSource !== undefined) {
|
||||
appUpdates.workspaceTitleSource = updates.workspaceTitleSource;
|
||||
}
|
||||
const promises: Promise<void>[] = [];
|
||||
if (Object.keys(appUpdates).length > 0) {
|
||||
promises.push(appSettings.updateSettings(appUpdates));
|
||||
|
||||
@@ -61,14 +61,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(result.language).toBe("system");
|
||||
});
|
||||
|
||||
it("defaults workspace title source to title when storage is empty", async () => {
|
||||
const deps = makeDeps();
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.workspaceTitleSource).toBe("title");
|
||||
});
|
||||
|
||||
it("loads configured terminal scrollback lines from app settings", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
@@ -81,30 +73,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
expect(result.terminalScrollbackLines).toBe(42_000);
|
||||
});
|
||||
|
||||
it("loads configured workspace title source from app settings", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "branch" }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.workspaceTitleSource).toBe("branch");
|
||||
});
|
||||
|
||||
it("drops an unknown workspace title source back to title", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "directory" }),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await loadAppSettingsFromStorage(deps);
|
||||
|
||||
expect(result.workspaceTitleSource).toBe("title");
|
||||
});
|
||||
|
||||
it("normalizes terminal scrollback lines from storage", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
|
||||
@@ -11,11 +11,9 @@ const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
export type ReleaseChannel = "stable" | "beta";
|
||||
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
|
||||
export type WorkspaceTitleSource = "title" | "branch";
|
||||
|
||||
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
|
||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
||||
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||
@@ -38,7 +36,6 @@ export interface AppSettings {
|
||||
uiFontSize: number; // clamped px, default 16
|
||||
codeFontSize: number; // clamped px, default 12
|
||||
syntaxTheme: SyntaxThemeId; // default "one"
|
||||
workspaceTitleSource: WorkspaceTitleSource;
|
||||
}
|
||||
|
||||
export interface Settings extends AppSettings {
|
||||
@@ -57,7 +54,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
uiFontSize: DEFAULT_UI_FONT_SIZE,
|
||||
codeFontSize: DEFAULT_CODE_FONT_SIZE,
|
||||
syntaxTheme: "one",
|
||||
workspaceTitleSource: "title",
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: Settings = {
|
||||
@@ -198,12 +194,6 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
|
||||
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
||||
result.syntaxTheme = stored.syntaxTheme;
|
||||
}
|
||||
if (
|
||||
typeof stored.workspaceTitleSource === "string" &&
|
||||
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
|
||||
) {
|
||||
result.workspaceTitleSource = stored.workspaceTitleSource;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarShortcutModel } from "./use-sidebar-shortcut-model";
|
||||
|
||||
function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `srv:${workspaceId}`,
|
||||
serverId: "srv",
|
||||
workspaceId,
|
||||
projectKey,
|
||||
projectRootPath: `/repo/${projectKey}`,
|
||||
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: workspaceId,
|
||||
title: null,
|
||||
currentBranch: null,
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
archiveHasUncommittedChanges: null,
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
}
|
||||
|
||||
function project(projectKey: string): SidebarProjectEntry {
|
||||
return {
|
||||
projectKey,
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/repo/${projectKey}`,
|
||||
canCreateWorktree: true,
|
||||
workspaces: [workspace(projectKey, `${projectKey}-main`)],
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECTS_BOTH = [project("p1"), project("p2")];
|
||||
const PROJECTS_ONLY_SECOND = [project("p2")];
|
||||
|
||||
function Probe({ projectSet }: { projectSet: "both" | "onlySecond" }) {
|
||||
const projects = projectSet === "both" ? PROJECTS_BOTH : PROJECTS_ONLY_SECOND;
|
||||
useSidebarShortcutModel({ projects });
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useSidebarShortcutModel", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
useSidebarCollapsedSectionsStore.setState({
|
||||
collapsedProjectKeys: new Set(),
|
||||
collapsedStatusGroupKeys: new Set(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("keeps a collapsed project collapsed when the project list temporarily omits it", async () => {
|
||||
useSidebarCollapsedSectionsStore.setState({
|
||||
collapsedProjectKeys: new Set(["p1"]),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root?.render(<Probe projectSet="both" />);
|
||||
});
|
||||
await act(async () => {
|
||||
root?.render(<Probe projectSet="onlySecond" />);
|
||||
});
|
||||
await act(async () => {
|
||||
root?.render(<Probe projectSet="both" />);
|
||||
});
|
||||
|
||||
expect(useSidebarCollapsedSectionsStore.getState().collapsedProjectKeys.has("p1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
|
||||
export function useSidebarShortcutModel(input: { projects: SidebarProjectEntry[] }) {
|
||||
const { projects } = input;
|
||||
export function useSidebarShortcutModel(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
isInitialLoad: boolean;
|
||||
}) {
|
||||
const { projects, isInitialLoad } = input;
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.collapsedProjectKeys,
|
||||
);
|
||||
@@ -24,6 +27,19 @@ export function useSidebarShortcutModel(input: { projects: SidebarProjectEntry[]
|
||||
[collapsedProjectKeys, projects],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoad || projects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectKeys = new Set(projects.map((project) => project.projectKey));
|
||||
for (const key of collapsedProjectKeys) {
|
||||
if (!projectKeys.has(key)) {
|
||||
setProjectCollapsed(key, false);
|
||||
}
|
||||
}
|
||||
}, [collapsedProjectKeys, isInitialLoad, projects, setProjectCollapsed]);
|
||||
|
||||
return {
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
|
||||
|
||||
@@ -5,8 +5,6 @@ import { ar } from "./resources/ar";
|
||||
import { en } from "./resources/en";
|
||||
import { es } from "./resources/es";
|
||||
import { fr } from "./resources/fr";
|
||||
import { ja } from "./resources/ja";
|
||||
import { ptBR } from "./resources/pt-BR";
|
||||
import { ru } from "./resources/ru";
|
||||
import { zhCN } from "./resources/zh-CN";
|
||||
|
||||
@@ -22,8 +20,6 @@ observeI18nInit(
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
fr: { translation: fr },
|
||||
ja: { translation: ja },
|
||||
"pt-BR": { translation: ptBR },
|
||||
ru: { translation: ru },
|
||||
"zh-CN": { translation: zhCN },
|
||||
},
|
||||
|
||||
@@ -7,10 +7,16 @@ import {
|
||||
} from "./locales";
|
||||
|
||||
describe("parseAppLanguage", () => {
|
||||
it("accepts system and all supported language locales", () => {
|
||||
expect(
|
||||
["system", "ar", "en", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"].map(parseAppLanguage),
|
||||
).toEqual(["system", "ar", "en", "es", "fr", "ja", "pt-BR", "ru", "zh-CN"]);
|
||||
it("accepts system and all UN official language locales", () => {
|
||||
expect(["system", "ar", "en", "es", "fr", "ru", "zh-CN"].map(parseAppLanguage)).toEqual([
|
||||
"system",
|
||||
"ar",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"ru",
|
||||
"zh-CN",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null for unknown values", () => {
|
||||
@@ -18,15 +24,13 @@ describe("parseAppLanguage", () => {
|
||||
expect(parseAppLanguage(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("offers system plus all supported languages", () => {
|
||||
it("offers system plus the six UN official languages", () => {
|
||||
expect(LANGUAGE_OPTIONS.map((option) => option.value)).toEqual([
|
||||
"system",
|
||||
"ar",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"ja",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"zh-CN",
|
||||
]);
|
||||
@@ -36,24 +40,14 @@ describe("parseAppLanguage", () => {
|
||||
describe("formatLanguageOptionLabel", () => {
|
||||
it("shows the native language name and English name in English UI", () => {
|
||||
const arabic = LANGUAGE_OPTIONS.find((option) => option.value === "ar");
|
||||
const japanese = LANGUAGE_OPTIONS.find((option) => option.value === "ja");
|
||||
const portuguese = LANGUAGE_OPTIONS.find((option) => option.value === "pt-BR");
|
||||
const spanish = LANGUAGE_OPTIONS.find((option) => option.value === "es");
|
||||
const chinese = LANGUAGE_OPTIONS.find((option) => option.value === "zh-CN");
|
||||
|
||||
expect([
|
||||
formatLanguageOptionLabel(arabic!, "en", "System"),
|
||||
formatLanguageOptionLabel(japanese!, "en", "System"),
|
||||
formatLanguageOptionLabel(portuguese!, "en", "System"),
|
||||
formatLanguageOptionLabel(spanish!, "en", "System"),
|
||||
formatLanguageOptionLabel(chinese!, "en", "System"),
|
||||
]).toEqual([
|
||||
"العربية - Arabic",
|
||||
"日本語 - Japanese",
|
||||
"Português brasileiro - Brazilian Portuguese",
|
||||
"Español - Spanish",
|
||||
"简体中文 - Simplified Chinese",
|
||||
]);
|
||||
]).toEqual(["العربية - Arabic", "Español - Spanish", "简体中文 - Simplified Chinese"]);
|
||||
});
|
||||
|
||||
it("shows the native language name and Chinese name in Chinese UI", () => {
|
||||
@@ -70,12 +64,8 @@ describe("formatLanguageOptionLabel", () => {
|
||||
|
||||
it("uses a single label when both language names match", () => {
|
||||
const english = LANGUAGE_OPTIONS.find((option) => option.value === "en");
|
||||
const japanese = LANGUAGE_OPTIONS.find((option) => option.value === "ja");
|
||||
const portuguese = LANGUAGE_OPTIONS.find((option) => option.value === "pt-BR");
|
||||
|
||||
expect(formatLanguageOptionLabel(english!, "en", "System")).toBe("English");
|
||||
expect(formatLanguageOptionLabel(japanese!, "ja", "システム")).toBe("日本語");
|
||||
expect(formatLanguageOptionLabel(portuguese!, "pt-BR", "Sistema")).toBe("Português brasileiro");
|
||||
});
|
||||
|
||||
it("uses the active-language name for System", () => {
|
||||
@@ -91,28 +81,18 @@ describe("resolveSupportedLocale", () => {
|
||||
expect(resolveSupportedLocale("en", ["zh-CN"])).toBe("en");
|
||||
expect(resolveSupportedLocale("es", ["en-US"])).toBe("es");
|
||||
expect(resolveSupportedLocale("fr", ["en-US"])).toBe("fr");
|
||||
expect(resolveSupportedLocale("ja", ["en-US"])).toBe("ja");
|
||||
expect(resolveSupportedLocale("pt-BR", ["en-US"])).toBe("pt-BR");
|
||||
expect(resolveSupportedLocale("ru", ["en-US"])).toBe("ru");
|
||||
expect(resolveSupportedLocale("zh-CN", ["en-US"])).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("maps supported system locales", () => {
|
||||
it("maps UN official system locales", () => {
|
||||
expect(resolveSupportedLocale("system", ["ar-EG"])).toBe("ar");
|
||||
expect(resolveSupportedLocale("system", ["en-US"])).toBe("en");
|
||||
expect(resolveSupportedLocale("system", ["es-MX"])).toBe("es");
|
||||
expect(resolveSupportedLocale("system", ["fr-CA"])).toBe("fr");
|
||||
expect(resolveSupportedLocale("system", ["ja-JP"])).toBe("ja");
|
||||
expect(resolveSupportedLocale("system", ["pt-BR"])).toBe("pt-BR");
|
||||
expect(resolveSupportedLocale("system", ["pt"])).toBe("pt-BR");
|
||||
expect(resolveSupportedLocale("system", ["ru-RU"])).toBe("ru");
|
||||
});
|
||||
|
||||
it("does not map non-Brazilian Portuguese system locales to Brazilian Portuguese", () => {
|
||||
expect(resolveSupportedLocale("system", ["pt-PT"])).toBe("en");
|
||||
expect(resolveSupportedLocale("system", ["pt-AO"])).toBe("en");
|
||||
});
|
||||
|
||||
it("keeps English when Spanish is a secondary system language", () => {
|
||||
expect(resolveSupportedLocale("system", ["en-US", "es-GB"])).toBe("en");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SupportedLocale = "ar" | "en" | "es" | "fr" | "ja" | "pt-BR" | "ru" | "zh-CN";
|
||||
export type SupportedLocale = "ar" | "en" | "es" | "fr" | "ru" | "zh-CN";
|
||||
export type AppLanguage = "system" | SupportedLocale;
|
||||
|
||||
export interface LanguageOption {
|
||||
@@ -14,30 +14,16 @@ export const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
{ value: "en", labelKey: "settings.general.language.options.en" },
|
||||
{ value: "es", labelKey: "settings.general.language.options.es" },
|
||||
{ value: "fr", labelKey: "settings.general.language.options.fr" },
|
||||
{ value: "ja", labelKey: "settings.general.language.options.ja" },
|
||||
{ value: "pt-BR", labelKey: "settings.general.language.options.ptBR" },
|
||||
{ value: "ru", labelKey: "settings.general.language.options.ru" },
|
||||
{ value: "zh-CN", labelKey: "settings.general.language.options.zhCN" },
|
||||
];
|
||||
|
||||
const SUPPORTED_LANGUAGES = new Set<AppLanguage>([
|
||||
"system",
|
||||
"ar",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"ja",
|
||||
"pt-BR",
|
||||
"ru",
|
||||
"zh-CN",
|
||||
]);
|
||||
const SUPPORTED_LANGUAGES = new Set<AppLanguage>(["system", "ar", "en", "es", "fr", "ru", "zh-CN"]);
|
||||
const LANGUAGE_NATIVE_NAMES: Record<SupportedLocale, string> = {
|
||||
ar: "العربية",
|
||||
en: "English",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
"pt-BR": "Português brasileiro",
|
||||
ru: "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
};
|
||||
@@ -47,8 +33,6 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
en: "الإنجليزية",
|
||||
es: "الإسبانية",
|
||||
fr: "الفرنسية",
|
||||
ja: "اليابانية",
|
||||
"pt-BR": "البرتغالية البرازيلية",
|
||||
ru: "الروسية",
|
||||
"zh-CN": "الصينية المبسطة",
|
||||
},
|
||||
@@ -57,8 +41,6 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
en: "English",
|
||||
es: "Spanish",
|
||||
fr: "French",
|
||||
ja: "Japanese",
|
||||
"pt-BR": "Brazilian Portuguese",
|
||||
ru: "Russian",
|
||||
"zh-CN": "Simplified Chinese",
|
||||
},
|
||||
@@ -67,8 +49,6 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
en: "inglés",
|
||||
es: "español",
|
||||
fr: "francés",
|
||||
ja: "japonés",
|
||||
"pt-BR": "portugués brasileño",
|
||||
ru: "ruso",
|
||||
"zh-CN": "chino simplificado",
|
||||
},
|
||||
@@ -77,38 +57,14 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
en: "anglais",
|
||||
es: "espagnol",
|
||||
fr: "français",
|
||||
ja: "japonais",
|
||||
"pt-BR": "portugais brésilien",
|
||||
ru: "russe",
|
||||
"zh-CN": "chinois simplifié",
|
||||
},
|
||||
ja: {
|
||||
ar: "アラビア語",
|
||||
en: "英語",
|
||||
es: "スペイン語",
|
||||
fr: "フランス語",
|
||||
ja: "日本語",
|
||||
"pt-BR": "ブラジルポルトガル語",
|
||||
ru: "ロシア語",
|
||||
"zh-CN": "簡体字中国語",
|
||||
},
|
||||
"pt-BR": {
|
||||
ar: "árabe",
|
||||
en: "inglês",
|
||||
es: "espanhol",
|
||||
fr: "francês",
|
||||
ja: "japonês",
|
||||
"pt-BR": "Português brasileiro",
|
||||
ru: "russo",
|
||||
"zh-CN": "chinês simplificado",
|
||||
},
|
||||
ru: {
|
||||
ar: "арабский",
|
||||
en: "английский",
|
||||
es: "испанский",
|
||||
fr: "французский",
|
||||
ja: "японский",
|
||||
"pt-BR": "бразильский португальский",
|
||||
ru: "русский",
|
||||
"zh-CN": "упрощенный китайский",
|
||||
},
|
||||
@@ -117,8 +73,6 @@ const LANGUAGE_NAMES_BY_LOCALE: Record<SupportedLocale, Record<SupportedLocale,
|
||||
en: "英语",
|
||||
es: "西班牙语",
|
||||
fr: "法语",
|
||||
ja: "日语",
|
||||
"pt-BR": "巴西葡萄牙语",
|
||||
ru: "俄语",
|
||||
"zh-CN": "简体中文",
|
||||
},
|
||||
@@ -170,12 +124,6 @@ export function resolveSupportedLocale(
|
||||
if (normalized === "fr" || normalized.startsWith("fr-")) {
|
||||
return "fr";
|
||||
}
|
||||
if (normalized === "ja" || normalized.startsWith("ja-")) {
|
||||
return "ja";
|
||||
}
|
||||
if (normalized === "pt" || normalized === "pt-br") {
|
||||
return "pt-BR";
|
||||
}
|
||||
if (normalized === "ru" || normalized.startsWith("ru-")) {
|
||||
return "ru";
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { ar } from "./resources/ar";
|
||||
import { en } from "./resources/en";
|
||||
import { es } from "./resources/es";
|
||||
import { fr } from "./resources/fr";
|
||||
import { ja } from "./resources/ja";
|
||||
import { ptBR } from "./resources/pt-BR";
|
||||
import { ru } from "./resources/ru";
|
||||
import { zhCN } from "./resources/zh-CN";
|
||||
|
||||
@@ -103,35 +101,28 @@ function findUntranslatedConnectionErrors(): string[] {
|
||||
}
|
||||
|
||||
describe("translation resources", () => {
|
||||
it("keeps all supported language keys in sync with English", () => {
|
||||
it("keeps UN official language keys in sync with English", () => {
|
||||
const englishKeys = flattenKeys(en).sort();
|
||||
expect(flattenKeys(ar).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(es).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(fr).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ja).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ptBR).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(ru).sort()).toEqual(englishKeys);
|
||||
expect(flattenKeys(zhCN).sort()).toEqual(englishKeys);
|
||||
});
|
||||
|
||||
it("keeps non-English supported languages translated beyond fallback labels", () => {
|
||||
it("keeps non-English UN official languages translated beyond fallback labels", () => {
|
||||
const totalStrings = Object.keys(flattenStrings(en)).length;
|
||||
const maxFallbackStrings = Math.floor(totalStrings * 0.25);
|
||||
expect(countMatchingEnglishStrings(ar)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(es)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(fr)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(ja)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(ptBR)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(ru)).toBeLessThan(maxFallbackStrings);
|
||||
expect(countMatchingEnglishStrings(zhCN)).toBeLessThan(maxFallbackStrings);
|
||||
});
|
||||
|
||||
it("preserves interpolation placeholders in every language", () => {
|
||||
expect(findInterpolationMismatches(ar)).toEqual([]);
|
||||
expect(findInterpolationMismatches(es)).toEqual([]);
|
||||
expect(findInterpolationMismatches(fr)).toEqual([]);
|
||||
expect(findInterpolationMismatches(ja)).toEqual([]);
|
||||
expect(findInterpolationMismatches(ptBR)).toEqual([]);
|
||||
expect(findInterpolationMismatches(ru)).toEqual([]);
|
||||
expect(findInterpolationMismatches(zhCN)).toEqual([]);
|
||||
});
|
||||
@@ -146,15 +137,11 @@ describe("translation resources", () => {
|
||||
expect(ar.modelSelector.modelCountPlural).toBe("{{count}} نماذج");
|
||||
expect(es.modelSelector.modelCountPlural).toBe("{{count}} modelos");
|
||||
expect(fr.modelSelector.modelCountPlural).toBe("{{count}} modèles");
|
||||
expect(ja.modelSelector.modelCountPlural).toBe("{{count}}つのモデル");
|
||||
expect(ptBR.modelSelector.modelCountPlural).toBe("{{count}} modelos");
|
||||
expect(ru.modelSelector.modelCountPlural).toBe("{{count}} моделей");
|
||||
expect(zhCN.modelSelector.modelCountPlural).toBe("{{count}} 个模型");
|
||||
expect(ar.settings.providers.models.many).toBe("{{count}} نماذج");
|
||||
expect(es.settings.providers.models.many).toBe("{{count}} modelos");
|
||||
expect(fr.settings.providers.models.many).toBe("{{count}} modèles");
|
||||
expect(ja.settings.providers.models.many).toBe("{{count}}つのモデル");
|
||||
expect(ptBR.settings.providers.models.many).toBe("{{count}} modelos");
|
||||
expect(ru.settings.providers.models.many).toBe("{{count}} моделей");
|
||||
expect(zhCN.settings.providers.models.many).toBe("{{count}} 个 Model");
|
||||
});
|
||||
@@ -196,7 +183,6 @@ describe("translation resources", () => {
|
||||
expect(en.composer.github.title).toBe("Attach issue or PR");
|
||||
expect(en.agentControls.provider.fallback).toBe("Provider");
|
||||
expect(en.agentControls.hints.model).toBe("Change model");
|
||||
expect(en.agentControls.hints.mode).toBe("Change mode");
|
||||
expect(en.agentControls.features.title).toBe("Features");
|
||||
expect(en.agentControls.mode.title).toBe("Mode");
|
||||
expect(en.agentStream.permission.required).toBe("Permission Required");
|
||||
@@ -257,7 +243,6 @@ describe("translation resources", () => {
|
||||
expect(en.workspace.git.actions.commit.label).toBe("Commit");
|
||||
expect(en.workspace.git.diff.binaryFile).toBe("Binary file");
|
||||
expect(en.workspace.git.pr.sections.checks).toBe("Checks");
|
||||
expect(en.workspace.git.pr.actions.viewPullRequest).toBe("View");
|
||||
expect(en.review.comment.placeholder).toBe("Leave a comment");
|
||||
});
|
||||
|
||||
@@ -303,7 +288,6 @@ describe("translation resources", () => {
|
||||
expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes");
|
||||
expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center");
|
||||
expect(en.settings.shortcuts.help.newWorkspace).toBe("New workspace");
|
||||
expect(en.settings.shortcuts.help.cycleAgentMode).toBe("Cycle agent mode");
|
||||
expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe(
|
||||
"Available when focus is not in a text field or terminal.",
|
||||
);
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ar: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "وضع التفكير",
|
||||
model: "تغيير النموذج",
|
||||
mode: "تغيير الوضع",
|
||||
mode: "تغيير وضع الإذن",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -717,9 +717,6 @@ export const ar: TranslationResources = {
|
||||
failedOpen: "فشل في فتح مساحة العمل",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "عرض",
|
||||
},
|
||||
sections: {
|
||||
checks: "الشيكات",
|
||||
reviews: "التعليقات",
|
||||
@@ -1438,8 +1435,6 @@ export const ar: TranslationResources = {
|
||||
en: "English",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
ptBR: "Português brasileiro",
|
||||
ru: "Русский",
|
||||
zhCN: "中文",
|
||||
},
|
||||
@@ -1451,22 +1446,6 @@ export const ar: TranslationResources = {
|
||||
playTest: "لعب الاختبار",
|
||||
playing: "جارٍ اللعب...",
|
||||
playbackFailed: "فشل التشغيل:{{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "عن",
|
||||
@@ -1585,7 +1564,6 @@ export const ar: TranslationResources = {
|
||||
toggleFocusMode: "تبديل وضع التركيز",
|
||||
cycleTheme: "موضوع الدورة",
|
||||
focusMessageInput: "التركيز على إدخال الرسالة",
|
||||
cycleAgentMode: "تبديل وضع الوكيل",
|
||||
toggleVoiceMode: "تبديل الوضع الصوتي",
|
||||
startStopDictation: "بدء إملاء /stop",
|
||||
interruptAgent: "عامل المقاطعة",
|
||||
|
||||
@@ -163,7 +163,7 @@ export const en = {
|
||||
hints: {
|
||||
thinking: "Thinking mode",
|
||||
model: "Change model",
|
||||
mode: "Change mode",
|
||||
mode: "Change permission mode",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -724,9 +724,6 @@ export const en = {
|
||||
failedOpen: "Failed to open workspace",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "View",
|
||||
},
|
||||
sections: {
|
||||
checks: "Checks",
|
||||
reviews: "Reviews",
|
||||
@@ -1444,8 +1441,6 @@ export const en = {
|
||||
en: "English",
|
||||
es: "Spanish",
|
||||
fr: "French",
|
||||
ja: "Japanese",
|
||||
ptBR: "Brazilian Portuguese",
|
||||
ru: "Russian",
|
||||
zhCN: "Simplified Chinese",
|
||||
},
|
||||
@@ -1457,22 +1452,6 @@ export const en = {
|
||||
playTest: "Play test",
|
||||
playing: "Playing...",
|
||||
playbackFailed: "Playback failed: {{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "About",
|
||||
@@ -1591,7 +1570,6 @@ export const en = {
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
cycleTheme: "Cycle theme",
|
||||
focusMessageInput: "Focus message input",
|
||||
cycleAgentMode: "Cycle agent mode",
|
||||
toggleVoiceMode: "Toggle voice mode",
|
||||
startStopDictation: "Start/stop dictation",
|
||||
interruptAgent: "Interrupt agent",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const es: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Modo de pensamiento",
|
||||
model: "Cambiar modelo",
|
||||
mode: "Cambiar modo",
|
||||
mode: "Cambiar modo de permiso",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -744,9 +744,6 @@ export const es: TranslationResources = {
|
||||
failedOpen: "No se pudo abrir el espacio de trabajo",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "Ver",
|
||||
},
|
||||
sections: {
|
||||
checks: "cheques",
|
||||
reviews: "Reseñas",
|
||||
@@ -1475,8 +1472,6 @@ export const es: TranslationResources = {
|
||||
en: "English",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
ptBR: "Português brasileiro",
|
||||
ru: "Русский",
|
||||
zhCN: "中文",
|
||||
},
|
||||
@@ -1488,22 +1483,6 @@ export const es: TranslationResources = {
|
||||
playTest: "Prueba de juego",
|
||||
playing: "Jugando...",
|
||||
playbackFailed: "Error de reproducción:{{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "Acerca de",
|
||||
@@ -1623,7 +1602,6 @@ export const es: TranslationResources = {
|
||||
toggleFocusMode: "Alternar modo de enfoque",
|
||||
cycleTheme: "Tema del ciclo",
|
||||
focusMessageInput: "Entrada de mensaje de enfoque",
|
||||
cycleAgentMode: "Alternar modo del agente",
|
||||
toggleVoiceMode: "Alternar modo de voz",
|
||||
startStopDictation: "Iniciar dictado/stop",
|
||||
interruptAgent: "agente de interrupción",
|
||||
|
||||
@@ -166,7 +166,7 @@ export const fr: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Mode réflexion",
|
||||
model: "Changer de modèle",
|
||||
mode: "Changer de mode",
|
||||
mode: "Changer le mode d'autorisation",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -743,9 +743,6 @@ export const fr: TranslationResources = {
|
||||
failedOpen: "Échec de l'ouverture de l'espace de travail",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "Voir",
|
||||
},
|
||||
sections: {
|
||||
checks: "Chèques",
|
||||
reviews: "Avis",
|
||||
@@ -1478,8 +1475,6 @@ export const fr: TranslationResources = {
|
||||
en: "English",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
ja: "日本語",
|
||||
ptBR: "Português brasileiro",
|
||||
ru: "Русский",
|
||||
zhCN: "中文",
|
||||
},
|
||||
@@ -1491,22 +1486,6 @@ export const fr: TranslationResources = {
|
||||
playTest: "Jouer à l'essai",
|
||||
playing: "Jouant...",
|
||||
playbackFailed: "Échec de la lecture:{{message}}",
|
||||
app: {
|
||||
title: "App diagnostic",
|
||||
rowTitle: "App diagnostic",
|
||||
rowHint: "Collect connection, daemon, provider, desktop, and log details",
|
||||
run: "Run",
|
||||
running: "Running diagnostic...",
|
||||
copyLabel: "diagnostic",
|
||||
copyAccessibility: "Copy diagnostic",
|
||||
copyFailed: "Failed to copy diagnostic",
|
||||
refreshAccessibility: "Refresh diagnostic",
|
||||
refreshingAccessibility: "Refreshing diagnostic",
|
||||
progress: {
|
||||
client: "Client",
|
||||
desktop: "Desktop",
|
||||
},
|
||||
},
|
||||
},
|
||||
about: {
|
||||
title: "À propos",
|
||||
@@ -1627,7 +1606,6 @@ export const fr: TranslationResources = {
|
||||
toggleFocusMode: "Basculer le mode de mise au point",
|
||||
cycleTheme: "Thème du cycle",
|
||||
focusMessageInput: "Saisie du message de focus",
|
||||
cycleAgentMode: "Parcourir les modes de l'agent",
|
||||
toggleVoiceMode: "Changer le mode vocal",
|
||||
startStopDictation: "Démarrer la dictée/stop",
|
||||
interruptAgent: "Agent d'interruption",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user