mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39094078a9 | ||
|
|
160d861534 | ||
|
|
6cbe4e6a14 | ||
|
|
0d05ff7efd | ||
|
|
dbe24b609d | ||
|
|
bb6403e426 | ||
|
|
9bf36cdb56 | ||
|
|
1f79c4d039 | ||
|
|
5a74970d4a | ||
|
|
eed61d338c | ||
|
|
2f13c83c0e | ||
|
|
12e24f9620 | ||
|
|
37bb168204 | ||
|
|
57db997028 | ||
|
|
29d46c5d92 | ||
|
|
a72c7f6c4b | ||
|
|
1962225647 | ||
|
|
2d3c9101f3 | ||
|
|
f27d39fb63 | ||
|
|
5a3fc96b76 | ||
|
|
fbeda4510e | ||
|
|
7e255a5249 | ||
|
|
a19b780eca | ||
|
|
faa0d93cb8 | ||
|
|
2dd77aae04 | ||
|
|
2e8bced707 | ||
|
|
8296c797ff | ||
|
|
e1ab779fe0 | ||
|
|
eb7fa02ac8 | ||
|
|
44292b2b69 | ||
|
|
05c12a9c5c | ||
|
|
5c7a6397da | ||
|
|
f1e5640a26 | ||
|
|
512702fe46 | ||
|
|
ecc6a269bc | ||
|
|
7a0606952d | ||
|
|
68fae16740 | ||
|
|
bc329a2d85 | ||
|
|
e96cd16a0f | ||
|
|
316eeee9ca | ||
|
|
c3012ea520 | ||
|
|
e2ecae0e72 | ||
|
|
49fa72d70d | ||
|
|
c6534b3a16 | ||
|
|
15631b815b | ||
|
|
d30a3a72da | ||
|
|
6ed7dfc42a | ||
|
|
77dc80b86f | ||
|
|
2bd2e8bc46 | ||
|
|
81697f85b2 | ||
|
|
72293f7f4f | ||
|
|
65d14d4433 | ||
|
|
9eb1ddf29d | ||
|
|
d18bfe9047 | ||
|
|
c741185100 | ||
|
|
ea0bb81100 | ||
|
|
9d7d1e8500 | ||
|
|
16c27d7404 | ||
|
|
b8a3eefd47 | ||
|
|
d24087c10a | ||
|
|
15cab6014c | ||
|
|
47d236a299 | ||
|
|
b9cea49f10 | ||
|
|
ad88682426 | ||
|
|
1ddbdf54f1 | ||
|
|
51d9563352 | ||
|
|
045b373168 | ||
|
|
985ad52cce | ||
|
|
d198c68b9e | ||
|
|
751a07124f | ||
|
|
defb4f82f7 | ||
|
|
4570e65ce8 | ||
|
|
db0d63dd90 | ||
|
|
1a8fdcd388 | ||
|
|
417abed6a5 | ||
|
|
6afdeef84a | ||
|
|
77c82dfdbd | ||
|
|
29277c900d | ||
|
|
ed1943058a | ||
|
|
e0361ddd22 | ||
|
|
ce9474055e | ||
|
|
8f9b4c8828 | ||
|
|
af4e0de9ab | ||
|
|
3acc71b8ad | ||
|
|
0759932dad | ||
|
|
95f45e4e2b | ||
|
|
a5c2b97e1d | ||
|
|
d32462e9ee | ||
|
|
33262843a5 | ||
|
|
1cd02a0e1a | ||
|
|
40ab9e3f20 |
4
.github/workflows/desktop-release.yml
vendored
4
.github/workflows/desktop-release.yml
vendored
@@ -40,7 +40,7 @@ on:
|
||||
rollout_hours:
|
||||
description: "Linear rollout duration in hours. Use 0 for instant rollout."
|
||||
required: false
|
||||
default: "24"
|
||||
default: "36"
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
@@ -51,7 +51,7 @@ env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
|
||||
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }}
|
||||
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
||||
|
||||
jobs:
|
||||
|
||||
72
.github/workflows/nix-build.yml
vendored
72
.github/workflows/nix-build.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates (main push only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Nix Update Hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-update-hash.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
|
||||
git config user.name "paseo-ai[bot]"
|
||||
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/npm-deps.hash
|
||||
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
|
||||
git push
|
||||
99
.github/workflows/nix.yml
vendored
Normal file
99
.github/workflows/nix.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Smoke Nix daemon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PASEO_HOME
|
||||
PASEO_HOME="$(mktemp -d)"
|
||||
export PASEO_LISTEN=127.0.0.1:6767
|
||||
|
||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
kill "$DAEMON_PID"
|
||||
wait "$DAEMON_PID" || true
|
||||
fi
|
||||
rm -rf "$PASEO_HOME"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
deadline=$((SECONDS + 30))
|
||||
while (( SECONDS < deadline )); do
|
||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
||||
echo "$STATUS_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
echo "Nix daemon exited before becoming reachable."
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "::group::daemon.log"
|
||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo-server stdout/stderr"
|
||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo daemon status"
|
||||
./result/bin/paseo daemon status || true
|
||||
echo "::endgroup::"
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Build Nix desktop package
|
||||
run: nix build .#desktop -o result-desktop
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -1,5 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.76 - 2026-05-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Chat timestamps and turn durations.** Every message shows when it was sent, and each turn surfaces how long the agent took.
|
||||
- **Auto Review permission mode for Claude Code and Codex.** Agents stop after each assistant turn for review instead of running unattended. ([#928](https://github.com/getpaseo/paseo/pull/928), [#963](https://github.com/getpaseo/paseo/pull/963) by [@bolasblack](https://github.com/bolasblack))
|
||||
- Surface Codex's context compaction events and the `/compact` command in chat.
|
||||
- Optional auto-archive for worktrees once their PR merges.
|
||||
- Paste a GitHub PR or issue URL into the composer to attach it as context.
|
||||
- Surface GitHub auto-merge actions in the PR hover card.
|
||||
- Show all PR check counts in the PR hover card.
|
||||
- Rename a project to disambiguate duplicates that share a folder name.
|
||||
- Confirm before archiving a worktree with uncommitted or unpushed work.
|
||||
- Claude Code now picks up models from `~/.claude/settings.json` so custom model lists show up in the model picker.
|
||||
- Local Claude Code settings (`.claude/settings.local.json`) apply per workspace.
|
||||
- Diagnostics for generic ACP providers surface in the model picker.
|
||||
- MCP: control and discover provider features from external orchestrators. ([#909](https://github.com/getpaseo/paseo/pull/909), [#910](https://github.com/getpaseo/paseo/pull/910) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
|
||||
### Improved
|
||||
|
||||
- Surface Claude error messages in chat instead of ending the turn silently.
|
||||
- Ref picker auto-selects when a single PR is attached.
|
||||
- New workspace flow honors the currently checked-out branch when branching off. ([#908](https://github.com/getpaseo/paseo/pull/908) by [@sbtobb](https://github.com/sbtobb))
|
||||
- OpenCode models from console subscription providers now appear in the model picker. ([#917](https://github.com/getpaseo/paseo/pull/917) by [@t2o2](https://github.com/t2o2))
|
||||
- Cursor model picker reflects the models advertised by the Cursor ACP client. ([#958](https://github.com/getpaseo/paseo/pull/958) by [@chrisbanes](https://github.com/chrisbanes))
|
||||
|
||||
### Fixed
|
||||
|
||||
- iPad hardware Enter submits the composer. ([#919](https://github.com/getpaseo/paseo/pull/919) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
- PR status falls back to a non-checks query for fine-grained GitHub tokens. ([#932](https://github.com/getpaseo/paseo/pull/932) by [@32r4](https://github.com/32r4))
|
||||
- ACP errors display as readable text instead of `[object Object]`.
|
||||
- OpenCode no longer hangs on retry when the upstream provider stalls.
|
||||
- Worktree ahead count is correct when the upstream branch has been deleted.
|
||||
- Branch-off worktrees track the correct upstream.
|
||||
- File changes view works on empty repositories with no commits yet.
|
||||
- Assistant message file links open the correct file.
|
||||
- Default thinking option matches the selected model's capabilities.
|
||||
- Shift+Enter works again in terminal input modes.
|
||||
- Duplicate project entries no longer appear after reopening a project.
|
||||
- Pi-backed sessions recover after a Copilot 413 instead of staying stuck.
|
||||
- Skip probing unrelated executable candidates when launching agents.
|
||||
- Relay E2EE reconnects cleanly under racing connect/disconnect.
|
||||
- Workspace kind stays in sync with project kind after reconfiguration.
|
||||
- zsh integration files install with usable runtime modes.
|
||||
- MCP worktree cache refreshes after create and archive. ([#911](https://github.com/getpaseo/paseo/pull/911) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
|
||||
## 0.1.75 - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
|
||||
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
|
||||
- Fixed Copilot's **Allow All** mode (renamed from Autopilot) ([#935](https://github.com/getpaseo/paseo/pull/935))
|
||||
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
|
||||
- iPhone HEIC photos now attach correctly from the image picker ([#934](https://github.com/getpaseo/paseo/pull/934))
|
||||
- Scheduled agents now archive automatically after each run ([#945](https://github.com/getpaseo/paseo/pull/945))
|
||||
- Windows: Codex command summaries trim `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
|
||||
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
|
||||
## 0.1.74 - 2026-05-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
|
||||
|
||||
## 0.1.73 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -21,26 +21,28 @@ This is an npm workspace monorepo:
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -86,6 +88,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
|
||||
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
|
||||
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
|
||||
|
||||
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
|
||||
|
||||
|
||||
@@ -1,63 +1,32 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
## How this project works
|
||||
Paseo is an opinionated product maintained by one person.
|
||||
|
||||
Paseo is opinionated and maintained by one person. I read every issue and PR myself, so review cost is real.
|
||||
I read every issue and PR myself, and I am selective about what contributions I accept.
|
||||
|
||||
- **Feature requests are welcome.** Open an issue describing the problem. Get a thumbs up before writing code. Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first.
|
||||
- **Objective bug fixes don't need a prior issue.** Reference what's broken, keep the diff narrow, open the PR.
|
||||
- **The product stays lean.** I'll close, scope down, or rewrite PRs that add surface area I don't want to maintain, even if the code is fine.
|
||||
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
|
||||
|
||||
## Reporting bugs
|
||||
Core product, design, architecture, and workflow changes are not accepted.
|
||||
|
||||
Fill in the bug report form. The fields are there because asking back for the surface, version, provider config, and logs is where most of my time on a bad report goes.
|
||||
Follow these rules if you want your PR to be merged:
|
||||
|
||||
- **Full logs, not AI summaries.** Use an agent to grab the relevant log section if you want, but paste the raw log. Agents routinely correlate adjacent lines as cause-and-effect when they aren't, and once a report is filtered through that the signal I need is gone.
|
||||
- **Agents for information gathering, not diagnosis.** A bot that grabs your daemon log, version, and OS is helpful. A bot that submits its own theory of the bug is noise 99% of the time.
|
||||
- **Screenshots or video for UI bugs.** A 10-second recording beats a paragraph.
|
||||
- **One bug per issue.** Three findings, three issues.
|
||||
- Keep it to one focused change
|
||||
- Link to an issue
|
||||
- Explain the problem you're solving
|
||||
- Include repro steps if it's a bug
|
||||
- Include QA/testing evidence
|
||||
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||
- If you only tested one platform, say that clearly
|
||||
|
||||
## Before you start
|
||||
Your PR will be closed if you do any of these:
|
||||
|
||||
- [README.md](README.md)
|
||||
- [docs/architecture.md](docs/architecture.md)
|
||||
- [docs/development.md](docs/development.md)
|
||||
- [docs/coding-standards.md](docs/coding-standards.md)
|
||||
- [docs/testing.md](docs/testing.md)
|
||||
- [CLAUDE.md](CLAUDE.md)
|
||||
- Bundle unrelated changes
|
||||
- Fail basic checks like typecheck, formatting or linting
|
||||
- Make product, design, or architecture changes without prior discussion
|
||||
- Submit no evidence of testing
|
||||
- Skip the linked issue
|
||||
- Clearly fully AI-generated PR
|
||||
|
||||
## What is most helpful
|
||||
## AI assistance
|
||||
|
||||
- bug fixes (especially Windows and Linux)
|
||||
- regression fixes
|
||||
- doc improvements
|
||||
- packaging and platform fixes
|
||||
- focused UX improvements that fit the product direction
|
||||
- tests that lock down important behavior
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
npm run dev # daemon + expo
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
```
|
||||
|
||||
[docs/development.md](docs/development.md) covers build sync, local state, and ports. Coding rules live in [docs/coding-standards.md](docs/coding-standards.md).
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One focused change per PR. Split unrelated cleanups out.
|
||||
- Reference the issue you're fixing, unless it's a small objective bug.
|
||||
- UI changes need screenshots or video on every affected platform (mobile, web, desktop). Things that look fine on one surface regularly break on another.
|
||||
- `npm run typecheck` and `npm run lint` must pass.
|
||||
- Don't make breaking WebSocket or protocol changes. Old apps and old daemons coexist in the wild.
|
||||
- The PR template applies whether you used the web UI or `gh pr create`. Don't strip it out.
|
||||
|
||||
**On AI-assisted PRs.** AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and usually gets closed. If you don't fully understand why your fix works, say so directly. "Here's the repro before and after, not sure why this fixes it" is much better than a fabricated explanation.
|
||||
|
||||
## Forks are fine
|
||||
|
||||
If you want to explore a different product direction, fork. Paseo is open source on purpose. Not every idea needs to land here to be valuable.
|
||||
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
|
||||
|
||||
@@ -146,6 +146,8 @@ There is no dedicated welcome message; the server emits a `status` session messa
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
@@ -205,6 +207,7 @@ initializing → idle ⇄ running
|
||||
|
||||
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
|
||||
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
|
||||
|
||||
@@ -59,6 +59,30 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
### Codex with an OpenAI-compatible endpoint
|
||||
|
||||
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
@@ -342,6 +366,14 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
|
||||
For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists.
|
||||
|
||||
ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen.
|
||||
|
||||
### Example: Google Gemini CLI
|
||||
|
||||
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
|
||||
@@ -504,6 +536,12 @@ Each entry in the `models` array:
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
|
||||
@@ -157,8 +157,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
providers: Record<providerId, ProviderOverride>
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
@@ -362,6 +362,11 @@ Array of project records.
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
|
||||
---
|
||||
|
||||
## 7. Workspace Registry
|
||||
|
||||
@@ -216,7 +216,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
@@ -37,9 +37,22 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
|
||||
In any worktree-style or portless setup, never assume default ports.
|
||||
|
||||
### Desktop renderer profiling
|
||||
|
||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
|
||||
session, and agent-manager traces for stuck-state debugging.
|
||||
|
||||
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
|
||||
138
docs/hover.md
Normal file
138
docs/hover.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Hover
|
||||
|
||||
Read this before writing any hover code. Every hover regression we ship is one of the three failure modes below, and every one of them is solved by the same canonical pattern. The pattern is hardwon — it survived every other shape we tried — so copy it, don't reinvent it.
|
||||
|
||||
## The pattern
|
||||
|
||||
The canonical implementation lives in `packages/app/src/components/sidebar-workspace-list.tsx`, in the workspace row (around line 1369). When in doubt, open that file and copy the shape.
|
||||
|
||||
```tsx
|
||||
//
|
||||
// ┌─ Plain View. Tracks hover via pointerenter/pointerleave.
|
||||
// │
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable // ┐ Separate inner Pressable.
|
||||
onPress={handlePress} // │ Handles press only.
|
||||
onPressIn={...} // │ Never has onHoverIn/onHoverOut.
|
||||
onPressOut={...} // ┘
|
||||
style={workspaceRowStyle}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View style={styles.workspaceRowLeft}>…</View>
|
||||
<WorkspaceRowRightGroup isHovered={isHovered} />
|
||||
{/* └─ Reveals content based on hover state. */}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
```
|
||||
|
||||
Five things make this work. Every one of them matters.
|
||||
|
||||
1. **Hover lives on a plain `View`, not a `Pressable`.** `Pressable` carries its own internal hover state machine. Nested `Pressable`s fight over it. A plain `View` just dispatches DOM events — no state machine, no fighting.
|
||||
2. **Press lives on a _separate_ inner `Pressable`.** Hover and press never share an element. The two state machines never see each other.
|
||||
3. **`onPointerEnter` / `onPointerLeave` are non-bubbling**, mouseenter-style by W3C spec. They fire only when crossing the outer `View`'s bounding box. Crossing into descendants — including descendant `Pressable`s (the kebab menu's buttons, a copy button, a tooltip target) — does **not** fire `pointerleave`. This is why nesting `Pressable`s inside is safe.
|
||||
4. **The row has a fixed `minHeight`.** When content swaps in on hover (kebab replacing a diff stat), both occupy the same fixed slot. Zero layout shift, zero geometry flicker.
|
||||
5. **The outer `View` has nothing but `position: relative`.** It exists only to be the hover target. All real layout lives on the inner `Pressable`. The hover-tracker is a sealed envelope around the row; layout changes inside it never leak out and re-enter through the side.
|
||||
|
||||
That's the whole pattern. Internalize it.
|
||||
|
||||
## When you skip the pattern, here is what breaks
|
||||
|
||||
### Failure mode 1 — Nested Pressables fight over hover state
|
||||
|
||||
If you put `onHoverIn` / `onHoverOut` on a `Pressable` that has another `Pressable` anywhere inside it (a copy button, an icon button, a nested action), the moment the cursor moves onto the inner `Pressable`, the inner one's hover state machine claims hover and the outer one's `onHoverOut` fires. Your reveal state flips off. The reveal hides. The cursor is no longer over the hidden reveal, so it ends up back over the trigger area. The outer's `onHoverIn` fires. Loop.
|
||||
|
||||
This is the most common hover bug shipped in this codebase, by a wide margin. It is what the workspace row is structured to avoid. The fix is not "be clever about handlers" — it's "don't put hover on a Pressable that contains other Pressables."
|
||||
|
||||
> **Rule:** the hover-tracking element is a plain `View` with `onPointerEnter` / `onPointerLeave`. Any `Pressable`s — including ones you forgot are Pressables, like `TurnCopyButton`, icon buttons, anything that handles a tap — live inside it.
|
||||
|
||||
### Failure mode 2 — The hovered state changes the trigger's geometry
|
||||
|
||||
Symptom: you hover a button, it changes appearance, then flickers between hovered and not-hovered without the cursor moving.
|
||||
|
||||
Cause: the hover state changed the size or position of the trigger. The cursor was on the original element; the new layout shifts or shrinks it out from under the cursor; `onHoverOut` fires; state reverts; original layout returns; cursor is back over the trigger; `onHoverIn` fires; loop.
|
||||
|
||||
Common variants:
|
||||
|
||||
- Hover state changes the trigger's `width`, `height`, `padding`, or `borderWidth`.
|
||||
- Hover state mounts/unmounts a child that pushes the trigger to a new position.
|
||||
- Hover state swaps the trigger for a different element type, remounting it.
|
||||
|
||||
Fixes, in preferred order:
|
||||
|
||||
1. **Don't change the trigger's outer geometry on hover.** Change colors, opacity, borders that don't take layout space (`outlineWidth` on web, absolutely positioned overlays), or child content that fits inside the same fixed box. Never change `width`, `height`, `padding`, or `borderWidth` of the hover target itself.
|
||||
2. **Hide with `opacity` + `pointerEvents`, not conditional rendering**, when the hidden element lives inside the trigger. Mounting/unmounting on hover reflows the layout under the cursor.
|
||||
3. **Pin the hit area.** Set a fixed `minHeight` / `minWidth` on the trigger so internal swaps (icon-A becomes icon-B on hover) leave the bounding box unchanged. The workspace row's `minHeight: 36` is what makes the kebab/diff-stat swap stable.
|
||||
|
||||
### Failure mode 3 — Revealed content lives outside the hover trigger
|
||||
|
||||
If hovering element A reveals element B, B must be **inside** A's hover trigger. If B is a sibling, the moment the cursor moves from A toward B it crosses out of A's bounding box, `pointerleave` fires, B disappears.
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
<View>
|
||||
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
|
||||
<Bubble />
|
||||
</View>
|
||||
<TrailingRow /> {/* OUTSIDE — sibling, not child */}
|
||||
</View>
|
||||
```
|
||||
|
||||
Right:
|
||||
|
||||
```tsx
|
||||
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
|
||||
<Bubble />
|
||||
<TrailingRow /> {/* INSIDE — child */}
|
||||
</View>
|
||||
```
|
||||
|
||||
Any gap between A and B (margins between siblings inside the same parent) is part of the parent's bounding box, so the cursor stays inside the hover region while crossing it. No bridge needed.
|
||||
|
||||
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
|
||||
|
||||
## Native fallback
|
||||
|
||||
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
|
||||
|
||||
```tsx
|
||||
const showControls = isHovered || isNative || isCompact;
|
||||
```
|
||||
|
||||
`isNative` and `isCompact` come from `@/constants/platform` and `@/constants/layout`. Don't use `Platform.OS === "ios"` as a proxy.
|
||||
|
||||
`onPointerEnter` / `onPointerLeave` are DOM events. They do not fire on native. You do not need to gate them — on native, hover is unreachable anyway and visibility is driven by `isNative` / `isCompact` in your show-the-controls expression above. This is why the workspace row's pointer events are not wrapped in `if (isWeb)`.
|
||||
|
||||
## What about `Pressable.onHoverIn` / `onHoverOut`?
|
||||
|
||||
It's fine when a `Pressable` styles **itself** based on its own hover — for example, an icon button that changes color on hover. That's self-contained. The render-prop `<Pressable style={({ hovered }) => ...}>` does the same thing more cleanly and is the preferred form.
|
||||
|
||||
It is **not** fine for tracking hover to drive state **outside** that `Pressable` (revealing a sibling, opening a tooltip, showing a kebab) when there is any other `Pressable` inside — because that's Failure Mode 1.
|
||||
|
||||
Heuristic: if your hover state is going to be `useState`'d and read by anything other than the same `Pressable`'s own style, do not use `onHoverIn` / `onHoverOut`. Use the canonical pattern.
|
||||
|
||||
## Real gaps with floating panels
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
## Pre-PR checklist
|
||||
|
||||
Before opening a PR that touches hover:
|
||||
|
||||
- [ ] Hover-tracking is on a plain `View` with `onPointerEnter` / `onPointerLeave`, **not** on a `Pressable` that wraps anything pressable.
|
||||
- [ ] Any press behavior lives on a separate inner `Pressable` that does not have `onHoverIn` / `onHoverOut`.
|
||||
- [ ] The hover trigger's bounding box contains every element the user might mouse into while interacting with the feature.
|
||||
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
|
||||
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
|
||||
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
|
||||
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
|
||||
49
docs/opencode-global-event-baseline.md
Normal file
49
docs/opencode-global-event-baseline.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# OpenCode Global Event Verification
|
||||
|
||||
Date: 2026-05-11
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
|
||||
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
Each OpenCode test file was run independently with:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
|
||||
|
||||
## Post-Change Result
|
||||
|
||||
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
|
||||
|
||||
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
|
||||
|
||||
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `git diff --check`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
@@ -18,6 +18,16 @@ Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
|
||||
|
||||
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
|
||||
|
||||
---
|
||||
|
||||
## ACP Provider Checklist
|
||||
|
||||
### 1. Create the provider class
|
||||
|
||||
@@ -61,7 +61,7 @@ Use the beta path when you need to:
|
||||
|
||||
## Staged rollout (stable channel)
|
||||
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
|
||||
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
|
||||
|
||||
@@ -74,16 +74,16 @@ Updater clients only discover a release through those `.yml` manifests, so there
|
||||
|
||||
### Default behavior
|
||||
|
||||
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
|
||||
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
|
||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (default 24h ramp from tag push).
|
||||
# 1. Cut and publish (default 36h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
@@ -92,7 +92,7 @@ gh workflow run desktop-rollout.yml \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
|
||||
|
||||
@@ -132,7 +132,7 @@ gh workflow run desktop-release.yml \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
|
||||
### Releasing during an active rollout
|
||||
|
||||
|
||||
84
docs/rpc-namespacing.md
Normal file
84
docs/rpc-namespacing.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# RPC Namespacing
|
||||
|
||||
New WebSocket session RPCs use dotted names with the direction as the final segment:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
checkout.github.set_auto_merge.response;
|
||||
```
|
||||
|
||||
The namespace reads left to right:
|
||||
|
||||
- Domain: `checkout`
|
||||
- Provider or subsystem: `github`
|
||||
- Operation: `set_auto_merge`
|
||||
- Direction: `request` or `response`
|
||||
|
||||
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
|
||||
|
||||
## Request/Response Pairs
|
||||
|
||||
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
// -> checkout.github.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
|
||||
## Message Shape
|
||||
|
||||
Requests keep their parameters at the top level:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
mergeMethod: "squash",
|
||||
requestId: "req_123"
|
||||
}
|
||||
```
|
||||
|
||||
Responses put correlated result data under `payload`:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.response",
|
||||
payload: {
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "req_123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `requestId` in both request and response payloads. It is the correlation key.
|
||||
|
||||
## Provider Namespacing
|
||||
|
||||
Provider-specific behavior belongs under the provider segment:
|
||||
|
||||
- `checkout.github.*` for GitHub-specific checkout operations
|
||||
- `checkout.gitlab.*` for future GitLab-specific checkout operations
|
||||
|
||||
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The existing flat RPC names remain part of the protocol until they are intentionally migrated:
|
||||
|
||||
```ts
|
||||
checkout_pr_merge_request;
|
||||
checkout_pr_merge_response;
|
||||
```
|
||||
|
||||
Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind:
|
||||
|
||||
- Add the new names first.
|
||||
- Gate new feature behavior through `server_info.features.*` when an old host cannot support it.
|
||||
- Keep old names accepted until the compatibility window expires.
|
||||
- Mark shims with `COMPAT(...)` and a removal date.
|
||||
@@ -26,11 +26,18 @@
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
isLinux = nixpkgs.lib.elem system [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
// nixpkgs.lib.optionalAttrs isLinux {
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { };
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
|
||||
@@ -3,6 +3,8 @@ pre-commit:
|
||||
jobs:
|
||||
- name: format
|
||||
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
|
||||
exclude:
|
||||
- "**/package-lock.json"
|
||||
run: npm run format:check:files -- {staged_files}
|
||||
- name: lint
|
||||
glob: "*.{js,jsx,ts,tsx}"
|
||||
|
||||
157
nix/desktop-package.nix
Normal file
157
nix/desktop-package.nix
Normal file
@@ -0,0 +1,157 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
electron,
|
||||
libuv,
|
||||
# Shares the daemon's npm-deps hash — same package-lock.json, same fetcher.
|
||||
# Override via `.override { npmDepsHash = "..."; }` if your nixpkgs computes a
|
||||
# different value.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo-desktop";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter = path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude mobile-only platform code (we only need the web/electron build)
|
||||
!(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
# Website is unrelated to the desktop app
|
||||
&& !(lib.hasPrefix "/packages/website" relPath)
|
||||
# Test fixtures and build artifacts
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store"
|
||||
&& baseName != "release";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit npmDepsHash;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty)
|
||||
makeWrapper
|
||||
copyDesktopItems
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
|
||||
|
||||
dontNpmBuild = true;
|
||||
|
||||
env = {
|
||||
EXPO_NO_TELEMETRY = "1";
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
||||
npm rebuild node-pty
|
||||
|
||||
# Daemon workspaces (highlight + relay + server + cli)
|
||||
npm run build:daemon
|
||||
|
||||
# App workspace deps not covered by build:daemon
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Expo web export for the Electron renderer
|
||||
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
|
||||
|
||||
# Desktop main process (tsc only — NOT electron-builder)
|
||||
npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/paseo-desktop $out/bin
|
||||
|
||||
# Preserve the monorepo layout so main.js's dev-mode path resolution
|
||||
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
|
||||
# without patching: invoked unpackaged via `electron path/to/main.js`,
|
||||
# `app.isPackaged` is false, so these relative paths are used.
|
||||
#
|
||||
# Copy the entire packages/ tree (not just built artifacts) because npm
|
||||
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
|
||||
# Missing any workspace package leaves dangling symlinks and fails the
|
||||
# noBrokenSymlinks output check. The cleanSourceWith filter above already
|
||||
# drops the big platform-specific things (android/ios, website, tests).
|
||||
cp package.json $out/share/paseo-desktop/
|
||||
cp -a packages $out/share/paseo-desktop/
|
||||
cp -a node_modules $out/share/paseo-desktop/
|
||||
|
||||
# Skills directory referenced at runtime by some agents
|
||||
if [ -d skills ]; then
|
||||
cp -a skills $out/share/paseo-desktop/
|
||||
fi
|
||||
|
||||
# Hicolor icon for desktop environments
|
||||
install -Dm644 packages/desktop/assets/icon.png \
|
||||
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
||||
|
||||
# Launcher wraps nixpkgs electron.
|
||||
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
|
||||
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
|
||||
# `security.wrappers` via a NixOS module for users who want the sandbox.
|
||||
#
|
||||
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
|
||||
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
|
||||
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
|
||||
# exist here). Point it at the `paseo://` protocol handler instead, which
|
||||
# serves from `__dirname/../../app/dist` (our install layout matches).
|
||||
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
|
||||
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
|
||||
--add-flags "--no-sandbox" \
|
||||
--set EXPO_DEV_URL "paseo://app/"
|
||||
|
||||
copyDesktopItems
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "paseo-desktop";
|
||||
desktopName = "Paseo";
|
||||
genericName = "AI Coding Agents";
|
||||
comment = "Self-hosted daemon for AI coding agents";
|
||||
exec = "paseo-desktop";
|
||||
icon = "paseo-desktop";
|
||||
categories = [ "Development" ];
|
||||
startupWMClass = "Paseo";
|
||||
})
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
@@ -81,7 +81,48 @@ in
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
description = ''
|
||||
Whether to enable relay-based remote access. When false, the daemon
|
||||
runs with `--no-relay` and only accepts direct (LAN/loopback)
|
||||
connections.
|
||||
'';
|
||||
};
|
||||
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "hosted" "remote" ];
|
||||
default = "hosted";
|
||||
description = ''
|
||||
How the daemon reaches the relay when `relay.enable = true`:
|
||||
|
||||
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
|
||||
Preserves the current behavior; no extra options needed.
|
||||
- `"remote"`: connect to a self-hosted relay at
|
||||
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
|
||||
`PASEO_RELAY_USE_TLS` for the daemon.
|
||||
|
||||
A `"local"` mode (running a relay on the same host as a systemd
|
||||
unit) is not yet implemented — the relay package currently only
|
||||
ships a Cloudflare Workers adapter. Tracked separately.
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "relay.example.com";
|
||||
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
description = "Relay port. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
useTls = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,9 +152,50 @@ in
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = (pkgs.formats.json { }).type;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
daemon.mcp = { enabled = true; injectIntoAgents = false; };
|
||||
agents.providers.myAcp = {
|
||||
extends = "acp";
|
||||
label = "My Agent";
|
||||
command = { path = "/run/current-system/sw/bin/my-acp"; };
|
||||
};
|
||||
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
|
||||
and installed on every service start.
|
||||
|
||||
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
|
||||
or the mobile app toggling MCP injection / provider overrides) are
|
||||
overwritten on the next restart. Pick one: manage via this option, or
|
||||
manage via the CLI — not both.
|
||||
|
||||
The full schema is defined by `PersistedConfigSchema` in
|
||||
`packages/server/src/server/persisted-config.ts`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf cfg.enable (
|
||||
let
|
||||
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
|
||||
in
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
|
||||
message = ''
|
||||
services.paseo.relay.host must be set when relay.mode = "remote".
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
@@ -131,6 +213,10 @@ in
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = lib.mkIf (cfg.settings != { }) ''
|
||||
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
||||
'';
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
@@ -149,6 +235,9 @@ in
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
|
||||
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
|
||||
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
@@ -172,5 +261,6 @@ in
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
1
nix/npm-deps.hash
Normal file
1
nix/npm-deps.hash
Normal file
@@ -0,0 +1 @@
|
||||
sha256-1NkvtCT9TelnukukDD+jkIbwxLbWiNeMbXtJIzhTzv0=
|
||||
@@ -7,6 +7,15 @@
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
|
||||
# can override via `.override { npmDepsHash = "sha256-..."; }` without
|
||||
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
|
||||
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
|
||||
#
|
||||
# The default is read from a sidecar file so the CI auto-updater can replace
|
||||
# the hash with a single file write instead of a sed against this source.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
@@ -40,9 +49,9 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-qXCfTM7Q1PyXL53C+AFgFA5b99uznKaKomwmX2UcZHo=";
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
567
package-lock.json
generated
567
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.76",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
@@ -42,4 +43,23 @@ test.describe("Agent stream UI", () => {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows elapsed timer on first app-created running turn", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "stream-first-app-turn-timer-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await page.getByText("Model defaults are still loading").waitFor({
|
||||
state: "hidden",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const prompt = "Stream briefly for first app-created turn timer test.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await page.getByText(prompt, { exact: true }).first().waitFor({ state: "visible" });
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await page.getByTestId("turn-working-elapsed").waitFor({ state: "visible", timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["PaseoHardwareKeyboardModule"],
|
||||
"reactDelegateHandlers": ["PaseoHardwareKeyboardReactDelegateHandler"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
require 'json'
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'PaseoHardwareKeyboard'
|
||||
s.version = '0.1.0'
|
||||
s.summary = 'Hardware keyboard shortcuts for Paseo'
|
||||
s.description = 'Hardware keyboard shortcuts for Paseo'
|
||||
s.license = 'AGPL-3.0-or-later'
|
||||
s.author = 'Paseo'
|
||||
s.homepage = 'https://paseo.sh'
|
||||
s.platforms = { :ios => '13.4' }
|
||||
s.swift_version = '5.4'
|
||||
s.source = { :path => '.' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "**/*.{h,m,swift}"
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
|
||||
private let hardwareSubmitEventName = "onHardwareKeyboardSubmit"
|
||||
|
||||
private weak var activeModule: PaseoHardwareKeyboardModule?
|
||||
private var isHardwareSubmitEnabled = false
|
||||
|
||||
@objc
|
||||
public class PaseoHardwareKeyboardReactDelegateHandler: ExpoReactDelegateHandler {
|
||||
public override func createRootViewController() -> UIViewController? {
|
||||
return PaseoHardwareKeyboardRootViewController()
|
||||
}
|
||||
}
|
||||
|
||||
public class PaseoHardwareKeyboardModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("PaseoHardwareKeyboard")
|
||||
|
||||
Events(hardwareSubmitEventName)
|
||||
|
||||
OnCreate {
|
||||
activeModule = self
|
||||
}
|
||||
|
||||
Function("setHardwareKeyboardSubmitEnabled") { (enabled: Bool) in
|
||||
DispatchQueue.main.async {
|
||||
isHardwareSubmitEnabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
if activeModule === self {
|
||||
activeModule = nil
|
||||
}
|
||||
isHardwareSubmitEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func emitHardwareKeyboardSubmit() {
|
||||
sendEvent(hardwareSubmitEventName, [:])
|
||||
}
|
||||
}
|
||||
|
||||
private final class PaseoHardwareKeyboardRootViewController: UIViewController {
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
guard isHardwareSubmitEnabled && UIDevice.current.userInterfaceIdiom == .pad else {
|
||||
return super.keyCommands
|
||||
}
|
||||
|
||||
let command = UIKeyCommand(
|
||||
input: "\r",
|
||||
modifierFlags: [],
|
||||
action: #selector(handleHardwareKeyboardSubmit(_:))
|
||||
)
|
||||
if #available(iOS 15.0, *) {
|
||||
command.wantsPriorityOverSystemBehavior = true
|
||||
}
|
||||
return (super.keyCommands ?? []) + [command]
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleHardwareKeyboardSubmit(_ sender: UIKeyCommand) {
|
||||
guard canSubmitCurrentTextInput() else {
|
||||
return
|
||||
}
|
||||
activeModule?.emitHardwareKeyboardSubmit()
|
||||
}
|
||||
|
||||
private func canSubmitCurrentTextInput() -> Bool {
|
||||
guard let responder = UIResponder.paseoCurrentFirstResponder else {
|
||||
return false
|
||||
}
|
||||
guard let textInput = responder as? UITextInput else {
|
||||
return false
|
||||
}
|
||||
return textInput.markedTextRange == nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIResponder {
|
||||
private static weak var currentFirstResponder: UIResponder?
|
||||
|
||||
static var paseoCurrentFirstResponder: UIResponder? {
|
||||
currentFirstResponder = nil
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(captureCurrentFirstResponder(_:)),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
return currentFirstResponder
|
||||
}
|
||||
|
||||
@objc
|
||||
private func captureCurrentFirstResponder(_ sender: Any?) {
|
||||
UIResponder.currentFirstResponder = self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "paseo-hardware-keyboard",
|
||||
"version": "0.1.0",
|
||||
"private": true
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.76",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -63,6 +63,7 @@
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "^17.0.8",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
|
||||
@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||
|
||||
export default function HostNewWorkspaceRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string;
|
||||
dir?: string;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
|
||||
if (!sourceDirectory) {
|
||||
return null;
|
||||
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
|
||||
serverId={serverId}
|
||||
sourceDirectory={sourceDirectory}
|
||||
displayName={displayName}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
ShieldQuestionMark,
|
||||
Zap,
|
||||
} from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
@@ -180,6 +181,7 @@ const MODE_ICONS = {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
ShieldQuestionMark,
|
||||
} as const;
|
||||
|
||||
function alwaysTrue() {
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
const head = [assistantMessage("live-a", 121)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
@@ -52,6 +53,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
const head = [assistantMessage("live-a", 3)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
@@ -69,12 +71,14 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
const secondHead = [assistantMessage("live-b", 4)];
|
||||
|
||||
const first = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head: firstHead,
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const second = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head: secondHead,
|
||||
platform: "native",
|
||||
@@ -85,4 +89,73 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
|
||||
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]);
|
||||
});
|
||||
|
||||
it("derives running turn timing across committed history and live head", () => {
|
||||
const tail = [userMessage("u1", 1)];
|
||||
const head = [assistantMessage("live-a", 4)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
|
||||
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps completed turn timing to assistant ids across committed history and live head", () => {
|
||||
const tail = [userMessage("u1", 1)];
|
||||
const head = [assistantMessage("live-a", 4)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "idle",
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: head[0]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the same timing for native inverted rendering", () => {
|
||||
const tail = [userMessage("u1", 1), assistantMessage("a1", 4)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "idle",
|
||||
tail,
|
||||
head: [],
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create completed timing for adjacent user messages", () => {
|
||||
const tail = [userMessage("u1", 1), userMessage("u2", 4)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "idle",
|
||||
tail,
|
||||
head: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
findMountedWindowStart,
|
||||
@@ -26,17 +27,19 @@ export interface StreamHistoryBoundary {
|
||||
|
||||
export interface StreamRenderAuxiliary {
|
||||
pendingPermissions: ReactNode;
|
||||
workingIndicator: ReactNode;
|
||||
turnFooter: ReactNode;
|
||||
}
|
||||
|
||||
export interface AgentStreamRenderModel {
|
||||
history: StreamItem[];
|
||||
segments: StreamRenderSegments;
|
||||
turnTiming: StreamTurnTiming;
|
||||
boundary: StreamHistoryBoundary;
|
||||
auxiliary: StreamRenderAuxiliary;
|
||||
}
|
||||
|
||||
export interface BuildAgentStreamRenderModelInput {
|
||||
agentStatus: string;
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
platform: "web" | "native";
|
||||
@@ -46,7 +49,7 @@ export interface BuildAgentStreamRenderModelInput {
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
|
||||
pendingPermissions: null,
|
||||
workingIndicator: null,
|
||||
turnFooter: null,
|
||||
};
|
||||
|
||||
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
|
||||
@@ -55,6 +58,10 @@ const splitHistoryCache = new WeakMap<
|
||||
StreamItem[],
|
||||
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
|
||||
>();
|
||||
const turnTimingCache = new WeakMap<
|
||||
StreamItem[],
|
||||
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
|
||||
>();
|
||||
|
||||
function getOrderedItems(params: {
|
||||
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
|
||||
@@ -127,6 +134,30 @@ function splitOrderedTail(params: {
|
||||
return split;
|
||||
}
|
||||
|
||||
function getTurnTiming(params: {
|
||||
agentStatus: string;
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): StreamTurnTiming {
|
||||
let cachedByHead = turnTimingCache.get(params.tail);
|
||||
if (!cachedByHead) {
|
||||
cachedByHead = new WeakMap();
|
||||
turnTimingCache.set(params.tail, cachedByHead);
|
||||
}
|
||||
let cachedByStatus = cachedByHead.get(params.head);
|
||||
if (!cachedByStatus) {
|
||||
cachedByStatus = new Map();
|
||||
cachedByHead.set(params.head, cachedByStatus);
|
||||
}
|
||||
const cached = cachedByStatus.get(params.agentStatus);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const timing = deriveStreamTurnTiming(params);
|
||||
cachedByStatus.set(params.agentStatus, timing);
|
||||
return timing;
|
||||
}
|
||||
|
||||
export function buildAgentStreamRenderModel(
|
||||
input: BuildAgentStreamRenderModelInput,
|
||||
): AgentStreamRenderModel {
|
||||
@@ -160,6 +191,11 @@ export function buildAgentStreamRenderModel(
|
||||
platform: input.platform,
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const turnTiming = getTurnTiming({
|
||||
agentStatus: input.agentStatus,
|
||||
tail: input.tail,
|
||||
head: input.head,
|
||||
});
|
||||
|
||||
return {
|
||||
history: splitHistory.history,
|
||||
@@ -167,6 +203,7 @@ export function buildAgentStreamRenderModel(
|
||||
...splitHistory.segments,
|
||||
liveHead: orderedHead,
|
||||
},
|
||||
turnTiming,
|
||||
boundary: {
|
||||
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
|
||||
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
|
||||
|
||||
287
packages/app/src/components/agent-stream-turn-footer.tsx
Normal file
287
packages/app/src/components/agent-stream-turn-footer.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { memo, useCallback, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
getWorkingIndicatorDotStrength,
|
||||
WORKING_INDICATOR_CYCLE_MS,
|
||||
WORKING_INDICATOR_OFFSETS,
|
||||
} from "@/utils/working-indicator";
|
||||
import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
|
||||
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
|
||||
export interface TurnFooterHost {
|
||||
itemId: string;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
export function resolveBottomTurnFooterHost(input: {
|
||||
agentStatus: string;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
isInverted: boolean;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
const usesLiveHead = input.liveHead.length > 0;
|
||||
const footerItems = usesLiveHead ? input.liveHead : input.history;
|
||||
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
|
||||
const item = footerItems[startIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
itemId: item.id,
|
||||
items: footerItems,
|
||||
timing: input.timingByAssistantId.get(item.id),
|
||||
startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRenderCompletedTurnFooter(input: {
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | undefined;
|
||||
agentStatus: string;
|
||||
suppressTurnFooter: boolean | undefined;
|
||||
}): boolean {
|
||||
return (
|
||||
input.item.kind === "assistant_message" &&
|
||||
!input.suppressTurnFooter &&
|
||||
(input.belowItem?.kind === "user_message" ||
|
||||
(input.belowItem === undefined && input.agentStatus !== "running"))
|
||||
);
|
||||
}
|
||||
|
||||
export const TurnFooter = memo(function TurnFooter({
|
||||
isRunning,
|
||||
inFlightTurnStartedAt,
|
||||
host,
|
||||
strategy,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
inFlightTurnStartedAt: Date | null;
|
||||
host: TurnFooterHost | null;
|
||||
strategy: TurnContentStrategy;
|
||||
}) {
|
||||
if (isRunning) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
||||
</TurnFooterRow>
|
||||
);
|
||||
}
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<CompletedTurnFooterRow
|
||||
strategy={strategy}
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
strategy,
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
<CompletedTurnFooter
|
||||
strategy={strategy}
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
});
|
||||
|
||||
const WorkingIndicator = memo(function WorkingIndicator({
|
||||
inFlightTurnStartedAt = null,
|
||||
}: {
|
||||
inFlightTurnStartedAt?: Date | null;
|
||||
}) {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = 0;
|
||||
progress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: WORKING_INDICATOR_CYCLE_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelAnimation(progress);
|
||||
progress.value = 0;
|
||||
};
|
||||
}, [progress]);
|
||||
|
||||
const translateDistance = -2;
|
||||
const dotOneStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotTwoStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotThreeStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
|
||||
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
|
||||
const dotThreeCombinedStyle = useMemo(
|
||||
() => [stylesheet.workingDot, dotThreeStyle],
|
||||
[dotThreeStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={stylesheet.turnFooterContent}>
|
||||
<View style={stylesheet.workingDotsRow}>
|
||||
<Animated.View style={dotOneCombinedStyle} />
|
||||
<Animated.View style={dotTwoCombinedStyle} />
|
||||
<Animated.View style={dotThreeCombinedStyle} />
|
||||
</View>
|
||||
{inFlightTurnStartedAt ? (
|
||||
<LiveElapsed
|
||||
startedAt={inFlightTurnStartedAt}
|
||||
style={stylesheet.workingElapsed}
|
||||
testID="turn-working-elapsed"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) {
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
|
||||
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CompletedTurnFooter({
|
||||
strategy,
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}) {
|
||||
const getContent = useCallback(
|
||||
() =>
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy,
|
||||
items,
|
||||
startIndex,
|
||||
}),
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnFooterRow({ children }: { children: ReactNode }) {
|
||||
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
|
||||
return <View style={rowStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
turnFooterRow: {
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
turnFooterSlot: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
minHeight: 24,
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
turnFooterContent: {
|
||||
height: 24,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
workingElapsed: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
workingDotsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
workingDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -1,20 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { NeighborResolver } from "./agent-stream-view-data";
|
||||
import {
|
||||
getAssistantBlockSpacing,
|
||||
isSameAssistantBlockGroup,
|
||||
resolveInlineWorkingIndicatorItemId,
|
||||
} from "./agent-stream-view-data";
|
||||
|
||||
// Minimal forward-order resolver: "below" = next item in array.
|
||||
// Matches web strategy rendering order (chronological, top-to-bottom).
|
||||
const forwardStrategy: NeighborResolver = {
|
||||
getNeighborItem(items, index, relation) {
|
||||
const neighborIndex = relation === "below" ? index + 1 : index - 1;
|
||||
return items[neighborIndex];
|
||||
},
|
||||
};
|
||||
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data";
|
||||
|
||||
function assistantBlock(params: {
|
||||
id: string;
|
||||
@@ -135,46 +121,3 @@ describe("getAssistantBlockSpacing", () => {
|
||||
).toBe("compactTop");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInlineWorkingIndicatorItemId", () => {
|
||||
it("returns null when the agent is not running", () => {
|
||||
const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the last assistant block id when running with a single head block", () => {
|
||||
const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 });
|
||||
expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe(
|
||||
"group-1:head",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => {
|
||||
const tc = toolCallBlock("tool-1");
|
||||
expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the footer assistant block when history and streaming head coexist", () => {
|
||||
const historyBlock = assistantBlock({
|
||||
id: "group-1:block:0",
|
||||
blockGroupId: "group-1",
|
||||
blockIndex: 0,
|
||||
});
|
||||
const streamingBlock = assistantBlock({
|
||||
id: "group-2:head",
|
||||
blockGroupId: "group-2",
|
||||
blockIndex: 0,
|
||||
});
|
||||
// historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block
|
||||
expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe(
|
||||
"group-2:head",
|
||||
);
|
||||
expect(
|
||||
resolveInlineWorkingIndicatorItemId(
|
||||
"running",
|
||||
[historyBlock, streamingBlock],
|
||||
forwardStrategy,
|
||||
),
|
||||
).toBe("group-2:head");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
export function isSameAssistantBlockGroup(params: {
|
||||
item: StreamItem | null | undefined;
|
||||
@@ -28,24 +29,35 @@ export function getAssistantBlockSpacing(params: {
|
||||
return "default";
|
||||
}
|
||||
|
||||
export interface NeighborResolver {
|
||||
getNeighborItem(
|
||||
items: StreamItem[],
|
||||
index: number,
|
||||
relation: "above" | "below",
|
||||
): StreamItem | undefined;
|
||||
}
|
||||
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem | null) =>
|
||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
|
||||
// null → auxiliary working indicator; non-null → inline footer on that block.
|
||||
export function resolveInlineWorkingIndicatorItemId(
|
||||
status: string,
|
||||
liveHeadItems: StreamItem[],
|
||||
strategy: NeighborResolver,
|
||||
): string | null {
|
||||
if (status !== "running") return null;
|
||||
const footerItem = liveHeadItems.find((item, index, items) => {
|
||||
if (item.kind !== "assistant_message") return false;
|
||||
return strategy.getNeighborItem(items, index, "below") === undefined;
|
||||
});
|
||||
return footerItem?.id ?? null;
|
||||
export function getGapBetweenStreamItems(
|
||||
item: StreamItem | null,
|
||||
belowItem: StreamItem | null,
|
||||
): number {
|
||||
if (!item || !belowItem) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||
return 0;
|
||||
}
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return SPACING[4];
|
||||
}
|
||||
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
return SPACING[1];
|
||||
}
|
||||
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
|
||||
return SPACING[3];
|
||||
}
|
||||
return SPACING[4];
|
||||
}
|
||||
|
||||
@@ -17,20 +17,13 @@ import {
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
@@ -41,7 +34,6 @@ import {
|
||||
ToolCall,
|
||||
TodoListCard,
|
||||
CompactionMarker,
|
||||
TurnCopyButton,
|
||||
MessageOuterSpacingProvider,
|
||||
type InlinePathTarget,
|
||||
} from "./message";
|
||||
@@ -63,42 +55,177 @@ import { QuestionFormCard } from "./question-form-card";
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import {
|
||||
buildAgentStreamRenderModel,
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
getStreamNeighborItem,
|
||||
resolveStreamRenderStrategy,
|
||||
type AgentStreamRenderModel,
|
||||
type StreamSegmentRenderers,
|
||||
type StreamViewportHandle,
|
||||
} from "./agent-stream-render-strategy";
|
||||
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
|
||||
import {
|
||||
getAssistantBlockSpacing,
|
||||
isSameAssistantBlockGroup,
|
||||
resolveInlineWorkingIndicatorItemId,
|
||||
} from "./agent-stream-view-data";
|
||||
CompletedTurnFooterRow,
|
||||
resolveBottomTurnFooterHost,
|
||||
shouldRenderCompletedTurnFooter,
|
||||
TurnFooter,
|
||||
type TurnContentStrategy,
|
||||
type TurnFooterHost,
|
||||
} from "./agent-stream-turn-footer";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
getWorkingIndicatorDotStrength,
|
||||
WORKING_INDICATOR_CYCLE_MS,
|
||||
WORKING_INDICATOR_OFFSETS,
|
||||
} from "@/utils/working-indicator";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface StreamItemBoundarySeams {
|
||||
aboveItem?: StreamItem | null;
|
||||
belowItem?: StreamItem | null;
|
||||
suppressTurnFooter?: boolean;
|
||||
}
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
turnFooter: ReactNode;
|
||||
}): ReactNode {
|
||||
if (!input.pendingPermissions && !input.turnFooter) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{input.turnFooter}
|
||||
{input.pendingPermissions ? (
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
<View style={stylesheet.listHeaderContent}>{input.pendingPermissions}</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPendingPermissionsNode(input: {
|
||||
pendingPermissions: PendingPermission[];
|
||||
client: DaemonClient | null;
|
||||
}): ReactNode {
|
||||
if (input.pendingPermissions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{input.pendingPermissions.map((permission) => (
|
||||
<PermissionRequestCard key={permission.key} permission={permission} client={input.client} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
item: StreamItem;
|
||||
nextItem: StreamItem | undefined;
|
||||
items: StreamItem[];
|
||||
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
|
||||
index: number;
|
||||
agentStatus: string;
|
||||
suppressTurnFooter: boolean | undefined;
|
||||
strategy: TurnContentStrategy;
|
||||
}): ReactNode {
|
||||
if (!input.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showCompletedFooter = shouldRenderCompletedTurnFooter({
|
||||
item: input.item,
|
||||
belowItem: input.nextItem,
|
||||
agentStatus: input.agentStatus,
|
||||
suppressTurnFooter: input.suppressTurnFooter,
|
||||
});
|
||||
const gapBelow = showCompletedFooter
|
||||
? 0
|
||||
: getGapBetweenStreamItems(input.item, input.nextItem ?? null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper>
|
||||
{showCompletedFooter ? (
|
||||
<CompletedTurnFooterRow
|
||||
strategy={input.strategy}
|
||||
items={input.items}
|
||||
timing={input.timing.get(input.item.id)}
|
||||
startIndex={input.index}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderListEmptyComponent(input: {
|
||||
renderModel: AgentStreamRenderModel;
|
||||
emptyStateStyle: StyleProp<ViewStyle>;
|
||||
}): ReactNode {
|
||||
if (
|
||||
input.renderModel.boundary.hasVirtualizedHistory ||
|
||||
input.renderModel.boundary.hasMountedHistory ||
|
||||
input.renderModel.boundary.hasLiveHead ||
|
||||
input.renderModel.auxiliary.pendingPermissions ||
|
||||
input.renderModel.auxiliary.turnFooter
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={input.emptyStateStyle}>
|
||||
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderHistoryStreamItem(input: {
|
||||
item: StreamItem;
|
||||
historyIndexById: Map<string, number>;
|
||||
historyItems: StreamItem[];
|
||||
lastHistoryItem: StreamItem | null;
|
||||
firstLiveHeadItem: StreamItem | null;
|
||||
bottomTurnFooterHost: TurnFooterHost | null;
|
||||
renderStreamItem: (
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seams?: StreamItemBoundarySeams,
|
||||
) => ReactNode;
|
||||
}): ReactNode {
|
||||
const historyIndex = input.historyIndexById.get(input.item.id);
|
||||
if (historyIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
const seamBelowItem =
|
||||
input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null;
|
||||
return input.renderStreamItem(input.item, historyIndex, input.historyItems, {
|
||||
belowItem: seamBelowItem,
|
||||
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
function renderLiveHeadStreamItem(input: {
|
||||
item: StreamItem;
|
||||
index: number;
|
||||
items: StreamItem[];
|
||||
lastHistoryItem: StreamItem | null;
|
||||
bottomTurnFooterHost: TurnFooterHost | null;
|
||||
renderStreamItem: (
|
||||
item: StreamItem,
|
||||
index: number,
|
||||
items: StreamItem[],
|
||||
seams?: StreamItemBoundarySeams,
|
||||
) => ReactNode;
|
||||
}): ReactNode {
|
||||
return input.renderStreamItem(input.item, input.index, input.items, {
|
||||
aboveItem: input.index === 0 ? input.lastHistoryItem : null,
|
||||
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
export interface AgentStreamViewHandle {
|
||||
@@ -118,6 +245,8 @@ export interface AgentStreamViewProps {
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -259,21 +388,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
agentStatus: agent.status,
|
||||
tail: streamItems,
|
||||
head: streamHead ?? [],
|
||||
head: streamHead ?? EMPTY_STREAM_HEAD,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
const inlineWorkingIndicatorItemId = useMemo(
|
||||
() =>
|
||||
resolveInlineWorkingIndicatorItemId(
|
||||
agent.status,
|
||||
baseRenderModel.segments.liveHead,
|
||||
streamRenderStrategy,
|
||||
),
|
||||
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
|
||||
);
|
||||
}, [agent.status, isMobile, streamHead, streamItems]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -291,39 +412,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
||||
}, []);
|
||||
|
||||
const tightGap = SPACING[1];
|
||||
const assistantBlockGap = SPACING[3];
|
||||
const looseGap = SPACING[4];
|
||||
|
||||
const getGapBetween = useCallback(
|
||||
(item: StreamItem | null, belowItem: StreamItem | null) => {
|
||||
if (!item || !belowItem) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||
return 0;
|
||||
}
|
||||
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||
return looseGap;
|
||||
}
|
||||
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
|
||||
return tightGap;
|
||||
}
|
||||
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||
return looseGap;
|
||||
}
|
||||
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
|
||||
return assistantBlockGap;
|
||||
}
|
||||
return looseGap;
|
||||
},
|
||||
[assistantBlockGap, looseGap, tightGap],
|
||||
);
|
||||
|
||||
const setInlineDetailsExpanded = useCallback(
|
||||
(itemId: string, expanded: boolean) => {
|
||||
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
|
||||
@@ -418,11 +506,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
client={client}
|
||||
toast={toast}
|
||||
spacing={spacing}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client],
|
||||
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
|
||||
);
|
||||
|
||||
const renderThoughtItem = useCallback(
|
||||
@@ -540,7 +629,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return <TodoListCard items={item.items} />;
|
||||
|
||||
case "compaction":
|
||||
return <CompactionMarker status={item.status} preTokens={item.preTokens} />;
|
||||
return (
|
||||
<CompactionMarker
|
||||
status={item.status}
|
||||
trigger={item.trigger}
|
||||
preTokens={item.preTokens}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
@@ -549,6 +644,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
|
||||
);
|
||||
|
||||
const bottomTurnFooterHost = useMemo(() => {
|
||||
return resolveBottomTurnFooterHost({
|
||||
agentStatus: agent.status,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
isInverted: streamRenderStrategy.getFlatListInverted(),
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
});
|
||||
}, [
|
||||
agent.status,
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
streamRenderStrategy,
|
||||
]);
|
||||
|
||||
const renderStreamItem = useCallback(
|
||||
(
|
||||
item: StreamItem,
|
||||
@@ -557,45 +668,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
seams: StreamItemBoundarySeams = {},
|
||||
) => {
|
||||
const content = renderStreamItemContent(item, index, items, seams);
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextItem = getStreamNeighborItem({
|
||||
strategy: streamRenderStrategy,
|
||||
items,
|
||||
index,
|
||||
relation: "below",
|
||||
});
|
||||
const gapBelow = getGapBetween(item, nextItem ?? null);
|
||||
const isEndOfAssistantTurn =
|
||||
item.kind === "assistant_message" &&
|
||||
(nextItem?.kind === "user_message" ||
|
||||
(nextItem === undefined && agent.status !== "running"));
|
||||
const isRunningAssistantTurnFooter =
|
||||
item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
|
||||
let footer: ReactNode = null;
|
||||
if (isRunningAssistantTurnFooter) {
|
||||
footer = <InlineWorkingIndicatorSlot />;
|
||||
} else if (isEndOfAssistantTurn) {
|
||||
footer = (
|
||||
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StreamItemWrapper gapBelow={gapBelow}>
|
||||
{content}
|
||||
{footer}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
return renderStreamItemWithTurnFooter({
|
||||
content,
|
||||
item,
|
||||
nextItem,
|
||||
items,
|
||||
timing: baseRenderModel.turnTiming.byAssistantId,
|
||||
index,
|
||||
agentStatus: agent.status,
|
||||
suppressTurnFooter: seams.suppressTurnFooter,
|
||||
strategy: streamRenderStrategy,
|
||||
});
|
||||
},
|
||||
[
|
||||
getGapBetween,
|
||||
renderStreamItemContent,
|
||||
agent.status,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
renderStreamItemContent,
|
||||
streamRenderStrategy,
|
||||
inlineWorkingIndicatorItemId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -604,66 +699,56 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[pendingPermissions, agentId],
|
||||
);
|
||||
|
||||
const showAuxiliaryWorkingIndicator =
|
||||
agent.status === "running" && inlineWorkingIndicatorItemId === null;
|
||||
const showRunningTurnFooter = agent.status === "running";
|
||||
const pendingPermissionsNode = useMemo(
|
||||
() =>
|
||||
pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
|
||||
))}
|
||||
</View>
|
||||
) : null,
|
||||
renderPendingPermissionsNode({
|
||||
pendingPermissions: pendingPermissionItems,
|
||||
client,
|
||||
}),
|
||||
[client, pendingPermissionItems],
|
||||
);
|
||||
const workingIndicatorNode = useMemo(
|
||||
const turnFooterNode = useMemo(
|
||||
() =>
|
||||
showAuxiliaryWorkingIndicator ? (
|
||||
<View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
|
||||
<WorkingIndicator />
|
||||
</View>
|
||||
showRunningTurnFooter || bottomTurnFooterHost ? (
|
||||
<TurnFooter
|
||||
isRunning={showRunningTurnFooter}
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
/>
|
||||
) : null,
|
||||
[showAuxiliaryWorkingIndicator],
|
||||
[
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
);
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
return {
|
||||
...baseRenderModel,
|
||||
boundary: {
|
||||
...baseRenderModel.boundary,
|
||||
historyToHeadGap: getGapBetween(
|
||||
historyToHeadGap: getGapBetweenStreamItems(
|
||||
baseRenderModel.history.at(-1) ?? null,
|
||||
baseRenderModel.segments.liveHead[0] ?? null,
|
||||
),
|
||||
},
|
||||
auxiliary: {
|
||||
pendingPermissions: pendingPermissionsNode,
|
||||
workingIndicator: workingIndicatorNode,
|
||||
turnFooter: turnFooterNode,
|
||||
},
|
||||
};
|
||||
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
|
||||
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
|
||||
|
||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||
const listEmptyComponent = useMemo(() => {
|
||||
if (
|
||||
renderModel.boundary.hasVirtualizedHistory ||
|
||||
renderModel.boundary.hasMountedHistory ||
|
||||
renderModel.boundary.hasLiveHead ||
|
||||
renderModel.auxiliary.pendingPermissions ||
|
||||
renderModel.auxiliary.workingIndicator
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={emptyStateStyle}>
|
||||
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
|
||||
</View>
|
||||
);
|
||||
}, [renderModel, emptyStateStyle]);
|
||||
const listEmptyComponent = useMemo(
|
||||
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
|
||||
[renderModel, emptyStateStyle],
|
||||
);
|
||||
|
||||
const historyItems = renderModel.history;
|
||||
const _liveHeadItems = renderModel.segments.liveHead;
|
||||
const { boundary, auxiliary } = renderModel;
|
||||
const lastHistoryItem = historyItems.at(-1) ?? null;
|
||||
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
|
||||
@@ -677,17 +762,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}, [historyItems]);
|
||||
|
||||
const renderHistoryRow = useCallback(
|
||||
(item: StreamItem) => {
|
||||
const historyIndex = historyIndexById.get(item.id);
|
||||
if (historyIndex === undefined) {
|
||||
return null;
|
||||
}
|
||||
const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null;
|
||||
return renderStreamItem(item, historyIndex, historyItems, {
|
||||
belowItem: seamBelowItem,
|
||||
});
|
||||
},
|
||||
[firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem],
|
||||
(item: StreamItem) =>
|
||||
renderHistoryStreamItem({
|
||||
item,
|
||||
historyIndexById,
|
||||
historyItems,
|
||||
lastHistoryItem,
|
||||
firstLiveHeadItem,
|
||||
bottomTurnFooterHost,
|
||||
renderStreamItem,
|
||||
}),
|
||||
[
|
||||
bottomTurnFooterHost,
|
||||
firstLiveHeadItem,
|
||||
historyIndexById,
|
||||
historyItems,
|
||||
lastHistoryItem,
|
||||
renderStreamItem,
|
||||
],
|
||||
);
|
||||
|
||||
const renderHistoryVirtualizedRow = useCallback<
|
||||
@@ -699,32 +791,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
||||
(item, index, items) =>
|
||||
renderStreamItem(item, index, items, {
|
||||
aboveItem: index === 0 ? lastHistoryItem : null,
|
||||
renderLiveHeadStreamItem({
|
||||
item,
|
||||
index,
|
||||
items,
|
||||
lastHistoryItem,
|
||||
bottomTurnFooterHost,
|
||||
renderStreamItem,
|
||||
}),
|
||||
[lastHistoryItem, renderStreamItem],
|
||||
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem],
|
||||
);
|
||||
const liveAuxiliaryHeaderStyle = useMemo(() => {
|
||||
let headerPadding: { paddingBottom: number } | { paddingTop: number } | null;
|
||||
if (!boundary.hasLiveHead) headerPadding = null;
|
||||
else if (streamRenderStrategy.getFlatListInverted())
|
||||
headerPadding = { paddingBottom: looseGap };
|
||||
else headerPadding = { paddingTop: looseGap };
|
||||
return [stylesheet.listHeaderContent, headerPadding];
|
||||
}, [boundary.hasLiveHead, streamRenderStrategy, looseGap]);
|
||||
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
|
||||
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
<View style={liveAuxiliaryHeaderStyle}>
|
||||
{auxiliary.pendingPermissions}
|
||||
{auxiliary.workingIndicator}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, liveAuxiliaryHeaderStyle]);
|
||||
return renderLiveAuxiliaryNode({
|
||||
pendingPermissions: auxiliary.pendingPermissions,
|
||||
turnFooter: auxiliary.turnFooter,
|
||||
});
|
||||
}, [auxiliary.pendingPermissions, auxiliary.turnFooter]);
|
||||
|
||||
const renderers = useMemo<StreamSegmentRenderers>(
|
||||
() => ({
|
||||
@@ -796,106 +878,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
export const AgentStreamView = memo(AgentStreamViewComponent);
|
||||
AgentStreamView.displayName = "AgentStreamView";
|
||||
|
||||
function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = 0;
|
||||
progress.value = withRepeat(
|
||||
withTiming(1, {
|
||||
duration: WORKING_INDICATOR_CYCLE_MS,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelAnimation(progress);
|
||||
progress.value = 0;
|
||||
};
|
||||
}, [progress]);
|
||||
|
||||
const translateDistance = -2;
|
||||
const dotOneStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotTwoStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotThreeStyle = useAnimatedStyle(() => {
|
||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
|
||||
return {
|
||||
opacity: 0.3 + strength * 0.7,
|
||||
transform: [{ translateY: strength * translateDistance }],
|
||||
};
|
||||
});
|
||||
|
||||
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
|
||||
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
|
||||
const dotThreeCombinedStyle = useMemo(
|
||||
() => [stylesheet.workingDot, dotThreeStyle],
|
||||
[dotThreeStyle],
|
||||
);
|
||||
|
||||
const containerStyle =
|
||||
variant === "inline"
|
||||
? stylesheet.inlineWorkingIndicatorFrame
|
||||
: stylesheet.workingIndicatorBubble;
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={stylesheet.workingDotsRow}>
|
||||
<Animated.View style={dotOneCombinedStyle} />
|
||||
<Animated.View style={dotTwoCombinedStyle} />
|
||||
<Animated.View style={dotThreeCombinedStyle} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineWorkingIndicatorSlot() {
|
||||
return (
|
||||
<View style={stylesheet.inlineTurnFooter} testID="turn-working-indicator">
|
||||
<WorkingIndicator variant="inline" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Permission Request Card Component
|
||||
type TurnContentStrategy = Parameters<
|
||||
typeof collectAssistantTurnContentForStreamRenderStrategy
|
||||
>[0]["strategy"];
|
||||
|
||||
interface TurnCopyButtonSlotProps {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) {
|
||||
const getContent = useCallback(
|
||||
() =>
|
||||
collectAssistantTurnContentForStreamRenderStrategy({
|
||||
strategy,
|
||||
items,
|
||||
startIndex,
|
||||
}),
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
return <TurnCopyButton getContent={getContent} />;
|
||||
}
|
||||
|
||||
interface ToolCallSlotProps extends Omit<
|
||||
ComponentProps<typeof ToolCall>,
|
||||
"onInlineDetailsExpandedChange"
|
||||
@@ -1192,6 +1174,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
listContentContainer: {
|
||||
paddingVertical: 0,
|
||||
@@ -1212,6 +1195,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
@@ -1225,51 +1209,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
listHeaderContent: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
bottomBarWrapper: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
marginTop: theme.spacing[4],
|
||||
paddingLeft: 3,
|
||||
paddingRight: 3,
|
||||
paddingTop: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
inlineTurnFooter: {
|
||||
alignSelf: "flex-start",
|
||||
marginTop: theme.spacing[2],
|
||||
padding: theme.spacing[2],
|
||||
paddingTop: 0,
|
||||
},
|
||||
inlineWorkingIndicatorFrame: {
|
||||
height: 18,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
workingIndicatorBubble: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 0,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
workingDotsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workingDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
syncingIndicator: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
type AgentStreamWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
@@ -696,6 +697,36 @@ describe("toggleGithubAttachment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleGithubAttachmentFromPicker", () => {
|
||||
it("marks an existing GitHub item as removed when picker toggle removes it", () => {
|
||||
const markGithubAttachmentRemoved = vi.fn();
|
||||
const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem };
|
||||
|
||||
const next = toggleGithubAttachmentFromPicker({
|
||||
current: [attachment],
|
||||
item: prItem,
|
||||
markGithubAttachmentRemoved,
|
||||
});
|
||||
|
||||
expect(next).toEqual([]);
|
||||
expect(markGithubAttachmentRemoved).toHaveBeenCalledTimes(1);
|
||||
expect(markGithubAttachmentRemoved).toHaveBeenCalledWith(attachment);
|
||||
});
|
||||
|
||||
it("does not mark a GitHub item removed when picker toggle adds it", () => {
|
||||
const markGithubAttachmentRemoved = vi.fn();
|
||||
|
||||
const next = toggleGithubAttachmentFromPicker({
|
||||
current: [],
|
||||
item: issueItem,
|
||||
markGithubAttachmentRemoved,
|
||||
});
|
||||
|
||||
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||
it("locates items via their composite kind:number id", () => {
|
||||
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||
|
||||
@@ -305,6 +305,29 @@ export function toggleGithubAttachment(
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
interface ToggleGithubAttachmentFromPickerInput {
|
||||
current: UserComposerAttachment[];
|
||||
item: GitHubSearchItem;
|
||||
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
|
||||
}
|
||||
|
||||
export function toggleGithubAttachmentFromPicker({
|
||||
current,
|
||||
item,
|
||||
markGithubAttachmentRemoved,
|
||||
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
|
||||
const existingAttachment = current.find(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
if (existingAttachment) {
|
||||
markGithubAttachmentRemoved(existingAttachment);
|
||||
}
|
||||
return toggleGithubAttachment(current, item);
|
||||
}
|
||||
|
||||
export function findGithubItemByOption(
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Paperclip,
|
||||
} from "lucide-react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import {
|
||||
AgentStatusBar,
|
||||
@@ -53,7 +52,7 @@ import {
|
||||
queueComposerMessage,
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
type AgentStreamWriter,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
@@ -97,6 +96,9 @@ import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
import { useGithubSearchQuery } from "@/git/use-github-search-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
|
||||
|
||||
type QueuedMessage = QueuedComposerMessage;
|
||||
|
||||
@@ -137,6 +139,20 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||
}
|
||||
|
||||
function resolveGithubSearchEnabled(
|
||||
isGithubPickerOpen: boolean,
|
||||
isConnected: boolean,
|
||||
cwd: string,
|
||||
): boolean {
|
||||
return isGithubPickerOpen && isConnected && cwd.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveCheckoutRemoteUrl(
|
||||
checkoutStatus: ReturnType<typeof useCheckoutStatusQuery>["status"],
|
||||
): string | null {
|
||||
return checkoutStatus?.remoteUrl ?? null;
|
||||
}
|
||||
|
||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||
@@ -164,31 +180,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildGithubSearchQueryOptionsArgs {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
githubSearchQueryTrimmed: string;
|
||||
isGithubPickerOpen: boolean;
|
||||
isConnected: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
}
|
||||
|
||||
function buildGithubSearchQueryOptions(args: BuildGithubSearchQueryOptionsArgs) {
|
||||
const { serverId, cwd, githubSearchQueryTrimmed, isGithubPickerOpen, isConnected, client } = args;
|
||||
const hasClient = Boolean(client);
|
||||
const cwdIsSet = cwd.trim().length > 0;
|
||||
const enabled = isGithubPickerOpen && isConnected && hasClient && cwdIsSet;
|
||||
return {
|
||||
queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed],
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error("Host is not connected");
|
||||
return client.searchGitHub({ cwd, query: githubSearchQueryTrimmed, limit: 20 });
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
};
|
||||
}
|
||||
|
||||
function renderContextWindowMeterSlot(
|
||||
contextWindowMaxTokens: number | null,
|
||||
contextWindowUsedTokens: number | null,
|
||||
@@ -890,6 +881,17 @@ export function Composer({
|
||||
onOpenWorkspaceAttachment,
|
||||
});
|
||||
const setSelectedAttachments = onChangeAttachments;
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const githubAutoAttach = useComposerGithubAutoAttach({
|
||||
text: userInput,
|
||||
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
|
||||
attachments,
|
||||
client,
|
||||
isConnected,
|
||||
serverId,
|
||||
cwd,
|
||||
setAttachments: setSelectedAttachments,
|
||||
});
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
@@ -1135,6 +1137,7 @@ export function Composer({
|
||||
|
||||
const handleRemoveAttachment = useCallback(
|
||||
(index: number) => {
|
||||
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
|
||||
const didRemoveWorkspaceAttachment = removeAttachment({
|
||||
selectedAttachments,
|
||||
index,
|
||||
@@ -1146,7 +1149,7 @@ export function Composer({
|
||||
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
|
||||
);
|
||||
},
|
||||
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||
[githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||
);
|
||||
|
||||
const handleOpenAttachment = useCallback(
|
||||
@@ -1376,16 +1379,13 @@ export function Composer({
|
||||
);
|
||||
|
||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||
const githubSearchResultsQuery = useQuery(
|
||||
buildGithubSearchQueryOptions({
|
||||
serverId,
|
||||
cwd,
|
||||
githubSearchQueryTrimmed,
|
||||
isGithubPickerOpen,
|
||||
isConnected,
|
||||
client,
|
||||
}),
|
||||
);
|
||||
const githubSearchResultsQuery = useGithubSearchQuery({
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
query: githubSearchQueryTrimmed,
|
||||
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
|
||||
});
|
||||
|
||||
const githubSearchItemsRaw = githubSearchResultsQuery.data?.items;
|
||||
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
|
||||
@@ -1423,11 +1423,22 @@ export function Composer({
|
||||
|
||||
const handleToggleGithubItem = useCallback(
|
||||
(item: GitHubSearchItem) => {
|
||||
setSelectedAttachments((current) => toggleGithubAttachment(current, item));
|
||||
const nextAttachments = toggleGithubAttachmentFromPicker({
|
||||
current: attachments,
|
||||
item,
|
||||
markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved,
|
||||
});
|
||||
setSelectedAttachments(nextAttachments);
|
||||
setIsGithubPickerOpen(false);
|
||||
setGithubSearchQuery("");
|
||||
},
|
||||
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||
[
|
||||
attachments,
|
||||
githubAutoAttach,
|
||||
setSelectedAttachments,
|
||||
setGithubSearchQuery,
|
||||
setIsGithubPickerOpen,
|
||||
],
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
|
||||
@@ -791,10 +791,13 @@ function DesktopSidebar({
|
||||
|
||||
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
|
||||
[resizeAnimatedStyle, insetsTop],
|
||||
() => [staticStyles.desktopSidebar, resizeAnimatedStyle],
|
||||
[resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
||||
[insetsTop],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
|
||||
const resizeHandleStyle = useMemo(
|
||||
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
|
||||
[],
|
||||
|
||||
19
packages/app/src/components/message-compaction-label.test.ts
Normal file
19
packages/app/src/components/message-compaction-label.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
|
||||
describe("getCompactionMarkerLabel", () => {
|
||||
it("renders loading, automatic, manual, tokenized, and fallback labels", () => {
|
||||
expect(getCompactionMarkerLabel({ status: "loading" })).toBe("Compacting...");
|
||||
expect(getCompactionMarkerLabel({ status: "completed", trigger: "auto" })).toBe(
|
||||
"Context automatically compacted",
|
||||
);
|
||||
expect(getCompactionMarkerLabel({ status: "completed", trigger: "manual" })).toBe(
|
||||
"Context manually compacted",
|
||||
);
|
||||
expect(getCompactionMarkerLabel({ status: "completed", preTokens: 12_345 })).toBe(
|
||||
"Context compacted (12K tokens)",
|
||||
);
|
||||
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted");
|
||||
});
|
||||
});
|
||||
17
packages/app/src/components/message-compaction-label.ts
Normal file
17
packages/app/src/components/message-compaction-label.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface CompactionMarkerLabelInput {
|
||||
status: "loading" | "completed";
|
||||
trigger?: "auto" | "manual";
|
||||
preTokens?: number;
|
||||
}
|
||||
|
||||
export function getCompactionMarkerLabel({
|
||||
status,
|
||||
trigger,
|
||||
preTokens,
|
||||
}: CompactionMarkerLabelInput): string {
|
||||
if (status === "loading") return "Compacting...";
|
||||
if (trigger === "auto") return "Context automatically compacted";
|
||||
if (trigger === "manual") return "Context manually compacted";
|
||||
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
|
||||
return "Context compacted";
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit";
|
||||
import { formatShortcut } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
@@ -1598,6 +1599,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
defaultSendBehavior,
|
||||
isAgentRunning,
|
||||
});
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: isInputFocused && !isSendButtonDisabled,
|
||||
onSubmit: handleDefaultSendAction,
|
||||
});
|
||||
const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({
|
||||
submitButtonAccessibilityLabel,
|
||||
canPressLoadingButton,
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -68,14 +68,13 @@ import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import {
|
||||
parseAssistantFileLink,
|
||||
parseInlinePathToken,
|
||||
type InlinePathTarget,
|
||||
} from "@/utils/inline-path";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { type InlinePathTarget, parseInlinePathToken } from "@/utils/inline-path";
|
||||
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
||||
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
|
||||
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import {
|
||||
getAssistantImageLoadStateFromMetadata,
|
||||
getAssistantImageMetadata,
|
||||
@@ -92,6 +91,7 @@ import {
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
@@ -187,6 +187,10 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||
`;
|
||||
let webToolCallShimmerRegistered = false;
|
||||
const SCROLL_EDGE_EPSILON = 0.5;
|
||||
|
||||
// Font size for stream metadata (timestamps, durations, live elapsed timer).
|
||||
// Lives between theme.fontSize.xs (12) and theme.fontSize.sm (14); no token.
|
||||
export const STREAM_METADATA_FONT_SIZE = 13;
|
||||
type ScrollAxis = "x" | "y";
|
||||
|
||||
function ensureWebToolCallShimmerKeyframes() {
|
||||
@@ -318,7 +322,6 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
...(isWeb ? { userSelect: "text" as const } : {}),
|
||||
},
|
||||
content: {
|
||||
@@ -392,16 +395,26 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
copyButton: {
|
||||
alignSelf: "flex-end",
|
||||
padding: theme.spacing[1],
|
||||
marginRight: -theme.spacing[1],
|
||||
},
|
||||
trailingRow: {
|
||||
alignSelf: "flex-end",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
copyButtonHidden: {
|
||||
trailingRowHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
copyButtonVisible: {
|
||||
trailingRowVisible: {
|
||||
opacity: 1,
|
||||
},
|
||||
timestampText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
},
|
||||
}));
|
||||
|
||||
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
||||
@@ -434,22 +447,25 @@ export const UserMessage = memo(function UserMessage({
|
||||
message,
|
||||
images = [],
|
||||
attachments = [],
|
||||
timestamp: _timestamp,
|
||||
timestamp,
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
}: UserMessageProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [messageHovered, setMessageHovered] = useState(false);
|
||||
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const hasText = message.trim().length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
const hasAttachments = attachments.length > 0;
|
||||
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
|
||||
const showTrailingRow = hasText && (isCompact || isNative || isHovered);
|
||||
const formattedTimestamp = useMemo(
|
||||
() => formatMessageTimestamp(new Date(timestamp)),
|
||||
[timestamp],
|
||||
);
|
||||
|
||||
const handleHoverIn = useCallback(() => setMessageHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setMessageHovered(false), []);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const getMessageContent = useCallback(() => message, [message]);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -477,22 +493,22 @@ export const UserMessage = memo(function UserMessage({
|
||||
],
|
||||
[hasText],
|
||||
);
|
||||
const copyButtonStyle = useMemo(
|
||||
const trailingRowStyle = useMemo(
|
||||
() => [
|
||||
userMessageStylesheet.copyButton,
|
||||
showCopyButton
|
||||
? userMessageStylesheet.copyButtonVisible
|
||||
: userMessageStylesheet.copyButtonHidden,
|
||||
userMessageStylesheet.trailingRow,
|
||||
showTrailingRow
|
||||
? userMessageStylesheet.trailingRowVisible
|
||||
: userMessageStylesheet.trailingRowHidden,
|
||||
],
|
||||
[showCopyButton],
|
||||
[showTrailingRow],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Pressable
|
||||
<View
|
||||
style={userMessageStylesheet.content}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<View style={userMessageStylesheet.bubble}>
|
||||
{hasImages ? (
|
||||
@@ -525,18 +541,171 @@ export const UserMessage = memo(function UserMessage({
|
||||
) : null}
|
||||
</View>
|
||||
{hasText ? (
|
||||
<TurnCopyButton
|
||||
getContent={getMessageContent}
|
||||
containerStyle={copyButtonStyle}
|
||||
accessibilityLabel="Copy message"
|
||||
onHoverChange={setCopyButtonHovered}
|
||||
/>
|
||||
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
|
||||
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
|
||||
<TurnCopyButton
|
||||
getContent={getMessageContent}
|
||||
containerStyle={userMessageStylesheet.copyButton}
|
||||
accessibilityLabel="Copy message"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
copyButton: {
|
||||
alignSelf: "center",
|
||||
padding: theme.spacing[1],
|
||||
paddingTop: theme.spacing[1],
|
||||
marginTop: 0,
|
||||
marginLeft: -theme.spacing[1],
|
||||
},
|
||||
labelWrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
labelSizer: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
opacity: 0,
|
||||
},
|
||||
labelOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||
},
|
||||
}));
|
||||
|
||||
const TIMESTAMP_REVEAL_MS = 3000;
|
||||
|
||||
/**
|
||||
* Footer rendered next to the copy button at the end of an assistant turn.
|
||||
* Always shows the turn duration; swaps to the end timestamp on hover (web)
|
||||
* or tap (native). The hidden sizer keeps the label width stable while the
|
||||
* visible text swaps.
|
||||
*/
|
||||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent,
|
||||
completedAt,
|
||||
durationMs,
|
||||
}: AssistantTurnFooterProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [pressedReveal, setPressedReveal] = useState(false);
|
||||
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (revealTimerRef.current) {
|
||||
clearTimeout(revealTimerRef.current);
|
||||
revealTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const durationLabel = useMemo(
|
||||
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""),
|
||||
[durationMs],
|
||||
);
|
||||
const timestampLabel = useMemo(
|
||||
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
|
||||
[completedAt],
|
||||
);
|
||||
|
||||
const canSwap = Boolean(timestampLabel);
|
||||
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
|
||||
|
||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
const handlePress = useCallback(() => {
|
||||
if (isWeb || !canSwap) return;
|
||||
if (revealTimerRef.current) {
|
||||
clearTimeout(revealTimerRef.current);
|
||||
}
|
||||
setPressedReveal((prev) => !prev);
|
||||
revealTimerRef.current = setTimeout(() => {
|
||||
setPressedReveal(false);
|
||||
revealTimerRef.current = null;
|
||||
}, TIMESTAMP_REVEAL_MS);
|
||||
}, [canSwap]);
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
<TurnCopyButton
|
||||
getContent={getContent}
|
||||
containerStyle={assistantTurnFooterStylesheet.copyButton}
|
||||
/>
|
||||
{durationLabel ? (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
accessibilityRole={canSwap ? "button" : undefined}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
|
||||
>
|
||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||
{/* Sizer reserves space for whichever label is longer so the
|
||||
container width is stable across hover transitions. */}
|
||||
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
|
||||
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
|
||||
</Text>
|
||||
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
|
||||
{showTimestamp ? timestampLabel : durationLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
interface LiveElapsedProps {
|
||||
startedAt: Date;
|
||||
style?: StyleProp<TextStyle>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
|
||||
* only this component re-renders on each tick.
|
||||
*/
|
||||
export const LiveElapsed = memo(function LiveElapsed({
|
||||
startedAt,
|
||||
style,
|
||||
testID,
|
||||
}: LiveElapsedProps) {
|
||||
const startedAtMs = startedAt.getTime();
|
||||
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
|
||||
|
||||
useEffect(() => {
|
||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||
const handle = setInterval(() => {
|
||||
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
|
||||
}, 100);
|
||||
return () => clearInterval(handle);
|
||||
}, [startedAtMs]);
|
||||
|
||||
return (
|
||||
<Text style={style} testID={testID}>
|
||||
{formatDuration(elapsedMs)}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
|
||||
interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
@@ -544,13 +713,12 @@ interface AssistantMessageProps {
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
disableOuterSpacing?: boolean;
|
||||
toast?: ToastApi | null;
|
||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
}
|
||||
|
||||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
...(isWeb ? { userSelect: "text" as const } : {}),
|
||||
},
|
||||
@@ -560,24 +728,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
containerCompactBottom: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
// Used in custom markdownRules for path chip styling
|
||||
pathChip: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
marginRight: theme.spacing[1],
|
||||
marginVertical: 2,
|
||||
},
|
||||
pathChipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
userSelect: isWeb ? "text" : "auto",
|
||||
},
|
||||
imageFrame: {
|
||||
width: "100%",
|
||||
minHeight: 160,
|
||||
@@ -851,44 +1001,27 @@ function resolveAssistantImageErrorText(fileError: unknown, dataError: unknown):
|
||||
return "Unable to load image preview.";
|
||||
}
|
||||
|
||||
interface InlinePathChipProps {
|
||||
content: string;
|
||||
parsed: InlinePathTarget;
|
||||
onPress: (target: InlinePathTarget) => void;
|
||||
}
|
||||
|
||||
const INLINE_PATH_CHIP_STYLE = [
|
||||
assistantMessageStylesheet.pathChip,
|
||||
assistantMessageStylesheet.pathChipText,
|
||||
];
|
||||
|
||||
function InlinePathChip({ content, parsed, onPress }: InlinePathChipProps) {
|
||||
const handlePress = useCallback(() => onPress(parsed), [onPress, parsed]);
|
||||
return (
|
||||
<Text
|
||||
onPress={handlePress}
|
||||
selectable={isWeb ? undefined : false}
|
||||
style={INLINE_PATH_CHIP_STYLE}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownLink({
|
||||
href,
|
||||
source,
|
||||
style,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
onPress: (url: string) => void;
|
||||
onPress: (source: AssistantFileLinkSource) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handlePress = useCallback(() => onPress(href), [onPress, href]);
|
||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||
const href = source.href;
|
||||
const handlePress = useCallback(() => onPress(source), [onPress, source]);
|
||||
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
|
||||
const handleHoverIn = useCallback(() => {
|
||||
setHovered(true);
|
||||
handlePrefetch();
|
||||
}, [handlePrefetch]);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
@@ -912,6 +1045,7 @@ function MarkdownLink({
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
onFocus={handlePrefetch}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
@@ -940,11 +1074,13 @@ function getInlineCodeAutoLinkUrl(
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches: Array<{
|
||||
index: number;
|
||||
lastIndex: number;
|
||||
url: string;
|
||||
}> | null = markdownParser.linkify.match(trimmed);
|
||||
const matches:
|
||||
| {
|
||||
index: number;
|
||||
lastIndex: number;
|
||||
url: string;
|
||||
}[]
|
||||
| null = markdownParser.linkify.match(trimmed);
|
||||
if (!matches || matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -957,6 +1093,40 @@ function getInlineCodeAutoLinkUrl(
|
||||
return match.url;
|
||||
}
|
||||
|
||||
function getInlineCodeAutoLinkSource(input: {
|
||||
href: string;
|
||||
content: string;
|
||||
}): AssistantFileLinkSource {
|
||||
return {
|
||||
href: input.href,
|
||||
text: input.content,
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
interface AssistantMarkdownAstNode extends ASTNode {
|
||||
sourceInfo?: string;
|
||||
}
|
||||
|
||||
function getMarkdownLinkSource(node: AssistantMarkdownAstNode): AssistantFileLinkSource {
|
||||
return {
|
||||
href: typeof node.attributes?.href === "string" ? node.attributes.href : "",
|
||||
text: getMarkdownNodeText(node),
|
||||
markup: node.markup,
|
||||
sourceInfo: node.sourceInfo,
|
||||
sourceType: node.sourceType === "inline-code" ? "inline-code" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getMarkdownNodeText(node: ASTNode): string {
|
||||
if (!node.children.length) {
|
||||
return node.content ?? "";
|
||||
}
|
||||
|
||||
return node.children.map(getMarkdownNodeText).join("");
|
||||
}
|
||||
|
||||
function nodeHasParentType(parent: unknown, type: string): boolean {
|
||||
if (Array.isArray(parent)) {
|
||||
return parent.some((entry) => entry?.type === type);
|
||||
@@ -990,7 +1160,6 @@ interface TurnCopyButtonProps {
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
accessibilityLabel?: string;
|
||||
copiedAccessibilityLabel?: string;
|
||||
onHoverChange?: (hovered: boolean) => void;
|
||||
}
|
||||
|
||||
export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
@@ -998,7 +1167,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
containerStyle,
|
||||
accessibilityLabel,
|
||||
copiedAccessibilityLabel,
|
||||
onHoverChange,
|
||||
}: TurnCopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -1030,8 +1198,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleHoverIn = useCallback(() => onHoverChange?.(true), [onHoverChange]);
|
||||
const handleHoverOut = useCallback(() => onHoverChange?.(false), [onHoverChange]);
|
||||
const pressableStyle = useMemo(
|
||||
() => [turnCopyButtonStylesheet.container, containerStyle],
|
||||
[containerStyle],
|
||||
@@ -1040,8 +1206,6 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handleCopy}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
@@ -1053,9 +1217,9 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
? turnCopyButtonStylesheet.iconHoveredColor.color
|
||||
: turnCopyButtonStylesheet.iconColor.color;
|
||||
return copied ? (
|
||||
<Check size={18} color={iconColor} />
|
||||
<Check size={16} color={iconColor} />
|
||||
) : (
|
||||
<Copy size={18} color={iconColor} />
|
||||
<Copy size={16} color={iconColor} />
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
@@ -1064,7 +1228,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
|
||||
const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
marginHorizontal: -6,
|
||||
marginHorizontal: -13,
|
||||
},
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
@@ -1135,8 +1299,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
chevron: {
|
||||
marginLeft: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
transform: [{ scale: 1.3 }],
|
||||
},
|
||||
openFileButton: {
|
||||
marginLeft: theme.spacing[1],
|
||||
@@ -1149,7 +1313,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
height: 14,
|
||||
},
|
||||
chevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
transform: [{ scale: 1.3 }, { rotate: "90deg" }],
|
||||
},
|
||||
detailWrapper: {
|
||||
borderBottomLeftRadius: theme.borderRadius.lg,
|
||||
@@ -1393,20 +1557,22 @@ function MarkdownInheritedText({
|
||||
}
|
||||
|
||||
interface MarkdownInheritedCodeLinkProps {
|
||||
href: string;
|
||||
source: AssistantFileLinkSource;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (url: string) => boolean;
|
||||
onPress: (source: AssistantFileLinkSource) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownInheritedCodeLink({
|
||||
href,
|
||||
source,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
children,
|
||||
}: MarkdownInheritedCodeLinkProps) {
|
||||
const style = useMemo(
|
||||
@@ -1414,12 +1580,52 @@ function MarkdownInheritedCodeLink({
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownLink href={href} style={style} onPress={onPress}>
|
||||
<MarkdownLink source={source} style={style} onPress={onPress} onPrefetch={onPrefetch}>
|
||||
{children}
|
||||
</MarkdownLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownInlinePathCodeLinkProps {
|
||||
content: string;
|
||||
inheritedStyles: TextStyle;
|
||||
codeInlineStyle: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onPress: (source: AssistantFileLinkSource) => void;
|
||||
onPrefetch: (source: AssistantFileLinkSource) => void;
|
||||
}
|
||||
|
||||
function MarkdownInlinePathCodeLink({
|
||||
content,
|
||||
inheritedStyles,
|
||||
codeInlineStyle,
|
||||
linkStyle,
|
||||
onPress,
|
||||
onPrefetch,
|
||||
}: MarkdownInlinePathCodeLinkProps) {
|
||||
const source = useMemo<AssistantFileLinkSource>(
|
||||
() => ({
|
||||
href: content,
|
||||
text: content,
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
[content],
|
||||
);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedCodeLink
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={codeInlineStyle}
|
||||
linkStyle={linkStyle}
|
||||
onPress={onPress}
|
||||
onPrefetch={onPrefetch}
|
||||
>
|
||||
{content}
|
||||
</MarkdownInheritedCodeLink>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
@@ -1444,6 +1650,42 @@ function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphVi
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
// List spacing in markdown:
|
||||
// - p -> list and list -> p use a slightly larger gap than p -> p, so lists
|
||||
// read as their own section against surrounding prose.
|
||||
// - list -> list keeps the normal p-to-p gap; back-to-back lists are
|
||||
// continuous content, not section breaks.
|
||||
//
|
||||
// Paragraph's marginBottom is SPACING[3] = 12 (and marginTop is 0). To produce
|
||||
// 16px gaps on p<->list transitions and 12px on list<->list, we add a constant
|
||||
// marginTop on lists (4) and switch marginBottom by next-sibling type:
|
||||
// p -> list = p.marginBottom(12) + list.marginTop(4) = 16
|
||||
// list -> p = list.marginBottom(16) + p.marginTop(0) = 16
|
||||
// list -> list = list.marginBottom(8) + list.marginTop(4) = 12
|
||||
const MARKDOWN_LIST_MARGIN_TOP = SPACING[1]; // 4
|
||||
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4]; // 16
|
||||
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2]; // 8
|
||||
|
||||
function getMarkdownListContextMarginBottom(node: ASTNode, parent: ASTNode[]): number {
|
||||
const nextType = getMarkdownNextSiblingType(node, parent);
|
||||
const nextIsList = nextType === "bullet_list" || nextType === "ordered_list";
|
||||
return nextIsList ? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST : MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
marginBottom: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(
|
||||
() => [baseStyle, { marginTop: MARKDOWN_LIST_MARGIN_TOP, marginBottom }],
|
||||
[baseStyle, marginBottom],
|
||||
);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
@@ -1451,13 +1693,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
client,
|
||||
disableOuterSpacing,
|
||||
toast,
|
||||
spacing = "default",
|
||||
}: AssistantMessageProps) {
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(
|
||||
disableOuterSpacing ?? spacing !== "default",
|
||||
);
|
||||
|
||||
const markdownParser = useMemo(() => {
|
||||
const parser = MarkdownIt({ typographer: true, linkify: true });
|
||||
const defaultValidateLink = parser.validateLink.bind(parser);
|
||||
@@ -1471,20 +1709,34 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
return parser;
|
||||
}, []);
|
||||
|
||||
const handleLinkPress = useCallback(
|
||||
(url: string) => {
|
||||
const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
|
||||
if (fileTarget) {
|
||||
onInlinePathPress?.(fileTarget);
|
||||
return false;
|
||||
}
|
||||
const fileLinkResolver = useAssistantFileLinkResolver({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile: onInlinePathPress,
|
||||
toast,
|
||||
});
|
||||
|
||||
void openExternalUrl(url);
|
||||
const handleLinkPress = useCallback(
|
||||
(source: AssistantFileLinkSource) => {
|
||||
fileLinkResolver.open({ source });
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
const handleLinkPrefetch = useCallback(
|
||||
(source: AssistantFileLinkSource) => {
|
||||
fileLinkResolver.prefetch({ source });
|
||||
},
|
||||
[fileLinkResolver],
|
||||
);
|
||||
const handleMarkdownLinkPress = useCallback(
|
||||
(url: string) => {
|
||||
fileLinkResolver.open({ source: { href: url } });
|
||||
// react-native-markdown-display opens the link itself when this returns true.
|
||||
// We already handled it above, so return false to avoid duplicate opens.
|
||||
return false;
|
||||
},
|
||||
[onInlinePathPress, workspaceRoot],
|
||||
[fileLinkResolver],
|
||||
);
|
||||
|
||||
const markdownRules = useMemo<RenderRules>(() => {
|
||||
@@ -1558,30 +1810,38 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
) => {
|
||||
const content = node.content ?? "";
|
||||
const isLinkedInlineCode = nodeHasParentType(parent, "link");
|
||||
const parsed =
|
||||
onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null;
|
||||
const shouldResolveInlinePath =
|
||||
onInlinePathPress && !isLinkedInlineCode && parseInlinePathToken(content);
|
||||
|
||||
if (parsed && onInlinePathPress) {
|
||||
if (shouldResolveInlinePath) {
|
||||
return (
|
||||
<InlinePathChip
|
||||
<MarkdownInlinePathCodeLink
|
||||
key={node.key}
|
||||
content={content}
|
||||
parsed={parsed}
|
||||
onPress={onInlinePathPress}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={styles.code_inline}
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
|
||||
if (inlineCodeLinkUrl) {
|
||||
const source = getInlineCodeAutoLinkSource({
|
||||
href: inlineCodeLinkUrl,
|
||||
content,
|
||||
});
|
||||
return (
|
||||
<MarkdownInheritedCodeLink
|
||||
key={node.key}
|
||||
href={inlineCodeLinkUrl}
|
||||
source={source}
|
||||
inheritedStyles={inheritedStyles}
|
||||
codeInlineStyle={styles.code_inline}
|
||||
linkStyle={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
>
|
||||
{content}
|
||||
</MarkdownInheritedCodeLink>
|
||||
@@ -1601,22 +1861,30 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<View key={node.key} style={styles.bullet_list}>
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<View key={node.key} style={styles.ordered_list}>
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
@@ -1650,9 +1918,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
|
||||
<MarkdownLink
|
||||
key={node.key}
|
||||
href={typeof node.attributes?.href === "string" ? node.attributes.href : ""}
|
||||
source={getMarkdownLinkSource(node)}
|
||||
style={styles.link}
|
||||
onPress={handleLinkPress}
|
||||
onPrefetch={handleLinkPrefetch}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (!isValidElement(child)) return child;
|
||||
@@ -1691,7 +1960,15 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]);
|
||||
}, [
|
||||
client,
|
||||
handleLinkPrefetch,
|
||||
handleLinkPress,
|
||||
markdownParser,
|
||||
onInlinePathPress,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
const keyedBlocks = useMemo(
|
||||
@@ -1706,9 +1983,8 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
assistantMessageStylesheet.containerCompactTop,
|
||||
(spacing === "compactBottom" || spacing === "compactBoth") &&
|
||||
assistantMessageStylesheet.containerCompactBottom,
|
||||
!resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing,
|
||||
],
|
||||
[spacing, resolvedDisableOuterSpacing],
|
||||
[spacing],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1723,7 +1999,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
text={block}
|
||||
rules={markdownRules}
|
||||
parser={markdownParser}
|
||||
onLinkPress={handleLinkPress}
|
||||
onLinkPress={handleMarkdownLinkPress}
|
||||
/>
|
||||
</AssistantMessageBlockContainer>
|
||||
))}
|
||||
@@ -1739,7 +2015,6 @@ interface SpeakMessageProps {
|
||||
|
||||
const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
containerSpacing: {
|
||||
@@ -1804,7 +2079,6 @@ interface ActivityLogProps {
|
||||
|
||||
const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
pressable: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
overflow: "hidden",
|
||||
},
|
||||
@@ -1978,6 +2252,7 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
|
||||
interface CompactionMarkerProps {
|
||||
status: "loading" | "completed";
|
||||
trigger?: "auto" | "manual";
|
||||
preTokens?: number;
|
||||
}
|
||||
|
||||
@@ -2008,12 +2283,10 @@ const compactionStylesheet = StyleSheet.create((theme) => ({
|
||||
|
||||
export const CompactionMarker = memo(function CompactionMarker({
|
||||
status,
|
||||
trigger,
|
||||
preTokens,
|
||||
}: CompactionMarkerProps) {
|
||||
let label: string;
|
||||
if (status === "loading") label = "Compacting...";
|
||||
else if (preTokens) label = `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
|
||||
else label = "Context compacted";
|
||||
const label = getCompactionMarkerLabel({ status, trigger, preTokens });
|
||||
|
||||
return (
|
||||
<View style={compactionStylesheet.container}>
|
||||
@@ -2340,6 +2613,26 @@ function ExpandableBadgeLabelRow({
|
||||
);
|
||||
}
|
||||
|
||||
// HACK: lucide ships every icon inside a 24×24 viewBox where the path
|
||||
// doesn't touch the edges — there's per-icon internal padding. The layout
|
||||
// already places the SVG element's box on the rail, but the visible glyph
|
||||
// inside the SVG sits inset by a few pixels (and the inset amount differs
|
||||
// per icon — chevron-right paints only in the right half of its viewBox,
|
||||
// regular tool icons paint roughly the full viewBox minus ~1 unit margin).
|
||||
//
|
||||
// Lucide has no viewBox knob, so the only way to nudge the visible glyph
|
||||
// flush with the rail is a per-icon negative margin. Cosmetic; not exact —
|
||||
// every lucide icon has slightly different padding and we're not measuring
|
||||
// each one. Two buckets is the compromise:
|
||||
// - LUCIDE_TOOL_ICON_NUDGE_LEFT: regular tool icons (path mostly fills
|
||||
// the viewBox); needs ~1px left shift.
|
||||
// - LUCIDE_CHEVRON_NUDGE_LEFT: chevron-right (path in right half of
|
||||
// viewBox, and we scale it 1.3×); needs ~4px left shift.
|
||||
// If we ever want this exact, the principled fix is a custom <Svg> wrapper
|
||||
// with a tight viewBox per icon — see option (2) in the design discussion.
|
||||
const LUCIDE_TOOL_ICON_NUDGE_LEFT: ViewStyle = { marginLeft: -1 };
|
||||
const LUCIDE_CHEVRON_NUDGE_LEFT: ViewStyle = { marginLeft: -4 };
|
||||
|
||||
function renderExpandableBadgeIcon({
|
||||
isError,
|
||||
isActive,
|
||||
@@ -2350,14 +2643,20 @@ function renderExpandableBadgeIcon({
|
||||
ThemedIcon: ComponentType<{ size?: number; uniProps?: typeof foregroundColorMapping }> | null;
|
||||
}): ReactNode {
|
||||
if (isError) {
|
||||
return <ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />;
|
||||
return (
|
||||
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
|
||||
<ThemedTriangleAlertIcon size={12} opacity={0.8} uniProps={destructiveColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (ThemedIcon) {
|
||||
return (
|
||||
<ThemedIcon
|
||||
size={12}
|
||||
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
|
||||
/>
|
||||
<View style={LUCIDE_TOOL_ICON_NUDGE_LEFT}>
|
||||
<ThemedIcon
|
||||
size={12}
|
||||
uniProps={isActive ? foregroundColorMapping : mutedForegroundColorMapping}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -2374,7 +2673,7 @@ function renderExpandableBadgeIconSlot({
|
||||
}): ReactNode {
|
||||
if (showChevron) {
|
||||
return (
|
||||
<ThemedChevronRightIcon size={14} style={chevronStyle} uniProps={foregroundColorMapping} />
|
||||
<ThemedChevronRightIcon size={12} style={chevronStyle} uniProps={foregroundColorMapping} />
|
||||
);
|
||||
}
|
||||
return iconNode;
|
||||
@@ -2499,7 +2798,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isOpenFileHovered, setIsOpenFileHovered] = useState(false);
|
||||
const [isIconHovered, setIsIconHovered] = useState(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const isInteractive = Boolean(onToggle);
|
||||
const hasDetailContent = Boolean(renderDetails);
|
||||
@@ -2527,8 +2825,6 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
);
|
||||
const handleOpenFileHoverIn = useCallback(() => setIsOpenFileHovered(true), []);
|
||||
const handleOpenFileHoverOut = useCallback(() => setIsOpenFileHovered(false), []);
|
||||
const handleIconHoverIn = useCallback(() => setIsIconHovered(true), []);
|
||||
const handleIconHoverOut = useCallback(() => setIsIconHovered(false), []);
|
||||
|
||||
const nativeGradientIdRef = useRef(
|
||||
`shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`,
|
||||
@@ -2718,6 +3014,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
() => [
|
||||
expandableBadgeStylesheet.chevron,
|
||||
isExpanded && expandableBadgeStylesheet.chevronExpanded,
|
||||
LUCIDE_CHEVRON_NUDGE_LEFT,
|
||||
],
|
||||
[isExpanded],
|
||||
);
|
||||
@@ -2725,7 +3022,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
const ThemedIcon = useMemo(() => (icon ? withUnistyles(icon) : null), [icon]);
|
||||
const iconNode = renderExpandableBadgeIcon({ isError, isActive, ThemedIcon });
|
||||
const iconSlotNode = renderExpandableBadgeIconSlot({
|
||||
showChevron: isInteractive && isHovered && !isIconHovered,
|
||||
showChevron: isInteractive && isHovered,
|
||||
chevronStyle,
|
||||
iconNode,
|
||||
});
|
||||
@@ -2753,13 +3050,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
style={pressableStyle}
|
||||
>
|
||||
<View style={expandableBadgeStylesheet.headerRow}>
|
||||
<View
|
||||
style={expandableBadgeStylesheet.iconBadge}
|
||||
onPointerEnter={isWeb ? handleIconHoverIn : undefined}
|
||||
onPointerLeave={isWeb ? handleIconHoverOut : undefined}
|
||||
>
|
||||
{iconSlotNode}
|
||||
</View>
|
||||
<View style={expandableBadgeStylesheet.iconBadge}>{iconSlotNode}</View>
|
||||
<ExpandableBadgeLabelRow
|
||||
label={label}
|
||||
labelStyle={labelStyle}
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
requireWorkspaceExecutionDirectory,
|
||||
resolveWorkspaceExecutionDirectory,
|
||||
} from "@/utils/workspace-execution";
|
||||
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
archiveWorkspacesOptimistically,
|
||||
@@ -1172,10 +1173,13 @@ function ProjectHeaderRow({
|
||||
return;
|
||||
}
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as Href,
|
||||
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
|
||||
displayName,
|
||||
projectId: project.projectKey,
|
||||
}) as Href,
|
||||
);
|
||||
onWorkspacePress?.();
|
||||
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
||||
}, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]);
|
||||
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const _toast = useToast();
|
||||
|
||||
@@ -1474,92 +1478,93 @@ function WorkspaceRowWithMenu({
|
||||
});
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const archiveWorktreeAfterConfirmation = useCallback(async () => {
|
||||
if (isArchiving) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Archive worktree?",
|
||||
message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`,
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
const confirmed = await confirmRiskyWorktreeArchive({
|
||||
worktreeName: workspace.name,
|
||||
isDirty: workspace.archiveHasUncommittedChanges,
|
||||
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
|
||||
diffStat: workspace.diffStat,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
let archiveDirectory: string;
|
||||
try {
|
||||
archiveDirectory = requireWorkspaceExecutionDirectory({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Workspace path not available");
|
||||
return;
|
||||
}
|
||||
if (!archiveDirectory) {
|
||||
toast.error("Workspace path not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!archiveDirectory) {
|
||||
toast.error("Workspace path not available");
|
||||
return;
|
||||
}
|
||||
redirectAfterArchive();
|
||||
|
||||
redirectAfterArchive();
|
||||
|
||||
void archiveWorktree({
|
||||
serverId: workspace.serverId,
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to archive worktree";
|
||||
toast.error(message);
|
||||
});
|
||||
})();
|
||||
void archiveWorktree({
|
||||
serverId: workspace.serverId,
|
||||
cwd: archiveDirectory,
|
||||
worktreePath: archiveDirectory,
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to archive worktree";
|
||||
toast.error(message);
|
||||
});
|
||||
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
void archiveWorktreeAfterConfirmation();
|
||||
}, [archiveWorktreeAfterConfirmation]);
|
||||
|
||||
const hideWorkspaceAfterConfirmation = useCallback(async () => {
|
||||
if (isArchivingWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Hide workspace?",
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Hide",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Hide workspace?",
|
||||
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
|
||||
confirmLabel: "Hide",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
toast.error("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await archiveWorkspaceOptimistically({
|
||||
client,
|
||||
workspace,
|
||||
afterHide: redirectAfterArchive,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
|
||||
} finally {
|
||||
setIsArchivingWorkspace(false);
|
||||
}
|
||||
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
void hideWorkspaceAfterConfirmation();
|
||||
}, [hideWorkspaceAfterConfirmation]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
let copyTargetDirectory: string;
|
||||
try {
|
||||
@@ -1589,7 +1594,7 @@ function WorkspaceRowWithMenu({
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
if (isWorktree) {
|
||||
handleArchiveWorktree();
|
||||
void archiveWorktreeAfterConfirmation();
|
||||
} else {
|
||||
handleArchiveWorkspace();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
@@ -129,6 +130,7 @@ interface TerminalEmulatorProps {
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
@@ -194,6 +196,7 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onRendererReadyChange,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
@@ -219,12 +222,14 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
});
|
||||
mountCallbacksRef.current = {
|
||||
onInput,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
};
|
||||
const initialSnapshotRef = useRef(initialSnapshot);
|
||||
initialSnapshotRef.current = initialSnapshot;
|
||||
@@ -444,10 +449,11 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onInputModeChange,
|
||||
onOpenExternalUrl: openExternalUrl,
|
||||
},
|
||||
});
|
||||
}, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||
}, [onInput, onInputModeChange, onPendingModifiersConsumed, onResize, onTerminalKey]);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useAppVisible } from "@/hooks/use-app-visible";
|
||||
@@ -188,6 +189,10 @@ export function TerminalPane({
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
||||
const terminalIdRef = useRef<string>(terminalId);
|
||||
const inputModeRef = useRef<TerminalInputModeState>({
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
});
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||
@@ -195,6 +200,10 @@ export function TerminalPane({
|
||||
|
||||
useEffect(() => {
|
||||
terminalIdRef.current = terminalId;
|
||||
inputModeRef.current = {
|
||||
kittyKeyboardFlags: 0,
|
||||
win32InputMode: false,
|
||||
};
|
||||
}, [terminalId]);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
@@ -413,7 +422,9 @@ export function TerminalPane({
|
||||
return true;
|
||||
}
|
||||
|
||||
const encoded = encodeTerminalKeyInput(entry.input);
|
||||
const encoded = encodeTerminalKeyInput(entry.input, {
|
||||
inputMode: inputModeRef.current,
|
||||
});
|
||||
if (encoded.length === 0) {
|
||||
return true;
|
||||
}
|
||||
@@ -595,6 +606,10 @@ export function TerminalPane({
|
||||
clearPendingModifiers();
|
||||
}, [clearPendingModifiers]);
|
||||
|
||||
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
|
||||
inputModeRef.current = state;
|
||||
}, []);
|
||||
|
||||
const toggleModifier = useCallback(
|
||||
(modifier: keyof ModifierState) => {
|
||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
||||
@@ -674,6 +689,7 @@ export function TerminalPane({
|
||||
onInput={handleTerminalData}
|
||||
onResize={handleTerminalResize}
|
||||
onTerminalKey={handleTerminalKey}
|
||||
onInputModeChange={handleInputModeChange}
|
||||
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||
pendingModifiers={modifiers}
|
||||
focusRequestToken={focusRequestToken}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import React, { type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { GitHubSearchClient } from "@/git/use-github-search-query";
|
||||
import type { GitHubSearchItem, GitHubSearchResponse } from "@server/shared/messages";
|
||||
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
|
||||
|
||||
type GitHubSearchPayload = GitHubSearchResponse["payload"];
|
||||
|
||||
const remoteUrl = "git@github.com:acme/paseo.git";
|
||||
const cwd = "/repo";
|
||||
|
||||
const pr101: GitHubSearchItem = {
|
||||
kind: "pr",
|
||||
number: 101,
|
||||
title: "Attach PR",
|
||||
url: "https://github.com/acme/paseo/pull/101",
|
||||
state: "open",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
};
|
||||
|
||||
const issue202: GitHubSearchItem = {
|
||||
kind: "issue",
|
||||
number: 202,
|
||||
title: "Attach issue",
|
||||
url: "https://github.com/acme/paseo/issues/202",
|
||||
state: "open",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
};
|
||||
|
||||
interface SearchCall {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface HarnessInput {
|
||||
initialAttachments?: UserComposerAttachment[];
|
||||
initialText?: string;
|
||||
remote?: string | null;
|
||||
}
|
||||
|
||||
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
|
||||
return {
|
||||
items,
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function createSearchClient(
|
||||
items: GitHubSearchItem[],
|
||||
): GitHubSearchClient & { calls: SearchCall[] } {
|
||||
const calls: SearchCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
async searchGitHub(options) {
|
||||
calls.push(options);
|
||||
return githubPayload(items, `search-${options.query}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
|
||||
const [text, setText] = useState(input.initialText ?? "");
|
||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||
input.initialAttachments ?? [],
|
||||
);
|
||||
const autoAttach = useComposerGithubAutoAttach({
|
||||
text,
|
||||
remoteUrl: input.remote ?? remoteUrl,
|
||||
attachments,
|
||||
client,
|
||||
isConnected: true,
|
||||
serverId: "server-1",
|
||||
cwd,
|
||||
setAttachments,
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
attachments,
|
||||
setAttachments,
|
||||
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushDebounce() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useComposerGithubAutoAttach", () => {
|
||||
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
|
||||
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores URLs that do not match the current remote", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Other repo https://github.com/other/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
expect(client.calls).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not add a second pill when the ref is already attached", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
|
||||
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Already here https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual(initialAttachments);
|
||||
expect(client.calls).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not re-add a GitHub ref removed earlier in the same composer session", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const initialAttachments: UserComposerAttachment[] = [{ kind: "github_pr", item: pr101 }];
|
||||
const { result } = renderHook(() => useHarness(client, { initialAttachments }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.markGithubAttachmentRemoved(initialAttachments[0]);
|
||||
result.current.setAttachments([]);
|
||||
result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
expect(client.calls).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("handles multiple matching URLs from one paste", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101, issue202]);
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText(
|
||||
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/issues/202",
|
||||
);
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([
|
||||
{ kind: "github_pr", item: pr101 },
|
||||
{ kind: "github_issue", item: issue202 },
|
||||
]);
|
||||
expect(client.calls).toEqual([
|
||||
{ cwd, query: "101", limit: 20 },
|
||||
{ cwd, query: "202", limit: 20 },
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
240
packages/app/src/components/use-composer-github-auto-attach.ts
Normal file
240
packages/app/src/components/use-composer-github-auto-attach.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
buildGithubSearchQueryOptions,
|
||||
type GitHubSearchClient,
|
||||
} from "@/git/use-github-search-query";
|
||||
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "./composer-actions";
|
||||
|
||||
const AUTO_ATTACH_DEBOUNCE_MS = 300;
|
||||
|
||||
interface ComposerGithubAutoAttachInput {
|
||||
text: string;
|
||||
remoteUrl: string | null | undefined;
|
||||
attachments: UserComposerAttachment[];
|
||||
client: GitHubSearchClient | null;
|
||||
isConnected: boolean;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||
}
|
||||
|
||||
interface ComposerGithubAutoAttachResult {
|
||||
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
||||
}
|
||||
|
||||
export function useComposerGithubAutoAttach(
|
||||
params: ComposerGithubAutoAttachInput,
|
||||
): ComposerGithubAutoAttachResult {
|
||||
const queryClient = useQueryClient();
|
||||
const latestRef = useRef(params);
|
||||
const removedRefKeysRef = useRef(new Set<string>());
|
||||
const pendingRefKeysRef = useRef(new Set<string>());
|
||||
|
||||
latestRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
const refs = refsReadyForLookup({
|
||||
params: latestRef.current,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
pendingRefKeys: pendingRefKeysRef.current,
|
||||
});
|
||||
if (refs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
void attachRefs({
|
||||
refs,
|
||||
queryClient,
|
||||
latestRef,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
pendingRefKeys: pendingRefKeysRef.current,
|
||||
});
|
||||
}, AUTO_ATTACH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
};
|
||||
}, [
|
||||
params.text,
|
||||
params.remoteUrl,
|
||||
params.attachments,
|
||||
params.client,
|
||||
params.isConnected,
|
||||
params.serverId,
|
||||
params.cwd,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => {
|
||||
const key = attachmentKey(attachment);
|
||||
if (key) {
|
||||
removedRefKeysRef.current.add(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
markGithubAttachmentRemoved,
|
||||
}),
|
||||
[markGithubAttachmentRemoved],
|
||||
);
|
||||
}
|
||||
|
||||
async function attachRefs({
|
||||
refs,
|
||||
queryClient,
|
||||
latestRef,
|
||||
removedRefKeys,
|
||||
pendingRefKeys,
|
||||
}: {
|
||||
refs: GithubRef[];
|
||||
queryClient: QueryClient;
|
||||
latestRef: RefObject<ComposerGithubAutoAttachInput>;
|
||||
removedRefKeys: Set<string>;
|
||||
pendingRefKeys: Set<string>;
|
||||
}): Promise<void> {
|
||||
for (const ref of refs) {
|
||||
const key = githubRefKey(ref);
|
||||
if (pendingRefKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
pendingRefKeys.add(key);
|
||||
try {
|
||||
await attachRef({ ref, key, queryClient, latestRef, removedRefKeys });
|
||||
} finally {
|
||||
pendingRefKeys.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function attachRef({
|
||||
ref,
|
||||
key,
|
||||
queryClient,
|
||||
latestRef,
|
||||
removedRefKeys,
|
||||
}: {
|
||||
ref: GithubRef;
|
||||
key: string;
|
||||
queryClient: QueryClient;
|
||||
latestRef: RefObject<ComposerGithubAutoAttachInput>;
|
||||
removedRefKeys: Set<string>;
|
||||
}): Promise<void> {
|
||||
const snapshot = latestRef.current;
|
||||
if (!snapshot.client || !snapshot.isConnected || !isRefStillPresent(ref, snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = await fetchGithubRefSearch({ ref, snapshot, queryClient });
|
||||
if (!search) {
|
||||
return;
|
||||
}
|
||||
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
||||
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestRef.current.setAttachments((current) => {
|
||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
|
||||
return current;
|
||||
}
|
||||
return toggleGithubAttachment(current, item);
|
||||
});
|
||||
}
|
||||
|
||||
function refsReadyForLookup({
|
||||
params,
|
||||
removedRefKeys,
|
||||
pendingRefKeys,
|
||||
}: {
|
||||
params: ComposerGithubAutoAttachInput;
|
||||
removedRefKeys: Set<string>;
|
||||
pendingRefKeys: Set<string>;
|
||||
}): GithubRef[] {
|
||||
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => {
|
||||
const key = githubRefKey(ref);
|
||||
return (
|
||||
!removedRefKeys.has(key) &&
|
||||
!pendingRefKeys.has(key) &&
|
||||
!hasGithubAttachment(params.attachments, ref)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchGithubRefSearch({
|
||||
ref,
|
||||
snapshot,
|
||||
queryClient,
|
||||
}: {
|
||||
ref: GithubRef;
|
||||
snapshot: ComposerGithubAutoAttachInput;
|
||||
queryClient: QueryClient;
|
||||
}) {
|
||||
if (!snapshot.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await queryClient.fetchQuery(
|
||||
buildGithubSearchQueryOptions({
|
||||
client: snapshot.client,
|
||||
serverId: snapshot.serverId,
|
||||
cwd: snapshot.cwd,
|
||||
query: String(ref.number),
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput): boolean {
|
||||
return extractGithubRefs(params.text, params.remoteUrl).some(
|
||||
(candidate) => githubRefKey(candidate) === githubRefKey(ref),
|
||||
);
|
||||
}
|
||||
|
||||
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
||||
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||
}
|
||||
|
||||
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
|
||||
return item.kind === githubItemKind(ref) && item.number === ref.number;
|
||||
}
|
||||
|
||||
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
|
||||
return ref.kind === "pull" ? "pr" : "issue";
|
||||
}
|
||||
|
||||
function githubRefKey(ref: GithubRef): string {
|
||||
return `${githubItemKind(ref)}:${ref.number}`;
|
||||
}
|
||||
|
||||
function attachmentKey(attachment: ComposerAttachment | undefined): string | null {
|
||||
if (
|
||||
!attachment ||
|
||||
attachment.kind === "image" ||
|
||||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${attachment.item.kind}:${attachment.item.number}`;
|
||||
}
|
||||
@@ -8,18 +8,10 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Dimensions, Platform, Text, View } from "react-native";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ExternalLink } from "lucide-react-native";
|
||||
import { CircleCheck, CircleDot, CircleX, ExternalLink } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DiffStat } from "@/components/diff-stat";
|
||||
@@ -316,9 +308,62 @@ function WorkspaceHoverCardContent({
|
||||
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
||||
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
||||
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
|
||||
function getChecksSummaryCounts(checks: NonNullable<PrHint["checks"]>) {
|
||||
return checks.reduce(
|
||||
(counts, check) => {
|
||||
if (check.status === "success") counts.passed += 1;
|
||||
else if (check.status === "failure") counts.failed += 1;
|
||||
else if (check.status !== "skipped" && check.status !== "cancelled") counts.pending += 1;
|
||||
return counts;
|
||||
},
|
||||
{ passed: 0, failed: 0, pending: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksSummaryPill({
|
||||
count,
|
||||
kind,
|
||||
}: {
|
||||
count: number;
|
||||
kind: "passed" | "failed" | "pending";
|
||||
}) {
|
||||
if (count === 0) return null;
|
||||
|
||||
if (kind === "passed") {
|
||||
return (
|
||||
<View style={styles.checksSummaryPill}>
|
||||
<ThemedCircleCheck size={12} uniProps={successColorMapping} />
|
||||
<Text style={styles.checksStatusTextPassed}>{count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "failed") {
|
||||
return (
|
||||
<View style={styles.checksSummaryPill}>
|
||||
<ThemedCircleX size={12} uniProps={dangerColorMapping} />
|
||||
<Text style={styles.checksStatusTextFailed}>{count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.checksSummaryPill}>
|
||||
<ThemedCircleDot size={12} uniProps={warningColorMapping} />
|
||||
<Text style={styles.checksStatusTextPending}>{count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksSummaryContent({
|
||||
checks,
|
||||
@@ -327,26 +372,7 @@ function ChecksSummaryContent({
|
||||
checks: NonNullable<PrHint["checks"]>;
|
||||
hovered: boolean;
|
||||
}) {
|
||||
const failed = checks.filter((c) => c.status === "failure").length;
|
||||
const pending = checks.filter((c) => c.status === "pending").length;
|
||||
|
||||
let badgeLabel: string;
|
||||
let dotStyle: StyleProp<ViewStyle>;
|
||||
let statusTextStyle: StyleProp<TextStyle>;
|
||||
|
||||
if (failed > 0) {
|
||||
badgeLabel = `${failed} failed`;
|
||||
dotStyle = styles.checksDotFailed;
|
||||
statusTextStyle = styles.checksStatusTextFailed;
|
||||
} else if (pending > 0) {
|
||||
badgeLabel = `${pending} running`;
|
||||
dotStyle = styles.checksDotPending;
|
||||
statusTextStyle = styles.checksStatusTextPending;
|
||||
} else {
|
||||
badgeLabel = `${checks.length} passed`;
|
||||
dotStyle = styles.checksDotPassed;
|
||||
statusTextStyle = styles.checksStatusTextPassed;
|
||||
}
|
||||
const { passed, failed, pending } = getChecksSummaryCounts(checks);
|
||||
|
||||
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
|
||||
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
@@ -360,8 +386,9 @@ function ChecksSummaryContent({
|
||||
)}
|
||||
<Text style={labelStyle}>Checks</Text>
|
||||
<View style={styles.checksSummaryCounts}>
|
||||
<View style={dotStyle} />
|
||||
<Text style={statusTextStyle}>{badgeLabel}</Text>
|
||||
<ChecksSummaryPill count={passed} kind="passed" />
|
||||
<ChecksSummaryPill count={failed} kind="failed" />
|
||||
<ChecksSummaryPill count={pending} kind="pending" />
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
@@ -465,42 +492,29 @@ const styles = StyleSheet.create((theme) => ({
|
||||
checksSummaryCounts: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
checksDotFailed: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.palette.red[500],
|
||||
},
|
||||
checksDotPending: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.palette.amber[500],
|
||||
},
|
||||
checksDotPassed: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
checksSummaryPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
checksStatusTextFailed: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.red[500],
|
||||
color: theme.colors.statusDanger,
|
||||
},
|
||||
checksStatusTextPending: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.amber[500],
|
||||
color: theme.colors.statusWarning,
|
||||
},
|
||||
checksStatusTextPassed: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.palette.green[500],
|
||||
color: theme.colors.statusSuccess,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host";
|
||||
// isElectron → Desktop wrapper features (file dialogs, titlebar, updates)
|
||||
//
|
||||
// For layout decisions, use useIsCompactFormFactor() from constants/layout.ts.
|
||||
// For hover, use onHoverIn/onHoverOut on Pressable — no platform gate needed.
|
||||
// For hover-tracking, see docs/hover.md — the short answer is `onPointerEnter`/
|
||||
// `onPointerLeave` on a plain `View`, with any press behavior on a separate
|
||||
// inner `Pressable`. No platform gate needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Browser or Electron — the JS runtime has access to the DOM. */
|
||||
|
||||
@@ -97,6 +97,13 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
[theme.colors.foreground, toast],
|
||||
);
|
||||
|
||||
const handlePrimaryPress = useCallback(() => {
|
||||
if (!gitActions.primary) {
|
||||
return;
|
||||
}
|
||||
handleActionSelect(gitActions.primary);
|
||||
}, [gitActions.primary, handleActionSelect]);
|
||||
|
||||
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
||||
|
||||
const primaryDisabled = gitActions.primary?.disabled;
|
||||
@@ -124,7 +131,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
<Pressable
|
||||
testID="changes-primary-cta"
|
||||
style={primaryPressableStyle}
|
||||
onPress={gitActions.primary.handler}
|
||||
onPress={handlePrimaryPress}
|
||||
disabled={gitActions.primary.disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={gitActions.primary.label}
|
||||
|
||||
@@ -170,6 +170,91 @@ describe("checkout-git-actions-store", () => {
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { checkoutGithubSetAutoMerge: true },
|
||||
});
|
||||
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
|
||||
|
||||
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
|
||||
enabled: true,
|
||||
method: "squash",
|
||||
});
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: false,
|
||||
success: true,
|
||||
error: null,
|
||||
})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: { checkoutGithubSetAutoMerge: true },
|
||||
});
|
||||
|
||||
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
|
||||
|
||||
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
|
||||
const client = {
|
||||
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
})),
|
||||
};
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
|
||||
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
|
||||
|
||||
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
).toBe("idle");
|
||||
});
|
||||
|
||||
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||
const deferred = createDeferred<Record<string, never>>();
|
||||
const client = {
|
||||
|
||||
@@ -32,6 +32,10 @@ export type CheckoutGitAsyncActionId =
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
| "merge-pr-rebase"
|
||||
| "enable-pr-auto-merge-squash"
|
||||
| "enable-pr-auto-merge-merge"
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
@@ -52,6 +56,13 @@ function resolveClient(serverId: string) {
|
||||
return client;
|
||||
}
|
||||
|
||||
function assertGitHubAutoMergeActionsSupported(serverId: string) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
|
||||
throw new Error("Update the host to use GitHub auto-merge actions.");
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(
|
||||
key: CheckoutKey,
|
||||
actionId: CheckoutGitAsyncActionId,
|
||||
@@ -231,6 +242,12 @@ interface CheckoutGitActionsStoreState {
|
||||
cwd: string;
|
||||
method: CheckoutPrMergeMethod;
|
||||
}) => Promise<void>;
|
||||
enablePrAutoMerge: (params: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
method: CheckoutPrMergeMethod;
|
||||
}) => Promise<void>;
|
||||
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
|
||||
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
||||
archiveWorktree: (params: {
|
||||
@@ -392,6 +409,38 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
});
|
||||
},
|
||||
|
||||
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
|
||||
assertGitHubAutoMergeActionsSupported(serverId);
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: `enable-pr-auto-merge-${method}`,
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
disablePrAutoMerge: async ({ serverId, cwd }) => {
|
||||
assertGitHubAutoMergeActionsSupported(serverId);
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
cwd,
|
||||
actionId: "disable-pr-auto-merge",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
mergeBranch: async ({ serverId, cwd, baseRef }) => {
|
||||
await runCheckoutAction({
|
||||
serverId,
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CheckoutPrStatusSchema } from "@server/shared/messages";
|
||||
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
|
||||
function githubStatus(
|
||||
overrides: Partial<NonNullable<BuildGitActionsInput["pullRequestGithub"]>> = {},
|
||||
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
|
||||
return {
|
||||
mergeStateStatus: "CLEAN",
|
||||
autoMergeRequest: null,
|
||||
viewerCanEnableAutoMerge: false,
|
||||
viewerCanDisableAutoMerge: false,
|
||||
viewerCanMergeAsAdmin: false,
|
||||
viewerCanUpdateBranch: false,
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: true,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
isMergeQueueEnabled: false,
|
||||
isInMergeQueue: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
|
||||
return {
|
||||
isGit: true,
|
||||
githubFeaturesEnabled: true,
|
||||
githubAutoMergeActionsEnabled: true,
|
||||
hasPullRequest: false,
|
||||
pullRequestUrl: null,
|
||||
pullRequestState: null,
|
||||
pullRequestIsDraft: false,
|
||||
pullRequestIsMerged: false,
|
||||
pullRequestMergeable: "UNKNOWN",
|
||||
pullRequestGithub: null,
|
||||
hasRemote: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isOnBaseBranch: true,
|
||||
@@ -65,6 +91,26 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"enable-pr-auto-merge-squash": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"enable-pr-auto-merge-merge": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"enable-pr-auto-merge-rebase": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"disable-pr-auto-merge": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
handler: () => undefined,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
@@ -167,6 +213,9 @@ describe("git-actions-policy", () => {
|
||||
behindBaseCount: 1,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -177,9 +226,6 @@ describe("git-actions-policy", () => {
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
|
||||
@@ -246,6 +292,28 @@ describe("git-actions-policy", () => {
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus(),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-squash",
|
||||
label: "Squash and merge",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses GitHub merge state, not mergeable, for direct merge readiness", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "UNKNOWN",
|
||||
pullRequestGithub: githubStatus({ mergeStateStatus: "CLEAN" }),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
@@ -266,6 +334,7 @@ describe("git-actions-policy", () => {
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus(),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
@@ -278,6 +347,7 @@ describe("git-actions-policy", () => {
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus(),
|
||||
shipDefault: "merge",
|
||||
}),
|
||||
);
|
||||
@@ -305,6 +375,7 @@ describe("git-actions-policy", () => {
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus(),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -321,6 +392,73 @@ describe("git-actions-policy", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the visible PR action model stable on feature branches", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus(),
|
||||
}),
|
||||
);
|
||||
const pullRequestActions = actions.secondary
|
||||
.filter((action) =>
|
||||
["pr", "merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||
)
|
||||
.map((action) => ({
|
||||
id: action.id,
|
||||
label: action.label,
|
||||
pendingLabel: action.pendingLabel,
|
||||
successLabel: action.successLabel,
|
||||
disabled: action.disabled,
|
||||
unavailableMessage: action.unavailableMessage,
|
||||
startsGroup: action.startsGroup,
|
||||
}));
|
||||
|
||||
expect(pullRequestActions).toEqual([
|
||||
{
|
||||
id: "pr",
|
||||
label: "View PR",
|
||||
pendingLabel: "View PR",
|
||||
successLabel: "View PR",
|
||||
disabled: false,
|
||||
unavailableMessage: undefined,
|
||||
startsGroup: false,
|
||||
},
|
||||
{
|
||||
id: "merge-pr-squash",
|
||||
label: "Squash and merge",
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: false,
|
||||
unavailableMessage: undefined,
|
||||
startsGroup: true,
|
||||
},
|
||||
{
|
||||
id: "merge-pr-merge",
|
||||
label: "Create a merge commit",
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: false,
|
||||
unavailableMessage: undefined,
|
||||
startsGroup: false,
|
||||
},
|
||||
{
|
||||
id: "merge-pr-rebase",
|
||||
label: "Rebase and merge",
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: false,
|
||||
unavailableMessage: undefined,
|
||||
startsGroup: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses Merge locally for the local merge action", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
@@ -334,27 +472,11 @@ describe("git-actions-policy", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"draft",
|
||||
{ pullRequestIsDraft: true },
|
||||
"Merge PR isn't available because the pull request is still a draft",
|
||||
],
|
||||
[
|
||||
"merged",
|
||||
{ pullRequestIsMerged: true },
|
||||
"Merge PR isn't available because the pull request is already merged",
|
||||
],
|
||||
[
|
||||
"closed",
|
||||
{ pullRequestState: "closed" as const },
|
||||
"Merge PR isn't available because the pull request is closed",
|
||||
],
|
||||
[
|
||||
"conflicting",
|
||||
{ pullRequestMergeable: "CONFLICTING" as const },
|
||||
"Merge PR isn't available because the pull request has conflicts",
|
||||
],
|
||||
])("marks merge-pr actions unavailable when the PR is %s", (_name, overrides, message) => {
|
||||
["draft", { pullRequestIsDraft: true }],
|
||||
["merged", { pullRequestIsMerged: true }],
|
||||
["closed", { pullRequestState: "closed" as const }],
|
||||
["conflicting", { pullRequestMergeable: "CONFLICTING" as const }],
|
||||
])("does not offer direct merge actions when the PR is %s", (_name, overrides) => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
@@ -364,6 +486,7 @@ describe("git-actions-policy", () => {
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus(),
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
@@ -371,13 +494,224 @@ describe("git-actions-policy", () => {
|
||||
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||
);
|
||||
|
||||
expect(mergePrActions).toHaveLength(3);
|
||||
expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([
|
||||
message,
|
||||
message,
|
||||
message,
|
||||
expect(mergePrActions).toEqual([]);
|
||||
expect(actions.secondary).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "pr", label: "View PR" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves legacy direct merge actions when old payloads have no GitHub facts", () => {
|
||||
const oldDaemonStatus = CheckoutPrStatusSchema.parse({
|
||||
number: 456,
|
||||
url: "https://example.com/pr/456",
|
||||
title: "Legacy payload",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
isMerged: false,
|
||||
mergeable: "MERGEABLE",
|
||||
});
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: oldDaemonStatus.url,
|
||||
pullRequestState: "open",
|
||||
pullRequestIsDraft: oldDaemonStatus.isDraft,
|
||||
pullRequestIsMerged: oldDaemonStatus.isMerged,
|
||||
pullRequestMergeable: oldDaemonStatus.mergeable,
|
||||
pullRequestGithub: oldDaemonStatus.github,
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(oldDaemonStatus.github).toBeUndefined();
|
||||
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Squash and merge" });
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
]);
|
||||
expect(mergePrActions.map((action) => action.disabled)).toEqual([true, true, true]);
|
||||
});
|
||||
|
||||
it("requires GitHub's direct-merge allowlist before promoting PR merge", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/993",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus({
|
||||
mergeStateStatus: "BLOCKED",
|
||||
viewerCanEnableAutoMerge: true,
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: true,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
}),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "enable-pr-auto-merge-squash",
|
||||
label: "Enable auto-merge with squash",
|
||||
});
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"enable-pr-auto-merge-squash",
|
||||
]);
|
||||
expect(
|
||||
actions.secondary.some((action) =>
|
||||
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not offer auto-merge when the daemon feature gate is missing", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/993",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus({
|
||||
mergeStateStatus: "BLOCKED",
|
||||
viewerCanEnableAutoMerge: true,
|
||||
}),
|
||||
githubAutoMergeActionsEnabled: false,
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
|
||||
expect(actions.secondary.some((action) => action.id.startsWith("enable-pr-auto-merge"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows existing auto-merge as state and disables it when the viewer can", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus({
|
||||
autoMergeRequest: {
|
||||
enabledAt: "2026-05-13T12:00:00Z",
|
||||
mergeMethod: "SQUASH",
|
||||
enabledBy: "octocat",
|
||||
},
|
||||
viewerCanDisableAutoMerge: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.secondary).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "disable-pr-auto-merge",
|
||||
label: "Auto-merge enabled",
|
||||
disabled: false,
|
||||
unavailableMessage: undefined,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
actions.secondary.some((action) =>
|
||||
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("respects repository merge method policy for direct merge actions", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus({
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
}),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({
|
||||
id: "merge-pr-merge",
|
||||
label: "Create a merge commit",
|
||||
});
|
||||
expect(actions.secondary.map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-merge",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat merge queue repositories as direct mergeable", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus({
|
||||
mergeStateStatus: "CLEAN",
|
||||
isMergeQueueEnabled: true,
|
||||
}),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({ id: "pr", label: "View PR" });
|
||||
expect(
|
||||
actions.secondary.some((action) =>
|
||||
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("groups merge-pr actions behind their own menu separator via startsGroup", () => {
|
||||
@@ -390,6 +724,7 @@ describe("git-actions-policy", () => {
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus(),
|
||||
isPaseoOwnedWorktree: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import type { ActionStatus } from "@/components/ui/dropdown-menu";
|
||||
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
|
||||
import type {
|
||||
CheckoutPrMergeMethod,
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestMergeable,
|
||||
} from "@server/shared/messages";
|
||||
|
||||
export type GitActionId =
|
||||
| "commit"
|
||||
@@ -12,6 +16,10 @@ export type GitActionId =
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
| "merge-pr-rebase"
|
||||
| "enable-pr-auto-merge-squash"
|
||||
| "enable-pr-auto-merge-merge"
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
| "merge-branch"
|
||||
| "merge-from-base"
|
||||
| "archive-worktree";
|
||||
@@ -46,12 +54,14 @@ interface GitActionRuntimeState {
|
||||
export interface BuildGitActionsInput {
|
||||
isGit: boolean;
|
||||
githubFeaturesEnabled: boolean;
|
||||
githubAutoMergeActionsEnabled: boolean;
|
||||
hasPullRequest: boolean;
|
||||
pullRequestUrl: string | null;
|
||||
pullRequestState: "open" | "closed" | null;
|
||||
pullRequestIsDraft: boolean;
|
||||
pullRequestIsMerged: boolean;
|
||||
pullRequestMergeable: PullRequestMergeable;
|
||||
pullRequestGithub: PullRequestGithubStatus | null;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
@@ -67,24 +77,117 @@ export interface BuildGitActionsInput {
|
||||
runtime: Record<GitActionId, GitActionRuntimeState>;
|
||||
}
|
||||
|
||||
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
|
||||
const FEATURE_ACTION_IDS: GitActionId[] = [
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"merge-pr-squash",
|
||||
"merge-pr-merge",
|
||||
"merge-pr-rebase",
|
||||
type PullRequestActionId = Extract<
|
||||
GitActionId,
|
||||
| "pr"
|
||||
| "merge-pr-squash"
|
||||
| "merge-pr-merge"
|
||||
| "merge-pr-rebase"
|
||||
| "enable-pr-auto-merge-squash"
|
||||
| "enable-pr-auto-merge-merge"
|
||||
| "enable-pr-auto-merge-rebase"
|
||||
| "disable-pr-auto-merge"
|
||||
>;
|
||||
type PullRequestDirectMergeActionId = Extract<
|
||||
GitActionId,
|
||||
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase"
|
||||
>;
|
||||
type PullRequestAutoMergeEnableActionId = Extract<
|
||||
GitActionId,
|
||||
"enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase"
|
||||
>;
|
||||
type PullRequestActionRole = "status" | "direct" | "auto";
|
||||
type PullRequestGithubStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>["github"];
|
||||
|
||||
interface PullRequestActionModel {
|
||||
readonly id: PullRequestActionId;
|
||||
readonly role: PullRequestActionRole;
|
||||
readonly build: (input: BuildGitActionsInput) => GitAction;
|
||||
}
|
||||
|
||||
interface PullRequestDirectMergeActionModel {
|
||||
readonly id: PullRequestDirectMergeActionId;
|
||||
readonly role: "direct";
|
||||
readonly label: string;
|
||||
readonly method: CheckoutPrMergeMethod;
|
||||
readonly startsGroup: boolean;
|
||||
}
|
||||
|
||||
interface PullRequestAutoMergeEnableActionModel {
|
||||
readonly id: PullRequestAutoMergeEnableActionId;
|
||||
readonly role: "auto";
|
||||
readonly label: string;
|
||||
readonly method: CheckoutPrMergeMethod;
|
||||
readonly startsGroup: boolean;
|
||||
}
|
||||
|
||||
const PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS = [
|
||||
{
|
||||
id: "merge-pr-squash",
|
||||
role: "direct",
|
||||
label: "Squash and merge",
|
||||
method: "squash",
|
||||
startsGroup: true,
|
||||
},
|
||||
{
|
||||
id: "merge-pr-merge",
|
||||
role: "direct",
|
||||
label: "Create a merge commit",
|
||||
method: "merge",
|
||||
startsGroup: false,
|
||||
},
|
||||
{
|
||||
id: "merge-pr-rebase",
|
||||
role: "direct",
|
||||
label: "Rebase and merge",
|
||||
method: "rebase",
|
||||
startsGroup: false,
|
||||
},
|
||||
] as const satisfies readonly PullRequestDirectMergeActionModel[];
|
||||
|
||||
const PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS = [
|
||||
{
|
||||
id: "enable-pr-auto-merge-squash",
|
||||
role: "auto",
|
||||
label: "Enable auto-merge with squash",
|
||||
method: "squash",
|
||||
startsGroup: true,
|
||||
},
|
||||
{
|
||||
id: "enable-pr-auto-merge-merge",
|
||||
role: "auto",
|
||||
label: "Enable auto-merge with merge commit",
|
||||
method: "merge",
|
||||
startsGroup: false,
|
||||
},
|
||||
{
|
||||
id: "enable-pr-auto-merge-rebase",
|
||||
role: "auto",
|
||||
label: "Enable auto-merge with rebase",
|
||||
method: "rebase",
|
||||
startsGroup: false,
|
||||
},
|
||||
] as const satisfies readonly PullRequestAutoMergeEnableActionModel[];
|
||||
|
||||
const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [
|
||||
{ id: "pr", role: "status", build: buildPrAction },
|
||||
...PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.map((model) => ({
|
||||
...model,
|
||||
build: (input: BuildGitActionsInput) => buildDirectPullRequestMergeAction(input, model),
|
||||
})),
|
||||
...PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.map((model) => ({
|
||||
...model,
|
||||
build: (input: BuildGitActionsInput) => buildEnablePullRequestAutoMergeAction(input, model),
|
||||
})),
|
||||
{
|
||||
id: "disable-pr-auto-merge",
|
||||
role: "auto",
|
||||
build: buildDisablePullRequestAutoMergeAction,
|
||||
},
|
||||
];
|
||||
|
||||
const MERGE_PR_METHODS: Record<
|
||||
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
|
||||
{ label: string; method: CheckoutPrMergeMethod }
|
||||
> = {
|
||||
"merge-pr-squash": { label: "Squash and merge", method: "squash" },
|
||||
"merge-pr-merge": { label: "Create a merge commit", method: "merge" },
|
||||
"merge-pr-rebase": { label: "Rebase and merge", method: "rebase" },
|
||||
};
|
||||
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
|
||||
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
|
||||
|
||||
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
|
||||
if (state === "open") return "open";
|
||||
@@ -152,11 +255,9 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
handler: input.runtime["pull-and-push"].handler,
|
||||
});
|
||||
|
||||
allActions.set("pr", buildPrAction(input));
|
||||
|
||||
allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash"));
|
||||
allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge"));
|
||||
allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase"));
|
||||
for (const model of PULL_REQUEST_ACTION_MODELS) {
|
||||
allActions.set(model.id, model.build(input));
|
||||
}
|
||||
|
||||
allActions.set("merge-branch", {
|
||||
id: "merge-branch",
|
||||
@@ -209,7 +310,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
|
||||
const secondaryIds = [...REMOTE_ACTION_IDS];
|
||||
if (!input.isOnBaseBranch) {
|
||||
secondaryIds.push(...FEATURE_ACTION_IDS);
|
||||
secondaryIds.push(...getFeatureActionIds(input));
|
||||
}
|
||||
if (input.isPaseoOwnedWorktree) {
|
||||
secondaryIds.push("archive-worktree");
|
||||
@@ -239,7 +340,10 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
return "merge-from-base";
|
||||
}
|
||||
if (canMergePr(input) && input.shipDefault === "pr") {
|
||||
return "merge-pr-squash";
|
||||
return getDefaultDirectPullRequestMergeActionId(input);
|
||||
}
|
||||
if (canEnablePrAutoMerge(input) && input.shipDefault === "pr") {
|
||||
return getDefaultEnablePullRequestAutoMergeActionId(input);
|
||||
}
|
||||
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
|
||||
return "merge-branch";
|
||||
@@ -253,6 +357,41 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPullRequestActionIds(filter: {
|
||||
roles: readonly PullRequestActionRole[];
|
||||
input: BuildGitActionsInput;
|
||||
}): PullRequestActionId[] {
|
||||
return PULL_REQUEST_ACTION_MODELS.filter((model) => filter.roles.includes(model.role))
|
||||
.filter((model) => shouldShowPullRequestAction(filter.input, model.id))
|
||||
.map((model) => model.id);
|
||||
}
|
||||
|
||||
function getFeatureActionIds(input: BuildGitActionsInput): GitActionId[] {
|
||||
return [
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
...getPullRequestActionIds({ roles: ["status", "direct", "auto"], input }),
|
||||
];
|
||||
}
|
||||
|
||||
function getDefaultDirectPullRequestMergeActionId(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestDirectMergeActionId {
|
||||
return (
|
||||
getPreferredDirectPullRequestMergeActionModel(input)?.id ??
|
||||
PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS[0].id
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultEnablePullRequestAutoMergeActionId(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestAutoMergeEnableActionId {
|
||||
return (
|
||||
getPreferredEnablePullRequestAutoMergeActionModel(input)?.id ??
|
||||
PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS[0].id
|
||||
);
|
||||
}
|
||||
|
||||
function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
if (input.hasPullRequest && input.pullRequestUrl) {
|
||||
return {
|
||||
@@ -288,23 +427,60 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
|
||||
};
|
||||
}
|
||||
|
||||
function buildMergePrAction(
|
||||
function buildDirectPullRequestMergeAction(
|
||||
input: BuildGitActionsInput,
|
||||
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
|
||||
model: PullRequestDirectMergeActionModel,
|
||||
): GitAction {
|
||||
const runtime = input.runtime[id];
|
||||
const config = MERGE_PR_METHODS[id];
|
||||
const runtime = input.runtime[model.id];
|
||||
const unavailableMessage = getMergePrUnavailableMessage(input);
|
||||
return {
|
||||
id,
|
||||
label: config.label,
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
pendingLabel: "Merging PR...",
|
||||
successLabel: "PR merged",
|
||||
disabled: runtime.disabled || shouldDisableMergePrAction(input),
|
||||
status: runtime.status,
|
||||
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
|
||||
icon: runtime.icon,
|
||||
startsGroup: id === "merge-pr-squash",
|
||||
startsGroup: model.startsGroup,
|
||||
handler: runtime.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEnablePullRequestAutoMergeAction(
|
||||
input: BuildGitActionsInput,
|
||||
model: PullRequestAutoMergeEnableActionModel,
|
||||
): GitAction {
|
||||
const runtime = input.runtime[model.id];
|
||||
return {
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
pendingLabel: "Enabling auto-merge...",
|
||||
successLabel: "Auto-merge enabled",
|
||||
disabled: runtime.disabled,
|
||||
status: runtime.status,
|
||||
icon: runtime.icon,
|
||||
startsGroup: model.startsGroup,
|
||||
handler: runtime.handler,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): GitAction {
|
||||
const runtime = input.runtime["disable-pr-auto-merge"];
|
||||
const unavailableMessage =
|
||||
input.pullRequestGithub?.viewerCanDisableAutoMerge === true
|
||||
? undefined
|
||||
: "Auto-merge is enabled, but this account can't disable it";
|
||||
return {
|
||||
id: "disable-pr-auto-merge",
|
||||
label: "Auto-merge enabled",
|
||||
pendingLabel: "Disabling auto-merge...",
|
||||
successLabel: "Auto-merge disabled",
|
||||
disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true,
|
||||
status: runtime.status,
|
||||
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
|
||||
icon: runtime.icon,
|
||||
startsGroup: true,
|
||||
handler: runtime.handler,
|
||||
};
|
||||
}
|
||||
@@ -327,18 +503,55 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
|
||||
}
|
||||
|
||||
function canMergePr(input: BuildGitActionsInput): boolean {
|
||||
return (
|
||||
const github = input.pullRequestGithub;
|
||||
const canMergeFromTopLevelStatus =
|
||||
input.githubFeaturesEnabled &&
|
||||
input.hasPullRequest &&
|
||||
input.pullRequestState === "open" &&
|
||||
!input.pullRequestIsDraft &&
|
||||
!input.pullRequestIsMerged &&
|
||||
input.pullRequestMergeable === "MERGEABLE" &&
|
||||
input.pullRequestMergeable !== "CONFLICTING" &&
|
||||
input.aheadCount > 0 &&
|
||||
!input.hasUncommittedChanges &&
|
||||
input.behindOfOrigin === 0 &&
|
||||
input.aheadOfOrigin === 0 &&
|
||||
!canMergeFromBase(input)
|
||||
!canMergeFromBase(input);
|
||||
|
||||
if (!canMergeFromTopLevelStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasPullRequestGithubFacts(github)) {
|
||||
return input.pullRequestMergeable === "MERGEABLE";
|
||||
}
|
||||
|
||||
return (
|
||||
GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "") &&
|
||||
github.autoMergeRequest === null &&
|
||||
!github.isMergeQueueEnabled &&
|
||||
!github.isInMergeQueue &&
|
||||
getAllowedDirectPullRequestMergeActionModels(input).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
|
||||
const github = input.pullRequestGithub;
|
||||
return (
|
||||
input.githubFeaturesEnabled &&
|
||||
input.githubAutoMergeActionsEnabled &&
|
||||
input.hasPullRequest &&
|
||||
input.pullRequestState === "open" &&
|
||||
!input.pullRequestIsDraft &&
|
||||
!input.pullRequestIsMerged &&
|
||||
input.pullRequestMergeable !== "CONFLICTING" &&
|
||||
hasPullRequestGithubFacts(github) &&
|
||||
github.autoMergeRequest === null &&
|
||||
github.mergeStateStatus === "BLOCKED" &&
|
||||
github.repository.autoMergeAllowed &&
|
||||
github.viewerCanEnableAutoMerge &&
|
||||
!github.isMergeQueueEnabled &&
|
||||
!github.isInMergeQueue &&
|
||||
getAllowedAutoMergeEnableActionModels(input).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,14 +649,131 @@ function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | und
|
||||
if (input.pullRequestMergeable === "CONFLICTING") {
|
||||
return "Merge PR isn't available because the pull request has conflicts";
|
||||
}
|
||||
if (!hasPullRequestGithubFacts(input.pullRequestGithub)) {
|
||||
return undefined;
|
||||
}
|
||||
if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) {
|
||||
return "Merge PR isn't available here because this repository uses a merge queue";
|
||||
}
|
||||
if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) {
|
||||
return "Merge PR isn't available until GitHub reports the pull request is ready to merge";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
|
||||
return (
|
||||
input.pullRequestIsDraft ||
|
||||
input.pullRequestIsMerged ||
|
||||
input.pullRequestState === "closed" ||
|
||||
input.pullRequestMergeable === "CONFLICTING"
|
||||
return !canMergePr(input);
|
||||
}
|
||||
|
||||
function shouldShowPullRequestAction(
|
||||
input: BuildGitActionsInput,
|
||||
id: PullRequestActionId,
|
||||
): boolean {
|
||||
if (id === "pr") {
|
||||
return true;
|
||||
}
|
||||
if (id === "disable-pr-auto-merge") {
|
||||
return (
|
||||
input.githubAutoMergeActionsEnabled &&
|
||||
hasPullRequestGithubFacts(input.pullRequestGithub) &&
|
||||
input.pullRequestGithub.autoMergeRequest !== null
|
||||
);
|
||||
}
|
||||
if (isDirectPullRequestMergeActionId(id)) {
|
||||
return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id);
|
||||
}
|
||||
if (isEnablePullRequestAutoMergeActionId(id)) {
|
||||
return canEnablePrAutoMerge(input) && getAllowedAutoMergeEnableActionIds(input).includes(id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDirectPullRequestMergeActionId(
|
||||
id: PullRequestActionId,
|
||||
): id is PullRequestDirectMergeActionId {
|
||||
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.some((model) => model.id === id);
|
||||
}
|
||||
|
||||
function isEnablePullRequestAutoMergeActionId(
|
||||
id: PullRequestActionId,
|
||||
): id is PullRequestAutoMergeEnableActionId {
|
||||
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.some((model) => model.id === id);
|
||||
}
|
||||
|
||||
function getAllowedDirectPullRequestMergeActionIds(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestDirectMergeActionId[] {
|
||||
return getAllowedDirectPullRequestMergeActionModels(input).map((model) => model.id);
|
||||
}
|
||||
|
||||
function getAllowedAutoMergeEnableActionIds(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestAutoMergeEnableActionId[] {
|
||||
return getAllowedAutoMergeEnableActionModels(input).map((model) => model.id);
|
||||
}
|
||||
|
||||
function getAllowedDirectPullRequestMergeActionModels(
|
||||
input: BuildGitActionsInput,
|
||||
): readonly PullRequestDirectMergeActionModel[] {
|
||||
return PULL_REQUEST_DIRECT_MERGE_ACTION_MODELS.filter((model) =>
|
||||
isPullRequestMergeMethodAllowed(input, model.method),
|
||||
);
|
||||
}
|
||||
|
||||
function getAllowedAutoMergeEnableActionModels(
|
||||
input: BuildGitActionsInput,
|
||||
): readonly PullRequestAutoMergeEnableActionModel[] {
|
||||
return PULL_REQUEST_AUTO_MERGE_ENABLE_ACTION_MODELS.filter((model) =>
|
||||
isPullRequestMergeMethodAllowed(input, model.method),
|
||||
);
|
||||
}
|
||||
|
||||
function getPreferredDirectPullRequestMergeActionModel(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestDirectMergeActionModel | null {
|
||||
const allowed = getAllowedDirectPullRequestMergeActionModels(input);
|
||||
const preferred = normalizeGithubMergeMethod(
|
||||
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
|
||||
);
|
||||
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
|
||||
}
|
||||
|
||||
function getPreferredEnablePullRequestAutoMergeActionModel(
|
||||
input: BuildGitActionsInput,
|
||||
): PullRequestAutoMergeEnableActionModel | null {
|
||||
const allowed = getAllowedAutoMergeEnableActionModels(input);
|
||||
const preferred = normalizeGithubMergeMethod(
|
||||
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
|
||||
);
|
||||
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
|
||||
}
|
||||
|
||||
function isPullRequestMergeMethodAllowed(
|
||||
input: BuildGitActionsInput,
|
||||
method: CheckoutPrMergeMethod,
|
||||
): boolean {
|
||||
const repository = input.pullRequestGithub?.repository;
|
||||
if (!repository) {
|
||||
return true;
|
||||
}
|
||||
if (method === "squash") {
|
||||
return repository.squashMergeAllowed;
|
||||
}
|
||||
if (method === "merge") {
|
||||
return repository.mergeCommitAllowed;
|
||||
}
|
||||
return repository.rebaseMergeAllowed;
|
||||
}
|
||||
|
||||
function hasPullRequestGithubFacts(
|
||||
github: PullRequestGithubStatus | null,
|
||||
): github is NonNullable<PullRequestGithubStatus> {
|
||||
return github !== null && github !== undefined;
|
||||
}
|
||||
|
||||
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
|
||||
if (value === "SQUASH") return "squash";
|
||||
if (value === "MERGE") return "merge";
|
||||
if (value === "REBASE") return "rebase";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,24 @@ import {
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
const githubStatus: CheckoutPrStatus["github"] = {
|
||||
mergeStateStatus: null,
|
||||
autoMergeRequest: null,
|
||||
viewerCanEnableAutoMerge: false,
|
||||
viewerCanDisableAutoMerge: false,
|
||||
viewerCanMergeAsAdmin: false,
|
||||
viewerCanUpdateBranch: false,
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: null,
|
||||
},
|
||||
isMergeQueueEnabled: false,
|
||||
isInMergeQueue: false,
|
||||
};
|
||||
|
||||
const baseStatus: CheckoutPrStatus = {
|
||||
number: 42,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
@@ -26,6 +44,7 @@ const baseStatus: CheckoutPrStatus = {
|
||||
mergeable: "UNKNOWN",
|
||||
checks: [],
|
||||
reviewDecision: null,
|
||||
github: githubStatus,
|
||||
};
|
||||
|
||||
const baseTimeline: PullRequestTimeline = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
@@ -169,6 +170,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
|
||||
const branchLabel = resolveBranchLabel({
|
||||
currentBranch: gitStatus?.currentBranch,
|
||||
notGit,
|
||||
});
|
||||
|
||||
// Ship default persistence
|
||||
const shipDefaultStorageKey = useMemo(() => {
|
||||
@@ -240,6 +246,20 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
|
||||
),
|
||||
};
|
||||
const enablePrAutoMergeStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
|
||||
squash: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
||||
),
|
||||
merge: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
||||
),
|
||||
rebase: useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-rebase" }),
|
||||
),
|
||||
};
|
||||
const disablePrAutoMergeStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
||||
);
|
||||
const mergeStatus = useCheckoutGitActionsStore((s) =>
|
||||
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
|
||||
);
|
||||
@@ -256,9 +276,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
|
||||
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
|
||||
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
|
||||
const runEnablePrAutoMerge = useCheckoutGitActionsStore((s) => s.enablePrAutoMerge);
|
||||
const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge);
|
||||
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
|
||||
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
|
||||
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
|
||||
const githubAutoMergeActionsEnabled = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
|
||||
);
|
||||
|
||||
const toastActionError = useCallback(
|
||||
(error: unknown, fallback: string) => {
|
||||
@@ -348,6 +373,32 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess],
|
||||
);
|
||||
|
||||
const handleEnablePrAutoMerge = useCallback(
|
||||
(method: CheckoutPrMergeMethod) => {
|
||||
void persistShipDefault("pr");
|
||||
void runEnablePrAutoMerge({ serverId, cwd, method })
|
||||
.then(() => {
|
||||
toastActionSuccess("Auto-merge enabled");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to enable auto-merge");
|
||||
});
|
||||
},
|
||||
[cwd, persistShipDefault, runEnablePrAutoMerge, serverId, toastActionError, toastActionSuccess],
|
||||
);
|
||||
|
||||
const handleDisablePrAutoMerge = useCallback(() => {
|
||||
void runDisablePrAutoMerge({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Auto-merge disabled");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to disable auto-merge");
|
||||
});
|
||||
}, [cwd, runDisablePrAutoMerge, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleMergeBranch = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
toast.error("Base ref unavailable");
|
||||
@@ -389,31 +440,59 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
});
|
||||
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const archiveWorktreeAfterConfirmation = useCallback(async () => {
|
||||
const worktreePath = status?.cwd;
|
||||
if (!worktreePath) {
|
||||
toast.error("Worktree path unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceList = Array.from(workspaces?.values() ?? []);
|
||||
const workspace = workspaceList.find(
|
||||
(candidate) => candidate.workspaceDirectory === worktreePath,
|
||||
);
|
||||
const confirmed = await confirmRiskyWorktreeArchive({
|
||||
worktreeName: workspace?.name ?? branchLabel,
|
||||
isDirty: gitStatus?.isDirty,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin,
|
||||
diffStat: workspace?.diffStat ?? null,
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archivedWorkspaceId =
|
||||
resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaces: workspaceList,
|
||||
workspaceDirectory: worktreePath,
|
||||
}) ?? worktreePath;
|
||||
router.replace(
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaces?.values() ?? [],
|
||||
workspaces: workspaceList,
|
||||
}) as Href,
|
||||
);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
|
||||
toastActionError(err, "Failed to archive worktree");
|
||||
});
|
||||
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||
}, [
|
||||
branchLabel,
|
||||
cwd,
|
||||
gitStatus?.aheadOfOrigin,
|
||||
gitStatus?.isDirty,
|
||||
runArchiveWorktree,
|
||||
serverId,
|
||||
status?.cwd,
|
||||
toast,
|
||||
toastActionError,
|
||||
]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
void archiveWorktreeAfterConfirmation();
|
||||
}, [archiveWorktreeAfterConfirmation]);
|
||||
|
||||
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
|
||||
const derived = deriveGitActionsState({
|
||||
isGit,
|
||||
status,
|
||||
@@ -437,11 +516,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
shouldPromoteArchive,
|
||||
} = derived;
|
||||
|
||||
const branchLabel = resolveBranchLabel({
|
||||
currentBranch: gitStatus?.currentBranch,
|
||||
notGit,
|
||||
});
|
||||
|
||||
const handlePrAction = useCallback(() => {
|
||||
if (prStatus?.url) {
|
||||
openURLInNewTab(prStatus.url);
|
||||
@@ -455,12 +529,14 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
return buildGitActions({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasPullRequest,
|
||||
pullRequestUrl: prStatus?.url ?? null,
|
||||
pullRequestState: narrowPullRequestState(prStatus?.state),
|
||||
pullRequestIsDraft: prStatus?.isDraft ?? false,
|
||||
pullRequestIsMerged: prStatus?.isMerged ?? false,
|
||||
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
|
||||
pullRequestGithub: prStatus?.github ?? null,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
@@ -522,6 +598,30 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
icon: icons.mergePrRebase,
|
||||
handler: () => handleMergePr("rebase"),
|
||||
},
|
||||
"enable-pr-auto-merge-squash": {
|
||||
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.squash),
|
||||
status: enablePrAutoMergeStatuses.squash,
|
||||
icon: icons.mergePrSquash,
|
||||
handler: () => handleEnablePrAutoMerge("squash"),
|
||||
},
|
||||
"enable-pr-auto-merge-merge": {
|
||||
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.merge),
|
||||
status: enablePrAutoMergeStatuses.merge,
|
||||
icon: icons.mergePrMerge,
|
||||
handler: () => handleEnablePrAutoMerge("merge"),
|
||||
},
|
||||
"enable-pr-auto-merge-rebase": {
|
||||
disabled: isActionDisabled(actionsDisabled, enablePrAutoMergeStatuses.rebase),
|
||||
status: enablePrAutoMergeStatuses.rebase,
|
||||
icon: icons.mergePrRebase,
|
||||
handler: () => handleEnablePrAutoMerge("rebase"),
|
||||
},
|
||||
"disable-pr-auto-merge": {
|
||||
disabled: isActionDisabled(actionsDisabled, disablePrAutoMergeStatus),
|
||||
status: disablePrAutoMergeStatus,
|
||||
icon: icons.viewPr,
|
||||
handler: handleDisablePrAutoMerge,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: isActionDisabled(actionsDisabled, mergeStatus),
|
||||
status: mergeStatus,
|
||||
@@ -551,11 +651,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
prStatus?.isDraft,
|
||||
prStatus?.isMerged,
|
||||
prStatus?.mergeable,
|
||||
prStatus?.github,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
@@ -571,6 +673,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
mergePrStatuses.squash,
|
||||
mergePrStatuses.merge,
|
||||
mergePrStatuses.rebase,
|
||||
enablePrAutoMergeStatuses.squash,
|
||||
enablePrAutoMergeStatuses.merge,
|
||||
enablePrAutoMergeStatuses.rebase,
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
@@ -580,6 +686,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergePr,
|
||||
handleEnablePrAutoMerge,
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
|
||||
42
packages/app/src/git/use-github-search-query.test.ts
Normal file
42
packages/app/src/git/use-github-search-query.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildGithubSearchQueryOptions, githubSearchQueryKey } from "./use-github-search-query";
|
||||
|
||||
describe("githubSearchQueryKey", () => {
|
||||
it("keeps the shared cache key shape for no-kinds searches", () => {
|
||||
expect(githubSearchQueryKey("server-1", "/repo", " 123 ")).toEqual([
|
||||
"github-search",
|
||||
"server-1",
|
||||
"/repo",
|
||||
"123",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds a deterministic kinds key when kinds are specified", () => {
|
||||
expect(githubSearchQueryKey("server-1", "/repo", "123", ["github-pr", "github-issue"])).toEqual(
|
||||
["github-search", "server-1", "/repo", "123", "github-issue,github-pr"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGithubSearchQueryOptions", () => {
|
||||
it("forwards kinds to the GitHub search request when specified", async () => {
|
||||
const requests: unknown[] = [];
|
||||
const query = buildGithubSearchQueryOptions({
|
||||
client: {
|
||||
async searchGitHub(options) {
|
||||
requests.push(options);
|
||||
return { items: [], githubFeaturesEnabled: true, error: null, requestId: "request-1" };
|
||||
},
|
||||
},
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
query: " 123 ",
|
||||
kinds: ["github-pr"],
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await query.queryFn();
|
||||
|
||||
expect(requests).toEqual([{ cwd: "/repo", query: "123", limit: 20, kinds: ["github-pr"] }]);
|
||||
});
|
||||
});
|
||||
64
packages/app/src/git/use-github-search-query.ts
Normal file
64
packages/app/src/git/use-github-search-query.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { GitHubSearchRequest, GitHubSearchResponse } from "@server/shared/messages";
|
||||
|
||||
export const GITHUB_SEARCH_STALE_TIME = 30_000;
|
||||
|
||||
export type GitHubSearchPayload = GitHubSearchResponse["payload"];
|
||||
|
||||
export interface GitHubSearchClient {
|
||||
searchGitHub: (
|
||||
options: {
|
||||
cwd: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
kinds?: GitHubSearchRequest["kinds"];
|
||||
},
|
||||
requestId?: string,
|
||||
) => Promise<GitHubSearchPayload>;
|
||||
}
|
||||
|
||||
interface GitHubSearchQueryInput {
|
||||
client: GitHubSearchClient | null;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
query: string;
|
||||
kinds?: GitHubSearchRequest["kinds"];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function githubSearchQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
query: string,
|
||||
kinds?: GitHubSearchRequest["kinds"],
|
||||
) {
|
||||
const trimmedQuery = query.trim();
|
||||
if (!kinds) {
|
||||
return ["github-search", serverId, cwd, trimmedQuery] as const;
|
||||
}
|
||||
return ["github-search", serverId, cwd, trimmedQuery, [...kinds].sort().join(",")] as const;
|
||||
}
|
||||
|
||||
export function buildGithubSearchQueryOptions(input: GitHubSearchQueryInput) {
|
||||
const query = input.query.trim();
|
||||
|
||||
return {
|
||||
queryKey: githubSearchQueryKey(input.serverId, input.cwd, query, input.kinds),
|
||||
queryFn: async (): Promise<GitHubSearchPayload> => {
|
||||
if (!input.client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const request = { cwd: input.cwd, query, limit: 20 };
|
||||
if (input.kinds) {
|
||||
return input.client.searchGitHub({ ...request, kinds: input.kinds });
|
||||
}
|
||||
return input.client.searchGitHub(request);
|
||||
},
|
||||
enabled: input.enabled && Boolean(input.client),
|
||||
staleTime: GITHUB_SEARCH_STALE_TIME,
|
||||
};
|
||||
}
|
||||
|
||||
export function useGithubSearchQuery(input: GitHubSearchQueryInput) {
|
||||
return useQuery(buildGithubSearchQueryOptions(input));
|
||||
}
|
||||
60
packages/app/src/git/worktree-archive-warning.test.ts
Normal file
60
packages/app/src/git/worktree-archive-warning.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildWorktreeArchiveConfirmationMessage,
|
||||
buildWorktreeArchiveRiskReasons,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
|
||||
describe("worktree archive warning", () => {
|
||||
it("does not require a confirmation for clean and pushed worktrees", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveConfirmationMessage({
|
||||
worktreeName: "feature",
|
||||
isDirty: false,
|
||||
aheadOfOrigin: 0,
|
||||
diffStat: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("explains uncommitted line changes", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveRiskReasons({
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 0,
|
||||
diffStat: { additions: 12, deletions: 1 },
|
||||
}),
|
||||
).toEqual(["Uncommitted changes (12 added lines, 1 deleted line)"]);
|
||||
});
|
||||
|
||||
it("treats nonzero diff stats as dirty when dirty state is missing", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveRiskReasons({
|
||||
isDirty: undefined,
|
||||
aheadOfOrigin: 0,
|
||||
diffStat: { additions: 4, deletions: 0 },
|
||||
}),
|
||||
).toEqual(["Uncommitted changes (4 added lines)"]);
|
||||
});
|
||||
|
||||
it("explains unpushed commits", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveRiskReasons({
|
||||
isDirty: false,
|
||||
aheadOfOrigin: 2,
|
||||
diffStat: null,
|
||||
}),
|
||||
).toEqual(["2 unpushed commits"]);
|
||||
});
|
||||
|
||||
it("includes every archive risk in the confirmation copy", () => {
|
||||
expect(
|
||||
buildWorktreeArchiveConfirmationMessage({
|
||||
worktreeName: "risky-feature",
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 1,
|
||||
diffStat: { additions: 1, deletions: 3 },
|
||||
}),
|
||||
).toBe("Uncommitted changes (1 added line, 3 deleted lines)\n1 unpushed commit");
|
||||
});
|
||||
});
|
||||
79
packages/app/src/git/worktree-archive-warning.ts
Normal file
79
packages/app/src/git/worktree-archive-warning.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
export interface WorktreeArchiveRisk {
|
||||
isDirty?: boolean | null;
|
||||
aheadOfOrigin?: number | null;
|
||||
diffStat?: { additions: number; deletions: number } | null;
|
||||
}
|
||||
|
||||
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
|
||||
worktreeName: string;
|
||||
}
|
||||
|
||||
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
|
||||
return count === 1 ? singular : plural;
|
||||
}
|
||||
|
||||
function formatDiffStat(diffStat: WorktreeArchiveRisk["diffStat"]): string | null {
|
||||
if (!diffStat) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (diffStat.additions > 0) {
|
||||
parts.push(`${diffStat.additions} added ${pluralize(diffStat.additions, "line")}`);
|
||||
}
|
||||
if (diffStat.deletions > 0) {
|
||||
parts.push(`${diffStat.deletions} deleted ${pluralize(diffStat.deletions, "line")}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): string[] {
|
||||
const reasons: string[] = [];
|
||||
const diffStat = input.diffStat;
|
||||
const hasDiffStatChanges = diffStat ? diffStat.additions > 0 || diffStat.deletions > 0 : false;
|
||||
const hasUncommittedChanges =
|
||||
input.isDirty === true || (input.isDirty == null && hasDiffStatChanges);
|
||||
|
||||
if (hasUncommittedChanges) {
|
||||
const diffStatLabel = formatDiffStat(diffStat);
|
||||
reasons.push(diffStatLabel ? `Uncommitted changes (${diffStatLabel})` : "Uncommitted changes");
|
||||
}
|
||||
|
||||
if ((input.aheadOfOrigin ?? 0) > 0) {
|
||||
const aheadOfOrigin = input.aheadOfOrigin ?? 0;
|
||||
reasons.push(`${aheadOfOrigin} unpushed ${pluralize(aheadOfOrigin, "commit")}`);
|
||||
}
|
||||
|
||||
return reasons;
|
||||
}
|
||||
|
||||
export function buildWorktreeArchiveConfirmationMessage(
|
||||
input: WorktreeArchiveConfirmationInput,
|
||||
): string | null {
|
||||
const reasons = buildWorktreeArchiveRiskReasons(input);
|
||||
if (reasons.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return reasons.join("\n");
|
||||
}
|
||||
|
||||
export async function confirmRiskyWorktreeArchive(
|
||||
input: WorktreeArchiveConfirmationInput,
|
||||
): Promise<boolean> {
|
||||
const message = buildWorktreeArchiveConfirmationMessage(input);
|
||||
if (!message) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await confirmDialog({
|
||||
title: `Archive "${input.worktreeName}"?`,
|
||||
message,
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("expo-image-manipulator", () => ({
|
||||
SaveFormat: { PNG: "png" },
|
||||
ImageManipulator: {
|
||||
manipulate: (_source: string) => ({
|
||||
async renderAsync() {
|
||||
return {
|
||||
release() {},
|
||||
async saveAsync(options: { format?: string }) {
|
||||
return {
|
||||
uri:
|
||||
options.format === "png"
|
||||
? "file:///cache/ImageManipulator/safe-picked.png"
|
||||
: "file:///cache/ImageManipulator/unsafe-picked.jpg",
|
||||
width: 100,
|
||||
height: 100,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
release() {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { normalizePickedImageAssets } from "./image-attachment-picker.native";
|
||||
|
||||
describe("native image attachment picker", () => {
|
||||
it("preserves native picked JPEG and PNG attachment inputs", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "file:///photos/IMG_0001.JPG",
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "picked.jpeg",
|
||||
},
|
||||
{
|
||||
uri: "file:///photos/screenshot.png",
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///photos/IMG_0001.JPG" },
|
||||
mimeType: "image/jpeg",
|
||||
fileName: "picked.jpg",
|
||||
},
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///photos/screenshot.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns a native picked HEIC-like asset into a PNG attachment input", async () => {
|
||||
const result = await normalizePickedImageAssets([
|
||||
{
|
||||
uri: "file:///photos/IMG_0001.HEIC",
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
source: { kind: "file_uri", uri: "file:///cache/ImageManipulator/safe-picked.png" },
|
||||
mimeType: "image/png",
|
||||
fileName: "picked.png",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
129
packages/app/src/hooks/image-attachment-picker.native.ts
Normal file
129
packages/app/src/hooks/image-attachment-picker.native.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ImageManipulator, SaveFormat } from "expo-image-manipulator";
|
||||
|
||||
export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob };
|
||||
|
||||
export interface PickedImageAttachmentInput {
|
||||
source: PickedImageSource;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
export interface ExpoImagePickerAssetLike {
|
||||
uri: string;
|
||||
mimeType?: string | null;
|
||||
fileName?: string | null;
|
||||
file?: File | null;
|
||||
}
|
||||
|
||||
interface SupportedPickedImageFormat {
|
||||
mimeType: "image/jpeg" | "image/png";
|
||||
extension: "jpg" | "png";
|
||||
}
|
||||
|
||||
const JPEG_FORMAT: SupportedPickedImageFormat = {
|
||||
mimeType: "image/jpeg",
|
||||
extension: "jpg",
|
||||
};
|
||||
|
||||
const PNG_FORMAT: SupportedPickedImageFormat = {
|
||||
mimeType: "image/png",
|
||||
extension: "png",
|
||||
};
|
||||
|
||||
function extensionFromPath(path: string | null | undefined): string | null {
|
||||
const match = path?.match(/\.([a-z0-9]+)(?:[?#].*)?$/i);
|
||||
return match?.[1]?.toLowerCase() ?? null;
|
||||
}
|
||||
|
||||
function supportedFormatForExtension(extension: string | null): SupportedPickedImageFormat | null {
|
||||
if (extension === "jpg" || extension === "jpeg") {
|
||||
return JPEG_FORMAT;
|
||||
}
|
||||
if (extension === "png") {
|
||||
return PNG_FORMAT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportedFormatForMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
): SupportedPickedImageFormat | null {
|
||||
const normalizedMimeType = mimeType?.toLowerCase();
|
||||
if (normalizedMimeType === "image/jpeg" || normalizedMimeType === "image/jpg") {
|
||||
return JPEG_FORMAT;
|
||||
}
|
||||
if (normalizedMimeType === "image/png") {
|
||||
return PNG_FORMAT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickedAssetSupportedFormat(
|
||||
asset: ExpoImagePickerAssetLike,
|
||||
): SupportedPickedImageFormat | null {
|
||||
const uriExtension = extensionFromPath(asset.uri);
|
||||
if (uriExtension) {
|
||||
return supportedFormatForExtension(uriExtension);
|
||||
}
|
||||
|
||||
return (
|
||||
supportedFormatForExtension(extensionFromPath(asset.fileName)) ??
|
||||
supportedFormatForMimeType(asset.mimeType)
|
||||
);
|
||||
}
|
||||
|
||||
function replaceFileExtension(
|
||||
fileName: string | null | undefined,
|
||||
extension: SupportedPickedImageFormat["extension"],
|
||||
): string | null {
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.replace(/\.[^./\\]+$/, "") + `.${extension}`;
|
||||
}
|
||||
|
||||
async function exportPickedImageAsPng(uri: string): Promise<string> {
|
||||
const context = ImageManipulator.manipulate(uri);
|
||||
let image: Awaited<ReturnType<typeof context.renderAsync>> | null = null;
|
||||
|
||||
try {
|
||||
image = await context.renderAsync();
|
||||
const result = await image.saveAsync({
|
||||
format: SaveFormat.PNG,
|
||||
});
|
||||
return result.uri;
|
||||
} finally {
|
||||
image?.release();
|
||||
context.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizePickedImageAssets(
|
||||
assets: readonly ExpoImagePickerAssetLike[],
|
||||
): Promise<PickedImageAttachmentInput[]> {
|
||||
return await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
const supportedFormat = pickedAssetSupportedFormat(asset);
|
||||
if (supportedFormat) {
|
||||
return {
|
||||
source: { kind: "file_uri", uri: asset.uri },
|
||||
mimeType: supportedFormat.mimeType,
|
||||
fileName: replaceFileExtension(asset.fileName, supportedFormat.extension),
|
||||
};
|
||||
}
|
||||
|
||||
const convertedUri = await exportPickedImageAsPng(asset.uri);
|
||||
|
||||
return {
|
||||
source: { kind: "file_uri", uri: convertedUri },
|
||||
mimeType: PNG_FORMAT.mimeType,
|
||||
fileName: replaceFileExtension(asset.fileName, PNG_FORMAT.extension),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openImagePathsWithDesktopDialog(): Promise<string[]> {
|
||||
throw new Error("Desktop dialog API is not available on native.");
|
||||
}
|
||||
88
packages/app/src/hooks/use-assistant-file-link-resolver.ts
Normal file
88
packages/app/src/hooks/use-assistant-file-link-resolver.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import {
|
||||
createAssistantFileLinkResolver,
|
||||
type AssistantFileLinkContext,
|
||||
type AssistantFileLinkInteraction,
|
||||
} from "@/utils/assistant-file-link-resolver";
|
||||
import type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
export interface UseAssistantFileLinkResolverOptions {
|
||||
client?: DaemonClient | null;
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
onOpenWorkspaceFile?: (target: InlinePathTarget) => void;
|
||||
toast?: ToastApi | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkActions {
|
||||
prefetch(input: Omit<AssistantFileLinkInteraction, "context">): void;
|
||||
open(input: Omit<AssistantFileLinkInteraction, "context">): void;
|
||||
}
|
||||
|
||||
export function useAssistantFileLinkResolver({
|
||||
client,
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
onOpenWorkspaceFile,
|
||||
toast,
|
||||
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
|
||||
const context: AssistantFileLinkContext = useMemo(
|
||||
() => ({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
}),
|
||||
[serverId, workspaceRoot],
|
||||
);
|
||||
const latestContextRef = useRef(context);
|
||||
latestContextRef.current = context;
|
||||
|
||||
const resolver = useMemo(
|
||||
() =>
|
||||
createAssistantFileLinkResolver({
|
||||
async getDirectorySuggestions(input) {
|
||||
if (!client) {
|
||||
return { entries: [], error: null };
|
||||
}
|
||||
|
||||
const result = await client.getDirectorySuggestions(input);
|
||||
return {
|
||||
entries: result.entries,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
openWorkspaceFile(target) {
|
||||
onOpenWorkspaceFile?.(target);
|
||||
},
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate(token) {
|
||||
toast?.show(`No file found for ${token}`, {
|
||||
variant: "error",
|
||||
testID: "assistant-file-link-not-found-toast",
|
||||
});
|
||||
},
|
||||
isCurrentContext(candidate) {
|
||||
const current = latestContextRef.current;
|
||||
return (
|
||||
current.serverId === candidate.serverId &&
|
||||
current.workspaceRoot === candidate.workspaceRoot
|
||||
);
|
||||
},
|
||||
}),
|
||||
[client, onOpenWorkspaceFile, toast],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
prefetch(input) {
|
||||
void resolver.prefetch({ ...input, context });
|
||||
},
|
||||
open(input) {
|
||||
void resolver.open({ ...input, context });
|
||||
},
|
||||
}),
|
||||
[context, resolver],
|
||||
);
|
||||
}
|
||||
119
packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx
Normal file
119
packages/app/src/hooks/use-ios-hardware-keyboard-submit.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useIosHardwareKeyboardSubmit } from "./use-ios-hardware-keyboard-submit";
|
||||
import {
|
||||
emitHardwareKeyboardSubmitForTest,
|
||||
resetHardwareKeyboardSubmitForTest,
|
||||
getHardwareKeyboardSubmitEnabledForTest,
|
||||
setHardwareKeyboardSubmitEnabled,
|
||||
} from "@/native/ios-hardware-keyboard-submit";
|
||||
|
||||
describe("useIosHardwareKeyboardSubmit", () => {
|
||||
beforeEach(() => {
|
||||
resetHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
it("submits when the focused composer receives a hardware keyboard submit event", () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: true,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
emitHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not submit when the composer is blurred", () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: false,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
emitHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not submit while the default send action is disabled", () => {
|
||||
const onSubmit = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: false,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
emitHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not disable native hardware submit when mounted disabled", () => {
|
||||
setHardwareKeyboardSubmitEnabled(true);
|
||||
|
||||
renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: false,
|
||||
onSubmit: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true);
|
||||
});
|
||||
|
||||
it("disables native hardware submit when focus moves away", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ isEnabled }) =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled,
|
||||
onSubmit,
|
||||
}),
|
||||
{ initialProps: { isEnabled: true } },
|
||||
);
|
||||
|
||||
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(true);
|
||||
|
||||
rerender({ isEnabled: false });
|
||||
|
||||
expect(getHardwareKeyboardSubmitEnabledForTest()).toBe(false);
|
||||
});
|
||||
|
||||
it("unsubscribes on unmount", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { unmount } = renderHook(() =>
|
||||
useIosHardwareKeyboardSubmit({
|
||||
isEnabled: true,
|
||||
onSubmit,
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
act(() => {
|
||||
emitHardwareKeyboardSubmitForTest();
|
||||
});
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
34
packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts
Normal file
34
packages/app/src/hooks/use-ios-hardware-keyboard-submit.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
addHardwareKeyboardSubmitListener,
|
||||
setHardwareKeyboardSubmitEnabled,
|
||||
} from "@/native/ios-hardware-keyboard-submit";
|
||||
|
||||
interface UseIosHardwareKeyboardSubmitInput {
|
||||
isEnabled: boolean;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function useIosHardwareKeyboardSubmit(input: UseIosHardwareKeyboardSubmitInput) {
|
||||
const onSubmitRef = useRef(input.onSubmit);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitRef.current = input.onSubmit;
|
||||
}, [input.onSubmit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = addHardwareKeyboardSubmitListener(() => {
|
||||
onSubmitRef.current();
|
||||
});
|
||||
setHardwareKeyboardSubmitEnabled(true);
|
||||
|
||||
return () => {
|
||||
setHardwareKeyboardSubmitEnabled(false);
|
||||
subscription.remove();
|
||||
};
|
||||
}, [input.isEnabled]);
|
||||
}
|
||||
@@ -54,6 +54,24 @@ vi.mock("@/runtime/host-runtime", () => ({
|
||||
const cwd = "/repo";
|
||||
const serverId = "server-1";
|
||||
|
||||
const githubStatus: CheckoutPrStatus["github"] = {
|
||||
mergeStateStatus: null,
|
||||
autoMergeRequest: null,
|
||||
viewerCanEnableAutoMerge: false,
|
||||
viewerCanDisableAutoMerge: false,
|
||||
viewerCanMergeAsAdmin: false,
|
||||
viewerCanUpdateBranch: false,
|
||||
repository: {
|
||||
autoMergeAllowed: false,
|
||||
mergeCommitAllowed: false,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: null,
|
||||
},
|
||||
isMergeQueueEnabled: false,
|
||||
isInMergeQueue: false,
|
||||
};
|
||||
|
||||
function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
|
||||
return {
|
||||
number: 42,
|
||||
@@ -69,6 +87,7 @@ function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
|
||||
reviewDecision: null,
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
github: githubStatus,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ describe("useProjects", () => {
|
||||
"hostCount",
|
||||
"hosts",
|
||||
"onlineHostCount",
|
||||
"projectCustomName",
|
||||
"projectKey",
|
||||
"projectName",
|
||||
"totalWorkspaceCount",
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface SidebarWorkspaceEntry {
|
||||
statusBucket: SidebarStateBucket;
|
||||
archivingAt: string | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
archiveHasUncommittedChanges: boolean | null;
|
||||
archiveUnpushedCommitCount: number | null;
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
hasRunningScripts: boolean;
|
||||
}
|
||||
@@ -69,6 +71,8 @@ function createStructuralWorkspaceEntry(input: {
|
||||
statusBucket: "done",
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
archiveHasUncommittedChanges: null,
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
@@ -91,6 +95,8 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
statusBucket: input.workspace.status,
|
||||
archivingAt: input.workspace.archivingAt,
|
||||
diffStat: input.workspace.diffStat,
|
||||
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
|
||||
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
|
||||
scripts: input.workspace.scripts,
|
||||
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
|
||||
};
|
||||
|
||||
29
packages/app/src/native/ios-hardware-keyboard-submit.ios.ts
Normal file
29
packages/app/src/native/ios-hardware-keyboard-submit.ios.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { requireNativeModule, type EventSubscription } from "expo-modules-core";
|
||||
|
||||
type HardwareKeyboardSubmitHandler = () => void;
|
||||
|
||||
interface PaseoHardwareKeyboardModule {
|
||||
setHardwareKeyboardSubmitEnabled(enabled: boolean): void;
|
||||
addListener(
|
||||
eventName: "onHardwareKeyboardSubmit",
|
||||
handler: HardwareKeyboardSubmitHandler,
|
||||
): EventSubscription;
|
||||
}
|
||||
|
||||
const module = requireNativeModule<PaseoHardwareKeyboardModule>("PaseoHardwareKeyboard");
|
||||
|
||||
export function setHardwareKeyboardSubmitEnabled(enabled: boolean) {
|
||||
module.setHardwareKeyboardSubmitEnabled(enabled);
|
||||
}
|
||||
|
||||
export function addHardwareKeyboardSubmitListener(handler: HardwareKeyboardSubmitHandler) {
|
||||
return module.addListener("onHardwareKeyboardSubmit", handler);
|
||||
}
|
||||
|
||||
export function emitHardwareKeyboardSubmitForTest() {}
|
||||
|
||||
export function resetHardwareKeyboardSubmitForTest() {}
|
||||
|
||||
export function getHardwareKeyboardSubmitEnabledForTest() {
|
||||
return false;
|
||||
}
|
||||
34
packages/app/src/native/ios-hardware-keyboard-submit.ts
Normal file
34
packages/app/src/native/ios-hardware-keyboard-submit.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
|
||||
type HardwareKeyboardSubmitHandler = () => void;
|
||||
|
||||
const testHandlers = new Set<HardwareKeyboardSubmitHandler>();
|
||||
let isEnabledForTest = false;
|
||||
|
||||
export function setHardwareKeyboardSubmitEnabled(enabled: boolean) {
|
||||
isEnabledForTest = enabled;
|
||||
}
|
||||
|
||||
export function addHardwareKeyboardSubmitListener(
|
||||
handler: HardwareKeyboardSubmitHandler,
|
||||
): EventSubscription {
|
||||
testHandlers.add(handler);
|
||||
return {
|
||||
remove: () => {
|
||||
testHandlers.delete(handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function emitHardwareKeyboardSubmitForTest() {
|
||||
testHandlers.forEach((handler) => handler());
|
||||
}
|
||||
|
||||
export function resetHardwareKeyboardSubmitForTest() {
|
||||
testHandlers.clear();
|
||||
isEnabledForTest = false;
|
||||
}
|
||||
|
||||
export function getHardwareKeyboardSubmitEnabledForTest() {
|
||||
return isEnabledForTest;
|
||||
}
|
||||
@@ -302,6 +302,58 @@ function makeProbeMap(
|
||||
}
|
||||
|
||||
describe("HostRuntimeController", () => {
|
||||
it("replaces the active relay client when re-pairing changes the daemon public key", async () => {
|
||||
const oldRelay: HostConnection = {
|
||||
id: "relay:wss:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: true,
|
||||
daemonPublicKeyB64: "pk_old",
|
||||
};
|
||||
const newRelay: HostConnection = {
|
||||
...oldRelay,
|
||||
daemonPublicKeyB64: "pk_new",
|
||||
};
|
||||
const createdClients: Array<{ client: FakeDaemonClient; connection: HostConnection }> = [];
|
||||
const controller = new HostRuntimeController({
|
||||
host: makeHost({
|
||||
connections: [oldRelay],
|
||||
preferredConnectionId: oldRelay.id,
|
||||
}),
|
||||
deps: {
|
||||
createClient: ({ connection }) => {
|
||||
const client = new FakeDaemonClient();
|
||||
createdClients.push({ client, connection });
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
connectToDaemon: async ({ host, connection }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: connection.id,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
await (
|
||||
controller as unknown as {
|
||||
switchToConnection: (input: { connectionId: string }) => Promise<void>;
|
||||
}
|
||||
).switchToConnection({ connectionId: oldRelay.id });
|
||||
expect(controller.getSnapshot().client).toBe(createdClients[0]?.client);
|
||||
|
||||
await controller.updateHost(
|
||||
makeHost({
|
||||
connections: [newRelay],
|
||||
preferredConnectionId: newRelay.id,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createdClients.map((entry) => entry.connection)).toEqual([oldRelay, newRelay]);
|
||||
expect(createdClients[0]?.client.closeCalls).toBe(1);
|
||||
expect(controller.getSnapshot().client).toBe(createdClients[1]?.client);
|
||||
});
|
||||
|
||||
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
|
||||
@@ -602,8 +602,20 @@ export class HostRuntimeController {
|
||||
}
|
||||
|
||||
async updateHost(host: HostProfile): Promise<void> {
|
||||
const activeConnectionId = this.snapshot.activeConnectionId;
|
||||
const previousActiveConnection = findConnectionById(this.host, activeConnectionId);
|
||||
this.host = host;
|
||||
this.trackConnectionFirstSeen();
|
||||
const nextActiveConnection = findConnectionById(this.host, activeConnectionId);
|
||||
if (
|
||||
activeConnectionId &&
|
||||
previousActiveConnection &&
|
||||
nextActiveConnection &&
|
||||
!equal(previousActiveConnection, nextActiveConnection)
|
||||
) {
|
||||
this.connectionLastProbedAt.delete(activeConnectionId);
|
||||
await this.switchToConnection({ connectionId: activeConnectionId });
|
||||
}
|
||||
await this.runProbeCycleNow();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||
|
||||
function makePrItem(number: number, title: string, headRefName = "feature/x"): GitHubSearchItem {
|
||||
return {
|
||||
@@ -17,7 +17,9 @@ function makePrItem(number: number, title: string, headRefName = "feature/x"): G
|
||||
};
|
||||
}
|
||||
|
||||
function prAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
function prAttachment(
|
||||
item: GitHubSearchItem,
|
||||
): Extract<UserComposerAttachment, { kind: "github_pr" }> {
|
||||
return { kind: "github_pr", item };
|
||||
}
|
||||
|
||||
@@ -94,3 +96,54 @@ describe("syncPickerPrAttachment", () => {
|
||||
expect(result.attachments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCheckoutHintPrAttachment", () => {
|
||||
it("returns the first attached PR that is not selected or dismissed", () => {
|
||||
const first = prAttachment(makePrItem(101, "A"));
|
||||
const second = prAttachment(makePrItem(202, "B"));
|
||||
|
||||
expect(
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: [issueAttachment(44), first, second],
|
||||
selectedItem: null,
|
||||
dismissedPrNumbers: new Set(),
|
||||
}),
|
||||
).toBe(first);
|
||||
});
|
||||
|
||||
it("skips the selected PR and offers the next attached PR", () => {
|
||||
const selected = prAttachment(makePrItem(101, "A"));
|
||||
const next = prAttachment(makePrItem(202, "B"));
|
||||
|
||||
expect(
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: [selected, next],
|
||||
selectedItem: { kind: "github-pr", item: selected.item },
|
||||
dismissedPrNumbers: new Set(),
|
||||
}),
|
||||
).toBe(next);
|
||||
});
|
||||
|
||||
it("skips dismissed PRs and ignores issues", () => {
|
||||
const dismissed = prAttachment(makePrItem(101, "A"));
|
||||
const next = prAttachment(makePrItem(202, "B"));
|
||||
|
||||
expect(
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: [issueAttachment(44), dismissed, next],
|
||||
selectedItem: null,
|
||||
dismissedPrNumbers: new Set([101]),
|
||||
}),
|
||||
).toBe(next);
|
||||
});
|
||||
|
||||
it("returns null when only issues qualify", () => {
|
||||
expect(
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: [issueAttachment(44)],
|
||||
selectedItem: null,
|
||||
dismissedPrNumbers: new Set(),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,3 +33,22 @@ export function syncPickerPrAttachment(input: {
|
||||
|
||||
return { attachments: nextAttachments, attachedPrNumber };
|
||||
}
|
||||
|
||||
export function findCheckoutHintPrAttachment(input: {
|
||||
attachments: ReadonlyArray<UserComposerAttachment>;
|
||||
selectedItem: PickerItem | null;
|
||||
dismissedPrNumbers: ReadonlySet<number>;
|
||||
}): Extract<UserComposerAttachment, { kind: "github_pr" }> | null {
|
||||
const selectedPrNumber =
|
||||
input.selectedItem?.kind === "github-pr" ? input.selectedItem.item.number : null;
|
||||
|
||||
for (const attachment of input.attachments) {
|
||||
if (attachment.kind !== "github_pr") continue;
|
||||
const prNumber = attachment.item.number;
|
||||
if (prNumber === selectedPrNumber) continue;
|
||||
if (input.dismissedPrNumbers.has(prNumber)) continue;
|
||||
return attachment;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
@@ -19,6 +19,7 @@ import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useGithubSearchQuery } from "@/git/use-github-search-query";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
@@ -30,14 +31,33 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||
import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item";
|
||||
import { syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||
import {
|
||||
pickerItemToCheckoutRequest,
|
||||
type PickerCheckoutRequest,
|
||||
type PickerItem,
|
||||
} from "./new-workspace-picker-item";
|
||||
import { findCheckoutHintPrAttachment, syncPickerPrAttachment } from "./new-workspace-picker-state";
|
||||
|
||||
function resolveCheckoutRequest(
|
||||
selectedItem: PickerItem | null,
|
||||
currentBranch: string | null,
|
||||
): PickerCheckoutRequest | undefined {
|
||||
const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem);
|
||||
if (selectedCheckoutRequest) return selectedCheckoutRequest;
|
||||
if (!currentBranch) return undefined;
|
||||
return {
|
||||
action: "branch-off",
|
||||
refName: currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
sourceDirectory: string;
|
||||
projectId?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
@@ -128,6 +148,46 @@ function RefPickerTrigger({
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutHintBadge({
|
||||
prNumber,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
iconColor,
|
||||
iconSize,
|
||||
}: {
|
||||
prNumber: number;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
iconColor: string;
|
||||
iconSize: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.checkoutHintBadge}>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
Check out PR #{prNumber}?
|
||||
</Text>
|
||||
<Pressable
|
||||
testID="new-workspace-checkout-hint-accept"
|
||||
onPress={onAccept}
|
||||
style={styles.checkoutHintAction}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Check out PR #${prNumber}`}
|
||||
>
|
||||
<Check size={iconSize} color={iconColor} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="new-workspace-checkout-hint-dismiss"
|
||||
onPress={onDismiss}
|
||||
style={styles.checkoutHintAction}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Dismiss PR #${prNumber} checkout hint`}
|
||||
>
|
||||
<X size={iconSize} color={iconColor} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerOptionItem({
|
||||
testID,
|
||||
label,
|
||||
@@ -230,6 +290,17 @@ function computePickerOptionData(
|
||||
return { options: timedOptions.map((t) => t.option), itemById: idMap };
|
||||
}
|
||||
|
||||
function normalizeBranchDetails(
|
||||
data:
|
||||
| { branchDetails?: Array<{ name: string; committerDate: number }>; branches?: string[] }
|
||||
| undefined,
|
||||
): Array<{ name: string; committerDate: number }> {
|
||||
const details = data?.branchDetails;
|
||||
if (details && details.length > 0) return details;
|
||||
const names = data?.branches ?? [];
|
||||
return names.map((name) => ({ name, committerDate: 0 }));
|
||||
}
|
||||
|
||||
interface SubmitDraftInput {
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
@@ -331,6 +402,47 @@ function computeWorkspaceTitle(
|
||||
);
|
||||
}
|
||||
|
||||
function collectAttachedPrNumbers(attachments: ReadonlyArray<UserComposerAttachment>): Set<number> {
|
||||
const numbers = new Set<number>();
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.kind === "github_pr") {
|
||||
numbers.add(attachment.item.number);
|
||||
}
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
function pruneDismissedCheckoutHintPrNumbers(
|
||||
dismissed: ReadonlySet<number>,
|
||||
attached: ReadonlySet<number>,
|
||||
): ReadonlySet<number> {
|
||||
let changed = false;
|
||||
const next = new Set<number>();
|
||||
for (const prNumber of dismissed) {
|
||||
if (attached.has(prNumber)) {
|
||||
next.add(prNumber);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : dismissed;
|
||||
}
|
||||
|
||||
function useCheckoutHintDismissals(attachments: ReadonlyArray<UserComposerAttachment>) {
|
||||
const [dismissedPrNumbers, setDismissedPrNumbers] = useState<ReadonlySet<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const attachedPrNumbers = useMemo(() => collectAttachedPrNumbers(attachments), [attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissedPrNumbers((current) =>
|
||||
pruneDismissedCheckoutHintPrNumbers(current, attachedPrNumbers),
|
||||
);
|
||||
}, [attachedPrNumbers]);
|
||||
|
||||
return [dismissedPrNumbers, setDismissedPrNumbers] as const;
|
||||
}
|
||||
|
||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
const {
|
||||
serverId,
|
||||
@@ -385,6 +497,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
export function NewWorkspaceScreen({
|
||||
serverId,
|
||||
sourceDirectory,
|
||||
projectId,
|
||||
displayName: displayNameProp,
|
||||
}: NewWorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -400,7 +513,7 @@ export function NewWorkspaceScreen({
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
> | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null);
|
||||
const [pickerSelection, setPickerSelection] = useState<PickerSelection | null>(null);
|
||||
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickerSearchQuery, setPickerSearchQuery] = useState("");
|
||||
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
|
||||
@@ -414,7 +527,6 @@ export function NewWorkspaceScreen({
|
||||
|
||||
const displayName = displayNameProp?.trim() ?? "";
|
||||
const workspace = createdWorkspace;
|
||||
const selectedItem = pickerSelection?.item ?? null;
|
||||
const isPending = pendingAction !== null;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -429,6 +541,10 @@ export function NewWorkspaceScreen({
|
||||
}),
|
||||
});
|
||||
const composerState = chatDraft.composerState;
|
||||
const [dismissedCheckoutHintPrNumbers, setDismissedCheckoutHintPrNumbers] =
|
||||
useCheckoutHintDismissals(chatDraft.attachments);
|
||||
|
||||
const selectedItem = manualPickerSelection?.item ?? null;
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
@@ -469,32 +585,23 @@ export function NewWorkspaceScreen({
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const githubPrSearchQuery = useQuery({
|
||||
queryKey: ["new-workspace-github-prs", serverId, sourceDirectory, debouncedPickerSearchQuery],
|
||||
queryFn: async () => {
|
||||
const connectedClient = withConnectedClient();
|
||||
return connectedClient.searchGitHub({
|
||||
cwd: sourceDirectory,
|
||||
query: debouncedPickerSearchQuery,
|
||||
limit: 20,
|
||||
kinds: ["github-pr"],
|
||||
});
|
||||
},
|
||||
const githubPrSearchQuery = useGithubSearchQuery({
|
||||
client,
|
||||
serverId,
|
||||
cwd: sourceDirectory,
|
||||
query: debouncedPickerSearchQuery,
|
||||
kinds: ["github-pr"],
|
||||
enabled: pickerQueryEnabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const branchDetails = useMemo(() => {
|
||||
const details = branchSuggestionsQuery.data?.branchDetails;
|
||||
if (details && details.length > 0) return details;
|
||||
const names = branchSuggestionsQuery.data?.branches ?? [];
|
||||
return names.map((name) => ({ name, committerDate: 0 }));
|
||||
}, [branchSuggestionsQuery.data?.branchDetails, branchSuggestionsQuery.data?.branches]);
|
||||
const branchDetails = useMemo(
|
||||
() => normalizeBranchDetails(branchSuggestionsQuery.data),
|
||||
[branchSuggestionsQuery.data],
|
||||
);
|
||||
const githubFeaturesEnabled = githubPrSearchQuery.data?.githubFeaturesEnabled !== false;
|
||||
const prItems: GitHubSearchItem[] = useMemo(() => {
|
||||
if (!githubFeaturesEnabled) return [];
|
||||
const items = githubPrSearchQuery.data?.items ?? [];
|
||||
return items.filter((item): item is GitHubSearchItem => item.kind === "pr");
|
||||
return githubPrSearchQuery.data?.items ?? [];
|
||||
}, [githubFeaturesEnabled, githubPrSearchQuery.data?.items]);
|
||||
|
||||
const { options, itemById }: PickerOptionData = useMemo(
|
||||
@@ -514,18 +621,15 @@ export function NewWorkspaceScreen({
|
||||
: prOptionId(selectedItem.item.number);
|
||||
}, [selectedItem]);
|
||||
|
||||
const handleSelectOption = useCallback(
|
||||
(id: string) => {
|
||||
const item = itemById.get(id);
|
||||
if (!item) return;
|
||||
|
||||
const selectPickerItem = useCallback(
|
||||
(item: PickerItem) => {
|
||||
const next = syncPickerPrAttachment({
|
||||
attachments: chatDraft.attachments,
|
||||
previousPickerPrNumber: pickerSelection?.attachedPrNumber ?? null,
|
||||
previousPickerPrNumber: manualPickerSelection?.attachedPrNumber ?? null,
|
||||
item,
|
||||
});
|
||||
|
||||
setPickerSelection({
|
||||
setManualPickerSelection({
|
||||
item,
|
||||
attachedPrNumber: next.attachedPrNumber,
|
||||
});
|
||||
@@ -534,9 +638,44 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
setPickerOpen(false);
|
||||
},
|
||||
[chatDraft, itemById, pickerSelection?.attachedPrNumber],
|
||||
[chatDraft, manualPickerSelection?.attachedPrNumber],
|
||||
);
|
||||
|
||||
const handleSelectOption = useCallback(
|
||||
(id: string) => {
|
||||
const item = itemById.get(id);
|
||||
if (!item) return;
|
||||
selectPickerItem(item);
|
||||
},
|
||||
[itemById, selectPickerItem],
|
||||
);
|
||||
|
||||
const checkoutHintPrAttachment = useMemo(
|
||||
() =>
|
||||
findCheckoutHintPrAttachment({
|
||||
attachments: chatDraft.attachments,
|
||||
selectedItem,
|
||||
dismissedPrNumbers: dismissedCheckoutHintPrNumbers,
|
||||
}),
|
||||
[chatDraft.attachments, dismissedCheckoutHintPrNumbers, selectedItem],
|
||||
);
|
||||
|
||||
const acceptCheckoutHint = useCallback(() => {
|
||||
if (!checkoutHintPrAttachment) return;
|
||||
selectPickerItem({ kind: "github-pr", item: checkoutHintPrAttachment.item });
|
||||
}, [checkoutHintPrAttachment, selectPickerItem]);
|
||||
|
||||
const dismissCheckoutHint = useCallback(() => {
|
||||
if (!checkoutHintPrAttachment) return;
|
||||
const prNumber = checkoutHintPrAttachment.item.number;
|
||||
setDismissedCheckoutHintPrNumbers((current) => {
|
||||
if (current.has(prNumber)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(prNumber);
|
||||
return next;
|
||||
});
|
||||
}, [checkoutHintPrAttachment, setDismissedCheckoutHintPrNumbers]);
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
setPickerOpen(true);
|
||||
}, []);
|
||||
@@ -563,13 +702,18 @@ export function NewWorkspaceScreen({
|
||||
}, []);
|
||||
|
||||
const buildCreateWorktreeInput = useCallback(
|
||||
(input: { cwd: string; prompt: string; attachments: AgentAttachment[] }) => {
|
||||
const checkoutRequest = pickerItemToCheckoutRequest(selectedItem);
|
||||
(input: {
|
||||
cwd: string;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
}): CreatePaseoWorktreeInput => {
|
||||
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
|
||||
const trimmedPrompt = input.prompt.trim();
|
||||
const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0;
|
||||
|
||||
return {
|
||||
cwd: input.cwd,
|
||||
...(projectId ? { projectId } : {}),
|
||||
worktreeSlug: createNameId(),
|
||||
...(hasFirstAgentContext
|
||||
? {
|
||||
@@ -582,7 +726,7 @@ export function NewWorkspaceScreen({
|
||||
...checkoutRequest,
|
||||
};
|
||||
},
|
||||
[selectedItem],
|
||||
[currentBranch, projectId, selectedItem],
|
||||
);
|
||||
|
||||
const ensureWorkspace = useCallback(
|
||||
@@ -789,6 +933,15 @@ export function NewWorkspaceScreen({
|
||||
renderOption={renderPickerOption}
|
||||
/>
|
||||
</View>
|
||||
{checkoutHintPrAttachment ? (
|
||||
<CheckoutHintBadge
|
||||
prNumber={checkoutHintPrAttachment.item.number}
|
||||
onAccept={acceptCheckoutHint}
|
||||
onDismiss={dismissCheckoutHint}
|
||||
iconColor={theme.colors.foregroundMuted}
|
||||
iconSize={theme.iconSize.sm}
|
||||
/>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
@@ -866,6 +1019,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
checkoutHintBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: 28,
|
||||
maxWidth: 240,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
gap: theme.spacing[1],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
checkoutHintAction: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
badgeHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Image, Pressable, Text, TextInput, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, ChevronDown, MoreVertical, Plus } from "lucide-react-native";
|
||||
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
import type {
|
||||
PaseoConfigRaw,
|
||||
@@ -234,7 +234,7 @@ function ProjectSettingsBody({
|
||||
<View style={styles.headerBlock}>
|
||||
<View style={styles.titleRow}>
|
||||
<ProjectTitleIcon host={selectedHost} projectName={project.projectName} />
|
||||
<Text style={styles.projectTitle}>{project.projectName}</Text>
|
||||
<ProjectNameEditor project={project} client={client} />
|
||||
</View>
|
||||
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
||||
</View>
|
||||
@@ -771,6 +771,124 @@ function ResolveSpinnerColor(): string {
|
||||
return styles.spinnerColor.color;
|
||||
}
|
||||
|
||||
interface ProjectNameEditorProps {
|
||||
project: ProjectSummary;
|
||||
client: DaemonClient;
|
||||
}
|
||||
|
||||
function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(project.projectCustomName ?? "");
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
setIsEditing(false);
|
||||
toast.show("Project renamed", { variant: "success" });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : "Couldn't rename project";
|
||||
toast.show(message, { variant: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setValue(project.projectCustomName ?? "");
|
||||
setIsEditing(true);
|
||||
}, [project.projectCustomName]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setValue(project.projectCustomName ?? "");
|
||||
}, [project.projectCustomName]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
const next = trimmed.length === 0 ? null : trimmed;
|
||||
if (next === (project.projectCustomName ?? null)) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
renameMutation.mutate(next);
|
||||
}, [value, project.projectCustomName, renameMutation]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
renameMutation.mutate(null);
|
||||
}, [renameMutation]);
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<View style={styles.nameEditorRow}>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{project.projectName}
|
||||
</Text>
|
||||
<Pressable
|
||||
testID="project-name-edit-button"
|
||||
accessibilityLabel="Rename project"
|
||||
onPress={handleStartEdit}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorIconButton}
|
||||
>
|
||||
<Pencil size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
{project.projectCustomName ? (
|
||||
<Pressable
|
||||
testID="project-name-reset-button"
|
||||
accessibilityLabel="Reset project name to default"
|
||||
onPress={handleReset}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorResetButton}
|
||||
>
|
||||
<Text style={styles.nameEditorResetText}>Reset</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.nameEditorRow}>
|
||||
<TextInput
|
||||
testID="project-name-input"
|
||||
accessibilityLabel="Project name"
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
placeholder={project.projectName}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
autoFocus
|
||||
style={styles.nameEditorInput}
|
||||
editable={!renameMutation.isPending}
|
||||
onSubmitEditing={handleSave}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<Pressable
|
||||
testID="project-name-save-button"
|
||||
accessibilityLabel="Save project name"
|
||||
onPress={handleSave}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorIconButton}
|
||||
>
|
||||
<Check size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="project-name-cancel-button"
|
||||
accessibilityLabel="Cancel renaming"
|
||||
onPress={handleCancel}
|
||||
disabled={renameMutation.isPending}
|
||||
hitSlop={8}
|
||||
style={styles.nameEditorIconButton}
|
||||
>
|
||||
<X size={ICON_SIZE} color={styles.iconColor.color} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectTitleIcon({ host, projectName }: { host: ProjectHostEntry; projectName: string }) {
|
||||
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
||||
const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot });
|
||||
@@ -1140,6 +1258,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
nameEditorRow: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
nameEditorIconButton: {
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
nameEditorInput: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
minWidth: 0,
|
||||
},
|
||||
nameEditorResetButton: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
nameEditorResetText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
titleIcon: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -720,9 +720,16 @@ function SettingsSidebar({
|
||||
}, [hosts, localServerId]);
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||
const insets = useSafeAreaInsets();
|
||||
const padding = useWindowControlsPadding("sidebar");
|
||||
const isDesktop = layout === "desktop";
|
||||
const containerStyle = isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer;
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
isDesktop ? sidebarStyles.desktopContainer : sidebarStyles.mobileContainer,
|
||||
isDesktop ? { paddingTop: insets.top } : null,
|
||||
],
|
||||
[insets.top, isDesktop],
|
||||
);
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
|
||||
@@ -202,7 +202,7 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
|
||||
};
|
||||
|
||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||
return { mcp: { injectIntoAgents: false }, providers };
|
||||
return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false };
|
||||
}
|
||||
|
||||
function descendants(el: HTMLElement): HTMLElement[] {
|
||||
@@ -347,6 +347,26 @@ describe("ProvidersSection", () => {
|
||||
expect(patchConfigMock).toHaveBeenCalledWith({
|
||||
providers: { claude: { enabled: false } },
|
||||
});
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("forces a provider snapshot refresh from the settings refresh action", async () => {
|
||||
snapshotState.entries = [claudeEntry];
|
||||
configState.config = makeConfig();
|
||||
|
||||
render();
|
||||
|
||||
const refreshButton = container?.querySelector<HTMLElement>(
|
||||
'[role="button"][aria-label="Refresh providers"]',
|
||||
);
|
||||
expect(refreshButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
refreshButton?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(refreshMock).toHaveBeenCalledTimes(1);
|
||||
expect(patchConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface WorkspaceDescriptor {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectCustomName?: string | null;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
@@ -134,6 +135,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
workspaceDirectory: payload.workspaceDirectory,
|
||||
projectKind: payload.projectKind,
|
||||
|
||||
@@ -252,13 +252,11 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
bullet_list: {
|
||||
marginVertical: theme.spacing[2],
|
||||
paddingLeft: 0,
|
||||
width: "100%" as const,
|
||||
},
|
||||
|
||||
ordered_list: {
|
||||
marginVertical: theme.spacing[2],
|
||||
paddingLeft: 0,
|
||||
width: "100%" as const,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||
import { TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
|
||||
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
@@ -15,6 +16,14 @@ interface TerminalSize {
|
||||
cols: number;
|
||||
}
|
||||
|
||||
interface TerminalKeyRecord {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
alt: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
type BrowserTerminal = TerminalSize & {
|
||||
refresh: (start: number, end: number) => void;
|
||||
reset: () => void;
|
||||
@@ -26,6 +35,8 @@ interface MountedTerminal {
|
||||
runtime: TerminalEmulatorRuntime;
|
||||
inputs: string[];
|
||||
sizes: TerminalSize[];
|
||||
terminalKeys: TerminalKeyRecord[];
|
||||
inputModeChanges: TerminalInputModeState[];
|
||||
}
|
||||
|
||||
const mountedTerminals: MountedTerminal[] = [];
|
||||
@@ -67,6 +78,8 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
|
||||
|
||||
const sizes: TerminalSize[] = [];
|
||||
const inputs: string[] = [];
|
||||
const terminalKeys: TerminalKeyRecord[] = [];
|
||||
const inputModeChanges: TerminalInputModeState[] = [];
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
@@ -76,6 +89,12 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
|
||||
onResize: (size) => {
|
||||
sizes.push(size);
|
||||
},
|
||||
onTerminalKey: (key) => {
|
||||
terminalKeys.push(key);
|
||||
},
|
||||
onInputModeChange: (state) => {
|
||||
inputModeChanges.push(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
runtime.mount({
|
||||
@@ -89,7 +108,7 @@ function createTerminalHost(input: { width: number; height: number }): MountedTe
|
||||
},
|
||||
});
|
||||
|
||||
const mounted = { host, root, runtime, inputs, sizes };
|
||||
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
|
||||
mountedTerminals.push(mounted);
|
||||
return mounted;
|
||||
}
|
||||
@@ -110,6 +129,32 @@ function getBrowserTerminal(): BrowserTerminal {
|
||||
return terminal;
|
||||
}
|
||||
|
||||
function dispatchTerminalKey(input: {
|
||||
host: HTMLElement;
|
||||
key: string;
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
}): boolean {
|
||||
const textarea = input.host.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Expected xterm textarea to be mounted");
|
||||
}
|
||||
textarea.focus();
|
||||
return textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: input.key,
|
||||
shiftKey: input.shiftKey ?? false,
|
||||
ctrlKey: input.ctrlKey ?? false,
|
||||
altKey: input.altKey ?? false,
|
||||
metaKey: input.metaKey ?? false,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const mounted of mountedTerminals.splice(0)) {
|
||||
mounted.runtime.unmount();
|
||||
@@ -162,6 +207,73 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
expect(refreshCalls.at(-1)).toEqual([0, terminal.rows - 1]);
|
||||
});
|
||||
|
||||
it("intercepts Shift+Enter only after enhanced terminal input mode is active", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
dispatchTerminalKey({
|
||||
host: mounted.host,
|
||||
key: "Enter",
|
||||
shiftKey: true,
|
||||
});
|
||||
await nextFrame();
|
||||
|
||||
expect(mounted.terminalKeys).toEqual([]);
|
||||
|
||||
mounted.runtime.write({ text: "\x1b[>7u" });
|
||||
await waitFor({
|
||||
predicate: () =>
|
||||
mounted.inputModeChanges.some(
|
||||
(state) => state.kittyKeyboardFlags === 7 && !state.win32InputMode,
|
||||
),
|
||||
});
|
||||
|
||||
dispatchTerminalKey({
|
||||
host: mounted.host,
|
||||
key: "Enter",
|
||||
shiftKey: true,
|
||||
});
|
||||
await nextFrame();
|
||||
|
||||
expect(mounted.terminalKeys).toEqual([
|
||||
{
|
||||
key: "Enter",
|
||||
ctrl: false,
|
||||
shift: true,
|
||||
alt: false,
|
||||
meta: false,
|
||||
},
|
||||
]);
|
||||
|
||||
mounted.terminalKeys.length = 0;
|
||||
mounted.runtime.write({ text: "\x1b[=0;0u\x1b[?9001h" });
|
||||
await waitFor({
|
||||
predicate: () =>
|
||||
mounted.inputModeChanges.some(
|
||||
(state) => state.kittyKeyboardFlags === 0 && state.win32InputMode,
|
||||
),
|
||||
});
|
||||
|
||||
dispatchTerminalKey({
|
||||
host: mounted.host,
|
||||
key: "Enter",
|
||||
shiftKey: true,
|
||||
});
|
||||
await nextFrame();
|
||||
|
||||
expect(mounted.terminalKeys).toEqual([
|
||||
{
|
||||
key: "Enter",
|
||||
ctrl: false,
|
||||
shift: true,
|
||||
alt: false,
|
||||
meta: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "DA1", bytes: "\x1b[c" },
|
||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||
|
||||
@@ -176,6 +176,38 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(onCommitted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports input mode changes from terminal output and resets them on snapshots", () => {
|
||||
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
|
||||
const inputModeChanges: Array<{ kittyKeyboardFlags: number; win32InputMode: boolean }> = [];
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInputModeChange: (state) => {
|
||||
inputModeChanges.push(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
runtime.write({ text: "\x1b[>7u" });
|
||||
runtime.renderSnapshot({
|
||||
state: {
|
||||
rows: 2,
|
||||
cols: 8,
|
||||
scrollback: [],
|
||||
grid: [[{ char: "$" }, { char: " " }]],
|
||||
cursor: {
|
||||
row: 0,
|
||||
col: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
writeCallbacks[0]?.();
|
||||
|
||||
expect(inputModeChanges).toEqual([
|
||||
{ kittyKeyboardFlags: 7, win32InputMode: false },
|
||||
{ kittyKeyboardFlags: 0, win32InputMode: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores stale duplicate write callbacks from a previous operation", () => {
|
||||
const { runtime, writeCallbacks } = createRuntimeWithTerminal();
|
||||
const committed: string[] = [];
|
||||
|
||||
@@ -8,6 +8,11 @@ import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
||||
import { Terminal, type ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import {
|
||||
type TerminalInputModeState,
|
||||
TerminalInputModeTracker,
|
||||
terminalInputModeStatesEqual,
|
||||
} from "@server/shared/terminal-input-mode";
|
||||
import {
|
||||
type PendingTerminalModifiers,
|
||||
isTerminalModifierDomKey,
|
||||
@@ -37,6 +42,7 @@ export interface TerminalEmulatorRuntimeCallbacks {
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onOpenExternalUrl?: (url: string) => Promise<void> | void;
|
||||
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface TerminalEmulatorRuntimeDisposables {
|
||||
@@ -125,6 +131,8 @@ export class TerminalEmulatorRuntime {
|
||||
private inFlightOutputOperation: TerminalOutputOperation | null = null;
|
||||
private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private suppressInput = false;
|
||||
private readonly inputModeTracker = new TerminalInputModeTracker();
|
||||
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
|
||||
|
||||
private handleVisibilityRestore = (): void => {
|
||||
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
||||
@@ -152,6 +160,8 @@ export class TerminalEmulatorRuntime {
|
||||
|
||||
input.host.innerHTML = "";
|
||||
this.lastSize = null;
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
|
||||
const terminal = new Terminal({
|
||||
allowProposedApi: true,
|
||||
@@ -353,6 +363,7 @@ export class TerminalEmulatorRuntime {
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
pendingModifiers: this.pendingModifiers,
|
||||
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
@@ -606,6 +617,8 @@ export class TerminalEmulatorRuntime {
|
||||
this.fitAndEmitResize = null;
|
||||
this.lastSize = null;
|
||||
this.suppressInput = false;
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
}
|
||||
|
||||
private processOutputQueue(): void {
|
||||
@@ -640,12 +653,16 @@ export class TerminalEmulatorRuntime {
|
||||
};
|
||||
|
||||
if (operation.type === "clear") {
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
terminal.reset();
|
||||
finalizeOperation(operation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.type === "snapshot") {
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
try {
|
||||
if (
|
||||
typeof operation.cols === "number" &&
|
||||
@@ -661,6 +678,12 @@ export class TerminalEmulatorRuntime {
|
||||
}
|
||||
|
||||
const text = operation.text;
|
||||
if (operation.type === "write") {
|
||||
const result = this.inputModeTracker.feed(text);
|
||||
if (result.changed) {
|
||||
this.emitInputModeChange();
|
||||
}
|
||||
}
|
||||
this.inFlightOutputOperationTimeout = setTimeout(() => {
|
||||
finalizeOperation(operation);
|
||||
}, OUTPUT_OPERATION_TIMEOUT_MS);
|
||||
@@ -682,6 +705,15 @@ export class TerminalEmulatorRuntime {
|
||||
this.inFlightOutputOperationTimeout = null;
|
||||
}
|
||||
|
||||
private emitInputModeChange(): void {
|
||||
const state = this.inputModeTracker.getState();
|
||||
if (terminalInputModeStatesEqual(state, this.lastInputModeState)) {
|
||||
return;
|
||||
}
|
||||
this.lastInputModeState = state;
|
||||
this.callbacks.onInputModeChange?.(state);
|
||||
}
|
||||
|
||||
private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void {
|
||||
const documentElement = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
@@ -218,6 +218,34 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the timeline entry timestamp as canonical", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 2 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(1, "hello", "user_message"),
|
||||
timestamp: new Date("2025-01-01T12:00:03Z").toISOString(),
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(2, "reply"),
|
||||
timestamp: new Date("2025-01-01T12:00:04Z").toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const user = result.tail.find((item) => item.kind === "user_message");
|
||||
const assistant = result.tail.find((item) => item.kind === "assistant_message");
|
||||
|
||||
expect(user?.timestamp.toISOString()).toBe("2025-01-01T12:00:03.000Z");
|
||||
expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z");
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
|
||||
@@ -395,7 +395,10 @@ function applyTimelineIncrementalPath(args: {
|
||||
if (acceptedUnits.length > 0) {
|
||||
if (payload.direction === "before") {
|
||||
const olderTail = hydrateStreamState(
|
||||
acceptedUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
acceptedUnits.map(({ event, timestamp }) => ({
|
||||
event,
|
||||
timestamp,
|
||||
})),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
|
||||
@@ -414,7 +417,9 @@ function applyTimelineIncrementalPath(args: {
|
||||
} else {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, { source: "canonical" }),
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
@@ -482,7 +487,7 @@ export function processTimelineResponse(
|
||||
}));
|
||||
|
||||
const toHydratedEvents = (
|
||||
units: typeof timelineUnits,
|
||||
units: TimelineUnit[],
|
||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
||||
|
||||
|
||||
87
packages/app/src/timeline/turn-time.test.ts
Normal file
87
packages/app/src/timeline/turn-time.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "vitest";
|
||||
import { deriveStreamTurnTiming } from "./turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
function user(id: string, timestamp: Date): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function assistant(id: string, timestamp: Date): StreamItem {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveStreamTurnTiming", () => {
|
||||
it("uses the last user message as the running turn start", () => {
|
||||
const firstUserAt = new Date("2026-05-15T00:00:00.000Z");
|
||||
const secondUserAt = new Date("2026-05-15T00:01:00.000Z");
|
||||
|
||||
const timing = deriveStreamTurnTiming({
|
||||
agentStatus: "running",
|
||||
tail: [
|
||||
user("u1", firstUserAt),
|
||||
assistant("a1", new Date("2026-05-15T00:00:05.000Z")),
|
||||
user("u2", secondUserAt),
|
||||
],
|
||||
head: [assistant("a2", new Date("2026-05-15T00:01:04.000Z"))],
|
||||
});
|
||||
|
||||
assert.equal(timing.runningStartedAt, secondUserAt);
|
||||
assert.equal(timing.byAssistantId.has("a2"), false);
|
||||
});
|
||||
|
||||
it("derives completed turn timing from user and assistant item timestamps", () => {
|
||||
const userAt = new Date("2026-05-15T00:00:00.000Z");
|
||||
const assistantAt = new Date("2026-05-15T00:00:07.000Z");
|
||||
|
||||
const timing = deriveStreamTurnTiming({
|
||||
agentStatus: "idle",
|
||||
tail: [
|
||||
user("u1", userAt),
|
||||
assistant("a1", assistantAt),
|
||||
user("u2", new Date("2026-05-15T00:01:00.000Z")),
|
||||
],
|
||||
head: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(timing.byAssistantId.get("a1"), {
|
||||
startedAt: userAt,
|
||||
completedAt: assistantAt,
|
||||
durationMs: 7000,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps multiple assistant chunks in one turn to the same timing", () => {
|
||||
const userAt = new Date("2026-05-15T00:00:00.000Z");
|
||||
const firstAssistantAt = new Date("2026-05-15T00:00:03.000Z");
|
||||
const lastAssistantAt = new Date("2026-05-15T00:00:07.000Z");
|
||||
|
||||
const timing = deriveStreamTurnTiming({
|
||||
agentStatus: "idle",
|
||||
tail: [
|
||||
user("u1", userAt),
|
||||
assistant("a1", firstAssistantAt),
|
||||
assistant("a2", lastAssistantAt),
|
||||
],
|
||||
head: [],
|
||||
});
|
||||
|
||||
const expected = {
|
||||
startedAt: userAt,
|
||||
completedAt: lastAssistantAt,
|
||||
durationMs: 7000,
|
||||
};
|
||||
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
|
||||
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
|
||||
});
|
||||
});
|
||||
84
packages/app/src/timeline/turn-time.ts
Normal file
84
packages/app/src/timeline/turn-time.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export interface TurnTiming {
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface StreamTurnTiming {
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
}
|
||||
|
||||
export function deriveStreamTurnTiming(params: {
|
||||
agentStatus: string;
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): StreamTurnTiming {
|
||||
const byAssistantId = new Map<string, TurnTiming>();
|
||||
let currentUserAt: Date | null = null;
|
||||
let currentLastItemAt: Date | null = null;
|
||||
let currentAssistantIds: string[] = [];
|
||||
|
||||
const flushCompletedTurn = () => {
|
||||
if (!currentUserAt || !currentLastItemAt || currentAssistantIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timing: TurnTiming = {
|
||||
startedAt: currentUserAt,
|
||||
completedAt: currentLastItemAt,
|
||||
durationMs: Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime()),
|
||||
};
|
||||
for (const id of currentAssistantIds) {
|
||||
byAssistantId.set(id, timing);
|
||||
}
|
||||
};
|
||||
|
||||
const visitItem = (item: StreamItem) => {
|
||||
if (item.kind === "user_message") {
|
||||
flushCompletedTurn();
|
||||
currentUserAt = item.timestamp;
|
||||
currentLastItemAt = null;
|
||||
currentAssistantIds = [];
|
||||
return;
|
||||
}
|
||||
if (!currentUserAt) {
|
||||
return;
|
||||
}
|
||||
currentLastItemAt = item.timestamp;
|
||||
if (item.kind === "assistant_message") {
|
||||
currentAssistantIds.push(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
for (const item of params.tail) {
|
||||
visitItem(item);
|
||||
}
|
||||
for (const item of params.head) {
|
||||
visitItem(item);
|
||||
}
|
||||
|
||||
const runningStartedAt =
|
||||
params.agentStatus === "running"
|
||||
? (findLastUserMessageTimestamp(params.head) ?? currentUserAt)
|
||||
: null;
|
||||
if (params.agentStatus !== "running") {
|
||||
flushCompletedTurn();
|
||||
}
|
||||
|
||||
return {
|
||||
byAssistantId,
|
||||
runningStartedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function findLastUserMessageTimestamp(items: StreamItem[]): Date | null {
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const item = items[i];
|
||||
if (item?.kind === "user_message") {
|
||||
return item.timestamp;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import { applyStreamEvent } from "@/types/stream";
|
||||
|
||||
const baseTimestamp = new Date(0);
|
||||
|
||||
const assistantChunk = (text: string): AgentStreamEventPayload => ({
|
||||
const assistantChunk = (text: string, messageId?: string): AgentStreamEventPayload => ({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text,
|
||||
...(messageId ? { messageId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,6 +132,36 @@ describe("applyStreamEvent", () => {
|
||||
expect(result.tail[0].kind).toBe("assistant_message");
|
||||
});
|
||||
|
||||
it("does not continue a tail assistant message when the incoming message id differs", () => {
|
||||
const result = applyStreamEvent({
|
||||
tail: [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "msg-first",
|
||||
messageId: "msg-first",
|
||||
text: "First answer.",
|
||||
timestamp: baseTimestamp,
|
||||
},
|
||||
],
|
||||
head: [],
|
||||
event: assistantChunk("Second answer.", "msg-second"),
|
||||
timestamp: baseTimestamp,
|
||||
});
|
||||
|
||||
expect(result.tail).toHaveLength(1);
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.tail[0].kind).toBe("assistant_message");
|
||||
expect(result.head[0].kind).toBe("assistant_message");
|
||||
if (
|
||||
result.tail[0].kind === "assistant_message" &&
|
||||
result.head[0].kind === "assistant_message"
|
||||
) {
|
||||
expect(result.tail[0].text).toBe("First answer.");
|
||||
expect(result.head[0].text).toBe("Second answer.");
|
||||
expect(result.head[0].messageId).toBe("msg-second");
|
||||
}
|
||||
});
|
||||
|
||||
it("flushes reasoning when assistant message starts", () => {
|
||||
let result = applyStreamEvent({
|
||||
tail: [],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import invariant from "tiny-invariant";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -18,11 +19,12 @@ type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||
function assistantTimeline(
|
||||
text: string,
|
||||
provider: AgentProvider = "claude",
|
||||
messageId?: string,
|
||||
): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: { type: "assistant_message", text },
|
||||
item: { type: "assistant_message", text, ...(messageId ? { messageId } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,6 +314,68 @@ describe("stream reducer canonical tool calls", () => {
|
||||
assert.strictEqual(assistantMessage?.text, "Hello world");
|
||||
});
|
||||
|
||||
it("keeps adjacent assistant timeline items separate when message ids differ", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: assistantTimeline("First answer.", "codex", "msg-first"),
|
||||
timestamp: new Date("2025-01-01T10:01:00Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("Second answer.", "codex", "msg-second"),
|
||||
timestamp: new Date("2025-01-01T10:01:01Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const messages = state.filter((item) => item.kind === "assistant_message");
|
||||
assert.strictEqual(messages.length, 2);
|
||||
const first = messages[0];
|
||||
const second = messages[1];
|
||||
invariant(first?.kind === "assistant_message");
|
||||
invariant(second?.kind === "assistant_message");
|
||||
assert.deepStrictEqual([first.text, second.text], ["First answer.", "Second answer."]);
|
||||
assert.deepStrictEqual([first.messageId, second.messageId], ["msg-first", "msg-second"]);
|
||||
});
|
||||
|
||||
it("merges adjacent assistant deltas when message ids match", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: assistantTimeline("Hel", "codex", "msg-same"),
|
||||
timestamp: new Date("2025-01-01T10:02:00Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("lo", "codex", "msg-same"),
|
||||
timestamp: new Date("2025-01-01T10:02:01Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const messages = state.filter((item) => item.kind === "assistant_message");
|
||||
assert.strictEqual(messages.length, 1);
|
||||
const first = messages[0];
|
||||
invariant(first?.kind === "assistant_message");
|
||||
assert.strictEqual(first.text, "Hello");
|
||||
assert.strictEqual(first.id, "msg-same");
|
||||
assert.strictEqual(first.messageId, "msg-same");
|
||||
});
|
||||
|
||||
it("preserves old assistant merge behavior when message ids are absent", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: assistantTimeline("Hel", "codex"),
|
||||
timestamp: new Date("2025-01-01T10:03:00Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("lo", "codex"),
|
||||
timestamp: new Date("2025-01-01T10:03:01Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const messages = state.filter((item) => item.kind === "assistant_message");
|
||||
assert.strictEqual(messages.length, 1);
|
||||
const first = messages[0];
|
||||
invariant(first?.kind === "assistant_message");
|
||||
assert.strictEqual(first.text, "Hello");
|
||||
});
|
||||
|
||||
it("merges running and completed events by callId", () => {
|
||||
const callId = "tool-merge-1";
|
||||
const updates = [
|
||||
@@ -776,3 +840,99 @@ describe("stream reducer canonical tool calls", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn lifecycle events", () => {
|
||||
it("finalizes active stream items without adding timeline rows", () => {
|
||||
const startedAt = new Date("2025-01-01T12:00:00Z");
|
||||
const completedAt = new Date("2025-01-01T12:00:05Z");
|
||||
|
||||
let state = reduceStreamUpdate([], { type: "turn_started", provider: "claude" }, startedAt);
|
||||
state = reduceStreamUpdate(
|
||||
state,
|
||||
{ type: "timeline", provider: "claude", item: { type: "assistant_message", text: "ok" } },
|
||||
new Date("2025-01-01T12:00:02Z"),
|
||||
);
|
||||
state = reduceStreamUpdate(state, { type: "turn_completed", provider: "claude" }, completedAt);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
state.map((item) => item.kind),
|
||||
["assistant_message"],
|
||||
);
|
||||
});
|
||||
|
||||
it("hydrates canonical timeline rows without synthetic turn rows", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: "hi" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T13:00:00Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("Working on it.", "claude", "msg-1"),
|
||||
timestamp: new Date("2025-01-01T13:00:01Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("Done.", "claude", "msg-2"),
|
||||
timestamp: new Date("2025-01-01T13:00:04Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
state.map((item) => item.kind),
|
||||
["user_message", "assistant_message", "assistant_message"],
|
||||
);
|
||||
});
|
||||
|
||||
it("does not materialize turn_started events during hydration", () => {
|
||||
const startedAt = new Date("2025-01-01T14:00:00Z");
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: "hi" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T13:59:59Z"),
|
||||
},
|
||||
{ event: { type: "turn_started", provider: "claude" }, timestamp: startedAt },
|
||||
{
|
||||
event: assistantTimeline("ok", "claude", "msg-1"),
|
||||
timestamp: new Date("2025-01-01T14:00:02Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
state.map((item) => item.kind),
|
||||
["user_message", "assistant_message"],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps adjacent user messages as adjacent timeline rows", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: "hi" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:00:00Z"),
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: "still there?" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:01:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
state.map((item) => item.kind),
|
||||
["user_message", "user_message"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface UserMessageItem {
|
||||
export interface AssistantMessageItem {
|
||||
kind: "assistant_message";
|
||||
id: string;
|
||||
messageId?: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
blockGroupId?: string;
|
||||
@@ -227,6 +228,7 @@ function appendAssistantMessage(
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
messageId?: string,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
@@ -234,7 +236,11 @@ function appendAssistantMessage(
|
||||
}
|
||||
|
||||
const last = state[state.length - 1];
|
||||
if (last && last.kind === "assistant_message") {
|
||||
const shouldAppendToLast =
|
||||
last &&
|
||||
last.kind === "assistant_message" &&
|
||||
(messageId === undefined || last.messageId === messageId);
|
||||
if (shouldAppendToLast) {
|
||||
const updated: AssistantMessageItem = {
|
||||
...last,
|
||||
text: `${last.text}${chunk}`,
|
||||
@@ -249,7 +255,8 @@ function appendAssistantMessage(
|
||||
if (
|
||||
source === "live" &&
|
||||
last?.kind === "user_message" &&
|
||||
secondLast?.kind === "assistant_message"
|
||||
secondLast?.kind === "assistant_message" &&
|
||||
(messageId === undefined || secondLast.messageId === messageId)
|
||||
) {
|
||||
const updated: AssistantMessageItem = {
|
||||
...secondLast,
|
||||
@@ -264,9 +271,11 @@ function appendAssistantMessage(
|
||||
}
|
||||
|
||||
const idSeed = chunk.trim() || chunk;
|
||||
const entryId = messageId ?? createUniqueTimelineId(state, "assistant", idSeed, timestamp);
|
||||
const item: AssistantMessageItem = {
|
||||
kind: "assistant_message",
|
||||
id: createUniqueTimelineId(state, "assistant", idSeed, timestamp),
|
||||
id: entryId,
|
||||
...(messageId ? { messageId } : {}),
|
||||
text: chunk,
|
||||
timestamp,
|
||||
};
|
||||
@@ -647,7 +656,9 @@ function reduceTimelineEvent(
|
||||
case "user_message":
|
||||
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
|
||||
case "assistant_message":
|
||||
return finalizeActiveThoughts(appendAssistantMessage(state, item.text, timestamp, source));
|
||||
return finalizeActiveThoughts(
|
||||
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
|
||||
);
|
||||
case "reasoning":
|
||||
return appendThought(state, item.text, timestamp);
|
||||
case "tool_call":
|
||||
@@ -710,7 +721,10 @@ export function reduceStreamUpdate(
|
||||
* Hydrate stream state from a batch of AgentManager stream events
|
||||
*/
|
||||
export function hydrateStreamState(
|
||||
events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>,
|
||||
events: Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
}>,
|
||||
options?: { source?: StreamUpdateSource },
|
||||
): StreamItem[] {
|
||||
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp }) => {
|
||||
@@ -738,6 +752,16 @@ const STREAM_COMPLETION_EVENTS = new Set<AgentStreamEventPayload["type"]>([
|
||||
"turn_canceled",
|
||||
]);
|
||||
|
||||
function applyCompletionToTail(
|
||||
tail: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
): StreamItem[] {
|
||||
const finalized = finalizeActiveThoughts(tail);
|
||||
return reduceStreamUpdate(finalized, event, timestamp, { source });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what kind of StreamItem an event would produce
|
||||
*/
|
||||
@@ -802,6 +826,28 @@ function getActiveAssistantHeadIndex(head: StreamItem[]): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getTailAssistantToResume(params: {
|
||||
incomingKind: StreamItem["kind"] | null;
|
||||
event: AgentStreamEventPayload;
|
||||
nextHead: StreamItem[];
|
||||
tailAssistant: StreamItem | undefined;
|
||||
}): AssistantMessageItem | null {
|
||||
if (params.incomingKind !== "assistant_message" || params.nextHead.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
if (params.tailAssistant?.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
const incomingMessageId =
|
||||
params.event.type === "timeline" && params.event.item.type === "assistant_message"
|
||||
? params.event.item.messageId
|
||||
: undefined;
|
||||
if (incomingMessageId !== undefined && params.tailAssistant.messageId !== incomingMessageId) {
|
||||
return null;
|
||||
}
|
||||
return params.tailAssistant;
|
||||
}
|
||||
|
||||
function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: StreamItem[] }): {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
@@ -975,8 +1021,7 @@ export function applyStreamEvent(params: {
|
||||
// Handle turn completion events - flush everything
|
||||
if (STREAM_COMPLETION_EVENTS.has(event.type)) {
|
||||
flushHead();
|
||||
// Also finalize any remaining thoughts in tail
|
||||
const finalized = finalizeActiveThoughts(nextTail);
|
||||
const finalized = applyCompletionToTail(nextTail, event, timestamp, source);
|
||||
if (finalized !== nextTail) {
|
||||
nextTail = finalized;
|
||||
changedTail = true;
|
||||
@@ -991,14 +1036,17 @@ export function applyStreamEvent(params: {
|
||||
flushHead();
|
||||
}
|
||||
|
||||
if (incomingKind === "assistant_message" && nextHead.length === 0) {
|
||||
const tailAssistant = nextTail.at(-1);
|
||||
if (tailAssistant?.kind === "assistant_message") {
|
||||
nextTail = nextTail.slice(0, -1);
|
||||
nextHead = [tailAssistant];
|
||||
changedTail = true;
|
||||
changedHead = true;
|
||||
}
|
||||
const tailAssistant = getTailAssistantToResume({
|
||||
incomingKind,
|
||||
event,
|
||||
nextHead,
|
||||
tailAssistant: nextTail.at(-1),
|
||||
});
|
||||
if (tailAssistant) {
|
||||
nextTail = nextTail.slice(0, -1);
|
||||
nextHead = [tailAssistant];
|
||||
changedTail = true;
|
||||
changedHead = true;
|
||||
}
|
||||
|
||||
// For streamable kinds, apply to head
|
||||
|
||||
440
packages/app/src/utils/assistant-file-link-resolver.test.ts
Normal file
440
packages/app/src/utils/assistant-file-link-resolver.test.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAssistantFileLinkResolver,
|
||||
getAssistantFileLinkToken,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionResult,
|
||||
} from "./assistant-file-link-resolver";
|
||||
|
||||
const CONTEXT: AssistantFileLinkContext = {
|
||||
serverId: "server-1",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
function resolvedSuggestions(
|
||||
entries: DirectorySuggestionResult["entries"],
|
||||
): DirectorySuggestionResult {
|
||||
return { entries, error: null };
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("assistant file link resolver", () => {
|
||||
it("dedupes in-flight prefetches and serves the click from cache", async () => {
|
||||
const suggestions = vi.fn(async () =>
|
||||
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
|
||||
);
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
await Promise.all([
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
resolver.prefetch({ context: CONTEXT, source }),
|
||||
]);
|
||||
const result = await resolver.open({ context: CONTEXT, source });
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(suggestions).toHaveBeenCalledWith({
|
||||
query: "dumm.md",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 1,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/src/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("click consumes an in-flight hover resolution", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
const suggestions = vi.fn(() => deferred.promise);
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
|
||||
|
||||
const prefetch = resolver.prefetch({ context: CONTEXT, source });
|
||||
const opened = resolver.open({ context: CONTEXT, source });
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
|
||||
await prefetch;
|
||||
const result = await opened;
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(1);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("retries a click after hover prefetch fails to query suggestions", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("daemon unavailable"))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
|
||||
const openResult = await resolver.open({ context: CONTEXT, source });
|
||||
|
||||
expect(prefetchResult).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
});
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(openResult.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("does not cache unresolved candidates", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resolvedSuggestions([]))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
const first = await resolver.open({ context: CONTEXT, source });
|
||||
const second = await resolver.open({ context: CONTEXT, source });
|
||||
|
||||
expect(first).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
expect(second.opened).toBe(true);
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/project/docs/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keys cache entries by server, workspace, and token", async () => {
|
||||
const suggestions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
|
||||
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
|
||||
|
||||
await resolver.open({ context: CONTEXT, source });
|
||||
await resolver.open({
|
||||
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
|
||||
source,
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveBeenCalledTimes(2);
|
||||
expect(openWorkspaceFile).toHaveBeenLastCalledWith({
|
||||
raw: "dumm.md",
|
||||
path: "/Users/test/other/two/dumm.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply stale async results after the active context changes", async () => {
|
||||
const deferred = createDeferred<DirectorySuggestionResult>();
|
||||
let isCurrent = true;
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(() => deferred.promise),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
isCurrentContext: () => isCurrent,
|
||||
});
|
||||
|
||||
const opened = resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||
});
|
||||
isCurrent = false;
|
||||
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
|
||||
const result = await opened;
|
||||
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||
expect(result.opened).toBe(false);
|
||||
expect(result.kind).toBe("file");
|
||||
});
|
||||
|
||||
it("opens direct workspace file links without querying suggestions", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "src/components/message.tsx#L33" },
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "src/components/message.tsx#L33",
|
||||
path: "/Users/test/project/src/components/message.tsx",
|
||||
lineStart: 33,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves inline-code basename line refs through directory suggestions", async () => {
|
||||
const suggestions = vi.fn(async () =>
|
||||
resolvedSuggestions([
|
||||
{ path: "packages/server/src/server/workspace-git-service.ts", kind: "file" },
|
||||
]),
|
||||
);
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile,
|
||||
openExternalUrl: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
|
||||
source: {
|
||||
href: "workspace-git-service.ts:1553",
|
||||
text: "workspace-git-service.ts:1553",
|
||||
sourceType: "inline-code",
|
||||
},
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveBeenCalledWith({
|
||||
query: "workspace-git-service.ts",
|
||||
cwd: "/Users/test/project",
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 1,
|
||||
});
|
||||
expect(openWorkspaceFile).toHaveBeenCalledWith({
|
||||
raw: "workspace-git-service.ts:1553",
|
||||
path: "/Users/test/project/packages/server/src/server/workspace-git-service.ts",
|
||||
lineStart: 1553,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(result.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps explicit external URLs external", async () => {
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md" },
|
||||
});
|
||||
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://dumm.md",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domains external", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://google.com",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps auto-linkified normal domain paths external", async () => {
|
||||
const suggestions = vi.fn(async () => resolvedSuggestions([]));
|
||||
const openExternalUrl = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: suggestions,
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
|
||||
});
|
||||
|
||||
expect(suggestions).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
|
||||
expect(result).toEqual({
|
||||
kind: "external",
|
||||
url: "http://openai.com/path",
|
||||
opened: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not open unresolved linkified markdown filenames in the browser", async () => {
|
||||
const openWorkspaceFile = vi.fn();
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
|
||||
openWorkspaceFile,
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
|
||||
const prefetchResult = await resolver.prefetch({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||
});
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
|
||||
});
|
||||
|
||||
expect(openWorkspaceFile).not.toHaveBeenCalled();
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(prefetchResult).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
});
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps failed ambiguous resolution out of the browser", async () => {
|
||||
const openExternalUrl = vi.fn();
|
||||
const onUnresolvedFileCandidate = vi.fn();
|
||||
const resolver = createAssistantFileLinkResolver({
|
||||
getDirectorySuggestions: vi.fn(async () => {
|
||||
throw new Error("daemon unavailable");
|
||||
}),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
openExternalUrl,
|
||||
onUnresolvedFileCandidate,
|
||||
});
|
||||
|
||||
const result = await resolver.open({
|
||||
context: CONTEXT,
|
||||
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
|
||||
});
|
||||
|
||||
expect(openExternalUrl).not.toHaveBeenCalled();
|
||||
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
|
||||
expect(result).toEqual({
|
||||
kind: "unresolvedFileCandidate",
|
||||
token: "dumm.md",
|
||||
opened: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://dumm.md",
|
||||
text: "dumm.md",
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
}),
|
||||
).toBe("dumm.md");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://google.com",
|
||||
text: "google.com",
|
||||
markup: "linkify",
|
||||
sourceInfo: "auto",
|
||||
}),
|
||||
).toBe("http://google.com");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "http://dumm.md",
|
||||
text: "dumm.md",
|
||||
markup: "",
|
||||
sourceInfo: "",
|
||||
}),
|
||||
).toBe("http://dumm.md");
|
||||
expect(
|
||||
getAssistantFileLinkToken({
|
||||
href: "workspace-git-service.ts:1553",
|
||||
text: "workspace-git-service.ts:1553",
|
||||
sourceType: "inline-code",
|
||||
}),
|
||||
).toBe("workspace-git-service.ts:1553");
|
||||
});
|
||||
});
|
||||
258
packages/app/src/utils/assistant-file-link-resolver.ts
Normal file
258
packages/app/src/utils/assistant-file-link-resolver.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
classifyAssistantFileLink,
|
||||
isFileLookingAssistantToken,
|
||||
type AssistantFileLinkClassification,
|
||||
type InlinePathTarget,
|
||||
} from "./inline-path";
|
||||
|
||||
export interface AssistantFileLinkContext {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
text?: string;
|
||||
markup?: string;
|
||||
sourceInfo?: string;
|
||||
sourceType?: "inline-code";
|
||||
}
|
||||
|
||||
export interface DirectorySuggestionEntry {
|
||||
path: string;
|
||||
kind: "file" | "directory";
|
||||
}
|
||||
|
||||
export interface DirectorySuggestionResult {
|
||||
entries: DirectorySuggestionEntry[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolverDependencies {
|
||||
getDirectorySuggestions: (input: {
|
||||
query: string;
|
||||
cwd: string;
|
||||
includeFiles: true;
|
||||
includeDirectories: false;
|
||||
limit: number;
|
||||
}) => Promise<DirectorySuggestionResult>;
|
||||
openWorkspaceFile: (target: InlinePathTarget) => void;
|
||||
openExternalUrl: (url: string) => void | Promise<void>;
|
||||
onUnresolvedFileCandidate?: (token: string) => void;
|
||||
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkResolver {
|
||||
prefetch(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink>;
|
||||
open(input: AssistantFileLinkInteraction): Promise<AssistantFileLinkOpenResult>;
|
||||
}
|
||||
|
||||
export interface AssistantFileLinkInteraction {
|
||||
context: AssistantFileLinkContext;
|
||||
source: AssistantFileLinkSource;
|
||||
}
|
||||
|
||||
export type ResolvedAssistantFileLink =
|
||||
| {
|
||||
kind: "external";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
kind: "file";
|
||||
target: InlinePathTarget;
|
||||
}
|
||||
| {
|
||||
kind: "unresolvedFileCandidate";
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
kind: "ignored";
|
||||
};
|
||||
|
||||
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
|
||||
opened: boolean;
|
||||
};
|
||||
|
||||
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
|
||||
|
||||
interface ParsedAssistantFileLinkInteraction {
|
||||
token: string;
|
||||
classification: AssistantFileLinkClassification;
|
||||
}
|
||||
|
||||
export function createAssistantFileLinkResolver(
|
||||
dependencies: AssistantFileLinkResolverDependencies,
|
||||
): AssistantFileLinkResolver {
|
||||
const cache = new Map<string, CachedAssistantFileLink>();
|
||||
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
|
||||
|
||||
async function resolve(input: AssistantFileLinkInteraction): Promise<ResolvedAssistantFileLink> {
|
||||
const parsed = parseInteraction(input);
|
||||
if (!parsed) {
|
||||
return { kind: "ignored" };
|
||||
}
|
||||
|
||||
if (parsed.classification.kind === "external") {
|
||||
return { kind: "external", url: parsed.classification.raw };
|
||||
}
|
||||
|
||||
if (parsed.classification.kind === "directFile") {
|
||||
return { kind: "file", target: parsed.classification.target };
|
||||
}
|
||||
|
||||
const key = getResolutionKey(input.context, parsed.token);
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const active = inFlight.get(key);
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
|
||||
const request = resolveAmbiguousCandidate({
|
||||
context: input.context,
|
||||
token: parsed.token,
|
||||
target: parsed.classification.target,
|
||||
getDirectorySuggestions: dependencies.getDirectorySuggestions,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.kind === "file") {
|
||||
cache.set(key, result);
|
||||
}
|
||||
inFlight.delete(key);
|
||||
return result;
|
||||
})
|
||||
.catch((): CachedAssistantFileLink => {
|
||||
inFlight.delete(key);
|
||||
return { kind: "unresolvedFileCandidate", token: parsed.token };
|
||||
});
|
||||
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
prefetch(input) {
|
||||
return resolve(input);
|
||||
},
|
||||
async open(input) {
|
||||
const resolved = await resolve(input);
|
||||
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
|
||||
return { ...resolved, opened: false };
|
||||
}
|
||||
|
||||
if (resolved.kind === "file") {
|
||||
dependencies.openWorkspaceFile(resolved.target);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
|
||||
if (resolved.kind === "external") {
|
||||
await dependencies.openExternalUrl(resolved.url);
|
||||
return { ...resolved, opened: true };
|
||||
}
|
||||
|
||||
if (resolved.kind === "unresolvedFileCandidate") {
|
||||
dependencies.onUnresolvedFileCandidate?.(resolved.token);
|
||||
}
|
||||
|
||||
return { ...resolved, opened: false };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
||||
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
|
||||
const text = source.text?.trim();
|
||||
if (text && isFileLookingAssistantToken(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return source.href;
|
||||
}
|
||||
|
||||
function parseInteraction(
|
||||
input: AssistantFileLinkInteraction,
|
||||
): ParsedAssistantFileLinkInteraction | null {
|
||||
const token = getAssistantFileLinkToken(input.source).trim();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: input.context.workspaceRoot,
|
||||
});
|
||||
if (!classification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { token, classification };
|
||||
}
|
||||
|
||||
async function resolveAmbiguousCandidate(input: {
|
||||
context: AssistantFileLinkContext;
|
||||
token: string;
|
||||
target: InlinePathTarget;
|
||||
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
|
||||
}): Promise<CachedAssistantFileLink> {
|
||||
const workspaceRoot = input.context.workspaceRoot?.trim();
|
||||
if (!workspaceRoot) {
|
||||
return { kind: "unresolvedFileCandidate", token: input.token };
|
||||
}
|
||||
|
||||
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
|
||||
const suggestions = await input.getDirectorySuggestions({
|
||||
query,
|
||||
cwd: workspaceRoot,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
limit: 1,
|
||||
});
|
||||
const match = suggestions.entries.find((entry) => entry.kind === "file");
|
||||
if (!match || suggestions.error) {
|
||||
return { kind: "unresolvedFileCandidate", token: input.token };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "file",
|
||||
target: {
|
||||
...input.target,
|
||||
path: joinWorkspacePath(workspaceRoot, match.path),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
|
||||
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = target.path.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
if (normalizedPath.startsWith(prefix)) {
|
||||
return normalizedPath.slice(prefix.length);
|
||||
}
|
||||
|
||||
const lastSlash = normalizedPath.lastIndexOf("/");
|
||||
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
|
||||
}
|
||||
|
||||
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
|
||||
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
return source.markup === "linkify" || source.sourceInfo === "auto";
|
||||
}
|
||||
|
||||
function canApplyResult(
|
||||
context: AssistantFileLinkContext,
|
||||
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
|
||||
): boolean {
|
||||
return isCurrentContext ? isCurrentContext(context) : true;
|
||||
}
|
||||
|
||||
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
|
||||
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
return root ? `${root}/${child}` : child;
|
||||
}
|
||||
130
packages/app/src/utils/github-refs.test.ts
Normal file
130
packages/app/src/utils/github-refs.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractGithubRefs, normalizeGithubRemote, parseGithubRef } from "./github-refs";
|
||||
|
||||
const httpsRemote = "https://github.com/getpaseo/paseo.git";
|
||||
const sshRemote = "git@github.com:getpaseo/paseo.git";
|
||||
|
||||
describe("normalizeGithubRemote", () => {
|
||||
it.each([
|
||||
["https://github.com/getpaseo/paseo", { owner: "getpaseo", repo: "paseo", host: "github.com" }],
|
||||
[
|
||||
"https://github.com/getpaseo/paseo.git",
|
||||
{ owner: "getpaseo", repo: "paseo", host: "github.com" },
|
||||
],
|
||||
["git@github.com:getpaseo/paseo.git", { owner: "getpaseo", repo: "paseo", host: "github.com" }],
|
||||
[
|
||||
"ssh://git@github.com/getpaseo/paseo.git",
|
||||
{ owner: "getpaseo", repo: "paseo", host: "github.com" },
|
||||
],
|
||||
])("extracts GitHub identity from %s", (remoteUrl, expected) => {
|
||||
expect(normalizeGithubRemote(remoteUrl)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub remotes and empty input", () => {
|
||||
expect(normalizeGithubRemote("git@gitlab.com:getpaseo/paseo.git")).toBeNull();
|
||||
expect(normalizeGithubRemote(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGithubRef", () => {
|
||||
it.each([
|
||||
"https://github.com/getpaseo/paseo/pull/994",
|
||||
"https://github.com/getpaseo/paseo/pull/994/",
|
||||
"https://github.com/getpaseo/paseo/pull/994/files",
|
||||
"https://github.com/getpaseo/paseo/pull/994?diff=split",
|
||||
"https://github.com/getpaseo/paseo/pull/994#discussion_r123",
|
||||
])("parses a matching pull request URL: %s", (text) => {
|
||||
expect(parseGithubRef(text, httpsRemote)).toEqual({
|
||||
kind: "pull",
|
||||
number: 994,
|
||||
owner: "getpaseo",
|
||||
repo: "paseo",
|
||||
url: "https://github.com/getpaseo/paseo/pull/994",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a matching issue URL", () => {
|
||||
expect(parseGithubRef("https://github.com/getpaseo/paseo/issues/456", httpsRemote)).toEqual({
|
||||
kind: "issues",
|
||||
number: 456,
|
||||
owner: "getpaseo",
|
||||
repo: "paseo",
|
||||
url: "https://github.com/getpaseo/paseo/issues/456",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches HTTPS pasted URLs against an SSH remote", () => {
|
||||
expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", sshRemote)).toEqual({
|
||||
kind: "pull",
|
||||
number: 994,
|
||||
owner: "getpaseo",
|
||||
repo: "paseo",
|
||||
url: "https://github.com/getpaseo/paseo/pull/994",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores URLs for another owner or repo", () => {
|
||||
expect(parseGithubRef("https://github.com/other/paseo/pull/994", httpsRemote)).toBeNull();
|
||||
expect(parseGithubRef("https://github.com/getpaseo/other/pull/994", httpsRemote)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub remotes and empty text", () => {
|
||||
expect(
|
||||
parseGithubRef(
|
||||
"https://github.com/getpaseo/paseo/pull/994",
|
||||
"git@gitlab.com:getpaseo/paseo.git",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(parseGithubRef("", httpsRemote)).toBeNull();
|
||||
expect(parseGithubRef("https://github.com/getpaseo/paseo/pull/994", null)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds URLs embedded in text and markdown links", () => {
|
||||
expect(
|
||||
parseGithubRef(
|
||||
"See:\n[the PR](https://github.com/getpaseo/paseo/pull/994/files).",
|
||||
httpsRemote,
|
||||
),
|
||||
).toEqual({
|
||||
kind: "pull",
|
||||
number: 994,
|
||||
owner: "getpaseo",
|
||||
repo: "paseo",
|
||||
url: "https://github.com/getpaseo/paseo/pull/994",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractGithubRefs", () => {
|
||||
it("returns every matching ref deduped by kind and number", () => {
|
||||
const text = [
|
||||
"https://github.com/getpaseo/paseo/pull/994",
|
||||
"https://github.com/getpaseo/paseo/issues/456#issuecomment-1",
|
||||
"https://github.com/getpaseo/paseo/pull/994/files",
|
||||
"https://github.com/other/paseo/issues/1",
|
||||
].join("\n");
|
||||
|
||||
expect(extractGithubRefs(text, httpsRemote)).toEqual([
|
||||
{
|
||||
kind: "pull",
|
||||
number: 994,
|
||||
owner: "getpaseo",
|
||||
repo: "paseo",
|
||||
url: "https://github.com/getpaseo/paseo/pull/994",
|
||||
},
|
||||
{
|
||||
kind: "issues",
|
||||
number: 456,
|
||||
owner: "getpaseo",
|
||||
repo: "paseo",
|
||||
url: "https://github.com/getpaseo/paseo/issues/456",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array for empty text or null remote", () => {
|
||||
expect(extractGithubRefs("", httpsRemote)).toEqual([]);
|
||||
expect(extractGithubRefs("https://github.com/getpaseo/paseo/pull/994", null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
117
packages/app/src/utils/github-refs.ts
Normal file
117
packages/app/src/utils/github-refs.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
|
||||
|
||||
export type GithubRefKind = "pull" | "issues";
|
||||
|
||||
export interface GithubRemote {
|
||||
owner: string;
|
||||
repo: string;
|
||||
host: "github.com";
|
||||
}
|
||||
|
||||
export interface GithubRef {
|
||||
kind: GithubRefKind;
|
||||
number: number;
|
||||
owner: string;
|
||||
repo: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ParsedGithubUrl {
|
||||
kind: GithubRefKind;
|
||||
number: number;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
const GITHUB_REF_URL_PATTERN =
|
||||
/https?:\/\/github\.com\/([^/\s<>)\]]+)\/([^/\s<>)\]]+)\/(pull|issues)\/(\d+)(?:[/?#][^\s<>)\]]*)?/giu;
|
||||
|
||||
export function normalizeGithubRemote(remoteUrl: string | null | undefined): GithubRemote | null {
|
||||
const trimmed = remoteUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const identity = parseGitHubRemoteUrl(trimmed);
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
owner: identity.owner,
|
||||
repo: identity.name,
|
||||
host: "github.com",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGithubRef(
|
||||
text: string | null | undefined,
|
||||
remoteUrl: string | null | undefined,
|
||||
): GithubRef | null {
|
||||
return extractGithubRefs(text, remoteUrl)[0] ?? null;
|
||||
}
|
||||
|
||||
export function extractGithubRefs(
|
||||
text: string | null | undefined,
|
||||
remoteUrl: string | null | undefined,
|
||||
): GithubRef[] {
|
||||
const remote = normalizeGithubRemote(remoteUrl);
|
||||
const body = text?.trim();
|
||||
if (!remote || !body) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const refs: GithubRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const match of body.matchAll(GITHUB_REF_URL_PATTERN)) {
|
||||
const parsed = parseGithubUrlMatch(match);
|
||||
if (!parsed || !matchesRemote(parsed, remote)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dedupeKey = `${parsed.kind}:${parsed.number}`;
|
||||
if (seen.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(dedupeKey);
|
||||
|
||||
refs.push({
|
||||
kind: parsed.kind,
|
||||
number: parsed.number,
|
||||
owner: remote.owner,
|
||||
repo: remote.repo,
|
||||
url: `https://github.com/${remote.owner}/${remote.repo}/${parsed.kind}/${parsed.number}`,
|
||||
});
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function parseGithubUrlMatch(match: RegExpMatchArray): ParsedGithubUrl | null {
|
||||
const owner = match[1];
|
||||
const repo = match[2];
|
||||
const kind = match[3];
|
||||
const numberText = match[4];
|
||||
if (!owner || !repo || !isGithubRefKind(kind) || !numberText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = Number.parseInt(numberText, 10);
|
||||
if (!Number.isSafeInteger(number) || number <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { owner, repo, kind, number };
|
||||
}
|
||||
|
||||
function isGithubRefKind(value: string): value is GithubRefKind {
|
||||
return value === "pull" || value === "issues";
|
||||
}
|
||||
|
||||
function matchesRemote(parsed: ParsedGithubUrl, remote: GithubRemote): boolean {
|
||||
return (
|
||||
parsed.owner.toLowerCase() === remote.owner.toLowerCase() &&
|
||||
parsed.repo.toLowerCase() === remote.repo.toLowerCase()
|
||||
);
|
||||
}
|
||||
@@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
|
||||
export function buildHostNewWorkspaceRoute(
|
||||
serverId: string,
|
||||
sourceDirectory: string,
|
||||
options?: { displayName?: string },
|
||||
options?: { displayName?: string; projectId?: string },
|
||||
) {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
@@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute(
|
||||
if (options?.displayName) {
|
||||
params.set("name", options.displayName);
|
||||
}
|
||||
if (options?.projectId) {
|
||||
params.set("projectId", options.projectId);
|
||||
}
|
||||
return `${base}/new?${params.toString()}` as const;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user