mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-25 03:13:46 +08:00
Compare commits
123 Commits
pytest-fas
...
bb/project
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5969d3fc30 | ||
|
|
aee3ba7036 | ||
|
|
e9a5b7d8e6 | ||
|
|
a91a541a28 | ||
|
|
d2bf5aa830 | ||
|
|
4603e1bf61 | ||
|
|
f00334a7b1 | ||
|
|
74265c8e84 | ||
|
|
9e924f79a8 | ||
|
|
e32ebc6aa2 | ||
|
|
190b01c553 | ||
|
|
4b7f3826c2 | ||
|
|
aaa2e2cb88 | ||
|
|
e155ca20ea | ||
|
|
f881e8e27f | ||
|
|
02050859f3 | ||
|
|
23c47371d2 | ||
|
|
4daa98d7fc | ||
|
|
96376c0656 | ||
|
|
b3a8d262df | ||
|
|
0fac74de74 | ||
|
|
64131bf975 | ||
|
|
221cd60242 | ||
|
|
72bfc48e63 | ||
|
|
da80ac0042 | ||
|
|
3a9faf122d | ||
|
|
82924fb32a | ||
|
|
062ff4a7e4 | ||
|
|
cc9b33499f | ||
|
|
3f2e41eb10 | ||
|
|
70d28b62fb | ||
|
|
6cc07b6cd0 | ||
|
|
f32be4439c | ||
|
|
97888fed48 | ||
|
|
0089bd820f | ||
|
|
9fd2b2cb9f | ||
|
|
a0471e2464 | ||
|
|
c820eb6a5a | ||
|
|
05c896cf52 | ||
|
|
56b4ef74a6 | ||
|
|
2977e74543 | ||
|
|
45540cfb5e | ||
|
|
baea9a6c79 | ||
|
|
880bb04991 | ||
|
|
351afd353d | ||
|
|
00de9a646f | ||
|
|
67e9d8a937 | ||
|
|
718698761f | ||
|
|
5db6fd4065 | ||
|
|
49fd3b8e96 | ||
|
|
b650cc4f58 | ||
|
|
d6156bfef2 | ||
|
|
b85d33556b | ||
|
|
5ecf3bf0e0 | ||
|
|
2196584161 | ||
|
|
208accd45d | ||
|
|
f6df9ce437 | ||
|
|
45bc4fb37f | ||
|
|
3686f98b1e | ||
|
|
d3d98b83f2 | ||
|
|
c4f25b178a | ||
|
|
2828d010af | ||
|
|
6a35bf78cf | ||
|
|
1cd318c377 | ||
|
|
c877c768d8 | ||
|
|
700903d8c1 | ||
|
|
452e06d467 | ||
|
|
58adb95065 | ||
|
|
ad9ea5f3e2 | ||
|
|
7a5ae7c430 | ||
|
|
211ba9c7d3 | ||
|
|
af7b7f6322 | ||
|
|
bb7ff7dc30 | ||
|
|
633f65889e | ||
|
|
eb579a2817 | ||
|
|
0102a93038 | ||
|
|
ed9451db5e | ||
|
|
9389e6245a | ||
|
|
65e4dd9b87 | ||
|
|
892f6bdd85 | ||
|
|
79b6a02b08 | ||
|
|
07109187bf | ||
|
|
ce638d9fe9 | ||
|
|
25731da5be | ||
|
|
a057b7b4d9 | ||
|
|
4f6a644239 | ||
|
|
8bb9b8e39b | ||
|
|
2a10b8384a | ||
|
|
7daa6d83fc | ||
|
|
48a8f84169 | ||
|
|
d0af7fc954 | ||
|
|
cb17a9efb2 | ||
|
|
ba9e3a491b | ||
|
|
672ea1f894 | ||
|
|
833710d33e | ||
|
|
116331dd3f | ||
|
|
760fd9513e | ||
|
|
6780cee679 | ||
|
|
3fffecbdaf | ||
|
|
9bacd7d4bb | ||
|
|
b90f1e4ac0 | ||
|
|
88e136448d | ||
|
|
a6b670d4a2 | ||
|
|
3c1058e2e9 | ||
|
|
2dfcead683 | ||
|
|
807b696295 | ||
|
|
0223ea5f59 | ||
|
|
87c4a5ebb8 | ||
|
|
660e36f097 | ||
|
|
15880da8bb | ||
|
|
c080b2dc3e | ||
|
|
0e69cd4b37 | ||
|
|
3147cbb136 | ||
|
|
100e7be20e | ||
|
|
e9b86f352f | ||
|
|
91c465f6e7 | ||
|
|
ae7e857420 | ||
|
|
3972701424 | ||
|
|
0f741cef28 | ||
|
|
5f1d23cfb2 | ||
|
|
f721d2cda9 | ||
|
|
30e5d0092d | ||
|
|
5250335863 |
62
.github/actions/detect-changes/action.yml
vendored
Normal file
62
.github/actions/detect-changes/action.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Detect affected areas
|
||||
description: >-
|
||||
Classify a PR's changed files into CI work lanes (python, frontend, site,
|
||||
scan, deps, mcp_catalog) so the orchestrator can conditionally call only
|
||||
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
|
||||
events and fail open (everything "true") when the diff cannot be computed.
|
||||
|
||||
outputs:
|
||||
python:
|
||||
description: Run Python tests / ruff / ty / windows-footguns.
|
||||
value: ${{ steps.classify.outputs.python }}
|
||||
frontend:
|
||||
description: Run the TypeScript typecheck matrix + desktop build.
|
||||
value: ${{ steps.classify.outputs.frontend }}
|
||||
docker_meta:
|
||||
description: Docker setup and meta files have changed.
|
||||
value: ${{ steps.classify.outputs.docker_meta }}
|
||||
site:
|
||||
description: Build the Docusaurus docs site.
|
||||
value: ${{ steps.classify.outputs.site }}
|
||||
scan:
|
||||
description: Run the supply-chain critical-pattern scanner.
|
||||
value: ${{ steps.classify.outputs.scan }}
|
||||
deps:
|
||||
description: Check pyproject.toml dependency upper bounds.
|
||||
value: ${{ steps.classify.outputs.deps }}
|
||||
mcp_catalog:
|
||||
description: Require MCP catalog security review label.
|
||||
value: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Classify changed files
|
||||
id: classify
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Only pull_request events are gated. Other events (push, release,
|
||||
# dispatch) leave CHANGED empty, so the classifier fails open and every
|
||||
# lane runs. Post-merge / on-demand validation is never weakened.
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
# Use the compare endpoint with the pinned base/head SHAs from the
|
||||
# event payload instead of the "current PR files" endpoint. The SHAs
|
||||
# are frozen at trigger time, so the file list is deterministic even
|
||||
# if the PR receives a new push between trigger and detect.
|
||||
CHANGED="$(gh api \
|
||||
--paginate \
|
||||
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
|
||||
--jq '.files[].filename' || true)"
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
printf '%s\n' "${CHANGED:-(none)}"
|
||||
printf '%s\n' "${CHANGED:-}" | python3 scripts/ci/classify_changes.py
|
||||
50
.github/actions/retry/action.yml
vendored
Normal file
50
.github/actions/retry/action.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Retry a flaky command
|
||||
description: >-
|
||||
Run a shell command, retrying on non-zero exit. For dependency installs
|
||||
(npm ci, uv sync) whose only failures are transient network/toolchain
|
||||
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
|
||||
instead of needing a manual re-run.
|
||||
|
||||
inputs:
|
||||
command:
|
||||
description: Shell command to run (and retry).
|
||||
required: true
|
||||
attempts:
|
||||
description: Max attempts before giving up.
|
||||
default: "3"
|
||||
delay:
|
||||
description: Seconds to wait between attempts.
|
||||
default: "10"
|
||||
working-directory:
|
||||
description: Directory to run in.
|
||||
default: "."
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
# command goes through env, never interpolated into the script body, so
|
||||
# a command with quotes/specials can't break or inject into the runner.
|
||||
env:
|
||||
_CMD: ${{ inputs.command }}
|
||||
_ATTEMPTS: ${{ inputs.attempts }}
|
||||
_DELAY: ${{ inputs.delay }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
n=0
|
||||
while :; do
|
||||
n=$((n + 1))
|
||||
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
|
||||
if bash -c "$_CMD"; then
|
||||
echo "::endgroup::"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ "$n" -ge "$_ATTEMPTS" ]; then
|
||||
echo "::error::failed after $n attempts: $_CMD"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::attempt $n failed; retrying in ${_DELAY}s: $_CMD"
|
||||
sleep "$_DELAY"
|
||||
done
|
||||
100
.github/workflows/build-windows-installer.yml
vendored
100
.github/workflows/build-windows-installer.yml
vendored
@@ -1,100 +0,0 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Gate: workflow_dispatch is already restricted to users with write access,
|
||||
# but we want ADMIN-only. Explicitly check the triggering actor's repo
|
||||
# permission via the API and fail fast for anyone below admin.
|
||||
authorize:
|
||||
name: Authorize (admins only)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check actor is a repo admin
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
perm=$(gh api \
|
||||
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission')
|
||||
echo "Actor '${ACTOR}' has permission: ${perm}"
|
||||
if [ "${perm}" != "admin" ]; then
|
||||
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
|
||||
exit 1
|
||||
fi
|
||||
echo "Authorized: '${ACTOR}' is an admin."
|
||||
|
||||
build:
|
||||
name: Hermes-Setup.exe
|
||||
needs: authorize
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
# Required for OIDC auth to Azure (azure/login federated credentials).
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
|
||||
- name: Cache Rust targets
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: apps/bootstrap-installer/src-tauri
|
||||
|
||||
- name: Build installer
|
||||
run: npm run tauri:build
|
||||
working-directory: apps/bootstrap-installer
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
|
||||
with:
|
||||
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
|
||||
# Sign both the raw exe and the bundled NSIS installer.
|
||||
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
|
||||
files-folder-filter: exe
|
||||
files-folder-recurse: true
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Upload NSIS installer
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-installer
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
- name: Upload raw exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-exe
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe
|
||||
146
.github/workflows/ci.yml
vendored
Normal file
146
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
name: CI
|
||||
|
||||
# Orchestrator workflow. Runs ``detect-changes`` once, then conditionally
|
||||
# calls the sub-workflows that a PR can actually affect. A final
|
||||
# ``all-checks-pass`` gate job aggregates results so branch protection only
|
||||
# needs to require a single check.
|
||||
#
|
||||
# Sub-workflows are triggered via ``workflow_call`` and keep their own job
|
||||
# definitions, matrices, and concurrency settings. They no longer have
|
||||
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
|
||||
# through this file.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
|
||||
actions: read # needed by osv-scanner (SARIF upload)
|
||||
security-events: write # needed by osv-scanner (SARIF upload)
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# detect: run the classifier once. Every downstream job reads its outputs
|
||||
# to decide whether to run. On push/dispatch the classifier fails open
|
||||
# (all lanes true) so post-merge validation is never weakened.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
detect:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
python: ${{ steps.classify.outputs.python }}
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
site: ${{ steps.classify.outputs.site }}
|
||||
scan: ${{ steps.classify.outputs.scan }}
|
||||
deps: ${{ steps.classify.outputs.deps }}
|
||||
docker_meta: ${{ steps.classify.outputs.docker_meta }}
|
||||
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Detect affected areas
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
||||
# Skipped workflows (if condition is false) don't spin up runners.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
tests:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/tests.yml
|
||||
|
||||
lint:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
|
||||
typecheck:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
|
||||
docs-site:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.site == 'true'
|
||||
uses: ./.github/workflows/docs-site-checks.yml
|
||||
|
||||
history-check:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/history-check.yml
|
||||
|
||||
contributor-check:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/contributor-check.yml
|
||||
|
||||
uv-lockfile:
|
||||
needs: detect
|
||||
uses: ./.github/workflows/uv-lockfile-check.yml
|
||||
|
||||
docker-lint:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.docker_meta == 'true'
|
||||
uses: ./.github/workflows/docker-lint.yml
|
||||
|
||||
supply-chain:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
|
||||
uses: ./.github/workflows/supply-chain-audit.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
scan: ${{ needs.detect.outputs.scan == 'true' }}
|
||||
deps: ${{ needs.detect.outputs.deps == 'true' }}
|
||||
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
|
||||
|
||||
osv-scanner:
|
||||
needs: detect
|
||||
uses: ./.github/workflows/osv-scanner.yml
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
||||
# status even when some deps were skipped. Only actual ``failure``
|
||||
# results cause it to fail; ``skipped`` is treated as success.
|
||||
#
|
||||
# Branch protection should require ONLY this check.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
all-checks-pass:
|
||||
name: All required checks pass
|
||||
needs:
|
||||
- tests
|
||||
- lint
|
||||
- typecheck
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
- uv-lockfile
|
||||
- docker-lint
|
||||
- supply-chain
|
||||
- osv-scanner
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
env:
|
||||
RESULTS: ${{ toJSON(needs.*.result) }}
|
||||
run: |
|
||||
echo "$RESULTS" | python3 -c "
|
||||
import json, sys
|
||||
results = json.load(sys.stdin)
|
||||
failed = [r for r in results if r == 'failure']
|
||||
if failed:
|
||||
print(f'::error::{len(failed)} job(s) failed')
|
||||
sys.exit(1)
|
||||
print('All checks passed (or were skipped)')
|
||||
"
|
||||
21
.github/workflows/contributor-check.yml
vendored
21
.github/workflows/contributor-check.yml
vendored
@@ -1,11 +1,8 @@
|
||||
name: Contributor Attribution Check
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -17,21 +14,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
14
.github/workflows/docker-lint.yml
vendored
14
.github/workflows/docker-lint.yml
vendored
@@ -11,19 +11,7 @@ name: Docker / shell lint
|
||||
# activate script doesn't exist at lint time.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile
|
||||
- docker/**
|
||||
- .hadolint.yaml
|
||||
- .github/workflows/docker-lint.yml
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
44
.github/workflows/docker-publish.yml
vendored
44
.github/workflows/docker-publish.yml
vendored
@@ -56,13 +56,21 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# The image build + smoke test + integration tests run ONLY on
|
||||
# push-to-main and release — never on PRs. They are the heaviest jobs
|
||||
# in CI (~15-45 min) and a broken build surfaces on the main push (and
|
||||
# is gated pre-merge by docker-lint + uv-lockfile-check). Every step
|
||||
# below is skipped on PRs, so the job still reports green and the
|
||||
# required check never hangs.
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for smoke testing. Cached
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (amd64, smoke test)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
@@ -76,6 +84,7 @@ jobs:
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
|
||||
- name: Smoke test image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}:test
|
||||
@@ -102,12 +111,15 @@ jobs:
|
||||
# cheapest path to coverage on every PR that touches docker code.
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Install uv (for docker tests)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Set up Python 3.11 (for docker tests)
|
||||
if: github.event_name != 'pull_request'
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies (for docker tests)
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
@@ -118,6 +130,7 @@ jobs:
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run docker integration tests
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
# Skip rebuild; use the image already loaded by the build step.
|
||||
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
|
||||
@@ -190,7 +203,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# arm64 build runs only on push-to-main and release (see build-amd64).
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Log in to ghcr.io so the registry-backed build cache below can be
|
||||
@@ -201,41 +216,21 @@ jobs:
|
||||
# crashed the build before the smoke test (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build once, load into the local daemon for smoke testing.
|
||||
#
|
||||
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
|
||||
# they pull warm layers pushed by the most recent main build but never
|
||||
# write, so rapid PR pushes don't race on cache writes or pollute the
|
||||
# cache ref. This restores warm-cache speed to arm64 PR builds (which
|
||||
# were running fully uncached and were ~45% slower than amd64, making
|
||||
# them the job most often cancelled on supersede).
|
||||
# Build once, load into the local daemon for smoke testing, then push
|
||||
# by digest below. Reads AND writes the registry-backed cache so the
|
||||
# push reuses layers from this build and the next build starts warm.
|
||||
#
|
||||
# Registry cache (type=registry on ghcr.io) is used instead of the gha
|
||||
# cache that previously broke here: its credential is the job-lifetime
|
||||
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
|
||||
# token failure mode cannot recur.
|
||||
- name: Build image (arm64, smoke test, cache read-only PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
|
||||
# Main/release builds read AND write the registry cache so the digest
|
||||
# push below reuses layers from this smoke-test build, and so the next
|
||||
# PR/main build starts warm.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
@@ -251,6 +246,7 @@ jobs:
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Smoke test image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}:test
|
||||
|
||||
18
.github/workflows/docs-site-checks.yml
vendored
18
.github/workflows/docs-site-checks.yml
vendored
@@ -1,13 +1,7 @@
|
||||
name: Docs Site Checks
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -25,15 +19,19 @@ jobs:
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- name: Install website dependencies
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
working-directory: website
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install ascii-guard
|
||||
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
6
.github/workflows/history-check.yml
vendored
6
.github/workflows/history-check.yml
vendored
@@ -14,11 +14,7 @@ name: History Check
|
||||
# the PR head and main to be non-empty.
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
35
.github/workflows/lint.yml
vendored
35
.github/workflows/lint.yml
vendored
@@ -9,18 +9,12 @@ name: Lint (ruff + ty)
|
||||
# enforcement fails.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
event_name:
|
||||
description: The event name from the calling orchestrator (pull_request or push).
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -33,6 +27,7 @@ concurrency:
|
||||
jobs:
|
||||
lint-diff:
|
||||
name: ruff + ty diff
|
||||
if: inputs.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -45,16 +40,16 @@ jobs:
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Install ruff + ty
|
||||
run: |
|
||||
uv tool install ruff
|
||||
uv tool install ty
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv tool install ruff && uv tool install ty
|
||||
|
||||
- name: Determine base ref
|
||||
id: base
|
||||
run: |
|
||||
# For PRs, diff against the merge base with the target branch.
|
||||
# For pushes to main, diff against the previous commit on main.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
if [ "${{ inputs.event_name }}" = "pull_request" ]; then
|
||||
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
else
|
||||
@@ -110,7 +105,7 @@ jobs:
|
||||
--base-ty .lint-reports/base/ty.json \
|
||||
--head-ty .lint-reports/head/ty.json \
|
||||
--base-ref "${{ steps.base.outputs.ref }}" \
|
||||
--head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--output .lint-reports/summary.md
|
||||
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -122,7 +117,7 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
- name: Post / update PR comment
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
if: inputs.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
@@ -172,7 +167,9 @@ jobs:
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Install ruff
|
||||
run: uv tool install ruff
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv tool install ruff
|
||||
|
||||
- name: ruff check .
|
||||
# No --exit-zero, no || true. Exit code propagates to the job,
|
||||
|
||||
24
.github/workflows/osv-scanner.yml
vendored
24
.github/workflows/osv-scanner.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: OSV-Scanner
|
||||
|
||||
# Scans lockfiles (uv.lock, package-lock.json) against the OSV vulnerability
|
||||
# database. Runs on every PR that touches a lockfile and on a weekly schedule
|
||||
# against main.
|
||||
# database. Runs on every PR/push (via the ci.yml orchestrator's workflow_call)
|
||||
# and on a weekly schedule against main.
|
||||
#
|
||||
# This is detection-only — OSV-Scanner does NOT open PRs or modify pins.
|
||||
# It reports known CVEs in currently-pinned dependency versions so we can
|
||||
@@ -10,9 +10,9 @@ name: OSV-Scanner
|
||||
# (full SHA / exact version) is preserved; only the notification signal
|
||||
# is added.
|
||||
#
|
||||
# Complements the existing supply-chain-audit.yml workflow (which scans
|
||||
# for malicious code patterns in PR diffs) by covering the orthogonal
|
||||
# "currently-pinned dep became known-vulnerable" case.
|
||||
# Complements the supply-chain-audit.yml workflow (which scans for malicious
|
||||
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
|
||||
# dep became known-vulnerable" case.
|
||||
#
|
||||
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
|
||||
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
|
||||
@@ -20,19 +20,7 @@ name: OSV-Scanner
|
||||
# vulnerabilities in pinned deps that we may need to patch deliberately.
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "uv.lock"
|
||||
- "pyproject.toml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "website/package-lock.json"
|
||||
workflow_call:
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
# deps that haven't changed since.
|
||||
|
||||
133
.github/workflows/supply-chain-audit.yml
vendored
133
.github/workflows/supply-chain-audit.yml
vendored
@@ -1,16 +1,5 @@
|
||||
name: Supply Chain Audit
|
||||
|
||||
on:
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
# Narrow, high-signal scanner. Only fires on critical indicators of supply
|
||||
# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics
|
||||
# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits,
|
||||
@@ -19,56 +8,40 @@ permissions:
|
||||
# the scanner. Keep this file's checks ruthlessly narrow: if you find
|
||||
# yourself adding WARNING-tier patterns here again, make a separate
|
||||
# advisory-only workflow instead.
|
||||
#
|
||||
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
|
||||
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
|
||||
# inputs instead of re-computing the diff.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
event_name:
|
||||
description: The event name from the calling orchestrator.
|
||||
type: string
|
||||
required: true
|
||||
scan:
|
||||
description: Whether supply-chain-relevant files changed.
|
||||
type: boolean
|
||||
required: true
|
||||
deps:
|
||||
description: Whether pyproject.toml changed.
|
||||
type: boolean
|
||||
required: true
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
# True when the curated MCP catalog / bundled MCP manifests changed.
|
||||
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'optional-mcps/**' \
|
||||
'hermes_cli/mcp_catalog.py' || true)
|
||||
if [ -n "$MCP_CATALOG_FILES" ]; then
|
||||
echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
if: inputs.scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -111,7 +84,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# --- base64 decode + exec/eval on the same line (the litellm attack pattern) ---
|
||||
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
|
||||
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
|
||||
if [ -n "$B64_EXEC_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: base64 decode + exec/eval combo
|
||||
@@ -125,7 +98,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# --- subprocess with encoded/obfuscated command argument ---
|
||||
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
|
||||
PROC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
|
||||
if [ -n "$PROC_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: subprocess with encoded/obfuscated command
|
||||
@@ -187,23 +160,9 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
if: inputs.deps
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -253,7 +212,7 @@ jobs:
|
||||
$(cat /tmp/unbounded.txt)
|
||||
\`\`\`
|
||||
|
||||
**Fix:** Add an upper bound, e.g. \`\"package>=1.2.0,<2\"\`
|
||||
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
|
||||
|
||||
---
|
||||
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
|
||||
@@ -266,23 +225,9 @@ jobs:
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mcp_catalog == 'true'
|
||||
if: inputs.mcp_catalog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -317,11 +262,3 @@ jobs:
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
||||
exit 1
|
||||
|
||||
mcp-catalog-review-gate:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: always() && needs.changes.outputs.mcp_catalog != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No MCP catalog changes, skipping MCP catalog security review."
|
||||
|
||||
25
.github/workflows/tests.yml
vendored
25
.github/workflows/tests.yml
vendored
@@ -1,21 +1,12 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel in-progress runs for the same PR/branch
|
||||
# Cancel in-progress runs for the same ref
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -49,7 +40,7 @@ jobs:
|
||||
RG_VERSION=15.1.0
|
||||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||||
curl -sSfL -o "$RG_TARBALL" \
|
||||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||||
tar -xzf "$RG_TARBALL"
|
||||
@@ -78,7 +69,9 @@ jobs:
|
||||
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||
# reproducible env. It also creates .venv itself, so no separate
|
||||
# `uv venv` step is needed.
|
||||
run: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
- name: Minimize uv cache
|
||||
# Optimized for CI: prunes pre-built wheels that are cheap to
|
||||
@@ -171,7 +164,7 @@ jobs:
|
||||
RG_VERSION=15.1.0
|
||||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||||
curl -sSfL -o "$RG_TARBALL" \
|
||||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||||
tar -xzf "$RG_TARBALL"
|
||||
@@ -200,7 +193,9 @@ jobs:
|
||||
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||
# reproducible env. It also creates .venv itself, so no separate
|
||||
# `uv venv` step is needed.
|
||||
run: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
- name: Minimize uv cache
|
||||
# Optimized for CI: prunes pre-built wheels that are cheap to
|
||||
|
||||
24
.github/workflows/typecheck.yml
vendored
24
.github/workflows/typecheck.yml
vendored
@@ -2,13 +2,7 @@
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
@@ -24,7 +18,14 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
|
||||
# native builds. Skipping install scripts drops node-pty's node-gyp
|
||||
# header fetch — the transient flake that killed this job pre-`tsc` — and
|
||||
# is faster. retry covers the remaining registry blips.
|
||||
-
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
|
||||
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
|
||||
@@ -41,5 +42,10 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Keep install scripts here: the production build may need node-pty's
|
||||
# native binary. retry handles the transient install-time fetch flakes.
|
||||
-
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
|
||||
15
.github/workflows/uv-lockfile-check.yml
vendored
15
.github/workflows/uv-lockfile-check.yml
vendored
@@ -44,25 +44,14 @@ name: uv.lock check
|
||||
# the same way. Better to catch it here than after merge.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/uv-lockfile-check.yml"
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
|
||||
@@ -27,6 +27,131 @@ from typing import Any, Dict, List, Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background-review aux-model selector + routed digest.
|
||||
#
|
||||
# The review fork runs on the MAIN model by default ("auto"), replaying the
|
||||
# full conversation — already warm in the prompt cache, so cheap cache reads.
|
||||
# Optimal and unchanged. A user can route the review to a different, cheaper
|
||||
# model via auxiliary.background_review.{provider,model}. A different model
|
||||
# cannot reuse the parent's cache (different key), so the fork is cold
|
||||
# regardless — replaying the full transcript would just cold-write it. So when
|
||||
# (and only when) routed to a different model, we replay a compact DIGEST to
|
||||
# minimise cold-written tokens. Same model -> full replay; different model ->
|
||||
# digest. That's the whole policy.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_review_runtime(agent: Any) -> Dict[str, Any]:
|
||||
"""Resolve provider/model/credentials for the review fork.
|
||||
|
||||
Default (auto / unset / same as parent): inherit the parent's live runtime
|
||||
(with codex_app_server -> codex_responses downgrade). ``routed`` is False —
|
||||
the fork uses the main model and the warm cache, exactly as before. When
|
||||
``auxiliary.background_review.{provider,model}`` names a concrete model
|
||||
different from the parent's, resolve that runtime and set ``routed=True``.
|
||||
"""
|
||||
parent_runtime = agent._current_main_runtime()
|
||||
parent_api_mode = parent_runtime.get("api_mode") or None
|
||||
if parent_api_mode == "codex_app_server":
|
||||
parent_api_mode = "codex_responses"
|
||||
parent = {
|
||||
"provider": agent.provider,
|
||||
"model": agent.model,
|
||||
"api_key": parent_runtime.get("api_key") or None,
|
||||
"base_url": parent_runtime.get("base_url") or None,
|
||||
"api_mode": parent_api_mode,
|
||||
"routed": False,
|
||||
}
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
return parent
|
||||
aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {}
|
||||
task = aux.get("background_review", {}) if isinstance(aux.get("background_review"), dict) else {}
|
||||
task_provider = (str(task.get("provider", "")).strip() or None)
|
||||
task_model = (str(task.get("model", "")).strip() or None)
|
||||
task_base_url = (str(task.get("base_url", "")).strip() or None)
|
||||
task_api_key = (str(task.get("api_key", "")).strip() or None)
|
||||
if not (task_provider and task_provider != "auto" and task_model):
|
||||
return parent
|
||||
if task_provider == (agent.provider or "") and task_model == (agent.model or ""):
|
||||
return parent # same model/provider as parent -> not routed
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
rp = resolve_runtime_provider(
|
||||
requested=task_provider,
|
||||
target_model=task_model,
|
||||
explicit_api_key=task_api_key,
|
||||
explicit_base_url=task_base_url,
|
||||
)
|
||||
return {
|
||||
"provider": rp.get("provider") or task_provider,
|
||||
"model": task_model,
|
||||
"api_key": rp.get("api_key"),
|
||||
"base_url": rp.get("base_url"),
|
||||
"api_mode": rp.get("api_mode"),
|
||||
"routed": True,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("background-review aux routing failed (%s); using main model", e)
|
||||
return parent
|
||||
|
||||
|
||||
def _msg_text(m: Dict) -> str:
|
||||
c = m.get("content")
|
||||
if isinstance(c, str):
|
||||
return c.strip()
|
||||
if isinstance(c, list):
|
||||
return " ".join(b.get("text", "") for b in c if isinstance(b, dict)).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _digest_history(messages_snapshot: List[Dict], tail: int = 24) -> List[Dict]:
|
||||
"""Compact replay for the routed (different-model) path only.
|
||||
|
||||
Keeps the recent ``tail`` messages verbatim, collapses older turns into one
|
||||
synthetic user-role digest, preserving role alternation. Used ONLY when
|
||||
routed to a different model (cache cold regardless, so fewer cold-written
|
||||
tokens is a pure win). Never on the main-model path (full replay stays warm).
|
||||
"""
|
||||
msgs = list(messages_snapshot or [])
|
||||
if len(msgs) <= tail:
|
||||
return msgs
|
||||
keep = msgs[-tail:]
|
||||
while keep and isinstance(keep[0], dict) and keep[0].get("role") == "tool":
|
||||
tail += 1
|
||||
if len(msgs) <= tail:
|
||||
return msgs
|
||||
keep = msgs[-tail:]
|
||||
old = msgs[:-len(keep)]
|
||||
lines: List[str] = []
|
||||
for m in old:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
role = m.get("role")
|
||||
text = _msg_text(m).replace("\n", " ")
|
||||
if role == "user" and text:
|
||||
lines.append(f"USER: {text[:300]}")
|
||||
elif role == "assistant":
|
||||
tcs = m.get("tool_calls") or []
|
||||
if tcs:
|
||||
names = [(tc.get("function") or {}).get("name", "?") for tc in tcs if isinstance(tc, dict)]
|
||||
lines.append(f"ASSISTANT[tools: {', '.join(names)}]")
|
||||
if text:
|
||||
lines.append(f"ASSISTANT: {text[:200]}")
|
||||
digest = {
|
||||
"role": "user",
|
||||
"content": (
|
||||
"[Earlier conversation digest — older turns summarised to bound the "
|
||||
"review's cold-write cost on the routed aux model. Recent turns "
|
||||
"follow verbatim below.]\n" + "\n".join(lines)
|
||||
),
|
||||
}
|
||||
return [digest] + keep
|
||||
|
||||
|
||||
# Review-prompt strings — used by ``spawn_background_review_thread`` to build
|
||||
# the user-message that the forked review agent receives. AIAgent exposes
|
||||
# them as class attributes (``_MEMORY_REVIEW_PROMPT`` etc.) for back-compat;
|
||||
@@ -488,18 +613,13 @@ def _run_review_in_thread(
|
||||
# creds, or credential-pool setups where the resolver can't
|
||||
# reconstruct auth from scratch -- producing the spurious
|
||||
# "No LLM provider configured" warning at end of turn.
|
||||
_parent_runtime = agent._current_main_runtime()
|
||||
_parent_api_mode = _parent_runtime.get("api_mode") or None
|
||||
# The review fork needs to call agent-loop tools (memory,
|
||||
# skill_manage). Those tools require Hermes' own dispatch,
|
||||
# which the codex_app_server runtime bypasses entirely
|
||||
# (it runs the turn inside codex's subprocess). So when
|
||||
# the parent is on codex_app_server, downgrade the review
|
||||
# fork to codex_responses — same auth/credentials, but
|
||||
# talks to the OpenAI Responses API directly so Hermes
|
||||
# owns the loop and the agent-loop tools dispatch.
|
||||
if _parent_api_mode == "codex_app_server":
|
||||
_parent_api_mode = "codex_responses"
|
||||
# _resolve_review_runtime() returns the parent's live runtime by
|
||||
# default (routed=False; main model, warm cache), or — when the user
|
||||
# set auxiliary.background_review.{provider,model} to a different
|
||||
# model — that model's runtime (routed=True). The codex_app_server
|
||||
# -> codex_responses downgrade is applied inside the resolver.
|
||||
_rt = _resolve_review_runtime(agent)
|
||||
_routed = bool(_rt.get("routed"))
|
||||
# skip_memory=True keeps the review fork from
|
||||
# touching external memory plugins (honcho, mem0,
|
||||
# supermemory, etc.). Without it, the fork's
|
||||
@@ -519,14 +639,14 @@ def _run_review_in_thread(
|
||||
# in the request body — Anthropic's cache key includes it.
|
||||
# (The runtime whitelist below still restricts dispatch.)
|
||||
review_agent = AIAgent(
|
||||
model=agent.model,
|
||||
model=_rt.get("model") or agent.model,
|
||||
max_iterations=16,
|
||||
quiet_mode=True,
|
||||
platform=agent.platform,
|
||||
provider=agent.provider,
|
||||
api_mode=_parent_api_mode,
|
||||
base_url=_parent_runtime.get("base_url") or None,
|
||||
api_key=_parent_runtime.get("api_key") or None,
|
||||
provider=_rt.get("provider") or agent.provider,
|
||||
api_mode=_rt.get("api_mode"),
|
||||
base_url=_rt.get("base_url") or None,
|
||||
api_key=_rt.get("api_key") or None,
|
||||
credential_pool=getattr(agent, "_credential_pool", None),
|
||||
parent_session_id=agent.session_id,
|
||||
enabled_toolsets=getattr(agent, "enabled_toolsets", None),
|
||||
@@ -565,15 +685,20 @@ def _run_review_in_thread(
|
||||
# issue #25322 and PR #17276 for the full analysis +
|
||||
# measured impact (~26% end-to-end cost reduction on
|
||||
# Sonnet 4.5).
|
||||
review_agent._cached_system_prompt = agent._cached_system_prompt
|
||||
# Defensive: pin session_start + session_id to the
|
||||
# parent's so any code path that re-renders parts of
|
||||
# the system prompt (compression, plugin hooks) still
|
||||
# produces byte-identical output. The cached-prompt
|
||||
# assignment above already short-circuits the normal
|
||||
# rebuild path, but these pins guarantee parity even
|
||||
# if a future code path bypasses the cache.
|
||||
review_agent.session_start = agent.session_start
|
||||
# Share the parent's warm cached system prompt ONLY when the review
|
||||
# runs on the SAME model (not routed). When routed to a different
|
||||
# model the parent's cached prompt is for the wrong model/cache key
|
||||
# and would miss anyway, so let the routed fork build its own.
|
||||
if not _routed:
|
||||
review_agent._cached_system_prompt = agent._cached_system_prompt
|
||||
# Defensive: pin session_start + session_id to the
|
||||
# parent's so any code path that re-renders parts of
|
||||
# the system prompt (compression, plugin hooks) still
|
||||
# produces byte-identical output. The cached-prompt
|
||||
# assignment above already short-circuits the normal
|
||||
# rebuild path, but these pins guarantee parity even
|
||||
# if a future code path bypasses the cache.
|
||||
review_agent.session_start = agent.session_start
|
||||
review_agent.session_id = agent.session_id
|
||||
# The fork shares the parent's live session_id (pinned above for
|
||||
# prefix-cache parity). It is single-lifecycle and calls close()
|
||||
@@ -615,6 +740,13 @@ def _run_review_in_thread(
|
||||
),
|
||||
)
|
||||
try:
|
||||
# Routed to a different model -> replay a digest (cache is cold
|
||||
# on that model anyway, so minimise cold-written tokens). Same
|
||||
# model -> replay the full snapshot (warm cache reads).
|
||||
_review_history = (
|
||||
_digest_history(messages_snapshot) if _routed
|
||||
else messages_snapshot
|
||||
)
|
||||
review_agent.run_conversation(
|
||||
user_message=(
|
||||
prompt
|
||||
@@ -622,7 +754,7 @@ def _run_review_in_thread(
|
||||
"management tools. Other tools will be denied "
|
||||
"at runtime — do not attempt them."
|
||||
),
|
||||
conversation_history=messages_snapshot,
|
||||
conversation_history=_review_history,
|
||||
)
|
||||
finally:
|
||||
clear_thread_tool_whitelist()
|
||||
|
||||
@@ -83,6 +83,59 @@ _PROJECT_MARKERS = (
|
||||
# Agent-instruction files surfaced separately from manifests in the snapshot.
|
||||
_CONTEXT_FILES = ("AGENTS.md", "CLAUDE.md", ".cursorrules")
|
||||
|
||||
# Source-file extensions that make a git repo a *code* workspace even with no
|
||||
# manifest. Without this, `git init` on a notes/writing/research folder (a huge
|
||||
# non-coding use case) would flip the whole session into the coding posture just
|
||||
# for having a `.git`. A manifest still wins on its own (see `_PROJECT_MARKERS`).
|
||||
_CODE_EXTENSIONS = frozenset({
|
||||
".py", ".pyi", ".ipynb", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
|
||||
".go", ".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php", ".c", ".h",
|
||||
".cc", ".cpp", ".hpp", ".cs", ".swift", ".m", ".mm", ".dart", ".ex", ".exs",
|
||||
".lua", ".sh", ".bash", ".zsh", ".sql", ".vue", ".svelte", ".r", ".jl",
|
||||
".hs", ".clj", ".erl", ".pl",
|
||||
})
|
||||
|
||||
# Dirs never worth scanning for the code check (deps/build/vcs/venv noise).
|
||||
_CODE_SCAN_SKIP_DIRS = frozenset({
|
||||
".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
|
||||
"target", ".next", ".turbo", "vendor",
|
||||
})
|
||||
|
||||
# Bounded sweep: a code workspace reveals itself in the first handful of entries.
|
||||
_CODE_SCAN_MAX_ENTRIES = 500
|
||||
|
||||
|
||||
def _has_code_files(root: Path) -> bool:
|
||||
"""Cheap, bounded check for source files in a repo's top two levels.
|
||||
|
||||
Lets a git repo of loose scripts (no manifest) still read as a code
|
||||
workspace while a bare notes/writing repo does not. Scans the root and its
|
||||
immediate subdirectories only, capped at ``_CODE_SCAN_MAX_ENTRIES`` stats —
|
||||
a handful of readdirs at session start, not a full walk.
|
||||
"""
|
||||
seen = 0
|
||||
stack = [(root, True)]
|
||||
while stack:
|
||||
directory, is_root = stack.pop()
|
||||
try:
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
seen += 1
|
||||
if seen > _CODE_SCAN_MAX_ENTRIES:
|
||||
return False
|
||||
name = entry.name
|
||||
try:
|
||||
if entry.is_file():
|
||||
if os.path.splitext(name)[1].lower() in _CODE_EXTENSIONS:
|
||||
return True
|
||||
elif is_root and entry.is_dir() and name not in _CODE_SCAN_SKIP_DIRS and not name.startswith("."):
|
||||
stack.append((Path(entry.path), False))
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
# Lockfile → package manager, checked in priority order.
|
||||
_PY_LOCKFILES = (("uv.lock", "uv"), ("poetry.lock", "poetry"), ("Pipfile.lock", "pipenv"))
|
||||
_JS_LOCKFILES = (
|
||||
@@ -368,10 +421,16 @@ def _detect_profile_name(mode: str, platform: str, cwd_str: str) -> str:
|
||||
if platform and platform.strip().lower() not in INTERACTIVE_CODING_PLATFORMS:
|
||||
return GENERAL_PROFILE.name
|
||||
cwd = Path(cwd_str)
|
||||
# A recognized project root (manifest / AGENTS.md / .cursorrules) is a code
|
||||
# workspace on its own — cheap stat checks, no scan.
|
||||
if _marker_root(cwd) is not None:
|
||||
return CODING_PROFILE.name
|
||||
git_root = _git_root(cwd)
|
||||
if git_root is not None and git_root == _home():
|
||||
git_root = None # dotfiles repo at $HOME — not a code workspace
|
||||
if git_root is not None or _marker_root(cwd) is not None:
|
||||
# A bare git repo only counts when it actually holds code, so `git init` on a
|
||||
# notes/writing/research folder stays in the general posture.
|
||||
if git_root is not None and _has_code_files(git_root):
|
||||
return CODING_PROFILE.name
|
||||
return GENERAL_PROFILE.name
|
||||
|
||||
@@ -635,25 +694,32 @@ def _read_small(path: Path) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _project_facts(root: Path) -> list[str]:
|
||||
"""Detected project facts for the workspace snapshot.
|
||||
@dataclass(frozen=True)
|
||||
class ProjectFacts:
|
||||
"""Structured project facts — the model's verify loop, detected once.
|
||||
|
||||
The point is to hand the model its *verify loop* up front — which manifest,
|
||||
which package manager, and the exact test/lint/build commands — instead of
|
||||
making it rediscover them every session. Cheap: stat calls plus reads of a
|
||||
couple of small files; built once at prompt-build time (cache-safe).
|
||||
The same data that feeds the workspace snapshot, exposed structurally so
|
||||
non-prompt consumers (e.g. the desktop verify UI) read it instead of
|
||||
re-detecting and drifting from the prompt.
|
||||
"""
|
||||
facts: list[str] = []
|
||||
|
||||
manifests: list[str]
|
||||
package_managers: list[str]
|
||||
verify_commands: list[str]
|
||||
context_files: list[str]
|
||||
|
||||
|
||||
def detect_project_facts(root: Path) -> ProjectFacts:
|
||||
"""Detect manifests, package manager(s), verify commands, and context files.
|
||||
|
||||
Cheap: stat calls plus reads of a couple of small files. The single source
|
||||
of truth for both the prompt snapshot (:func:`_project_facts`) and the
|
||||
gateway's ``project.facts`` — so the UI never re-sniffs verify commands.
|
||||
"""
|
||||
manifests = [m for m in _PROJECT_MARKERS if m not in _CONTEXT_FILES and (root / m).is_file()]
|
||||
package_managers = [
|
||||
pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file()
|
||||
]
|
||||
if manifests:
|
||||
line = f"- Project: {', '.join(manifests[:6])}"
|
||||
if package_managers:
|
||||
line += f" ({'/'.join(dict.fromkeys(package_managers))})"
|
||||
facts.append(line)
|
||||
package_managers = list(
|
||||
dict.fromkeys(pm for lock, pm in (*_PY_LOCKFILES, *_JS_LOCKFILES) if (root / lock).is_file())
|
||||
)
|
||||
|
||||
verify: list[str] = []
|
||||
if (root / "scripts" / "run_tests.sh").is_file():
|
||||
@@ -673,17 +739,61 @@ def _project_facts(root: Path) -> list[str]:
|
||||
f"make {name}" for name in _VERIFY_TARGETS
|
||||
if re.search(rf"^{re.escape(name)}\s*:", makefile, re.MULTILINE)
|
||||
)
|
||||
if verify:
|
||||
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
|
||||
facts.append(f"- Verify: {'; '.join(deduped)}")
|
||||
|
||||
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
|
||||
if context_files:
|
||||
facts.append(f"- Context files: {', '.join(context_files)}")
|
||||
return ProjectFacts(
|
||||
manifests=manifests,
|
||||
package_managers=package_managers,
|
||||
verify_commands=list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS],
|
||||
context_files=[c for c in _CONTEXT_FILES if (root / c).is_file()],
|
||||
)
|
||||
|
||||
|
||||
def _project_facts(root: Path) -> list[str]:
|
||||
"""Render :func:`detect_project_facts` as workspace-snapshot lines.
|
||||
|
||||
Hands the model its *verify loop* up front — which manifest, which package
|
||||
manager, and the exact test/lint/build commands — instead of making it
|
||||
rediscover them every session. Built once at prompt-build time; the string
|
||||
output must stay byte-stable to preserve the prompt cache.
|
||||
"""
|
||||
f = detect_project_facts(root)
|
||||
facts: list[str] = []
|
||||
|
||||
if f.manifests:
|
||||
line = f"- Project: {', '.join(f.manifests[:6])}"
|
||||
if f.package_managers:
|
||||
line += f" ({'/'.join(f.package_managers)})"
|
||||
facts.append(line)
|
||||
if f.verify_commands:
|
||||
facts.append(f"- Verify: {'; '.join(f.verify_commands)}")
|
||||
if f.context_files:
|
||||
facts.append(f"- Context files: {', '.join(f.context_files)}")
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
def project_facts_for(cwd: Optional[str | Path] = None) -> Optional[dict[str, Any]]:
|
||||
"""Structured project facts for ``cwd`` — ``None`` outside a workspace.
|
||||
|
||||
Same detection the system-prompt snapshot uses (git root, else marker root),
|
||||
exposed for non-prompt consumers (the desktop verify UI) so they never
|
||||
re-derive "are we coding?" or duplicate the verify-command sniffing.
|
||||
"""
|
||||
resolved = _resolve_cwd(cwd)
|
||||
root = _git_root(resolved) or _marker_root(resolved)
|
||||
if root is None:
|
||||
return None
|
||||
|
||||
f = detect_project_facts(root)
|
||||
return {
|
||||
"root": str(root),
|
||||
"manifests": f.manifests,
|
||||
"packageManagers": f.package_managers,
|
||||
"verifyCommands": f.verify_commands,
|
||||
"contextFiles": f.context_files,
|
||||
}
|
||||
|
||||
|
||||
def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
|
||||
"""Workspace snapshot for the system prompt (empty outside a workspace).
|
||||
|
||||
|
||||
@@ -805,10 +805,11 @@ def try_shrink_image_parts_in_messages(
|
||||
Pillow couldn't help (caller should surface the original error).
|
||||
|
||||
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
|
||||
``data:image/...;base64,...`` payload. For each one whose encoded
|
||||
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
|
||||
ceiling with header overhead) or whose longest side exceeds
|
||||
``max_dimension``, write the base64 to a tempfile, call
|
||||
``data:image/...;base64,...`` payload, plus Anthropic-native
|
||||
``{"type": "image", "source": {"type": "base64", ...}}`` blocks.
|
||||
For each one whose encoded size exceeds 4 MB (a safe target that slides
|
||||
under Anthropic's 5 MB ceiling with header overhead) or whose longest side
|
||||
exceeds ``max_dimension``, write the base64 to a tempfile, call
|
||||
``vision_tools._resize_image_for_vision`` to produce a smaller data
|
||||
URL, and substitute it in place.
|
||||
|
||||
@@ -964,6 +965,28 @@ def try_shrink_image_parts_in_messages(
|
||||
logger.warning("image-shrink recovery: re-encode failed — %s", exc)
|
||||
return None, triggered_by is not None
|
||||
|
||||
def _source_to_data_url(source: Any) -> Optional[str]:
|
||||
if not isinstance(source, dict) or source.get("type") != "base64":
|
||||
return None
|
||||
data = source.get("data")
|
||||
if not isinstance(data, str) or not data:
|
||||
return None
|
||||
media_type = str(source.get("media_type") or "image/jpeg").strip()
|
||||
if not media_type.startswith("image/"):
|
||||
media_type = "image/jpeg"
|
||||
return f"data:{media_type};base64,{data}"
|
||||
|
||||
def _write_data_url_to_source(source: dict, data_url: str) -> None:
|
||||
header, _, data = data_url.partition(",")
|
||||
media_type = "image/jpeg"
|
||||
if header.startswith("data:"):
|
||||
candidate = header[len("data:"):].split(";", 1)[0].strip()
|
||||
if candidate.startswith("image/"):
|
||||
media_type = candidate
|
||||
source["type"] = "base64"
|
||||
source["media_type"] = media_type
|
||||
source["data"] = data
|
||||
|
||||
for msg in api_messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
@@ -974,6 +997,16 @@ def try_shrink_image_parts_in_messages(
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
ptype = part.get("type")
|
||||
if ptype == "image":
|
||||
source = part.get("source")
|
||||
url = _source_to_data_url(source)
|
||||
resized, unshrinkable = _shrink_data_url(url or "")
|
||||
if resized and isinstance(source, dict):
|
||||
_write_data_url_to_source(source, resized)
|
||||
changed_count += 1
|
||||
elif unshrinkable:
|
||||
unshrinkable_oversized += 1
|
||||
continue
|
||||
if ptype not in {"image_url", "input_image"}:
|
||||
continue
|
||||
image_value = part.get("image_url")
|
||||
|
||||
@@ -4050,6 +4050,19 @@ def run_conversation(
|
||||
|
||||
messages.append(assistant_msg)
|
||||
agent._emit_interim_assistant_message(assistant_msg)
|
||||
try:
|
||||
# Persist the assistant tool-call turn before any tool
|
||||
# side effects run. If a destructive tool restarts or
|
||||
# terminates Hermes mid-turn, resume logic still sees the
|
||||
# exact tool-call block that already executed.
|
||||
agent._flush_messages_to_session_db(messages, conversation_history)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Incremental tool-call persistence failed before execution "
|
||||
"(session=%s): %s",
|
||||
agent.session_id or "none",
|
||||
exc,
|
||||
)
|
||||
|
||||
# Close any open streaming display (response box, reasoning
|
||||
# box) before tool execution begins. Intermediate turns may
|
||||
|
||||
109
agent/learn_prompt.py
Normal file
109
agent/learn_prompt.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""``/learn`` — build the standards-guided prompt that turns whatever the user
|
||||
described into a reusable skill.
|
||||
|
||||
``/learn`` is open-ended. The user can point it at anything they can describe:
|
||||
a directory of code, an API doc URL, a workflow they just walked the agent
|
||||
through in this conversation, or pasted notes. This module builds ONE prompt
|
||||
that instructs the live agent to:
|
||||
|
||||
1. Gather the sources the user named, using the tools it already has
|
||||
(``read_file`` / ``search_files`` for dirs, ``web_extract`` for URLs, the
|
||||
current conversation for "what I just did", the user's text for pasted
|
||||
material).
|
||||
2. Author a single ``SKILL.md`` via ``skill_manage`` that follows the Hermes
|
||||
skill-authoring standards (description <=60 chars, the modern section
|
||||
order, Hermes-tool framing, no invented commands).
|
||||
|
||||
There is no separate distillation engine and no model-tool footprint: the
|
||||
agent does the work with its existing toolset, so this works identically on
|
||||
local, Docker, and remote terminal backends. Every surface (CLI ``/learn``,
|
||||
gateway ``/learn``, the dashboard "Learn a skill" panel) calls
|
||||
:func:`build_learn_prompt` and feeds the result to the agent as a normal turn.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# The house-style rules, distilled from AGENTS.md "Skill authoring standards
|
||||
# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in
|
||||
# the prompt so the agent authors skills the way a maintainer would by hand.
|
||||
_AUTHORING_STANDARDS = """\
|
||||
Follow the Hermes skill-authoring standards exactly:
|
||||
|
||||
Frontmatter:
|
||||
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
||||
- description: ONE sentence, <=60 characters, ends with a period. State the
|
||||
capability, not the implementation. No marketing words (powerful,
|
||||
comprehensive, seamless, advanced). Do NOT repeat the skill name. If the
|
||||
description contains a colon, wrap the whole value in double quotes.
|
||||
- version: 0.1.0
|
||||
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
||||
|
||||
Body section order (omit a section only if it genuinely has no content):
|
||||
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT
|
||||
do, and the key dependency stance (e.g. "stdlib only").
|
||||
2. "## When to Use" — bullet list of concrete trigger phrases.
|
||||
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
||||
4. "## How to Run" — the canonical invocation, framed through Hermes tools.
|
||||
5. "## Quick Reference" — a flat command/endpoint list, no narration.
|
||||
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
||||
7. "## Pitfalls" — known limits, rate limits, things that look broken but aren't.
|
||||
8. "## Verification" — a single command/check that proves the skill worked.
|
||||
|
||||
Hermes-tool framing (this is what makes it a skill, not shell docs):
|
||||
- Frame running scripts as "invoke through the `terminal` tool".
|
||||
- Use `read_file` (not cat/head/tail), `search_files` (not grep/find/ls),
|
||||
`patch` (not sed/awk), `web_extract` (not curl-to-scrape),
|
||||
`vision_analyze` for images. Reference these tools by name in backticks.
|
||||
- Do NOT name shell utilities the agent already has wrapped.
|
||||
|
||||
Quality bar:
|
||||
- Prefer exact commands, endpoint URLs, function signatures, and config keys
|
||||
that appear VERBATIM in the source. NEVER invent flags, paths, or APIs — if
|
||||
you didn't see it in the source, don't write it.
|
||||
- Keep it tight and scannable: ~100 lines for a simple skill, ~200 for a
|
||||
complex one. Don't re-paste the source docs.
|
||||
- Don't write a router/index/hub skill that only points at other skills.
|
||||
- Larger scripts/parsers belong in a `scripts/` file (add via
|
||||
`skill_manage` write_file), referenced from SKILL.md by relative path — not
|
||||
inlined for the agent to re-type every run."""
|
||||
|
||||
|
||||
def build_learn_prompt(user_request: str) -> str:
|
||||
"""Build the agent prompt for an open-ended ``/learn`` request.
|
||||
|
||||
Args:
|
||||
user_request: the free-text the user gave after ``/learn`` — a
|
||||
description of the workflow, paths, URLs, or "what I just did".
|
||||
|
||||
Returns:
|
||||
A complete instruction the agent runs as a normal turn. The agent
|
||||
gathers the described sources with its existing tools and authors the
|
||||
skill via ``skill_manage``.
|
||||
"""
|
||||
req = (user_request or "").strip()
|
||||
if not req:
|
||||
req = (
|
||||
"the workflow we just went through in this conversation — review "
|
||||
"the steps taken and distill them into a reusable skill"
|
||||
)
|
||||
|
||||
return (
|
||||
"[/learn] The user wants you to learn a reusable skill from the "
|
||||
"source(s) they described below, and save it.\n\n"
|
||||
f"WHAT TO LEARN FROM:\n{req}\n\n"
|
||||
"Do this:\n"
|
||||
"1. Gather the material. Resolve whatever the user named using the "
|
||||
"tools you already have — `read_file`/`search_files` for local files "
|
||||
"or directories, `web_extract` for URLs, the current conversation "
|
||||
"history if they referred to something you just did, and the text "
|
||||
"they pasted as-is. If the request is ambiguous about scope, make a "
|
||||
"reasonable choice and note it; do not stall.\n"
|
||||
"2. Author ONE SKILL.md and save it with the `skill_manage` tool "
|
||||
"(action=\"create\"). Pick a sensible category. If the procedure needs "
|
||||
"a non-trivial script, add it under the skill's `scripts/` with "
|
||||
"`skill_manage` write_file and reference it by relative path.\n\n"
|
||||
f"{_AUTHORING_STANDARDS}\n\n"
|
||||
"When done, tell the user the skill name, its category, and a "
|
||||
"one-line summary of what it captured."
|
||||
)
|
||||
158
agent/oneshot.py
Normal file
158
agent/oneshot.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Shared one-off LLM requests for non-conversational helpers.
|
||||
|
||||
A "one-shot" is a single, stateless model call that runs *outside* any
|
||||
conversation: it never touches a session's history, never breaks prompt
|
||||
caching, and returns plain text. UI surfaces use it for small generative
|
||||
chores — a commit message from a diff, a rename suggestion, a summary —
|
||||
where spinning up an agent turn would be wrong (it would pollute the thread)
|
||||
and hand-rolling an LLM call at every call site would be worse.
|
||||
|
||||
Two ways to call it:
|
||||
|
||||
* ``run_oneshot(instructions=..., user_input=...)`` — caller supplies the
|
||||
full prompt.
|
||||
* ``run_oneshot(template="commit_message", variables={...})`` — caller
|
||||
names a registered template and passes its variables; the template owns
|
||||
the prompt engineering so it stays consistent across CLI/TUI/desktop.
|
||||
|
||||
Model selection rides the same auxiliary plumbing as title generation
|
||||
(:func:`agent.auxiliary_client.call_llm`): pass ``main_runtime`` to inherit
|
||||
the live session's provider/model, otherwise the configured ``task`` (default
|
||||
``title_generation``) resolves a cheap/fast backend.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from agent.auxiliary_client import call_llm, extract_content_or_reasoning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A template turns a variables dict into a (instructions, user_input) pair.
|
||||
# Templates are plain callables (not str.format) so diff/code payloads with
|
||||
# literal "{" / "}" pass through untouched.
|
||||
PromptTemplate = Callable[[Dict[str, Any]], Tuple[str, str]]
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
text = text or ""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "\n…(truncated)"
|
||||
|
||||
|
||||
_COMMIT_INSTRUCTIONS = (
|
||||
"You write git commit messages. Given a diff of staged changes, write ONE "
|
||||
"concise Conventional Commits message describing what the change does and why.\n"
|
||||
"Rules:\n"
|
||||
"- Subject line: type(scope): summary — imperative mood, lower-case, no "
|
||||
"trailing period, ≤ 72 characters. Types: feat, fix, refactor, perf, docs, "
|
||||
"test, build, chore, style, ci.\n"
|
||||
"- Omit the scope if it isn't obvious.\n"
|
||||
"- Add a short body (wrapped at ~72 cols) ONLY when the change needs "
|
||||
"explanation; skip it for small/obvious changes.\n"
|
||||
"- Describe the actual change, never restate the diff line-by-line.\n"
|
||||
"- Return ONLY the commit message text — no quotes, no markdown fences, no "
|
||||
"preamble."
|
||||
)
|
||||
|
||||
|
||||
def _commit_message_template(variables: Dict[str, Any]) -> Tuple[str, str]:
|
||||
diff = _truncate(str(variables.get("diff") or ""), 12000)
|
||||
recent = _truncate(str(variables.get("recent_commits") or ""), 1500)
|
||||
|
||||
parts = []
|
||||
if recent.strip():
|
||||
parts.append(
|
||||
"Recent commit subjects from this repo (match their style/conventions):\n"
|
||||
f"{recent}"
|
||||
)
|
||||
parts.append("Diff to describe:\n" + (diff or "(no textual diff available)"))
|
||||
|
||||
# "Regenerate" must yield something new even on models that decode greedily
|
||||
# / pin temperature server-side. A trailing nonce isn't enough, so we hand
|
||||
# back the previous message and require a genuinely different one.
|
||||
avoid = _truncate(str(variables.get("avoid") or "").strip(), 1000)
|
||||
if avoid:
|
||||
parts.append(
|
||||
"You already proposed the message below and the user wants a "
|
||||
"different one. Write a NEW message with different wording (and, if "
|
||||
"reasonable, a different emphasis or scope framing) — do not repeat "
|
||||
f"it:\n{avoid}"
|
||||
)
|
||||
|
||||
return _COMMIT_INSTRUCTIONS, "\n\n".join(parts)
|
||||
|
||||
|
||||
# Registry of named templates. Add an entry here to give a new surface a
|
||||
# consistent, reusable prompt without teaching every caller the prompt text.
|
||||
PROMPT_TEMPLATES: Dict[str, PromptTemplate] = {
|
||||
"commit_message": _commit_message_template,
|
||||
}
|
||||
|
||||
|
||||
def render_template(name: str, variables: Optional[Dict[str, Any]] = None) -> Tuple[str, str]:
|
||||
"""Resolve a registered template into (instructions, user_input).
|
||||
|
||||
Raises KeyError if the template name is unknown so callers fail loudly
|
||||
instead of silently sending an empty prompt.
|
||||
"""
|
||||
template = PROMPT_TEMPLATES.get(name)
|
||||
if template is None:
|
||||
raise KeyError(f"unknown one-shot template: {name}")
|
||||
return template(variables or {})
|
||||
|
||||
|
||||
def run_oneshot(
|
||||
*,
|
||||
instructions: str = "",
|
||||
user_input: str = "",
|
||||
template: Optional[str] = None,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
task: str = "title_generation",
|
||||
max_tokens: int = 1024,
|
||||
temperature: Optional[float] = 0.3,
|
||||
timeout: float = 60.0,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Run a single stateless LLM request and return its text.
|
||||
|
||||
Provide either a registered ``template`` (+ ``variables``) or an explicit
|
||||
``instructions`` / ``user_input`` pair. Returns the model's text answer,
|
||||
stripped of surrounding whitespace and any wrapping code fence.
|
||||
|
||||
Raises RuntimeError when no LLM provider is configured (surfaced from
|
||||
:func:`call_llm`) and KeyError for an unknown template name.
|
||||
"""
|
||||
if template:
|
||||
instructions, user_input = render_template(template, variables)
|
||||
|
||||
if not (instructions or "").strip() and not (user_input or "").strip():
|
||||
raise ValueError("run_oneshot requires a template or instructions/user_input")
|
||||
|
||||
messages = []
|
||||
if (instructions or "").strip():
|
||||
messages.append({"role": "system", "content": instructions})
|
||||
messages.append({"role": "user", "content": user_input or ""})
|
||||
|
||||
response = call_llm(
|
||||
task=task,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
timeout=timeout,
|
||||
main_runtime=main_runtime,
|
||||
)
|
||||
|
||||
text = (extract_content_or_reasoning(response) or "").strip()
|
||||
return _strip_code_fence(text)
|
||||
|
||||
|
||||
def _strip_code_fence(text: str) -> str:
|
||||
"""Drop a single wrapping ``` fence the model may have added."""
|
||||
if not text.startswith("```"):
|
||||
return text
|
||||
lines = text.splitlines()
|
||||
if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```":
|
||||
return "\n".join(lines[1:-1]).strip()
|
||||
return text
|
||||
@@ -243,7 +243,10 @@ KANBAN_GUIDANCE = (
|
||||
"- **Workspace.** `cd $HERMES_KANBAN_WORKSPACE` first. For a `worktree` kind "
|
||||
"with no `.git`, `git worktree add <path> "
|
||||
"${HERMES_KANBAN_BRANCH:-wt/$HERMES_KANBAN_TASK}` from the main repo, then "
|
||||
"cd there.\n"
|
||||
"cd there. For a project-linked task the workspace is a fresh "
|
||||
"`<repo>/.worktrees/<task-id>` and `$HERMES_KANBAN_BRANCH` a deterministic "
|
||||
"`<project-slug>/<task-id>` — the main repo is two levels up, so run "
|
||||
"`git worktree add` from there.\n"
|
||||
"- **Deliverables.** Files a human wants go in "
|
||||
"`kanban_complete(artifacts=[<absolute paths>])` (top-level param; paths in "
|
||||
"`metadata` are NOT uploaded). Files must exist at completion.\n"
|
||||
|
||||
@@ -69,6 +69,25 @@ def _budget_for_agent(agent) -> BudgetConfig:
|
||||
_MAX_TOOL_WORKERS = 8
|
||||
|
||||
|
||||
def _flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages: list,
|
||||
*,
|
||||
stage: str,
|
||||
) -> None:
|
||||
"""Best-effort incremental SessionDB flush for tool-call progress.
|
||||
|
||||
Tool execution can perform side effects that terminate or restart the
|
||||
current Hermes process before the normal turn-end persistence path runs.
|
||||
Flush the already-appended assistant/tool messages immediately so the
|
||||
transcript survives destructive-but-valid tool calls.
|
||||
"""
|
||||
try:
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
except Exception as exc:
|
||||
logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc)
|
||||
|
||||
|
||||
def _ra():
|
||||
"""Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work."""
|
||||
import run_agent
|
||||
@@ -279,6 +298,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
|
||||
tc.id,
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"cancelled tool result {tc.function.name}",
|
||||
)
|
||||
return
|
||||
|
||||
# ── Parse args + pre-execution bookkeeping ───────────────────────
|
||||
@@ -768,6 +792,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# String results pass through unchanged.
|
||||
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
|
||||
messages.append(make_tool_result_message(name, _tool_content, tc.id))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"tool result {name}",
|
||||
)
|
||||
|
||||
# ── Per-tool /steer drain ───────────────────────────────────
|
||||
# Same as the sequential path: drain between each collected
|
||||
@@ -803,13 +832,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)", force=True)
|
||||
for skipped_tc in remaining_calls:
|
||||
skipped_name = skipped_tc.function.name
|
||||
skip_msg = {
|
||||
"role": "tool",
|
||||
"name": skipped_name,
|
||||
"content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
|
||||
"tool_call_id": skipped_tc.id,
|
||||
}
|
||||
messages.append(skip_msg)
|
||||
messages.append(make_tool_result_message(
|
||||
skipped_name,
|
||||
f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
|
||||
skipped_tc.id,
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"cancelled tool result {skipped_name}",
|
||||
)
|
||||
break
|
||||
|
||||
function_name = tool_call.function.name
|
||||
@@ -1402,6 +1434,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
# (see parallel path for rationale). String results pass through.
|
||||
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
|
||||
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"tool result {function_name}",
|
||||
)
|
||||
|
||||
# ── Per-tool /steer drain ───────────────────────────────────
|
||||
# Drain pending steer BETWEEN individual tool calls so the
|
||||
@@ -1428,6 +1465,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
|
||||
skipped_tc.id,
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"skipped tool result {skipped_name}",
|
||||
)
|
||||
break
|
||||
|
||||
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):
|
||||
|
||||
@@ -122,10 +122,14 @@ def finalize_turn(
|
||||
)
|
||||
|
||||
# Determine if conversation completed successfully
|
||||
normal_text_response = str(_turn_exit_reason).startswith("text_response(")
|
||||
completed = (
|
||||
final_response is not None
|
||||
and api_call_count < agent.max_iterations
|
||||
and not failed
|
||||
and (
|
||||
api_call_count < agent.max_iterations
|
||||
or normal_text_response
|
||||
)
|
||||
)
|
||||
|
||||
# Post-loop cleanup must never lose the response. Trajectory save,
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"iconLibrary": "tabler"
|
||||
}
|
||||
|
||||
98
apps/desktop/electron/git-repo-scan.cjs
Normal file
98
apps/desktop/electron/git-repo-scan.cjs
Normal file
@@ -0,0 +1,98 @@
|
||||
'use strict'
|
||||
|
||||
// Repo-first discovery: walk bounded roots for git repos using only Node's `fs`
|
||||
// — no native addon, so it just works for anyone who pulls main (no
|
||||
// electron-rebuild). Mirrors how GitHub Desktop scans: stop at the first `.git`
|
||||
// (don't descend into a repo), cap depth, and skip heavy non-repo trees so the
|
||||
// first scan stays fast. Results are cached by the backend after the first run.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const fsp = fs.promises
|
||||
|
||||
// Shallow on purpose: real projects live a few levels under home
|
||||
// (`~/www/repo`, `~/code/org/repo`); deeper `.git` dirs are almost always
|
||||
// fixtures/vendored/eval checkouts (e.g. `~/www/ha-evals/tasks/*/repo`). Repos
|
||||
// you actually use but keep deeper still surface via session-derived discovery,
|
||||
// so this only prunes noise, never repos with history.
|
||||
const DEFAULT_MAX_DEPTH = 3
|
||||
const MAX_CONCURRENCY = 32
|
||||
|
||||
// Big trees that are never themselves repos and would waste the walk. Anything
|
||||
// hidden (dotdirs like .cache/.Trash/.npm) is skipped wholesale below, so this
|
||||
// only needs the non-hidden heavyweights.
|
||||
const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv'])
|
||||
|
||||
async function mapLimit(items, limit, fn) {
|
||||
let cursor = 0
|
||||
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
await fn(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `roots` (default: the home dir) for git repositories. Returns deduped
|
||||
* `{ root, label }` entries. `options.maxDepth` caps recursion (default 3).
|
||||
*/
|
||||
async function scanGitRepos(roots, options = {}) {
|
||||
const maxDepth = Number(options.maxDepth) || DEFAULT_MAX_DEPTH
|
||||
const searchRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()]
|
||||
const found = new Map()
|
||||
|
||||
async function walk(dir, depth) {
|
||||
if (depth > maxDepth) {
|
||||
return
|
||||
}
|
||||
|
||||
let entries
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return // unreadable / permission denied
|
||||
}
|
||||
|
||||
// A `.git` DIRECTORY marks a real repo root (a main checkout). A `.git`
|
||||
// FILE is a linked worktree or submodule — those belong to their parent
|
||||
// repo as lanes, not as separate projects, so we don't list them (and we
|
||||
// keep descending in case a real repo sits deeper). This is what kills the
|
||||
// worktree/eval-repo duplicate explosion.
|
||||
if (entries.some(entry => entry.name === '.git' && entry.isDirectory())) {
|
||||
const root = dir.replace(/[/\\]+$/, '')
|
||||
found.set(root, path.basename(root) || root)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const subdirs = []
|
||||
for (const entry of entries) {
|
||||
// Real directories only (skip symlinks to avoid loops), no hidden dirs, no
|
||||
// known heavy trees.
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || JUNK_DIRS.has(entry.name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
subdirs.push(path.join(dir, entry.name))
|
||||
}
|
||||
|
||||
await mapLimit(subdirs, MAX_CONCURRENCY, sub => walk(sub, depth + 1))
|
||||
}
|
||||
|
||||
await mapLimit(
|
||||
searchRoots.map(root => String(root || '').trim()).filter(Boolean),
|
||||
MAX_CONCURRENCY,
|
||||
root => walk(root, 0)
|
||||
)
|
||||
|
||||
return [...found.entries()].map(([root, label]) => ({ label, root }))
|
||||
}
|
||||
|
||||
module.exports = { scanGitRepos }
|
||||
679
apps/desktop/electron/git-review-ops.cjs
Normal file
679
apps/desktop/electron/git-review-ops.cjs
Normal file
@@ -0,0 +1,679 @@
|
||||
'use strict'
|
||||
|
||||
// Git ops backing the coding rail + Codex-style review pane. Built on `simple-git`
|
||||
// (a maintained wrapper around the system git binary — same git the rest of the
|
||||
// app shells to, no native build) so we read structured status()/diffSummary()
|
||||
// results instead of hand-parsing porcelain. Reads degrade to null/empty on a
|
||||
// non-repo / remote backend; mutations reject so the renderer can toast.
|
||||
|
||||
const { execFile } = require('node:child_process')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
|
||||
const simpleGit = require('simple-git')
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
const COMMIT_CONTEXT_DIFF_MAX_CHARS = 120_000
|
||||
const COMMIT_CONTEXT_UNTRACKED_MAX = 80
|
||||
const UNTRACKED_LINE_COUNT_CONCURRENCY = 16
|
||||
const UNTRACKED_LINE_COUNT_MAX_BYTES = 1024 * 1024
|
||||
|
||||
// GUI-launched Electron apps on macOS inherit only a minimal PATH (no
|
||||
// /opt/homebrew/bin or /usr/local/bin), so `gh` — and the `git` gh shells out
|
||||
// to — aren't found. Augment PATH with the resolved gh dir + the common
|
||||
// package-manager bins so gh runs the same way it does in a terminal.
|
||||
function ghEnv(ghBin) {
|
||||
const extra = [ghBin ? path.dirname(ghBin) : '', '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'].filter(
|
||||
dir => dir && dir !== '.'
|
||||
)
|
||||
|
||||
return { ...process.env, PATH: [...extra, process.env.PATH].filter(Boolean).join(path.delimiter) }
|
||||
}
|
||||
|
||||
// Run the `gh` CLI in a repo. Resolves { ok, stdout } so callers branch on
|
||||
// availability/auth without a throw. gh missing/unauthed → ok:false.
|
||||
function runGh(args, cwd, ghBin) {
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
ghBin || 'gh',
|
||||
args,
|
||||
{ cwd, env: ghEnv(ghBin), windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
(err, stdout) => resolve({ ok: !err, stdout: String(stdout || '') })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function gitFor(cwd, gitBin) {
|
||||
return simpleGit({ baseDir: cwd, binary: gitBin || 'git', maxConcurrentProcesses: 4, trimmed: false })
|
||||
}
|
||||
|
||||
// simple-git reports renames as `old => new` (and `dir/{old => new}/f`); resolve
|
||||
// to the NEW path so the row addresses the real file for diff/stage.
|
||||
function resolveRenamePath(raw) {
|
||||
const path = String(raw || '').trim()
|
||||
|
||||
if (!path.includes(' => ')) {
|
||||
return path
|
||||
}
|
||||
|
||||
const brace = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/)
|
||||
|
||||
if (brace) {
|
||||
const [, prefix, , to, suffix] = brace
|
||||
|
||||
return `${prefix}${to}${suffix}`.replace(/\/{2,}/g, '/')
|
||||
}
|
||||
|
||||
return path.split(' => ').pop().trim()
|
||||
}
|
||||
|
||||
// DiffResult.files → Map<path, {added, removed}> (binary files carry no line
|
||||
// delta).
|
||||
function countsByPath(summary) {
|
||||
const map = new Map()
|
||||
|
||||
for (const file of summary.files) {
|
||||
map.set(resolveRenamePath(file.file), {
|
||||
added: file.binary ? 0 : file.insertions,
|
||||
removed: file.binary ? 0 : file.deletions
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
// Untracked files don't appear in diffSummary(); count insertions from disk so
|
||||
// the review tree can show +N for new files (matches an all-add diff view).
|
||||
// Insertions = line count: newline bytes, plus one for a final unterminated
|
||||
// line. Binary (NUL byte) → 0, mirroring git numstat's "-".
|
||||
async function untrackedInsertions(cwd, relPath) {
|
||||
try {
|
||||
const fullPath = path.join(cwd, relPath)
|
||||
const stat = await fs.stat(fullPath)
|
||||
|
||||
if (!stat.isFile() || stat.size > UNTRACKED_LINE_COUNT_MAX_BYTES) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const buf = await fs.readFile(fullPath)
|
||||
|
||||
if (buf.includes(0)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let lines = 0
|
||||
|
||||
for (const byte of buf) {
|
||||
if (byte === 10) {
|
||||
lines++
|
||||
}
|
||||
}
|
||||
|
||||
return buf.length > 0 && buf[buf.length - 1] !== 10 ? lines + 1 : lines
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function capText(text, maxChars, label = 'truncated') {
|
||||
const value = String(text || '')
|
||||
|
||||
if (value.length <= maxChars) {
|
||||
return value
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxChars)}\n# ${label}: ${value.length - maxChars} chars omitted\n`
|
||||
}
|
||||
|
||||
async function fillUntrackedCounts(cwd, files) {
|
||||
const pending = files.filter(file => file.status === '?' && file.added === 0 && file.removed === 0)
|
||||
|
||||
for (let i = 0; i < pending.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
|
||||
await Promise.all(
|
||||
pending.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(async file => {
|
||||
file.added = await untrackedInsertions(cwd, file.path)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the base ref for "all branch changes": merge-base with the remote
|
||||
// default branch (origin/HEAD), falling back to common trunk names.
|
||||
async function branchBase(git) {
|
||||
const candidates = []
|
||||
|
||||
try {
|
||||
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
|
||||
|
||||
if (head) {
|
||||
candidates.push(head)
|
||||
}
|
||||
} catch {
|
||||
// No origin/HEAD configured.
|
||||
}
|
||||
|
||||
candidates.push('origin/main', 'origin/master', 'main', 'master')
|
||||
|
||||
for (const ref of candidates) {
|
||||
try {
|
||||
const base = (await git.raw(['merge-base', 'HEAD', ref])).trim()
|
||||
|
||||
if (base) {
|
||||
return base
|
||||
}
|
||||
} catch {
|
||||
// Ref doesn't exist; try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve the repo's default branch NAME ("main" / "master" / …), preferring
|
||||
// the remote's HEAD, then common local trunk names. Null when none is found
|
||||
// (e.g. a fresh repo with only a feature branch). Used to offer "branch off the
|
||||
// trunk" regardless of which branch you're currently on.
|
||||
async function defaultBranchName(git) {
|
||||
try {
|
||||
const head = (await git.revparse(['--abbrev-ref', 'origin/HEAD'])).trim()
|
||||
|
||||
// "origin/main" → "main"; skip the bare "origin/HEAD" placeholder.
|
||||
if (head && head !== 'origin/HEAD') {
|
||||
return head.replace(/^origin\//, '')
|
||||
}
|
||||
} catch {
|
||||
// No origin/HEAD configured.
|
||||
}
|
||||
|
||||
// Prefer a local trunk, then a remote-only one (returns the clean name either
|
||||
// way) so "branch off main" works even before main is checked out locally.
|
||||
for (const ref of ['refs/heads/main', 'refs/heads/master', 'refs/remotes/origin/main', 'refs/remotes/origin/master']) {
|
||||
try {
|
||||
await git.raw(['rev-parse', '--verify', '--quiet', ref])
|
||||
|
||||
return ref.replace(/^refs\/(?:heads|remotes\/origin)\//, '')
|
||||
} catch {
|
||||
// Ref doesn't exist; try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// A status file's single-letter classification, preferring the staged (index)
|
||||
// code over the worktree code; untracked wins (simple-git marks both '?').
|
||||
function statusLetter(file) {
|
||||
if (file.index === '?' || file.working_dir === '?') {
|
||||
return '?'
|
||||
}
|
||||
|
||||
const code = file.index && file.index !== ' ' ? file.index : file.working_dir
|
||||
|
||||
return (code || 'M').toUpperCase()
|
||||
}
|
||||
|
||||
const isStaged = file => Boolean(file.index && file.index !== ' ' && file.index !== '?')
|
||||
|
||||
async function reviewList(repoPath, scope, baseRef, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review list' })
|
||||
} catch {
|
||||
return { files: [], base: null }
|
||||
}
|
||||
|
||||
const git = gitFor(cwd, gitBin)
|
||||
|
||||
try {
|
||||
if (scope === 'branch' || scope === 'lastTurn') {
|
||||
const base = scope === 'branch' ? await branchBase(git) : baseRef
|
||||
|
||||
if (!base) {
|
||||
return { files: [], base: null }
|
||||
}
|
||||
|
||||
const range = scope === 'branch' ? `${base}...HEAD` : base
|
||||
const summary = await git.diffSummary([range])
|
||||
const files = summary.files.map(file => ({
|
||||
path: resolveRenamePath(file.file),
|
||||
added: file.binary ? 0 : file.insertions,
|
||||
removed: file.binary ? 0 : file.deletions,
|
||||
status: 'M',
|
||||
staged: false
|
||||
}))
|
||||
|
||||
// "Last turn" also surfaces files created since the baseline (untracked).
|
||||
if (scope === 'lastTurn') {
|
||||
const status = await git.status()
|
||||
|
||||
for (const path of status.not_added) {
|
||||
if (!files.some(f => f.path === path)) {
|
||||
files.push({ path, added: 0, removed: 0, status: '?', staged: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files.sort((a, b) => a.path.localeCompare(b.path))
|
||||
await fillUntrackedCounts(cwd, files)
|
||||
|
||||
return { files, base }
|
||||
}
|
||||
|
||||
// Default: uncommitted (staged + unstaged + untracked), one row per path.
|
||||
const [status, staged, unstaged] = await Promise.all([
|
||||
git.status(),
|
||||
git.diffSummary(['--cached']),
|
||||
git.diffSummary([])
|
||||
])
|
||||
const stagedCounts = countsByPath(staged)
|
||||
const unstagedCounts = countsByPath(unstaged)
|
||||
|
||||
const files = status.files.map(file => {
|
||||
const filePath = resolveRenamePath(file.path)
|
||||
const sc = stagedCounts.get(filePath) || { added: 0, removed: 0 }
|
||||
const uc = unstagedCounts.get(filePath) || { added: 0, removed: 0 }
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
added: sc.added + uc.added,
|
||||
removed: sc.removed + uc.removed,
|
||||
status: statusLetter(file),
|
||||
staged: isStaged(file)
|
||||
}
|
||||
})
|
||||
|
||||
files.sort((a, b) => a.path.localeCompare(b.path))
|
||||
await fillUntrackedCounts(cwd, files)
|
||||
|
||||
return { files, base: null }
|
||||
} catch {
|
||||
return { files: [], base: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewDiff(repoPath, filePath, scope, baseRef, staged, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review diff' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const safe = args => git.diff(args).catch(() => '')
|
||||
|
||||
if (scope === 'branch') {
|
||||
const base = await branchBase(git)
|
||||
|
||||
return base ? safe([`${base}...HEAD`, '--', filePath]) : ''
|
||||
}
|
||||
|
||||
if (scope === 'lastTurn') {
|
||||
return baseRef ? safe([baseRef, '--', filePath]) : ''
|
||||
}
|
||||
|
||||
if (staged) {
|
||||
return safe(['--cached', '--', filePath])
|
||||
}
|
||||
|
||||
const worktree = await safe(['--', filePath])
|
||||
|
||||
if (worktree.trim()) {
|
||||
return worktree
|
||||
}
|
||||
|
||||
// Untracked file: no worktree diff exists, so synthesize an all-add diff via
|
||||
// --no-index (exits non-zero by design when files differ, so go around
|
||||
// simple-git's reject-on-nonzero with a raw execFile).
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
gitBin || 'git',
|
||||
['diff', '--no-index', '--', '/dev/null', filePath],
|
||||
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
|
||||
(_err, stdout) => resolve(String(stdout || ''))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Working-tree-vs-HEAD diff for ONE file — the "what changed since the last
|
||||
// commit" view used by the file preview. Unlike reviewDiff this never synthesizes
|
||||
// a full-add for a clean tracked file (so a pristine file shows no diff); it only
|
||||
// all-adds a genuinely untracked file.
|
||||
async function fileDiffVsHead(repoPath, filePath, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'File diff' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const head = await git.diff(['HEAD', '--', filePath]).catch(() => '')
|
||||
|
||||
if (head.trim()) {
|
||||
return head
|
||||
}
|
||||
|
||||
// No tracked changes vs HEAD. Only synthesize an all-add diff for a file git
|
||||
// doesn't know yet; a clean tracked file must return empty.
|
||||
const status = await git.raw(['status', '--porcelain', '--', filePath]).catch(() => '')
|
||||
|
||||
if (!status.trim().startsWith('??')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
execFile(
|
||||
gitBin || 'git',
|
||||
['diff', '--no-index', '--', '/dev/null', filePath],
|
||||
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 32 * 1024 * 1024 },
|
||||
(_err, stdout) => resolve(String(stdout || ''))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function reviewStage(repoPath, filePath, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review stage' })
|
||||
|
||||
await gitFor(cwd, gitBin).raw(filePath ? ['add', '--', filePath] : ['add', '-A'])
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function reviewUnstage(repoPath, filePath, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review unstage' })
|
||||
|
||||
await gitFor(cwd, gitBin).raw(filePath ? ['reset', '-q', 'HEAD', '--', filePath] : ['reset', '-q', 'HEAD'])
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Discard changes back to the committed state. Destructive — the renderer
|
||||
// confirms first. Restores tracked files and removes untracked ones.
|
||||
async function reviewRevert(repoPath, filePath, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review revert' })
|
||||
const git = gitFor(cwd, gitBin)
|
||||
|
||||
if (filePath) {
|
||||
await git.raw(['checkout', 'HEAD', '--', filePath]).catch(() => undefined)
|
||||
await git.raw(['clean', '-fd', '--', filePath]).catch(() => undefined)
|
||||
} else {
|
||||
await git.raw(['checkout', 'HEAD', '--', '.']).catch(() => undefined)
|
||||
await git.raw(['clean', '-fd']).catch(() => undefined)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Resolve a ref to a commit sha (captures the turn baseline for "Last turn").
|
||||
async function reviewRevParse(repoPath, ref, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review rev-parse' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return (await gitFor(cwd, gitBin).revparse([ref || 'HEAD'])).trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the working tree. Mirrors VS Code: if nothing is staged, stage
|
||||
// everything first ("commit all"), then commit. Optionally push afterward,
|
||||
// setting upstream on the first push.
|
||||
async function reviewCommit(repoPath, message, push, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit' })
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const status = await git.status()
|
||||
|
||||
if (status.staged.length === 0) {
|
||||
await git.raw(['add', '-A'])
|
||||
}
|
||||
|
||||
await git.commit(message)
|
||||
|
||||
if (push) {
|
||||
const fresh = await git.status()
|
||||
|
||||
if (fresh.tracking) {
|
||||
await git.push()
|
||||
} else if (fresh.current) {
|
||||
await git.raw(['push', '-u', 'origin', fresh.current])
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Gather the context the model needs to draft a commit message: the diff of
|
||||
// what *will* be committed (staged when anything is staged, else everything
|
||||
// vs HEAD — mirroring reviewCommit's "stage all when nothing staged" rule),
|
||||
// the names of untracked files (which carry no diff), and recent commit
|
||||
// subjects for style. Diff is capped so the payload stays bounded. Reads only.
|
||||
async function reviewCommitContext(repoPath, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review commit context' })
|
||||
} catch {
|
||||
return { diff: '', recent: '' }
|
||||
}
|
||||
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const safe = args => git.diff(args).catch(() => '')
|
||||
|
||||
let status
|
||||
try {
|
||||
status = await git.status()
|
||||
} catch {
|
||||
return { diff: '', recent: '' }
|
||||
}
|
||||
|
||||
// What will land: staged changes if any, otherwise all tracked changes vs HEAD.
|
||||
let diff = capText(
|
||||
status.staged.length > 0 ? await safe(['--cached']) : await safe(['HEAD']),
|
||||
COMMIT_CONTEXT_DIFF_MAX_CHARS,
|
||||
'diff truncated for commit-message generation'
|
||||
)
|
||||
|
||||
// Untracked files have no diff — list them so new files aren't invisible.
|
||||
const untracked = status.not_added || []
|
||||
if (untracked.length > 0) {
|
||||
const visible = untracked.slice(0, COMMIT_CONTEXT_UNTRACKED_MAX)
|
||||
const omitted = untracked.length - visible.length
|
||||
const note =
|
||||
`\n# New (untracked) files:\n${visible.map(p => `# ${p}`).join('\n')}\n` +
|
||||
(omitted > 0 ? `# ... ${omitted} more omitted\n` : '')
|
||||
|
||||
diff = diff ? `${diff}${note}` : note
|
||||
}
|
||||
|
||||
const recent = await git.raw(['log', '-n', '10', '--pretty=format:%s']).catch(() => '')
|
||||
|
||||
return { diff: diff || '', recent: String(recent || '').trim() }
|
||||
}
|
||||
|
||||
async function reviewPush(repoPath, gitBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review push' })
|
||||
const git = gitFor(cwd, gitBin)
|
||||
const status = await git.status()
|
||||
|
||||
if (status.tracking) {
|
||||
await git.push()
|
||||
} else if (status.current) {
|
||||
await git.raw(['push', '-u', 'origin', status.current])
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// gh availability + auth + whether this branch already has a PR. Reads only;
|
||||
// drives the PR button's enabled/label state. `ghReady` is false when gh is
|
||||
// missing OR not authenticated — either way the PR action can't run.
|
||||
async function reviewShipInfo(repoPath, ghBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review ship info' })
|
||||
} catch {
|
||||
return { ghReady: false, pr: null }
|
||||
}
|
||||
|
||||
const auth = await runGh(['auth', 'status'], cwd, ghBin)
|
||||
|
||||
if (!auth.ok) {
|
||||
return { ghReady: false, pr: null }
|
||||
}
|
||||
|
||||
const view = await runGh(['pr', 'view', '--json', 'url,state,number'], cwd, ghBin)
|
||||
|
||||
if (!view.ok) {
|
||||
// gh exits non-zero when no PR exists for the branch — that's not an error.
|
||||
return { ghReady: true, pr: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const pr = JSON.parse(view.stdout)
|
||||
|
||||
return { ghReady: true, pr: pr && pr.url ? { url: pr.url, state: pr.state, number: pr.number } : null }
|
||||
} catch {
|
||||
return { ghReady: true, pr: null }
|
||||
}
|
||||
}
|
||||
|
||||
// Create a PR for the current branch (pushing first so gh has a remote ref),
|
||||
// letting gh fill title/body from the commits. Returns the new PR url.
|
||||
async function reviewCreatePr(repoPath, gitBin, ghBin) {
|
||||
const cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Review create PR' })
|
||||
|
||||
await reviewPush(repoPath, gitBin).catch(() => undefined)
|
||||
|
||||
const created = await runGh(['pr', 'create', '--fill'], cwd, ghBin)
|
||||
|
||||
if (!created.ok) {
|
||||
throw new Error('gh pr create failed (is gh installed and authenticated?)')
|
||||
}
|
||||
|
||||
const url = created.stdout.trim().split('\n').filter(Boolean).pop() || ''
|
||||
|
||||
return { url }
|
||||
}
|
||||
|
||||
// Compact working-tree status for the composer coding rail: branch, ahead/behind,
|
||||
// per-state change counts, +/- vs HEAD, and a capped changed-file list.
|
||||
async function repoStatus(repoPath, gitBin) {
|
||||
let cwd
|
||||
|
||||
try {
|
||||
cwd = resolveRequestedPathForIpc(repoPath, { purpose: 'Repo status' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
// Session cwds can point at a deleted worktree for a moment (or forever in a
|
||||
// stale row). simple-git throws at construction time on a missing baseDir, so
|
||||
// fail soft and hide the coding rail instead of spamming IPC handler errors.
|
||||
try {
|
||||
const stat = await fs.stat(cwd)
|
||||
if (!stat.isDirectory()) {
|
||||
return null
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let git
|
||||
try {
|
||||
git = gitFor(cwd, gitBin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
let status
|
||||
|
||||
try {
|
||||
status = await git.status()
|
||||
} catch {
|
||||
// Not a repo / git unavailable / remote backend.
|
||||
return null
|
||||
}
|
||||
|
||||
const detached = typeof status.detached === 'boolean' ? status.detached : !status.current
|
||||
const files = status.files.map(file => ({
|
||||
path: file.path,
|
||||
staged: isStaged(file),
|
||||
unstaged: Boolean(file.working_dir && file.working_dir !== ' ' && file.working_dir !== '?'),
|
||||
untracked: file.index === '?' || file.working_dir === '?',
|
||||
conflicted: file.index === 'U' || file.working_dir === 'U'
|
||||
}))
|
||||
|
||||
const result = {
|
||||
branch: detached ? null : status.current || null,
|
||||
defaultBranch: await defaultBranchName(git),
|
||||
detached,
|
||||
ahead: status.ahead || 0,
|
||||
behind: status.behind || 0,
|
||||
staged: files.filter(f => f.staged).length,
|
||||
unstaged: files.filter(f => f.unstaged).length,
|
||||
untracked: status.not_added.length,
|
||||
conflicted: status.conflicted.length,
|
||||
changed: files.length,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
files: files.slice(0, 200)
|
||||
}
|
||||
|
||||
// +/- vs HEAD (staged + unstaged tracked changes). No HEAD yet → leave 0.
|
||||
try {
|
||||
const summary = await git.diffSummary(['HEAD'])
|
||||
result.added = summary.insertions
|
||||
result.removed = summary.deletions
|
||||
} catch {
|
||||
// No commits yet.
|
||||
}
|
||||
|
||||
// `git diff HEAD` ignores untracked files, so a turn that only creates new
|
||||
// files (the common case — a fresh module, a demo dir) showed +0 in the rail
|
||||
// while the review pane counted them. Fold untracked insertions into `added`
|
||||
// so the rail matches reality. Bounded (size cap + concurrency) like the
|
||||
// review tree; only the capped file slice is counted so a huge untracked tree
|
||||
// can't stall the probe.
|
||||
try {
|
||||
const untracked = status.not_added.slice(0, 500)
|
||||
for (let i = 0; i < untracked.length; i += UNTRACKED_LINE_COUNT_CONCURRENCY) {
|
||||
const batch = await Promise.all(
|
||||
untracked.slice(i, i + UNTRACKED_LINE_COUNT_CONCURRENCY).map(path => untrackedInsertions(cwd, path))
|
||||
)
|
||||
result.added += batch.reduce((sum, n) => sum + n, 0)
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: a probe failure just leaves untracked lines uncounted.
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
branchBase,
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
resolveRenamePath,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
}
|
||||
22
apps/desktop/electron/git-review-ops.test.cjs
Normal file
22
apps/desktop/electron/git-review-ops.test.cjs
Normal file
@@ -0,0 +1,22 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { resolveRenamePath } = require('./git-review-ops.cjs')
|
||||
|
||||
test('resolveRenamePath: plain path is unchanged', () => {
|
||||
assert.equal(resolveRenamePath('src/a.ts'), 'src/a.ts')
|
||||
})
|
||||
|
||||
test('resolveRenamePath: simple rename resolves to the new path', () => {
|
||||
assert.equal(resolveRenamePath('old.ts => new.ts'), 'new.ts')
|
||||
})
|
||||
|
||||
test('resolveRenamePath: brace rename resolves to the new path', () => {
|
||||
assert.equal(resolveRenamePath('src/{old => new}/file.ts'), 'src/new/file.ts')
|
||||
})
|
||||
|
||||
test('resolveRenamePath: brace rename collapsing a segment', () => {
|
||||
assert.equal(resolveRenamePath('src/{lib => }/file.ts'), 'src/file.ts')
|
||||
})
|
||||
241
apps/desktop/electron/git-worktree-ops.cjs
Normal file
241
apps/desktop/electron/git-worktree-ops.cjs
Normal file
@@ -0,0 +1,241 @@
|
||||
'use strict'
|
||||
|
||||
// Git-driven worktree operations for the desktop "Start work" flow: spin up a
|
||||
// fresh worktree the lightest way (`git worktree add -b`), list real worktrees,
|
||||
// and remove them. Git is the source of truth; the renderer just drives these.
|
||||
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const { execFile } = require('node:child_process')
|
||||
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
function runGit(gitBin, args, cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
gitBin,
|
||||
args,
|
||||
{ cwd, windowsHide: true, timeout: 30_000, maxBuffer: 8 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
err.stderr = String(stderr || '')
|
||||
reject(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolve(String(stdout || ''))
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Parse `git worktree list --porcelain`. The first record is the main worktree.
|
||||
function parseWorktrees(out) {
|
||||
const trees = []
|
||||
let cur = null
|
||||
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (cur) {
|
||||
trees.push(cur)
|
||||
}
|
||||
|
||||
cur = { path: line.slice(9).trim(), branch: null, detached: false, bare: false, locked: false }
|
||||
} else if (!cur) {
|
||||
continue
|
||||
} else if (line.startsWith('branch ')) {
|
||||
cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '')
|
||||
} else if (line === 'detached') {
|
||||
cur.detached = true
|
||||
} else if (line === 'bare') {
|
||||
cur.bare = true
|
||||
} else if (line.startsWith('locked')) {
|
||||
cur.locked = true
|
||||
}
|
||||
}
|
||||
|
||||
if (cur) {
|
||||
trees.push(cur)
|
||||
}
|
||||
|
||||
return trees
|
||||
}
|
||||
|
||||
async function listWorktrees(repoPath, gitBin) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree list' })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runGit(gitBin, ['worktree', 'list', '--porcelain'], resolved)
|
||||
|
||||
return parseWorktrees(out).map((tree, index) => ({
|
||||
path: tree.path,
|
||||
branch: tree.branch,
|
||||
isMain: index === 0,
|
||||
detached: tree.detached,
|
||||
locked: tree.locked
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// A git-ref-safe branch name (spaces → "-", drop forbidden chars, trim edges),
|
||||
// or "" when nothing usable remains. Mirrors the renderer's `gitRef`, so a bad
|
||||
// value can't reach `git` no matter the caller (the GUI also enforces live).
|
||||
function sanitizeBranch(name) {
|
||||
return String(name || '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w./-]/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/\/{2,}/g, '/')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[-./]+|[-./]+$/g, '')
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
const slug = String(name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40)
|
||||
.replace(/-+$/g, '')
|
||||
|
||||
return slug || 'work'
|
||||
}
|
||||
|
||||
// A brand-new project folder isn't a git repo — and a freshly-init'd one has no
|
||||
// commit to branch from — so `git worktree add` would fail. Make the dir a repo
|
||||
// with a root commit on the user's behalf so worktrees "just work". No-op for a
|
||||
// repo that already has commits; never touches the user's files (the seed commit
|
||||
// is `--allow-empty`), and never inits a dir that already lives inside a repo.
|
||||
async function ensureGitRepo(gitBin, dir) {
|
||||
let needsRoot = false
|
||||
|
||||
try {
|
||||
const inside = (await runGit(gitBin, ['rev-parse', '--is-inside-work-tree'], dir)).trim()
|
||||
|
||||
if (inside !== 'true') {
|
||||
await runGit(gitBin, ['init'], dir)
|
||||
needsRoot = true
|
||||
} else {
|
||||
// Repo exists; a worktree still needs a HEAD to branch from.
|
||||
try {
|
||||
await runGit(gitBin, ['rev-parse', '--verify', 'HEAD'], dir)
|
||||
} catch {
|
||||
needsRoot = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await runGit(gitBin, ['init'], dir)
|
||||
needsRoot = true
|
||||
}
|
||||
|
||||
if (needsRoot) {
|
||||
// Inline identity so the seed commit lands even with no global git config.
|
||||
await runGit(
|
||||
gitBin,
|
||||
['-c', 'user.email=hermes@localhost', '-c', 'user.name=Hermes', 'commit', '--allow-empty', '-m', 'Initial commit'],
|
||||
dir
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the repo's MAIN worktree root, so `.worktrees/` always nests under the
|
||||
// primary checkout even when called from a linked worktree.
|
||||
async function mainRoot(gitBin, cwd) {
|
||||
const list = await listWorktrees(cwd, gitBin)
|
||||
const main = list.find(tree => tree.isMain)
|
||||
|
||||
return main ? main.path : cwd
|
||||
}
|
||||
|
||||
function uniqueDir(base) {
|
||||
let dir = base
|
||||
let n = 1
|
||||
|
||||
while (fs.existsSync(dir)) {
|
||||
n += 1
|
||||
dir = `${base}-${n}`
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
async function addWorktree(repoPath, options, gitBin) {
|
||||
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' })
|
||||
// A new project's folder may not be a git repo yet — init it (with a root
|
||||
// commit) so the worktree has something to branch from.
|
||||
await ensureGitRepo(gitBin, resolved)
|
||||
const root = await mainRoot(gitBin, resolved)
|
||||
const opts = options || {}
|
||||
const slug = slugify(opts.name || `work-${Date.now().toString(36)}`)
|
||||
const branch = sanitizeBranch(opts.branch) || `hermes/${slug}`
|
||||
const dir = uniqueDir(path.join(root, '.worktrees', slug))
|
||||
|
||||
const args = ['worktree', 'add', '-b', branch, dir]
|
||||
|
||||
if (opts.base) {
|
||||
args.push(String(opts.base))
|
||||
}
|
||||
|
||||
try {
|
||||
await runGit(gitBin, args, root)
|
||||
} catch (err) {
|
||||
// Branch name may already exist — retry checking out the existing branch
|
||||
// into a fresh worktree dir instead of failing the whole flow.
|
||||
if (/already exists/i.test(err.stderr || '')) {
|
||||
await runGit(gitBin, ['worktree', 'add', dir, branch], root)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return { path: dir, branch, repoRoot: root }
|
||||
}
|
||||
|
||||
async function removeWorktree(repoPath, worktreePath, options, gitBin) {
|
||||
const resolvedRepo = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree remove (repo)' })
|
||||
const resolvedTree = resolveRequestedPathForIpc(worktreePath, { purpose: 'Worktree remove (tree)' })
|
||||
const root = await mainRoot(gitBin, resolvedRepo)
|
||||
const args = ['worktree', 'remove']
|
||||
|
||||
if (options && options.force) {
|
||||
args.push('--force')
|
||||
}
|
||||
|
||||
args.push(resolvedTree)
|
||||
await runGit(gitBin, args, root)
|
||||
|
||||
return { removed: resolvedTree }
|
||||
}
|
||||
|
||||
async function switchBranch(repoPath, branch, gitBin) {
|
||||
const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Branch switch' })
|
||||
const target = sanitizeBranch(branch)
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Branch name is required.')
|
||||
}
|
||||
|
||||
await runGit(gitBin, ['switch', target], resolved)
|
||||
|
||||
return { branch: target }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addWorktree,
|
||||
ensureGitRepo,
|
||||
listWorktrees,
|
||||
parseWorktrees,
|
||||
removeWorktree,
|
||||
sanitizeBranch,
|
||||
switchBranch
|
||||
}
|
||||
88
apps/desktop/electron/git-worktree-ops.test.cjs
Normal file
88
apps/desktop/electron/git-worktree-ops.test.cjs
Normal file
@@ -0,0 +1,88 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { ensureGitRepo, parseWorktrees, sanitizeBranch, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
|
||||
test('sanitizeBranch: spaces → hyphens, forbidden chars dropped, edges trimmed', () => {
|
||||
assert.equal(sanitizeBranch('beach vibes'), 'beach-vibes')
|
||||
assert.equal(sanitizeBranch('feat/cool thing'), 'feat/cool-thing')
|
||||
assert.equal(sanitizeBranch(' wip~^:? '), 'wip')
|
||||
assert.equal(sanitizeBranch('///'), '')
|
||||
})
|
||||
|
||||
test('parseWorktrees: main checkout + linked worktree', () => {
|
||||
const out = [
|
||||
'worktree /repo',
|
||||
'HEAD abc123',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /repo/.worktrees/feat',
|
||||
'HEAD def456',
|
||||
'branch refs/heads/hermes/feat',
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
const trees = parseWorktrees(out)
|
||||
|
||||
assert.equal(trees.length, 2)
|
||||
assert.equal(trees[0].path, '/repo')
|
||||
assert.equal(trees[0].branch, 'main')
|
||||
assert.equal(trees[1].path, '/repo/.worktrees/feat')
|
||||
assert.equal(trees[1].branch, 'hermes/feat')
|
||||
})
|
||||
|
||||
test('parseWorktrees: detached + locked flags', () => {
|
||||
const out = ['worktree /repo/wt', 'HEAD abc', 'detached', 'locked reason', ''].join('\n')
|
||||
const trees = parseWorktrees(out)
|
||||
|
||||
assert.equal(trees.length, 1)
|
||||
assert.equal(trees[0].detached, true)
|
||||
assert.equal(trees[0].locked, true)
|
||||
assert.equal(trees[0].branch, null)
|
||||
})
|
||||
|
||||
test('parseWorktrees: empty input', () => {
|
||||
assert.deepEqual(parseWorktrees(''), [])
|
||||
})
|
||||
|
||||
test('ensureGitRepo: inits a plain dir with a root commit so worktrees branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-wt-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
assert.match(git('rev-parse', '--verify', 'HEAD'), /^[0-9a-f]{7,}$/)
|
||||
|
||||
// The whole point: a worktree can now branch off the seeded root commit.
|
||||
execFileSync('git', ['worktree', 'add', '-b', 'wt', path.join(dir, '.worktrees', 'wt')], { cwd: dir })
|
||||
assert.ok(fs.existsSync(path.join(dir, '.worktrees', 'wt')))
|
||||
|
||||
// Idempotent: an already-committed repo gets no extra commit.
|
||||
await ensureGitRepo('git', dir)
|
||||
assert.equal(git('rev-list', '--count', 'HEAD'), '1')
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('switchBranch: switches a normal checkout branch', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-switch-'))
|
||||
const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim()
|
||||
|
||||
try {
|
||||
await ensureGitRepo('git', dir)
|
||||
execFileSync('git', ['branch', 'feature'], { cwd: dir })
|
||||
|
||||
await switchBranch(dir, 'feature', 'git')
|
||||
|
||||
assert.equal(git('branch', '--show-current'), 'feature')
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,174 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// Resolve git-worktree relationships for a set of session cwds, reading git's
|
||||
// on-disk metadata directly (no `git` spawn per path):
|
||||
//
|
||||
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
|
||||
// worktree; its repo root IS that directory's parent.
|
||||
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
|
||||
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
|
||||
// parent is the main repo root.
|
||||
//
|
||||
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
|
||||
// linked worktrees, regardless of how the worktree directories are named. The
|
||||
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
|
||||
// label.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
|
||||
// (file for a linked worktree, dir for the main checkout). Capped so a stray
|
||||
// path can't loop forever.
|
||||
function findGitHost(start, fsImpl) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
const dotgit = path.join(dir, '.git')
|
||||
|
||||
try {
|
||||
if (fsImpl.existsSync(dotgit)) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readBranch(gitDir, fsImpl) {
|
||||
try {
|
||||
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
|
||||
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
|
||||
|
||||
if (ref) {
|
||||
return ref[1]
|
||||
}
|
||||
|
||||
// Detached HEAD: surface a short sha so the worktree still gets a label.
|
||||
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Given the directory that owns the `.git` entry, resolve its worktree identity.
|
||||
function resolveFromHost(host, fsImpl) {
|
||||
const dotgit = path.join(host, '.git')
|
||||
let stat
|
||||
|
||||
try {
|
||||
stat = fsImpl.statSync(dotgit)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return {
|
||||
repoRoot: host,
|
||||
worktreeRoot: host,
|
||||
isMainWorktree: true,
|
||||
branch: readBranch(dotgit, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
// Linked worktree: `.git` is a file pointing at the admin dir.
|
||||
let contents
|
||||
|
||||
try {
|
||||
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = contents.match(/^gitdir:\s*(.+)$/m)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const adminDir = path.resolve(host, match[1].trim())
|
||||
|
||||
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
|
||||
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
|
||||
let commonDir
|
||||
|
||||
try {
|
||||
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
|
||||
commonDir = path.resolve(adminDir, rel)
|
||||
} catch {
|
||||
commonDir = path.dirname(path.dirname(adminDir))
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot: path.dirname(commonDir),
|
||||
worktreeRoot: host,
|
||||
isMainWorktree: false,
|
||||
branch: readBranch(adminDir, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorktree(startPath, fsImpl = fs) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let start = resolved
|
||||
|
||||
try {
|
||||
const stat = fsImpl.statSync(resolved)
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
start = path.dirname(resolved)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = findGitHost(start, fsImpl)
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveFromHost(host, fsImpl)
|
||||
}
|
||||
|
||||
// Batch entry point for the renderer: maps each requested cwd to its worktree
|
||||
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
|
||||
// many sessions sharing a cwd cost one lookup.
|
||||
async function worktreesForIpc(cwds, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
const list = Array.isArray(cwds) ? cwds : []
|
||||
const out = {}
|
||||
|
||||
for (const cwd of list) {
|
||||
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
|
||||
continue
|
||||
}
|
||||
|
||||
out[cwd] = resolveWorktree(cwd, fsImpl)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveWorktree,
|
||||
worktreesForIpc
|
||||
}
|
||||
@@ -54,7 +54,23 @@ const {
|
||||
buildRelaunchScript
|
||||
} = require('./update-relaunch.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
const { addWorktree, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
const {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} = require('./git-review-ops.cjs')
|
||||
const { scanGitRepos } = require('./git-repo-scan.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
const {
|
||||
@@ -944,6 +960,33 @@ function openExternalUrl(rawUrl) {
|
||||
return true
|
||||
}
|
||||
|
||||
async function openPreviewInBrowser(rawUrl) {
|
||||
const raw = String(rawUrl || '').trim()
|
||||
if (!raw) return false
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
if (parsed.protocol === 'file:') {
|
||||
let localPath
|
||||
try {
|
||||
localPath = resolveRequestedPathForIpc(parsed.toString(), { purpose: 'Open preview in browser' })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
await shell.openExternal(pathToFileURL(localPath).toString())
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return openExternalUrl(raw)
|
||||
}
|
||||
|
||||
function ensureWslWindowsFonts() {
|
||||
if (!IS_WSL) return
|
||||
|
||||
@@ -1466,6 +1509,30 @@ function resolveGitBinary() {
|
||||
return _gitBinaryCache
|
||||
}
|
||||
|
||||
// resolveGhBinary — locate the GitHub CLI. GUI-launched apps get a minimal PATH
|
||||
// that omits Homebrew (/opt/homebrew/bin, /usr/local/bin) where `gh` usually
|
||||
// lives, so a bare spawn('gh') ENOENTs even though `gh` works in the user's
|
||||
// terminal. Check the common install locations first, then PATH. Cached.
|
||||
let _ghBinaryCache = null
|
||||
function resolveGhBinary() {
|
||||
if (_ghBinaryCache) return _ghBinaryCache
|
||||
|
||||
const candidates = []
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
candidates.push(path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'GitHub CLI', 'gh.exe'))
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
candidates.push(path.join(process.env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links', 'gh.exe'))
|
||||
}
|
||||
} else {
|
||||
const home = app.getPath('home')
|
||||
candidates.push('/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh', path.join(home, '.local', 'bin', 'gh'))
|
||||
}
|
||||
|
||||
_ghBinaryCache = candidates.find(fileExists) || findOnPath('gh') || 'gh'
|
||||
return _ghBinaryCache
|
||||
}
|
||||
|
||||
function recentHermesLog() {
|
||||
return hermesLog.slice(-20).join('\n')
|
||||
}
|
||||
@@ -2834,7 +2901,6 @@ async function ensureRuntime(backend) {
|
||||
return backend
|
||||
}
|
||||
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
@@ -5998,6 +6064,12 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:openPreviewInBrowser', async (_event, url) => {
|
||||
if (!(await openPreviewInBrowser(url))) {
|
||||
throw new Error('Invalid preview URL')
|
||||
}
|
||||
})
|
||||
|
||||
// User-configurable default project directory. The renderer reads this on
|
||||
// settings mount and seeds the value into the picker; writing back persists
|
||||
// it via writeDefaultProjectDir so resolveHermesCwd picks it up on the next
|
||||
@@ -6243,7 +6315,160 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
|
||||
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
|
||||
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) =>
|
||||
listWorktrees(repoPath, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
|
||||
@@ -44,6 +44,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd),
|
||||
settings: {
|
||||
@@ -55,7 +56,34 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
|
||||
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),
|
||||
renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName),
|
||||
writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content),
|
||||
trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath),
|
||||
git: {
|
||||
worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath),
|
||||
worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options),
|
||||
worktreeRemove: (repoPath, worktreePath, options) =>
|
||||
ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options),
|
||||
branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch),
|
||||
repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath),
|
||||
fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath),
|
||||
scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options),
|
||||
review: {
|
||||
list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef),
|
||||
diff: (repoPath, filePath, scope, baseRef, staged) =>
|
||||
ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged),
|
||||
stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath),
|
||||
unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath),
|
||||
revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath),
|
||||
revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref),
|
||||
commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push),
|
||||
commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath),
|
||||
push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath),
|
||||
shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath),
|
||||
createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath)
|
||||
}
|
||||
},
|
||||
terminal: {
|
||||
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
||||
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.3.0",
|
||||
"shiki": "^4.0.2",
|
||||
"simple-git": "^3.36.0",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
|
||||
33
apps/desktop/scripts/bundle-electron-main.mjs
Normal file
33
apps/desktop/scripts/bundle-electron-main.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// bundle-electron-main.mjs — bundles electron/main.cjs into a single
|
||||
// self-contained file so the nix build doesn't need to ship node_modules/.
|
||||
//
|
||||
// `electron` is provided by the runtime; `node-pty` is staged separately
|
||||
// via stage-native-deps.cjs. `preload.cjs` is NOT require()'d by main —
|
||||
// Electron loads it via path.join(__dirname, 'preload.cjs') — so it stays
|
||||
// as a separate file and doesn't need bundling.
|
||||
import { build } from 'esbuild'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { renameSync } from 'node:fs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(here, '..')
|
||||
const entry = resolve(root, 'electron/main.cjs')
|
||||
const tmp = resolve(root, 'electron/main.bundled.cjs')
|
||||
|
||||
await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node20',
|
||||
outfile: tmp,
|
||||
external: ['electron', 'node-pty'],
|
||||
logLevel: 'info'
|
||||
})
|
||||
|
||||
// Overwrite the original with the bundled version.
|
||||
renameSync(tmp, entry)
|
||||
|
||||
console.log(`bundled ${entry}`)
|
||||
@@ -4,9 +4,10 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
@@ -212,7 +213,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<div className="grid place-items-center gap-3 py-12 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/60" />
|
||||
<Codicon className="text-muted-foreground/60" name="hubot" size="1.5rem" />
|
||||
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
|
||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -42,22 +43,23 @@ export function ContextMenu({
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<Tip label={state.tools.label} side="top">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="start" className={cn('w-60', composerPanelCard)} side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
||||
{c.attachLabel}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
export type ComposerInsertMode = 'block' | 'inline'
|
||||
@@ -34,6 +34,12 @@ interface InsertRefsDetail {
|
||||
const FOCUS_EVENT = 'hermes:composer-focus'
|
||||
const INSERT_EVENT = 'hermes:composer-insert'
|
||||
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
||||
const SUBMIT_EVENT = 'hermes:composer-submit'
|
||||
|
||||
interface SubmitDetail {
|
||||
target: ComposerTarget
|
||||
text: string
|
||||
}
|
||||
|
||||
let activeTarget: ComposerTarget = 'main'
|
||||
|
||||
@@ -105,6 +111,23 @@ export const requestComposerInsertRefs = (
|
||||
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
|
||||
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
|
||||
|
||||
/** Submit a prompt through a composer as if the user typed + sent it. Lets
|
||||
* external panels (e.g. the review pane's "let the agent ship it" button) hand
|
||||
* the agent a task without the user round-tripping through the input. */
|
||||
export const requestComposerSubmit = (
|
||||
text: string,
|
||||
{ target = 'active' }: { target?: ComposerTarget | 'active' } = {}
|
||||
) => {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (trimmed) {
|
||||
dispatch<SubmitDetail>(SUBMIT_EVENT, { target: resolve(target), text: trimmed })
|
||||
}
|
||||
}
|
||||
|
||||
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
|
||||
subscribe<SubmitDetail>(SUBMIT_EVENT, handler)
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
*
|
||||
|
||||
@@ -45,8 +45,8 @@ import {
|
||||
$composerPoppedOut,
|
||||
POPOUT_WIDTH_REM,
|
||||
readPopoutBounds,
|
||||
setComposerPoppedOut,
|
||||
setComposerPopoutPosition
|
||||
setComposerPopoutPosition,
|
||||
setComposerPoppedOut
|
||||
} from '@/store/composer-popout'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
@@ -61,6 +61,9 @@ import {
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $previewStatusBySession } from '@/store/preview-status'
|
||||
import { requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
@@ -78,7 +81,8 @@ import {
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRefsRequest,
|
||||
onComposerInsertRequest
|
||||
onComposerInsertRequest,
|
||||
onComposerSubmitRequest
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
@@ -106,6 +110,7 @@ import {
|
||||
slashChipElement
|
||||
} from './rich-editor'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { CodingStatusRow } from './status-stack/coding-row'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
@@ -195,6 +200,7 @@ export function ChatBar({
|
||||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const previewStatusBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
// Pop-out is a shared, persisted state — but secondary windows (the Ctrl+Shift+N
|
||||
// tiny window, subagent watch windows) always start docked and can't pop out:
|
||||
@@ -217,8 +223,12 @@ export function ChatBar({
|
||||
|
||||
const statusStackVisible = useMemo(
|
||||
() =>
|
||||
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
|
||||
[queuedPrompts.length, statusItemsBySession, statusSessionId]
|
||||
queuedPrompts.length > 0 ||
|
||||
(statusSessionId
|
||||
? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 ||
|
||||
(previewStatusBySession[statusSessionId]?.length ?? 0) > 0
|
||||
: false),
|
||||
[previewStatusBySession, queuedPrompts.length, statusItemsBySession, statusSessionId]
|
||||
)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
@@ -1318,6 +1328,45 @@ export function ChatBar({
|
||||
}
|
||||
}, [aui])
|
||||
|
||||
// Hand a worktree off to the controller: open a fresh session anchored there,
|
||||
// carrying the composer draft as its first turn. Clearing here means the draft
|
||||
// travels to the new session instead of getting stashed under this one.
|
||||
const openInWorktree = useCallback(
|
||||
(path: string) => {
|
||||
const text = draftRef.current
|
||||
clearDraft()
|
||||
clearComposerAttachments()
|
||||
requestStartWorkSession(path, text)
|
||||
},
|
||||
[clearDraft]
|
||||
)
|
||||
|
||||
// Branch off into a NEW worktree (base = branch name, or current HEAD). A
|
||||
// create failure throws back to the row (which toasts) before we touch the
|
||||
// draft; a missing cwd / remote backend no-ops (the row hides the affordance).
|
||||
const handleBranchOff = useCallback(
|
||||
async (branch: string, base?: string) => {
|
||||
const repoPath = cwd?.trim()
|
||||
const result = repoPath && (await startWorkInRepo(repoPath, { base, branch, name: branch }))
|
||||
|
||||
if (result) {
|
||||
openInWorktree(result.path)
|
||||
}
|
||||
},
|
||||
[cwd, openInWorktree]
|
||||
)
|
||||
|
||||
const handleSwitchBranch = useCallback(
|
||||
async (branch: string) => {
|
||||
const repoPath = cwd?.trim()
|
||||
|
||||
if (repoPath) {
|
||||
await switchBranchInRepo(repoPath, branch)
|
||||
}
|
||||
},
|
||||
[cwd]
|
||||
)
|
||||
|
||||
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
|
||||
draftRef.current = text
|
||||
aui.composer().setText(text)
|
||||
@@ -1641,6 +1690,41 @@ export function ChatBar({
|
||||
}
|
||||
}, [autoDrainNext, busy, queuedPrompts.length])
|
||||
|
||||
// Esc cancels the in-flight turn when the CHAT has focus — not just the
|
||||
// composer input (which has its own handler above). Clicking into the
|
||||
// transcript and hitting Esc now stops the run, matching the Stop button.
|
||||
// Intentional only: we bail if (a) the composer/another field already
|
||||
// handled Esc (defaultPrevented), (b) focus is in any input/textarea/
|
||||
// contenteditable (you're typing, not stopping), or (c) a dialog/popover is
|
||||
// open — Esc must close that overlay, never double as canceling the stream
|
||||
// behind it. A latest-handler ref keeps the listener registered once.
|
||||
const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {})
|
||||
escCancelRef.current = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !busy) {
|
||||
return
|
||||
}
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerHaptic('cancel')
|
||||
void Promise.resolve(onCancel())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
useEffect(() => {
|
||||
@@ -1673,6 +1757,22 @@ export function ChatBar({
|
||||
.catch(restore)
|
||||
}
|
||||
|
||||
// External "submit this prompt" requests (e.g. the review pane's agent-ship
|
||||
// button) route through the same send path. A ref keeps the listener stable
|
||||
// while always calling the latest dispatchSubmit closure.
|
||||
const dispatchSubmitRef = useRef(dispatchSubmit)
|
||||
dispatchSubmitRef.current = dispatchSubmit
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onComposerSubmitRequest(({ target, text }) => {
|
||||
if (target === 'main' && !inputDisabled) {
|
||||
dispatchSubmitRef.current(text)
|
||||
}
|
||||
}),
|
||||
[inputDisabled]
|
||||
)
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
@@ -2048,7 +2148,7 @@ export function ChatBar({
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group/composer-surface relative z-4 isolate grid grid-rows-[auto_1fr] overflow-hidden rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
@@ -2063,6 +2163,12 @@ export function ChatBar({
|
||||
composerSurfaceGlass
|
||||
)}
|
||||
/>
|
||||
<CodingStatusRow
|
||||
onBranchOff={handleBranchOff}
|
||||
onOpen={toggleReview}
|
||||
onOpenWorktree={openInWorktree}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
@@ -74,34 +75,36 @@ export function ModelPill({
|
||||
|
||||
if (!model.modelMenuContent) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={copy.openModelPicker}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
title={copy.openModelPicker}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Tip label={copy.openModelPicker} side="top">
|
||||
<Button
|
||||
aria-label={title}
|
||||
aria-label={copy.openModelPicker}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<Tip label={title} side="top">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
|
||||
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
|
||||
{model.modelMenuContent}
|
||||
|
||||
324
apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
Normal file
324
apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { gitRef } from '@/lib/sanitize'
|
||||
import { $repoStatus, $repoWorktrees } from '@/store/coding-status'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $newWorktreeRequest } from '@/store/projects'
|
||||
|
||||
// Tiny uppercase section header, matching the composer "+" menu's labels.
|
||||
const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)'
|
||||
|
||||
interface CodingStatusRowProps {
|
||||
/** Branch the current draft off into a fresh worktree + session, based on
|
||||
* `base` (a branch name; omitted = current HEAD). The composer owns the
|
||||
* draft, so it supplies the orchestration; the row just collects the new
|
||||
* branch name + base. Omitted (e.g. remote backend) hides the affordance. */
|
||||
onBranchOff?: (branch: string, base?: string) => Promise<void>
|
||||
/** Open the review pane (changed files + diffs). */
|
||||
onOpen?: () => void
|
||||
/** Jump into an existing worktree (open a fresh session anchored there). */
|
||||
onOpenWorktree?: (path: string) => void
|
||||
/** Switch the current repo checkout to another branch. */
|
||||
onSwitchBranch?: (branch: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The always-on coding-context row, the BASE of the composer status stack:
|
||||
* current branch, dirty summary (+/-), and ahead/behind. A touch more prominent
|
||||
* than the per-turn rows above it (larger branch label, accent glyph), and the
|
||||
* entry point to the review pane. Hidden when the active session isn't in a
|
||||
* local git repo (the probe returns null).
|
||||
*/
|
||||
export const CodingStatusRow = memo(function CodingStatusRow({
|
||||
onBranchOff,
|
||||
onOpen,
|
||||
onOpenWorktree,
|
||||
onSwitchBranch
|
||||
}: CodingStatusRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack.coding
|
||||
const p = t.sidebar.projects
|
||||
const status = useStore($repoStatus)
|
||||
const worktrees = useStore($repoWorktrees)
|
||||
|
||||
const [branchOpen, setBranchOpen] = useState(false)
|
||||
const [branchName, setBranchName] = useState('')
|
||||
const [branchBase, setBranchBase] = useState<string | undefined>(undefined)
|
||||
const [branchPending, setBranchPending] = useState(false)
|
||||
|
||||
// Open the name dialog for a chosen base. Deferred so the dropdown finishes
|
||||
// closing before the dialog grabs focus (Radix focus-trap handoff races
|
||||
// otherwise).
|
||||
const startBranch = (base: string | undefined) => {
|
||||
setBranchBase(base)
|
||||
setBranchName('')
|
||||
setTimeout(() => setBranchOpen(true), 0)
|
||||
}
|
||||
|
||||
// Global ⌘⇧B (workspace.newWorktree): open the name dialog for a worktree off
|
||||
// current HEAD. The rail only renders inside a repo, so the hotkey naturally
|
||||
// no-ops elsewhere. Guarded by a token ref so it fires on the keypress, not on
|
||||
// mount or unrelated re-renders.
|
||||
const worktreeReq = useStore($newWorktreeRequest)
|
||||
const lastWorktreeReqRef = useRef(worktreeReq)
|
||||
|
||||
useEffect(() => {
|
||||
if (worktreeReq === lastWorktreeReqRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastWorktreeReqRef.current = worktreeReq
|
||||
|
||||
if (!onBranchOff) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchBase(undefined)
|
||||
setBranchName('')
|
||||
setBranchOpen(true)
|
||||
}, [onBranchOff, worktreeReq])
|
||||
|
||||
const submitBranch = async () => {
|
||||
const branch = branchName.trim()
|
||||
|
||||
if (branchPending || !branch || !onBranchOff) {
|
||||
return
|
||||
}
|
||||
|
||||
setBranchPending(true)
|
||||
|
||||
try {
|
||||
await onBranchOff(branch, branchBase)
|
||||
setBranchOpen(false)
|
||||
setBranchName('')
|
||||
} catch (err) {
|
||||
notifyError(err, p.startWorkFailed)
|
||||
} finally {
|
||||
setBranchPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const switchToBranch = async (branch: string) => {
|
||||
if (!onSwitchBranch) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onSwitchBranch(branch)
|
||||
} catch (err) {
|
||||
notifyError(err, s.switchFailed(branch))
|
||||
}
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
const branchLabel = status.detached ? s.detached : status.branch || s.noBranch
|
||||
// The kebab offers branching off the trunk and/or the current branch. The
|
||||
// worktree-add bases the new branch on `base` (a branch name; undefined =
|
||||
// current HEAD). We dedupe so "on main" shows a single trunk entry, and fall
|
||||
// back to a plain off-HEAD branch when no trunk is detected.
|
||||
const current = status.detached ? null : status.branch
|
||||
const branchTargets: { base: string | undefined; label: string }[] = []
|
||||
|
||||
// Current branch first (the 99% "branch off where I am"), then the trunk just
|
||||
// below it ("New branch from main"), deduped when they're the same.
|
||||
if (current) {
|
||||
branchTargets.push({ base: current, label: s.branchOffFrom(current) })
|
||||
}
|
||||
|
||||
if (status.defaultBranch && status.defaultBranch !== current) {
|
||||
branchTargets.push({ base: status.defaultBranch, label: s.branchOffFrom(status.defaultBranch) })
|
||||
}
|
||||
|
||||
if (branchTargets.length === 0) {
|
||||
branchTargets.push({ base: undefined, label: s.newBranch })
|
||||
}
|
||||
|
||||
const switchTarget = onSwitchBranch && current && status.defaultBranch && status.defaultBranch !== current ? status.defaultBranch : null
|
||||
|
||||
// Other worktrees to jump into — everything except the one we're already in
|
||||
// (matched by its checked-out branch) and the bare/main placeholder entry.
|
||||
const otherWorktrees = onOpenWorktree
|
||||
? worktrees.filter(w => w.path && !w.detached && w.branch && w.branch !== current)
|
||||
: []
|
||||
|
||||
const hasLineDelta = status.added > 0 || status.removed > 0
|
||||
// Untracked files carry no line delta vs HEAD, so surface them as a count when
|
||||
// they're the only change (otherwise +/- tells the story).
|
||||
const untrackedOnly = !hasLineDelta && status.untracked > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusRow
|
||||
// The base "where am I working" strip is part of the composer surface
|
||||
// itself, so it inherits the composer's width and clipped top radius.
|
||||
className="coding-status-bar min-h-7 rounded-t-[inherit] rounded-b-none border-b border-(--ui-stroke-tertiary) px-3.5 py-1.5 hover:bg-transparent"
|
||||
// Static branch glyph — never the loading spinner. This row only renders
|
||||
// once `status` exists, so a spinner here only ever fired on *refreshes*
|
||||
// of an already-loaded repo (window focus, turn settle), reading as an
|
||||
// annoying icon "blip" with no first-load value. Refreshes are silent.
|
||||
leading={<Codicon className="text-(--ui-green)" name="git-branch" size="0.8rem" />}
|
||||
onActivate={onOpen}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 truncate text-xs font-normal text-muted-foreground/92 transition-colors group-hover/status-row:text-foreground/90"
|
||||
title={branchLabel}
|
||||
>
|
||||
{branchLabel}
|
||||
</span>
|
||||
|
||||
{/* Branch actions kebab — same pattern as the session/worktree rows.
|
||||
ALWAYS laid out; only its opacity flips on hover/focus/open, so
|
||||
revealing it never reflows the row (no layout shift). pointer-events
|
||||
follow opacity so the invisible trigger isn't clickable at rest. */}
|
||||
{onBranchOff && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={s.newBranch}
|
||||
className="pointer-events-none size-4 shrink-0 text-muted-foreground/60 opacity-0 transition hover:text-foreground group-hover/status-row:pointer-events-auto group-hover/status-row:opacity-100 group-focus-within/status-row:pointer-events-auto group-focus-within/status-row:opacity-100 data-[state=open]:pointer-events-auto data-[state=open]:opacity-100"
|
||||
onClick={event => event.stopPropagation()}
|
||||
onKeyDown={event => {
|
||||
// The row's onActivate also fires on Enter/Space; keep it from
|
||||
// opening the review pane when the kebab is the focus target.
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.8rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* The row sits at the bottom of the screen (above the composer),
|
||||
so the menu opens upward. */}
|
||||
<DropdownMenuContent align="end" className="w-60" side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className={MENU_SECTION}>{s.newBranch}</DropdownMenuLabel>
|
||||
{branchTargets.map(target => (
|
||||
<DropdownMenuItem key={target.base ?? '__head__'} onSelect={() => startBranch(target.base)}>
|
||||
<span className="truncate">{target.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{switchTarget && (
|
||||
<DropdownMenuItem onSelect={() => void switchToBranch(switchTarget)}>
|
||||
<span className="truncate">{s.switchTo(switchTarget)}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className={MENU_SECTION}>{s.worktrees}</DropdownMenuLabel>
|
||||
{otherWorktrees.map(worktree => (
|
||||
<DropdownMenuItem key={worktree.path} onSelect={() => onOpenWorktree?.(worktree.path)}>
|
||||
<span className="truncate">{worktree.branch}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{/* Create a fresh worktree off the current HEAD (the generic
|
||||
"spin up a worktree here", mirroring the sidebar's + button). */}
|
||||
<DropdownMenuItem onSelect={() => startBranch(undefined)}>
|
||||
<span className="truncate">{p.startWork}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(status.ahead > 0 || status.behind > 0) && (
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1.5 text-[0.68rem] leading-4 text-muted-foreground/75 tabular-nums">
|
||||
{status.ahead > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.ahead(status.ahead)}>
|
||||
<span aria-hidden>↑</span>
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="flex items-center gap-0.5" title={s.behind(status.behind)}>
|
||||
<span aria-hidden>↓</span>
|
||||
{status.behind}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasLineDelta ? (
|
||||
<DiffCount
|
||||
added={status.added}
|
||||
className={`text-[0.72rem] leading-4 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
removed={status.removed}
|
||||
/>
|
||||
) : untrackedOnly ? (
|
||||
<span
|
||||
className={`shrink-0 text-[0.72rem] leading-4 text-amber-500/90 ${status.ahead === 0 && status.behind === 0 ? 'ml-auto' : ''}`}
|
||||
>
|
||||
{s.changed(status.untracked)}
|
||||
</span>
|
||||
) : null}
|
||||
</StatusRow>
|
||||
|
||||
<Dialog onOpenChange={open => !branchPending && setBranchOpen(open)} open={branchOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{p.newWorktreeTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{p.newWorktreeDesc}
|
||||
{branchBase && (
|
||||
<span className="mt-1 block text-(--ui-text-secondary)">{s.branchOffFrom(branchBase)}</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={branchPending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submitBranch()
|
||||
} else if (event.key === 'Escape') {
|
||||
setBranchOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setBranchName}
|
||||
placeholder={p.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={branchName}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button disabled={branchPending} onClick={() => setBranchOpen(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={branchPending || !branchName.trim()} onClick={() => void submitBranch()} type="button">
|
||||
{p.startWork}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -19,15 +19,30 @@ import {
|
||||
type StatusGroup,
|
||||
stopBackgroundProcess
|
||||
} from '@/store/composer-status'
|
||||
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { PreviewStatusRow } from './preview-row'
|
||||
import { StatusItemRow } from './status-row'
|
||||
|
||||
// Slow safety-net poll for silent exits (processes without notify_on_complete
|
||||
// emit no event when they die). Only armed while a running row is on screen.
|
||||
const BACKGROUND_POLL_MS = 5_000
|
||||
|
||||
// A localhost/loopback preview is only meaningful while its dev server is up, so
|
||||
// we tie it to a live background process rather than persisting dismissals or
|
||||
// letting dead URLs pile up. File previews (a real on-disk artifact) stand alone.
|
||||
const isLocalhostPreview = (target: string): boolean => /\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(target)
|
||||
|
||||
// Real codicons per group (no sparkles): a checklist for todos, a bot for
|
||||
// subagents, a background process glyph for background tasks.
|
||||
const GROUP_ICON: Record<StatusGroup['type'], string> = {
|
||||
todo: 'checklist',
|
||||
subagent: 'hubot',
|
||||
background: 'server-process'
|
||||
}
|
||||
|
||||
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
|
||||
if (group.type === 'todo') {
|
||||
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
|
||||
@@ -52,6 +67,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const previewsBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const groups = useMemo(
|
||||
@@ -59,6 +75,8 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
[itemsBySession, sessionId]
|
||||
)
|
||||
|
||||
const previews = sessionId ? (previewsBySession[sessionId] ?? []) : []
|
||||
|
||||
// Seed from the registry on session open; event-driven refreshes (terminal /
|
||||
// process tool completions) live in use-message-stream.
|
||||
useEffect(() => {
|
||||
@@ -69,6 +87,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
|
||||
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
|
||||
|
||||
// Drop localhost previews once no dev server is left running — that's what made
|
||||
// dead `localhost:5174` chips stick around. On-disk file previews are kept.
|
||||
const visiblePreviews = previews.filter(item => hasRunningBackground || !isLocalhostPreview(item.target))
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !hasRunningBackground) {
|
||||
return
|
||||
@@ -102,11 +124,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
icon={
|
||||
group.type === 'todo' ? (
|
||||
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
|
||||
) : undefined
|
||||
}
|
||||
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
|
||||
label={groupLabel(group, t.statusStack)}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
@@ -115,13 +133,28 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
key={item.id}
|
||||
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
|
||||
onOpen={() => openSubagent(item)}
|
||||
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
|
||||
onStop={sessionId ? id => void stopBackgroundProcess(sessionId, id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</StatusSection>
|
||||
)
|
||||
}))
|
||||
|
||||
if (visiblePreviews.length > 0 && sessionId) {
|
||||
sections.push({
|
||||
key: 'preview',
|
||||
// Not a collapsible group — preview links just sit there, one line each,
|
||||
// each individually closeable.
|
||||
node: (
|
||||
<div className="px-1 py-0.5">
|
||||
{visiblePreviews.map(item => (
|
||||
<PreviewStatusRow item={item} key={item.id} onDismiss={id => dismissPreviewArtifact(sessionId, id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (queue) {
|
||||
sections.push({ key: 'queue', node: queue })
|
||||
}
|
||||
@@ -170,12 +203,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
|
||||
return (
|
||||
<div
|
||||
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
|
||||
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
|
||||
// composer surface's top border. z BELOW the surface (z-4) so the surface's
|
||||
// top border paints over our transparent bottom border — one seam, no
|
||||
// double line.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
|
||||
// Sits in the overlay lane above the composer. The composer root has pt-2
|
||||
// before the actual surface; translate by that amount so the stack returns
|
||||
// to its original attachment point without intruding into the repo strip.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-2 overflow-y-auto"
|
||||
onPointerDownCapture={() => blurComposerInput()}
|
||||
ref={stackRef}
|
||||
>
|
||||
@@ -185,17 +216,19 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
Rounded top, square bottom; the bottom border is TRANSPARENT — the
|
||||
composer surface's visible top border (which sits at a higher z) is the
|
||||
single shared seam, so the two read as one fused capsule. */}
|
||||
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
composerDockCard('top'),
|
||||
// Inset (mx-2) so the stack reads slightly narrower than the composer
|
||||
// surface below it — the original look.
|
||||
'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5',
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
125
apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
Normal file
125
apps/desktop/src/app/chat/composer/status-stack/preview-row.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { memo, useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronRight } from '@/lib/icons'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { PREVIEW_PANE_ID } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $paneOpen } from '@/store/panes'
|
||||
import { $previewTarget, dismissPreviewTarget, setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { type PreviewArtifact } from '@/store/preview-status'
|
||||
|
||||
interface PreviewStatusRowProps {
|
||||
item: PreviewArtifact
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
/** One detected artifact, single line, always visible: filename + open + close. */
|
||||
export const PreviewStatusRow = memo(function PreviewStatusRow({ item, onDismiss }: PreviewStatusRowProps) {
|
||||
const { t } = useI18n()
|
||||
const activePreview = useStore($previewTarget)
|
||||
const previewPaneOpen = useStore($paneOpen(PREVIEW_PANE_ID))
|
||||
const [opening, setOpening] = useState(false)
|
||||
const isOpen = activePreview?.source === item.target && previewPaneOpen
|
||||
|
||||
const resolveTarget = async () => {
|
||||
const target = await normalizeOrLocalPreviewTarget(item.target, item.cwd || undefined)
|
||||
|
||||
if (!target) {
|
||||
throw new Error(`Could not open preview target: ${item.target}`)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
const togglePreview = async () => {
|
||||
if (opening) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
dismissPreviewTarget()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setOpening(true)
|
||||
|
||||
try {
|
||||
setCurrentSessionPreviewTarget(await resolveTarget(), 'tool-result', item.target)
|
||||
} catch (error) {
|
||||
notifyError(error, t.preview.unavailable)
|
||||
} finally {
|
||||
setOpening(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openInBrowser = async () => {
|
||||
try {
|
||||
const bridge = window.hermesDesktop?.openPreviewInBrowser
|
||||
|
||||
if (!bridge) {
|
||||
throw new Error('Desktop preview browser bridge is unavailable')
|
||||
}
|
||||
|
||||
await bridge((await resolveTarget()).url)
|
||||
} catch (error) {
|
||||
notifyError(error, t.preview.unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
leading={<ChevronRight aria-hidden className="size-[0.8rem] text-muted-foreground/80" />}
|
||||
onActivate={() => void togglePreview()}
|
||||
trailing={
|
||||
<span className="-my-1 flex items-center gap-0.5">
|
||||
<Tip label={t.preview.openInBrowser}>
|
||||
<Button
|
||||
aria-label={t.preview.openInBrowser}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void openInBrowser()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="link-external" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={t.statusStack.dismiss}>
|
||||
<Button
|
||||
aria-label={t.statusStack.dismiss}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onDismiss(item.id)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</span>
|
||||
}
|
||||
trailingVisible
|
||||
>
|
||||
<span className="min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4 text-foreground/92" title={item.target}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className={cn('shrink-0 text-[0.62rem] leading-4 text-muted-foreground/70', opening && 'animate-pulse')}>
|
||||
{opening ? t.preview.opening : isOpen ? t.preview.hide : t.preview.openPreview}
|
||||
</span>
|
||||
</StatusRow>
|
||||
)
|
||||
})
|
||||
@@ -8,7 +8,6 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUpRight, X } from '@/lib/icons'
|
||||
import type { TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerStatusItem } from '@/store/composer-status'
|
||||
@@ -50,7 +49,7 @@ function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']):
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={s.running}
|
||||
className="text-[0.9rem] leading-none text-muted-foreground/80"
|
||||
className="text-[0.85rem] leading-none text-muted-foreground/80"
|
||||
spinner="braille"
|
||||
/>
|
||||
)
|
||||
@@ -117,11 +116,11 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : canOpen ? (
|
||||
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
|
||||
<Codicon aria-hidden className="text-muted-foreground/55" name="link-external" size="0.85rem" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
|
||||
@@ -88,7 +88,10 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
||||
onRestoreToMessage?: (
|
||||
messageId: string,
|
||||
target?: { text?: string; userOrdinal?: number | null }
|
||||
) => Promise<void>
|
||||
onRetryResume: (sessionId: string) => void
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
onDismissError?: (messageId: string) => void
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode
|
||||
} from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
@@ -14,15 +14,21 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { desktopFileDiff, desktopGitRoot, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { shikiLanguageForFilename } from '@/lib/markdown-code'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
const SOURCE_CHUNK_LINES = 200
|
||||
const SOURCE_LINE_PX = 20
|
||||
const SOURCE_OVERSCAN_LINES = 400
|
||||
|
||||
type EmptyStateTone = 'neutral' | 'warning'
|
||||
|
||||
@@ -126,6 +132,8 @@ interface LocalPreviewState {
|
||||
binary?: boolean
|
||||
byteSize?: number
|
||||
dataUrl?: string
|
||||
/** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */
|
||||
diff?: string
|
||||
error?: string
|
||||
language?: string
|
||||
loading: boolean
|
||||
@@ -299,28 +307,44 @@ function MarkdownPreview({ text }: { text: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||
function PreviewModeSwitcher({
|
||||
active,
|
||||
modes,
|
||||
onSelect
|
||||
}: {
|
||||
active: PreviewViewMode
|
||||
modes: PreviewViewMode[]
|
||||
onSelect: (mode: PreviewViewMode) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const label: Record<PreviewViewMode, string> = {
|
||||
diff: t.preview.diff,
|
||||
rendered: t.preview.renderedPreview,
|
||||
source: t.preview.source
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{asSource ? t.preview.renderedPreview : t.preview.source}
|
||||
</button>
|
||||
<div className="flex shrink-0 justify-end gap-3 border-b border-border/40 px-3 py-1">
|
||||
{modes.map(mode => (
|
||||
<button
|
||||
className={cn(
|
||||
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
|
||||
mode === active
|
||||
? 'text-foreground underline decoration-current/30'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
key={mode}
|
||||
onClick={() => onSelect(mode)}
|
||||
type="button"
|
||||
>
|
||||
{label[mode]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so
|
||||
// each line aligns vertically. The selection overlay relies on the same
|
||||
// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself.
|
||||
const SOURCE_LINE_HEIGHT_REM = 1.21875
|
||||
const SOURCE_PAD_Y_REM = 0.75
|
||||
|
||||
interface LineSelection {
|
||||
end: number
|
||||
start: number
|
||||
@@ -337,7 +361,18 @@ function startLineDrag(event: ReactDragEvent<HTMLElement>, filePath: string, { e
|
||||
|
||||
function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) {
|
||||
const { t } = useI18n()
|
||||
const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text])
|
||||
const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text])
|
||||
const lastChunk = chunks.at(-1)
|
||||
const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0
|
||||
|
||||
const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({
|
||||
overscanRows: SOURCE_OVERSCAN_LINES,
|
||||
rowPx: SOURCE_LINE_PX,
|
||||
rowsPerChunk: SOURCE_CHUNK_LINES,
|
||||
totalRows: totalLines
|
||||
})
|
||||
|
||||
const visibleChunks = chunks.slice(startChunk, endChunk + 1)
|
||||
const [selection, setSelection] = useState<LineSelection | null>(null)
|
||||
const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end
|
||||
|
||||
@@ -394,69 +429,76 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
}, [filePath, selection])
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
{Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = index + 1
|
||||
const selected = inSelection(line)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer px-3 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title={t.preview.sourceLineTitle}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
|
||||
style={{
|
||||
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
|
||||
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
|
||||
}}
|
||||
/>
|
||||
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-[0.7rem] leading-relaxed">
|
||||
{beforeRows > 0 && (
|
||||
<div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />
|
||||
)}
|
||||
{visibleChunks.map(chunk => (
|
||||
<Fragment key={chunk.start}>
|
||||
<div className="select-none text-right text-muted-foreground/55">
|
||||
{chunk.lines.map((_lineText, offset) => {
|
||||
const line = chunk.start + offset + 1
|
||||
const selected = inSelection(line)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-5 w-9 cursor-pointer pr-2 leading-5 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title={t.preview.sourceLineTitle}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="preview-source-code min-w-0 [&_pre]:m-0" data-selectable-text="true">
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{chunk.text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{afterRows > 0 && (
|
||||
<div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />
|
||||
)}
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PreviewViewMode = 'diff' | 'rendered' | 'source'
|
||||
|
||||
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
||||
const { t } = useI18n()
|
||||
const [state, setState] = useState<LocalPreviewState>({ loading: true })
|
||||
const [forcePreview, setForcePreview] = useState(false)
|
||||
const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false)
|
||||
// User-picked view; null = auto (diff when changed, else rendered markdown,
|
||||
// else source). Reset when the previewed file changes.
|
||||
const [userMode, setUserMode] = useState<null | PreviewViewMode>(null)
|
||||
const filePath = filePathForTarget(target)
|
||||
const isImage = target.previewKind === 'image'
|
||||
|
||||
useEffect(() => {
|
||||
setUserMode(null)
|
||||
}, [filePath, reloadKey])
|
||||
|
||||
// HTML files are rendered as source code, not in a webview - so they take
|
||||
// the same path as plain text files. `previewKind === 'binary'` arrives
|
||||
// when the file is forcibly previewed past the binary refusal screen.
|
||||
@@ -508,6 +550,22 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
text: shouldBlock ? undefined : result.text,
|
||||
truncated: result.truncated
|
||||
})
|
||||
|
||||
// Best-effort: fetch the file's working-tree-vs-HEAD diff so the
|
||||
// preview can offer a DIFF view when there are uncommitted changes.
|
||||
// Empty (clean file / not a repo / remote) just hides the option.
|
||||
if (!shouldBlock) {
|
||||
try {
|
||||
const root = await desktopGitRoot(filePath)
|
||||
const diff = root ? await desktopFileDiff(root, filePath) : ''
|
||||
|
||||
if (active && diff.trim()) {
|
||||
setState(prev => (prev.text === result.text ? { ...prev, diff } : prev))
|
||||
}
|
||||
} catch {
|
||||
// No diff available; the preview just shows source.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
@@ -571,21 +629,50 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
|
||||
if (isText && state.text !== undefined) {
|
||||
const isMarkdown = (state.language || target.language) === 'markdown'
|
||||
const showRendered = isMarkdown && !renderMarkdownAsSource
|
||||
const hasDiff = Boolean(state.diff && state.diff.trim())
|
||||
// Order the toggle reads left→right; default lands on the most useful view.
|
||||
const modes: PreviewViewMode[] = []
|
||||
|
||||
if (isMarkdown) {
|
||||
modes.push('rendered')
|
||||
}
|
||||
|
||||
modes.push('source')
|
||||
|
||||
if (hasDiff) {
|
||||
modes.push('diff')
|
||||
}
|
||||
|
||||
const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source'
|
||||
const mode = userMode && modes.includes(userMode) ? userMode : autoMode
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-transparent">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-transparent">
|
||||
{state.truncated && (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
{t.preview.truncated}
|
||||
</div>
|
||||
)}
|
||||
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
||||
{showRendered ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
) : (
|
||||
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
|
||||
)}
|
||||
{modes.length > 1 && <PreviewModeSwitcher active={mode} modes={modes} onSelect={setUserMode} />}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{mode === 'rendered' ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
) : mode === 'diff' ? (
|
||||
<FileDiffPanel
|
||||
className="mx-0 mb-0 h-full max-h-none"
|
||||
diff={state.diff ?? ''}
|
||||
fullText={state.text}
|
||||
path={filePath}
|
||||
showLineNumbers
|
||||
/>
|
||||
) : (
|
||||
<SourceView
|
||||
filePath={filePath}
|
||||
language={shikiLanguageForFilename(filePath) || state.language || 'text'}
|
||||
text={state.text}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,19 @@ import { useEffect, useMemo } from 'react'
|
||||
|
||||
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$panesFlipped,
|
||||
$rightRailActiveTabId,
|
||||
RIGHT_RAIL_PREVIEW_TAB_ID,
|
||||
type RightRailTabId,
|
||||
@@ -16,8 +25,10 @@ import {
|
||||
$filePreviewTabs,
|
||||
$previewReloadRequest,
|
||||
$previewTarget,
|
||||
closeOtherRightRailTabs,
|
||||
closeRightRail,
|
||||
closeRightRailTab,
|
||||
closeRightRailTabsToRight,
|
||||
type PreviewTarget
|
||||
} from '@/store/preview'
|
||||
|
||||
@@ -56,6 +67,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
const { t } = useI18n()
|
||||
const previewReloadRequest = useStore($previewReloadRequest)
|
||||
const activeTabId = useStore($rightRailActiveTabId)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const filePreviewTabs = useStore($filePreviewTabs)
|
||||
const previewTarget = useStore($previewTarget)
|
||||
|
||||
@@ -82,68 +94,92 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID
|
||||
|
||||
return (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
|
||||
<aside
|
||||
className={cn(
|
||||
'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)',
|
||||
panesFlipped ? 'border-r' : 'border-l'
|
||||
)}
|
||||
>
|
||||
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
{tabs.map((tab, index) => {
|
||||
const active = tab.id === activeTab.id
|
||||
const hasOthers = tabs.length > 1
|
||||
const hasTabsToRight = index < tabs.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<Tip label={tab.label}>
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
<button
|
||||
aria-label={t.preview.closeTab(tab.label)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<Tip label={tab.target.path || tab.target.url || tab.label}>
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
<button
|
||||
aria-label={t.preview.closeTab(tab.label)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
|
||||
{t.common.close}
|
||||
<span className="ml-auto pl-4 text-(--ui-text-tertiary)">{formatCombo('mod+w')}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!hasOthers} onSelect={() => closeOtherRightRailTabs(tab.id)}>
|
||||
{t.preview.closeOthers}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!hasTabsToRight} onSelect={() => closeRightRailTabsToRight(tab.id)}>
|
||||
{t.preview.closeToRight}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={closeRightRail}>{t.preview.closeAll}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
158
apps/desktop/src/app/chat/sidebar/chrome.tsx
Normal file
158
apps/desktop/src/app/chat/sidebar/chrome.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared, content-agnostic sidebar chrome — used by both the flat session
|
||||
// sections and the project/workspace tree, so it lives outside either to keep
|
||||
// imports one-directional (no index <-> projects cycle).
|
||||
|
||||
/** `loaded/total` when there's more on the server, else just the loaded count. */
|
||||
export const countLabel = (loaded: number, total: number): string =>
|
||||
total > loaded ? `${loaded}/${total}` : String(loaded)
|
||||
|
||||
/** The muted count chip next to a section/workspace label. */
|
||||
export function SidebarCount({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
|
||||
}
|
||||
|
||||
// ── Row geometry (session row is canonical — everything composes these) ─────
|
||||
//
|
||||
// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children
|
||||
// stretch to fill the cell and center content internally — never items-center
|
||||
// on the shell grid, or short clusters (projects) float 1–2px off sessions.
|
||||
|
||||
const rowMinH = 'min-h-[1.625rem]'
|
||||
const rowPadX = 'pl-2 pr-1'
|
||||
const rowGap = 'gap-1.5'
|
||||
const rowLead = 'grid size-3.5 shrink-0 place-items-center'
|
||||
const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5')
|
||||
const rowLabel = 'min-w-0 truncate text-[0.8125rem] leading-none text-(--ui-text-secondary)'
|
||||
|
||||
/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */
|
||||
export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const
|
||||
|
||||
/** Vertical stack of rows (gap-px, single column). */
|
||||
export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('grid grid-cols-[minmax(0,1fr)] gap-px', className)} {...props} />
|
||||
}
|
||||
|
||||
/** Nested rows (session previews, worktree bodies). */
|
||||
export function SidebarRowNest({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
|
||||
}
|
||||
|
||||
/** Outer grid — sole owner of row height. */
|
||||
export function SidebarRowShell({
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { actions?: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn(rowMinH, 'grid grid-cols-[minmax(0,1fr)_auto] items-stretch rounded-md', className)} {...props}>
|
||||
{children}
|
||||
{actions ? <div className="flex shrink-0 items-center self-center">{actions}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Multi-control left cluster (project rows). */
|
||||
export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn(rowInset, className)} {...props} />
|
||||
}
|
||||
|
||||
/** Session row main tap target. */
|
||||
export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
return <button className={cn(rowInset, 'bg-transparent text-left', className)} type="button" {...props} />
|
||||
}
|
||||
|
||||
/** Tappable label — underline/truncate live on the inner span, not the button. */
|
||||
export function SidebarRowLink({
|
||||
className,
|
||||
labelClassName,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { labelClassName?: string }) {
|
||||
return (
|
||||
<button className={cn('min-w-0 shrink bg-transparent p-0 text-left', className)} type="button" {...props}>
|
||||
<span className={cn(rowLabel, labelClassName)}>{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fixed leading column (dot, icon, drag handle). */
|
||||
export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return <span className={cn(rowLead, className)} {...props} />
|
||||
}
|
||||
|
||||
/** Standard row label typography. */
|
||||
export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return <span className={cn(rowLabel, className)} {...props} />
|
||||
}
|
||||
|
||||
/** Dot ↔ grabber swap for dnd-kit reorder rows. */
|
||||
export function SidebarRowGrab({
|
||||
ariaLabel,
|
||||
children,
|
||||
className,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
leadClassName
|
||||
}: {
|
||||
ariaLabel: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
leadClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<SidebarRowLead
|
||||
{...dragHandleProps}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
'group/handle relative cursor-grab touch-none overflow-hidden active:cursor-grabbing',
|
||||
leadClassName,
|
||||
className
|
||||
)}
|
||||
data-reorder-handle
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<span className="grid size-full place-items-center transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0">
|
||||
{children}
|
||||
</span>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
size="0.75rem"
|
||||
/>
|
||||
</SidebarRowLead>
|
||||
)
|
||||
}
|
||||
|
||||
/** Icon/dot slot inside SidebarRowLead — caps visual size so rows align. */
|
||||
export function SidebarRowLeadGlyph({
|
||||
children,
|
||||
className,
|
||||
style
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'grid size-full place-items-center text-(--ui-text-tertiary) [&_.codicon]:leading-none',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getCronJobRuns, type SessionInfo } from '@/hermes'
|
||||
@@ -328,7 +329,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
|
||||
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
|
||||
{runs === null ? (
|
||||
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon name="loading" size="0.75rem" spinning />
|
||||
<GlyphSpinner ariaLabel={c.loading} className="text-[0.75rem]" />
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
interface SidebarLoadMoreRowProps {
|
||||
@@ -7,24 +8,22 @@ interface SidebarLoadMoreRowProps {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// "Load N more" affordance shared by the recents, messaging, and cron sections.
|
||||
// The chevron sits in the same w-3.5 column the rows use for their dot, so it
|
||||
// lines up with the list above.
|
||||
// Compact "load more" affordance shared by recents, messaging, and cron. Kept
|
||||
// intentionally identical to workspace "show more" controls (ellipsis button)
|
||||
// so pagination reads as one interaction everywhere.
|
||||
export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLoadMoreRowProps) {
|
||||
const { t } = useI18n()
|
||||
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
|
||||
aria-label={label}
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="grid w-3.5 shrink-0 place-items-center">
|
||||
<Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
{loading ? <GlyphSpinner ariaLabel={label} className="text-[0.75rem]" /> : <Codicon name="ellipsis" size="0.75rem" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/** New ids first, then ids still present in the persisted order. */
|
||||
export function reconcileFreshFirst(currentIds: string[], orderIds: string[]): string[] {
|
||||
const current = new Set(currentIds)
|
||||
const retained = orderIds.filter(id => current.has(id))
|
||||
const retainedSet = new Set(retained)
|
||||
|
||||
return [...currentIds.filter(id => !retainedSet.has(id)), ...retained]
|
||||
}
|
||||
|
||||
export function resolveManualSessionOrderIds(currentIds: string[], orderIds: string[], manual: boolean): string[] {
|
||||
if (!manual || !currentIds.length || !orderIds.length) {
|
||||
return []
|
||||
@@ -10,8 +19,5 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str
|
||||
return []
|
||||
}
|
||||
|
||||
const retainedSet = new Set(retained)
|
||||
const fresh = currentIds.filter(id => !retainedSet.has(id))
|
||||
|
||||
return [...fresh, ...retained]
|
||||
return reconcileFreshFirst(currentIds, orderIds)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ColorSwatches } from '@/components/ui/color-swatches'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -494,30 +495,14 @@ function ProfileSquare({ active, color, label, onDelete, onRecolor, onRename, on
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
side="top"
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{PROFILE_SWATCHES.map(swatch => (
|
||||
<button
|
||||
aria-label={p.setColor(swatch)}
|
||||
className="size-5 rounded-full transition-transform hover:scale-110"
|
||||
key={swatch}
|
||||
onClick={() => pickColor(swatch)}
|
||||
style={{
|
||||
backgroundColor: swatch,
|
||||
boxShadow: swatch === color ? '0 0 0 2px var(--ui-bg-elevated), 0 0 0 3.5px currentColor' : undefined,
|
||||
color: swatch
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md py-1 text-xs text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={() => pickColor(null)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="sync" size="0.75rem" />
|
||||
{p.autoColor}
|
||||
</button>
|
||||
<ColorSwatches
|
||||
clearIcon="sync"
|
||||
clearLabel={p.autoColor}
|
||||
onChange={pickColor}
|
||||
swatches={PROFILE_SWATCHES}
|
||||
swatchLabel={p.setColor}
|
||||
value={color}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
289
apps/desktop/src/app/chat/sidebar/project-dialog.tsx
Normal file
289
apps/desktop/src/app/chat/sidebar/project-dialog.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$projectDialog,
|
||||
addProjectFolder,
|
||||
closeProjectDialog,
|
||||
createProject,
|
||||
generateProjectIdea,
|
||||
pickProjectFolder,
|
||||
renameProject
|
||||
} from '@/store/projects'
|
||||
|
||||
// Single dialog mounted once in the sidebar; it renders create / rename /
|
||||
// add-folder flows driven by the $projectDialog atom. Folders are chosen via
|
||||
// the native directory picker (reused from the default-project-dir setting).
|
||||
export function ProjectDialog() {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const state = useStore($projectDialog)
|
||||
const open = state !== null
|
||||
const mode = state?.mode ?? 'create'
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [folders, setFolders] = useState<string[]>([])
|
||||
const [idea, setIdea] = useState('')
|
||||
const [templates, setTemplates] = useState<ProjectIdeaTemplate[]>([])
|
||||
const [generatingIdea, setGeneratingIdea] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(state?.name ?? '')
|
||||
setFolders([])
|
||||
setIdea('')
|
||||
setTemplates(randomIdeaTemplates())
|
||||
setGeneratingIdea(false)
|
||||
setSubmitting(false)
|
||||
|
||||
if (mode !== 'add-folder') {
|
||||
window.setTimeout(() => nameRef.current?.select(), 0)
|
||||
}
|
||||
}
|
||||
}, [open, mode, state?.name])
|
||||
|
||||
const onOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
closeProjectDialog()
|
||||
}
|
||||
}
|
||||
|
||||
// One submit beat for every flow: guard re-entry, run the write, close on
|
||||
// success, surface a toast on failure. Callers pass only the write.
|
||||
const runSubmit = async (write: () => Promise<unknown>) => {
|
||||
if (submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
await write()
|
||||
closeProjectDialog()
|
||||
} catch (err) {
|
||||
notifyError(err, p.createFailed)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pickFolder = async () => {
|
||||
const dir = await pickProjectFolder()
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const projectId = state?.projectId
|
||||
|
||||
if (mode === 'add-folder' && projectId) {
|
||||
await runSubmit(() => addProjectFolder(projectId, dir))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setFolders(prev => (prev.includes(dir) ? prev : [...prev, dir]))
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = name.trim()
|
||||
const projectId = state?.projectId
|
||||
|
||||
if (mode === 'rename' && projectId) {
|
||||
if (trimmed) {
|
||||
await runSubmit(() => renameProject(projectId, trimmed))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// A project owns sessions by folder (cwd-prefix), so creation requires at
|
||||
// least one — a folder-less project couldn't hold a session anyway.
|
||||
if (mode === 'create' && trimmed && folders.length) {
|
||||
await runSubmit(() => createProject({ folders, idea: idea.trim() || undefined, name: trimmed, use: true }))
|
||||
}
|
||||
}
|
||||
|
||||
const generateIdea = async () => {
|
||||
if (generatingIdea) {
|
||||
return
|
||||
}
|
||||
|
||||
setGeneratingIdea(true)
|
||||
|
||||
try {
|
||||
const text = await generateProjectIdea(name)
|
||||
|
||||
if (text) {
|
||||
setIdea(text)
|
||||
}
|
||||
} finally {
|
||||
setGeneratingIdea(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = mode === 'rename' ? p.renameTitle : mode === 'add-folder' ? p.addFolderTitle : p.createTitle
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{mode === 'create' && <DialogDescription>{p.createDesc}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
{mode !== 'add-folder' && (
|
||||
<Input
|
||||
autoFocus
|
||||
disabled={submitting}
|
||||
onChange={event => setName(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
placeholder={p.namePlaceholder}
|
||||
ref={nameRef}
|
||||
value={name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'create' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.foldersLabel}</span>
|
||||
{folders.length === 0 ? (
|
||||
<span className="text-[0.75rem] text-(--ui-text-quaternary)">{p.noFolders}</span>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{folders.map((folder, index) => (
|
||||
<li
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-(--ui-control-hover-background) px-2 py-1 text-[0.75rem]'
|
||||
)}
|
||||
key={folder}
|
||||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="folder" size="0.75rem" />
|
||||
<span className="min-w-0 flex-1 truncate" title={folder}>
|
||||
{folder}
|
||||
</span>
|
||||
{index === 0 && (
|
||||
<span className="shrink-0 text-[0.625rem] uppercase text-(--ui-text-quaternary)">
|
||||
{p.primaryBadge}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
aria-label={p.removeFolder}
|
||||
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
|
||||
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Button
|
||||
className="self-start"
|
||||
disabled={submitting}
|
||||
onClick={() => void pickFolder()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
{p.addFolder}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'create' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[0.6875rem] font-medium text-(--ui-text-tertiary)">{p.ideaLabel}</span>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
className="min-h-20 pr-8 text-[0.8125rem]"
|
||||
disabled={submitting}
|
||||
onChange={event => setIdea(event.target.value)}
|
||||
placeholder={p.ideaPlaceholder}
|
||||
value={idea}
|
||||
/>
|
||||
<GenerateButton
|
||||
className="absolute top-1 right-1"
|
||||
disabled={submitting}
|
||||
generating={generatingIdea}
|
||||
generatingLabel={p.ideaGenerating}
|
||||
label={p.ideaGenerate}
|
||||
onGenerate={() => void generateIdea()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{templates.map(template => (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded-full border border-(--ui-stroke-tertiary) px-2 py-0.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:border-(--ui-stroke-secondary) hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
key={template.label}
|
||||
onClick={() => setIdea(template.idea)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden>{template.emoji}</span>
|
||||
{template.label}
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
aria-label={p.ideaShuffle}
|
||||
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
|
||||
disabled={submitting}
|
||||
onClick={() => setTemplates(randomIdeaTemplates())}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.75rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'add-folder' && (
|
||||
<Button disabled={submitting} onClick={() => void pickFolder()} type="button">
|
||||
<Codicon name="folder-opened" size="0.875rem" />
|
||||
{p.addFolder}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mode !== 'add-folder' && (
|
||||
<DialogFooter>
|
||||
<Button disabled={submitting} onClick={() => onOpenChange(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={submitting || !name.trim() || (mode === 'create' && folders.length === 0)}
|
||||
onClick={() => void submit()}
|
||||
type="button"
|
||||
>
|
||||
{mode === 'rename' ? t.common.save : p.create}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
250
apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
Normal file
250
apps/desktop/src/app/chat/sidebar/projects/entered-content.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import type { HermesGitWorktree } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { removeWorktreePath } from '@/store/projects'
|
||||
|
||||
import { SidebarRowStack } from '../chrome'
|
||||
|
||||
import { useWorkspaceNodeOpen } from './model'
|
||||
import { SidebarWorkspaceGroup } from './workspace-group'
|
||||
import {
|
||||
mergeRepoWorktreeGroups,
|
||||
overlayRepoLanes,
|
||||
type SidebarProjectTree,
|
||||
type SidebarSessionGroup,
|
||||
type SidebarWorkspaceTree
|
||||
} from './workspace-groups'
|
||||
import { WorkspaceAddButton, WorkspaceHeader } from './workspace-header'
|
||||
|
||||
// The entered project's body. Main-checkout sessions render directly — no
|
||||
// redundant repo/branch header (the breadcrumb already names the project). Only
|
||||
// linked worktrees nest, shown by branch. Multi-folder projects keep per-repo
|
||||
// headers so the folders stay distinguishable.
|
||||
export function EnteredProjectContent({
|
||||
project,
|
||||
renderRows,
|
||||
onNewSession,
|
||||
repoWorktrees,
|
||||
liveSessions,
|
||||
removedSessionIds
|
||||
}: {
|
||||
project: SidebarProjectTree
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
repoWorktrees?: Record<string, HermesGitWorktree[]>
|
||||
liveSessions?: SessionInfo[]
|
||||
removedSessionIds?: ReadonlySet<string>
|
||||
}) {
|
||||
if (!project.repos.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const single = project.repos.length === 1
|
||||
|
||||
return (
|
||||
<>
|
||||
{project.repos.map(repo => (
|
||||
<RepoFlatSection
|
||||
discoveredWorktrees={repo.path ? repoWorktrees?.[repo.path] : undefined}
|
||||
key={repo.id}
|
||||
liveSessions={liveSessions}
|
||||
onNewSession={onNewSession}
|
||||
removedSessionIds={removedSessionIds}
|
||||
renderRows={renderRows}
|
||||
repo={repo}
|
||||
showHeader={!single}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RepoFlatSection({
|
||||
repo,
|
||||
showHeader,
|
||||
renderRows,
|
||||
onNewSession,
|
||||
discoveredWorktrees,
|
||||
liveSessions,
|
||||
removedSessionIds
|
||||
}: {
|
||||
repo: SidebarWorkspaceTree
|
||||
showHeader: boolean
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
discoveredWorktrees?: HermesGitWorktree[]
|
||||
liveSessions?: SessionInfo[]
|
||||
removedSessionIds?: ReadonlySet<string>
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const [open, toggleOpen] = useWorkspaceNodeOpen(repo.id)
|
||||
const dismissedWorktrees = useStore($dismissedWorktreeIds)
|
||||
|
||||
// The repo's session lanes already come fully built from the backend; this
|
||||
// only injects empty VISUAL lanes from a live `git worktree list`.
|
||||
const mergedGroups = useMemo(() => mergeRepoWorktreeGroups(repo, discoveredWorktrees), [repo, discoveredWorktrees])
|
||||
|
||||
// Optimistic placement runs against the MERGED lane set (backend + visual
|
||||
// git-worktree lanes) so out-of-tree/sibling worktrees — which exist as visual
|
||||
// lanes before the snapshot carries their sessions — get the new row. The
|
||||
// overlay drops lanes it empties, so re-merge to restore still-real worktrees.
|
||||
const overlaidGroups = useMemo(() => {
|
||||
if (!(liveSessions?.length || removedSessionIds?.size)) {
|
||||
return mergedGroups
|
||||
}
|
||||
|
||||
const { groups } = overlayRepoLanes({ ...repo, groups: mergedGroups }, liveSessions ?? [], removedSessionIds)
|
||||
|
||||
return mergeRepoWorktreeGroups({ id: repo.id, path: repo.path, groups }, discoveredWorktrees)
|
||||
}, [repo, mergedGroups, discoveredWorktrees, liveSessions, removedSessionIds])
|
||||
|
||||
// Main lanes are always visible; linked worktrees can be user-dismissed.
|
||||
const ordered = overlaidGroups.filter(group => group.isMain || !dismissedWorktrees.includes(group.id))
|
||||
const repoCount = ordered.reduce((sum, group) => sum + group.sessions.length, 0)
|
||||
|
||||
// Removal asks how: actually `git worktree remove` it, or just hide the lane
|
||||
// and leave the worktree on disk. A dirty worktree escalates to a force prompt
|
||||
// instead of erroring (those changes are usually throwaway).
|
||||
const [removeTarget, setRemoveTarget] = useState<null | SidebarSessionGroup>(null)
|
||||
const [forceTarget, setForceTarget] = useState<null | SidebarSessionGroup>(null)
|
||||
|
||||
const removeViaGit = async (group: SidebarSessionGroup, force = false) => {
|
||||
if (!repo.path || !group.path) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await removeWorktreePath(repo.path, group.path, { force })
|
||||
dismissWorktree(group.id)
|
||||
} catch (err) {
|
||||
// git refuses a non-force remove on a dirty/locked worktree — offer force
|
||||
// rather than dead-ending on an error toast.
|
||||
if (!force && /force|modified|untracked|dirty|locked|contains/i.test(String((err as Error)?.message ?? ''))) {
|
||||
setForceTarget(group)
|
||||
} else {
|
||||
notifyError(err, s.projects.removeWorktreeFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{ordered.map(group => (
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
// The kanban bucket is read-only: it aggregates many task worktrees, so
|
||||
// "new session here" and "remove worktree" have no single target.
|
||||
onNewSession={group.isKanban ? undefined : onNewSession}
|
||||
onRemove={group.isMain || group.isKanban ? undefined : () => setRemoveTarget(group)}
|
||||
renderRows={renderRows}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
// Both removal prompts share the shape (hide-from-sidebar + cancel + a
|
||||
// destructive action); only the copy and the destructive handler differ.
|
||||
const worktreeDialog = (
|
||||
target: null | SidebarSessionGroup,
|
||||
setTarget: (next: null | SidebarSessionGroup) => void,
|
||||
description: string,
|
||||
destructiveLabel: string,
|
||||
onDestructive: (group: SidebarSessionGroup) => void
|
||||
) => (
|
||||
<Dialog onOpenChange={isOpen => !isOpen && setTarget(null)} open={Boolean(target)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${s.projects.removeWorktree} "${target?.label ?? ''}"?`}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setTarget(null)} variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (target) {
|
||||
dismissWorktree(target.id)
|
||||
}
|
||||
|
||||
setTarget(null)
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{s.projects.removeFromSidebar}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTarget(null)
|
||||
|
||||
if (target) {
|
||||
onDestructive(target)
|
||||
}
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
{destructiveLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
const removeDialog = (
|
||||
<>
|
||||
{worktreeDialog(
|
||||
removeTarget,
|
||||
setRemoveTarget,
|
||||
s.projects.removeWorktreeConfirm,
|
||||
s.projects.removeWorktree,
|
||||
group => void removeViaGit(group)
|
||||
)}
|
||||
{worktreeDialog(
|
||||
forceTarget,
|
||||
setForceTarget,
|
||||
s.projects.removeWorktreeDirty,
|
||||
s.projects.forceRemove,
|
||||
group => void removeViaGit(group, true)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (!showHeader) {
|
||||
return (
|
||||
<>
|
||||
{body}
|
||||
{removeDialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarRowStack>
|
||||
<WorkspaceHeader
|
||||
action={
|
||||
onNewSession && <WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
|
||||
}
|
||||
count={repoCount}
|
||||
emphasis
|
||||
icon={<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="repo" size="0.75rem" />}
|
||||
label={repo.label}
|
||||
onToggle={toggleOpen}
|
||||
open={open}
|
||||
title={repo.path ?? undefined}
|
||||
/>
|
||||
{open && <SidebarRowStack className="pl-2.5">{body}</SidebarRowStack>}
|
||||
{removeDialog}
|
||||
</SidebarRowStack>
|
||||
)
|
||||
}
|
||||
15
apps/desktop/src/app/chat/sidebar/projects/index.ts
Normal file
15
apps/desktop/src/app/chat/sidebar/projects/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Public surface of the project/worktree sidebar, consumed by the sidebar root.
|
||||
export { EnteredProjectContent } from './entered-content'
|
||||
export { PROJECT_PREVIEW_COUNT, projectTreeCwd, sortProjectsForOverview, useRepoWorktreeMap } from './model'
|
||||
export { ProjectBackRow, ProjectOverviewRow } from './overview-row'
|
||||
export { ProjectMenu } from './project-menu'
|
||||
export { SidebarWorkspaceGroup } from './workspace-group'
|
||||
export {
|
||||
overlayLiveLanes,
|
||||
overlayLivePreviews,
|
||||
sessionRecency,
|
||||
type SidebarProjectTree,
|
||||
type SidebarSessionGroup,
|
||||
type SidebarWorkspaceTree
|
||||
} from './workspace-groups'
|
||||
export { StartWorkButton } from './workspace-header'
|
||||
128
apps/desktop/src/app/chat/sidebar/projects/model.ts
Normal file
128
apps/desktop/src/app/chat/sidebar/projects/model.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { HermesGitWorktree } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { mapPool } from '@/lib/pool'
|
||||
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
|
||||
import { $worktreeRefreshToken } from '@/store/projects'
|
||||
|
||||
import { sessionRecency, type SidebarProjectTree } from './workspace-groups'
|
||||
|
||||
// Page size when revealing more already-loaded rows within a workspace group.
|
||||
export const SIDEBAR_GROUP_PAGE = 5
|
||||
|
||||
// Recent sessions previewed under each project in the overview.
|
||||
export const PROJECT_PREVIEW_COUNT = 3
|
||||
|
||||
// Max concurrent `git worktree list` probes when a project spans many repos.
|
||||
const WORKTREE_PROBE_CONCURRENCY = 4
|
||||
|
||||
const pathListKey = (paths: string[]): string =>
|
||||
paths.map(path => path.trim()).filter(Boolean).sort((a, b) => a.localeCompare(b)).join('\n')
|
||||
|
||||
// Every session in a project, across its repos/worktrees (order-agnostic).
|
||||
const projectSessions = (project: SidebarProjectTree): SessionInfo[] =>
|
||||
project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions))
|
||||
|
||||
export const projectTreeCwd = (project: SidebarProjectTree): null | string =>
|
||||
project.path || project.repos.find(repo => repo.path)?.path || null
|
||||
|
||||
// Overview rows carry their activity stamp from the backend (lanes are empty in
|
||||
// overview mode), falling back to loaded session times when present.
|
||||
const projectActivityTime = (project: SidebarProjectTree): number =>
|
||||
Math.max(
|
||||
project.lastActive ?? 0,
|
||||
projectSessions(project).reduce((latest, s) => Math.max(latest, sessionRecency(s)), 0)
|
||||
)
|
||||
|
||||
// The project's most-recent sessions, for the overview preview under each row.
|
||||
export const latestProjectSessions = (project: SidebarProjectTree, limit: number): SessionInfo[] =>
|
||||
[...projectSessions(project)].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
|
||||
|
||||
export function sortProjectsForOverview(
|
||||
projects: SidebarProjectTree[],
|
||||
activeProjectId: null | string
|
||||
): SidebarProjectTree[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
const aActive = Boolean(activeProjectId && a.id === activeProjectId && !a.isAuto)
|
||||
const bActive = Boolean(activeProjectId && b.id === activeProjectId && !b.isAuto)
|
||||
|
||||
if (aActive !== bActive) {
|
||||
return aActive ? -1 : 1
|
||||
}
|
||||
|
||||
if (!a.isAuto !== !b.isAuto) {
|
||||
return a.isAuto ? 1 : -1
|
||||
}
|
||||
|
||||
const aHasSessions = a.sessionCount > 0
|
||||
const bHasSessions = b.sessionCount > 0
|
||||
|
||||
if (aHasSessions !== bHasSessions) {
|
||||
return aHasSessions ? -1 : 1
|
||||
}
|
||||
|
||||
return projectActivityTime(b) - projectActivityTime(a) || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|
||||
})
|
||||
}
|
||||
|
||||
// Project drill-in lanes are git-driven: source them from `git worktree list` so
|
||||
// linked worktrees still appear even when their sessions aren't in the recents
|
||||
// payload currently loaded in memory.
|
||||
export function useRepoWorktreeMap(
|
||||
repoPaths: string[],
|
||||
enabled: boolean
|
||||
): [Record<string, HermesGitWorktree[]>, boolean] {
|
||||
const [map, setMap] = useState<Record<string, HermesGitWorktree[]>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const key = useMemo(() => pathListKey(repoPaths), [repoPaths])
|
||||
// Refetch when a worktree is added/removed so a new lane shows immediately.
|
||||
const refreshToken = useStore($worktreeRefreshToken)
|
||||
|
||||
useEffect(() => {
|
||||
const git = window.hermesDesktop?.git
|
||||
|
||||
if (!enabled || !repoPaths.length || !git?.worktreeList) {
|
||||
setMap({})
|
||||
setLoading(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
setLoading(true)
|
||||
// Bounded so a many-repo project doesn't spawn a `git` process per repo at once.
|
||||
void mapPool(repoPaths, WORKTREE_PROBE_CONCURRENCY, async repoPath => {
|
||||
try {
|
||||
return [repoPath, await git.worktreeList(repoPath)] as const
|
||||
} catch {
|
||||
return [repoPath, []] as const
|
||||
}
|
||||
})
|
||||
.then(entries => void (cancelled || setMap(Object.fromEntries(entries))))
|
||||
.finally(() => void (cancelled || setLoading(false)))
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [enabled, key, repoPaths, refreshToken])
|
||||
|
||||
return [map, loading]
|
||||
}
|
||||
|
||||
// Persisted open/collapse for a repo/worktree node. Lets a project's folder
|
||||
// layout auto-restore when you enter it, and survive reloads.
|
||||
//
|
||||
// The persisted set is an OVERRIDE of `defaultOpen`, not an absolute "collapsed"
|
||||
// list: XOR lets one store serve both polarities. A default-open node (repo,
|
||||
// populated lane) lists collapses; a default-collapsed node (an EMPTY lane — no
|
||||
// sessions yet) instead records an explicit expand. So empty worktree/branch
|
||||
// lanes start collapsed and only open when the user clicks in.
|
||||
export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] {
|
||||
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
|
||||
const overridden = collapsed.includes(id)
|
||||
|
||||
return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)]
|
||||
}
|
||||
155
apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
Normal file
155
apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type * as React from 'react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
SIDEBAR_LEAD_ICON_SIZE,
|
||||
SidebarRowBody,
|
||||
SidebarRowCluster,
|
||||
SidebarRowGrab,
|
||||
SidebarRowLabel,
|
||||
SidebarRowLead,
|
||||
SidebarRowLeadGlyph,
|
||||
SidebarRowLink,
|
||||
SidebarRowNest,
|
||||
SidebarRowShell
|
||||
} from '../chrome'
|
||||
|
||||
import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model'
|
||||
import { ProjectMenu } from './project-menu'
|
||||
import type { SidebarProjectTree } from './workspace-groups'
|
||||
import { WorkspaceAddButton } from './workspace-header'
|
||||
|
||||
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
|
||||
// the lead's default tertiary. The glyph wrapper centers + caps size either way.
|
||||
export function projectIcon({ color, icon }: SidebarProjectTree) {
|
||||
if (color && !icon) {
|
||||
return (
|
||||
<SidebarRowLeadGlyph>
|
||||
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
|
||||
</SidebarRowLeadGlyph>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarRowLeadGlyph style={color ? { color } : undefined}>
|
||||
<Codicon name={icon || 'folder-library'} size={SIDEBAR_LEAD_ICON_SIZE} />
|
||||
</SidebarRowLeadGlyph>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectBackRow({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<SidebarRowShell>
|
||||
<SidebarRowBody
|
||||
className="group/back w-full text-(--ui-text-tertiary) opacity-40 hover:text-foreground"
|
||||
onClick={onClick}
|
||||
>
|
||||
<SidebarRowLead>
|
||||
<SidebarRowLeadGlyph>
|
||||
<Codicon name="arrow-left" size={SIDEBAR_LEAD_ICON_SIZE} />
|
||||
</SidebarRowLeadGlyph>
|
||||
</SidebarRowLead>
|
||||
<SidebarRowLabel className="text-xs underline-offset-4 group-hover/back:underline">{label}</SidebarRowLabel>
|
||||
</SidebarRowBody>
|
||||
</SidebarRowShell>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProjectOverviewRowProps {
|
||||
project: SidebarProjectTree
|
||||
onEnter?: (id: string) => void
|
||||
onNewSession?: (path: null | string) => void
|
||||
renderRows?: (sessions: SessionInfo[]) => React.ReactNode
|
||||
activeProjectId?: null | string
|
||||
previewSessions?: SessionInfo[]
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
ref?: React.Ref<HTMLDivElement>
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function ProjectOverviewRow({
|
||||
project,
|
||||
onEnter,
|
||||
onNewSession,
|
||||
renderRows,
|
||||
activeProjectId,
|
||||
previewSessions,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
ref,
|
||||
style
|
||||
}: ProjectOverviewRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const isActive = project.id === activeProjectId
|
||||
const [open, toggleOpen] = useWorkspaceNodeOpen(project.id)
|
||||
// The appearance popover anchors here (the full row) so it opens flush with
|
||||
// the sidebar's content edge regardless of which side the sidebar is on.
|
||||
const rowRef = useRef<HTMLDivElement>(null)
|
||||
const fetched = (previewSessions ?? []).slice(0, PROJECT_PREVIEW_COUNT)
|
||||
const preview = renderRows ? (fetched.length ? fetched : latestProjectSessions(project, PROJECT_PREVIEW_COUNT)) : []
|
||||
|
||||
const lead = reorderable ? (
|
||||
<SidebarRowGrab
|
||||
ariaLabel={s.projects.reorder(project.label)}
|
||||
dragging={dragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
leadClassName="overflow-visible"
|
||||
>
|
||||
{projectIcon(project)}
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead>{projectIcon(project)}</SidebarRowLead>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn(dragging && 'relative z-10')} ref={ref} style={style}>
|
||||
<SidebarRowShell
|
||||
actions={
|
||||
<>
|
||||
{onNewSession && <WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />}
|
||||
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
|
||||
</>
|
||||
}
|
||||
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}
|
||||
ref={rowRef}
|
||||
>
|
||||
<SidebarRowCluster className="min-w-0 flex-1">
|
||||
{lead}
|
||||
<SidebarRowLink
|
||||
aria-label={s.projects.enter(project.label)}
|
||||
labelClassName={cn('hover:text-foreground hover:underline', isActive && 'text-foreground')}
|
||||
onClick={() => onEnter?.(project.id)}
|
||||
>
|
||||
{project.label}
|
||||
</SidebarRowLink>
|
||||
{preview.length > 0 ? (
|
||||
<button
|
||||
aria-label={s.projects.toggle(project.label)}
|
||||
className="flex flex-1 items-center self-stretch bg-transparent p-0"
|
||||
onClick={toggleOpen}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret
|
||||
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
</SidebarRowCluster>
|
||||
</SidebarRowShell>
|
||||
{open && preview.length > 0 && <SidebarRowNest>{renderRows?.(preview)}</SidebarRowNest>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
Normal file
206
apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ColorSwatches } from '@/components/ui/color-swatches'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PROFILE_SWATCHES } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped, dismissAutoProject } from '@/store/layout'
|
||||
import {
|
||||
copyPath,
|
||||
deleteProject,
|
||||
openProjectAddFolder,
|
||||
openProjectRename,
|
||||
revealPath,
|
||||
setActiveProject,
|
||||
updateProject
|
||||
} from '@/store/projects'
|
||||
|
||||
import type { SidebarProjectTree } from './workspace-groups'
|
||||
|
||||
// Curated codicons for the project glyph (tinted by the chosen color).
|
||||
const ICONS = [
|
||||
'folder-library', 'repo', 'rocket', 'beaker', 'flame', 'star-full', 'heart',
|
||||
'zap', 'target', 'lightbulb', 'tools', 'device-desktop', 'device-mobile', 'terminal',
|
||||
'dashboard', 'globe', 'broadcast', 'cloud', 'database', 'package', 'book',
|
||||
'organization', 'bug', 'shield', 'key', 'gift', 'telescope', 'home'
|
||||
]
|
||||
|
||||
// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal
|
||||
// in the file manager, copy path, and "Remove from sidebar" (never deletes files
|
||||
// — auto projects are dismissed, explicit ones drop their entry). Explicit
|
||||
// projects additionally get rename / add folder / set active. Hidden until the
|
||||
// row is hovered (group/workspace), matching the + affordance.
|
||||
export function ProjectMenu({
|
||||
project,
|
||||
isActive,
|
||||
scoped = false,
|
||||
onExitScope,
|
||||
anchorRef
|
||||
}: {
|
||||
project: SidebarProjectTree
|
||||
isActive: boolean
|
||||
// True when rendered in the entered-project header, so removal can leave the
|
||||
// now-defunct scope.
|
||||
scoped?: boolean
|
||||
onExitScope?: () => void
|
||||
// Anchor the appearance popover to the whole row instead of the kebab, so it
|
||||
// opens flush against the sidebar's content-facing edge — otherwise a
|
||||
// right-side sidebar drags the picker across the entire panel (the kebab
|
||||
// lives at the row's outer edge). Falls back to the kebab when absent.
|
||||
anchorRef?: React.RefObject<HTMLElement | null>
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
const target = { id: project.id, name: project.label }
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
const [appearanceOpen, setAppearanceOpen] = useState(false)
|
||||
// Open toward the content area: right when the sidebar is on the left, left
|
||||
// when the panes are flipped (sidebar on the right).
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
|
||||
const removeAuto = () => {
|
||||
dismissAutoProject(project.id)
|
||||
|
||||
if (scoped) {
|
||||
onExitScope?.()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
await deleteProject(project.id)
|
||||
|
||||
if (scoped) {
|
||||
onExitScope?.()
|
||||
}
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={p.menu}
|
||||
className={cn(
|
||||
'grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:opacity-100',
|
||||
// In the project header reveal on the whole header hover; in overview
|
||||
// rows reveal on the row hover.
|
||||
scoped ? 'group-hover/section:opacity-100' : 'group-hover/workspace:opacity-100'
|
||||
)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.75rem" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setAppearanceOpen} open={appearanceOpen}>
|
||||
{/* Position the appearance popover against the row (when a ref is wired);
|
||||
the kebab is only the dropdown trigger then. */}
|
||||
{anchorRef ? <PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} /> : null}
|
||||
<DropdownMenu>
|
||||
{anchorRef ? trigger : <PopoverAnchor asChild>{trigger}</PopoverAnchor>}
|
||||
{/* Closing the menu refocuses the trigger (also the popover anchor),
|
||||
which the appearance popover would read as focus-outside and die on.
|
||||
Suppress that refocus so it survives. */}
|
||||
<DropdownMenuContent align="end" className="w-48" onCloseAutoFocus={event => event.preventDefault()} sideOffset={6}>
|
||||
{!project.isAuto && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => openProjectRename(target)}>
|
||||
<Codicon name="edit" size="0.875rem" />
|
||||
<span>{p.menuRename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setAppearanceOpen(true)}>
|
||||
<Codicon name="symbol-color" size="0.875rem" />
|
||||
<span>{p.menuAppearance}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => openProjectAddFolder(target)}>
|
||||
<Codicon name="new-folder" size="0.875rem" />
|
||||
<span>{p.menuAddFolder}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={isActive} onSelect={() => void setActiveProject(project.id)}>
|
||||
<Codicon name="target" size="0.875rem" />
|
||||
<span>{p.menuSetActive}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem disabled={!project.path} onSelect={() => void revealPath(project.path)}>
|
||||
<Codicon name="folder-opened" size="0.875rem" />
|
||||
<span>{p.reveal}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!project.path} onSelect={() => void copyPath(project.path)}>
|
||||
<Codicon name="copy" size="0.875rem" />
|
||||
<span>{p.copyPath}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{project.isAuto ? (
|
||||
<DropdownMenuItem onSelect={removeAuto} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{p.removeFromSidebar}</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => setConfirmDeleteOpen(true)} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{`${p.menuDelete}…`}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-auto p-2"
|
||||
onClick={event => event.stopPropagation()}
|
||||
side={panesFlipped ? 'left' : 'right'}
|
||||
sideOffset={6}
|
||||
>
|
||||
<ColorSwatches
|
||||
clearIcon="circle-slash"
|
||||
clearLabel={p.noColor}
|
||||
onChange={color => void updateProject(project.id, { color })}
|
||||
swatches={PROFILE_SWATCHES}
|
||||
value={project.color ?? null}
|
||||
/>
|
||||
{/* Same 6 columns + gap as the swatch grid so the popover keeps the
|
||||
profile picker's width (icons flex to fill, not fixed-width). */}
|
||||
<div className="mt-2 grid grid-cols-6 gap-1.5">
|
||||
{ICONS.map(name => (
|
||||
<button
|
||||
aria-label={name}
|
||||
className={cn(
|
||||
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
|
||||
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
key={name}
|
||||
onClick={() => void updateProject(project.id, { icon: project.icon === name ? null : name })}
|
||||
style={project.icon === name && project.color ? { color: project.color } : undefined}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={name} size="0.8125rem" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
<ConfirmDialog
|
||||
confirmLabel={p.menuDelete}
|
||||
description={p.deleteConfirm}
|
||||
destructive
|
||||
onClose={() => setConfirmDeleteOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
open={confirmDeleteOpen}
|
||||
title={`${p.menuDelete} "${project.label}"?`}
|
||||
/>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
144
apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
Normal file
144
apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import type * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { newSessionInProfile } from '@/store/profile'
|
||||
import { switchBranchInRepo } from '@/store/projects'
|
||||
|
||||
import { countLabel, SidebarRowStack } from '../chrome'
|
||||
import { SidebarLoadMoreRow } from '../load-more-row'
|
||||
|
||||
import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model'
|
||||
import type { SidebarSessionGroup } from './workspace-groups'
|
||||
import { WorkspaceAddButton, WorkspaceHeader, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header'
|
||||
|
||||
interface SidebarWorkspaceGroupProps {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
// When set (linked worktree rows), shows a remove affordance that runs a real
|
||||
// `git worktree remove`.
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemove }: SidebarWorkspaceGroupProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const isProfileGroup = group.mode === 'profile'
|
||||
// Empty worktree/branch lanes start collapsed — they only show a "No sessions
|
||||
// yet" placeholder, so defaulting them open just adds noise. Profile lanes and
|
||||
// lanes that already hold sessions default open.
|
||||
const defaultOpen = isProfileGroup || group.sessions.length > 0
|
||||
const [open, toggleOpen] = useWorkspaceNodeOpen(group.id, defaultOpen)
|
||||
const [visibleCount, setVisibleCount] = useState(SIDEBAR_GROUP_PAGE)
|
||||
|
||||
const loadedCount = group.sessions.length
|
||||
// Profile groups know their on-disk total (children excluded); workspace
|
||||
// groups only ever page within what's already loaded.
|
||||
const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
|
||||
const visibleSessions = group.sessions.slice(0, visibleCount)
|
||||
const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
|
||||
const nextCount = Math.min(SIDEBAR_GROUP_PAGE, hiddenCount)
|
||||
|
||||
// Leading glyph: profile color dot, a home mark for the repo's primary
|
||||
// checkout (labeled by its live branch), or a branch/kanban mark otherwise.
|
||||
const leadingIcon = group.color ? (
|
||||
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
|
||||
) : (
|
||||
<Codicon
|
||||
className="shrink-0 text-(--ui-text-tertiary)"
|
||||
name={group.isKanban ? 'checklist' : group.isHome ? 'home' : 'git-branch'}
|
||||
size="0.75rem"
|
||||
/>
|
||||
)
|
||||
|
||||
// Reveal already-loaded rows first; only hit the backend when the next page
|
||||
// crosses what's been fetched for this profile.
|
||||
const handleProfileLoadMore = () => {
|
||||
const target = visibleCount + SIDEBAR_GROUP_PAGE
|
||||
|
||||
setVisibleCount(target)
|
||||
|
||||
if (target > loadedCount && loadedCount < totalCount) {
|
||||
group.onLoadMore?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewSession = async () => {
|
||||
if (isProfileGroup) {
|
||||
newSessionInProfile(group.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!onNewSession) {
|
||||
return
|
||||
}
|
||||
|
||||
// Main-checkout lanes are branch-labeled views over the same repo root path.
|
||||
// Clicking "+" on `main` should open on `main`, not whatever branch the root
|
||||
// currently sits on (`test0`, etc.), so explicitly switch first.
|
||||
if (group.isMain && group.path && group.label) {
|
||||
try {
|
||||
await switchBranchInRepo(group.path, group.label)
|
||||
} catch (err) {
|
||||
notifyError(err, t.statusStack.coding.switchFailed(group.label))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onNewSession(group.path)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarRowStack>
|
||||
<WorkspaceHeader
|
||||
action={
|
||||
(onNewSession || isProfileGroup || onRemove) && (
|
||||
<div className="flex items-center">
|
||||
{(onNewSession || isProfileGroup) && (
|
||||
<WorkspaceAddButton
|
||||
label={s.newSessionIn(group.label)}
|
||||
// Profile groups start a fresh session in that profile but keep
|
||||
// the all-profiles browse view; workspace groups seed the new
|
||||
// session's cwd. Main checkout lanes are branch-targeted.
|
||||
onClick={() => void handleNewSession()}
|
||||
/>
|
||||
)}
|
||||
{onRemove && <WorkspaceMenu onRemove={onRemove} path={group.path} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
count={isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
|
||||
icon={leadingIcon}
|
||||
label={group.label}
|
||||
onToggle={toggleOpen}
|
||||
open={open}
|
||||
title={group.path ?? undefined}
|
||||
/>
|
||||
{open && (
|
||||
<>
|
||||
{visibleSessions.length === 0 ? (
|
||||
<div className="min-h-7 pl-2 text-[0.75rem] leading-7 text-(--ui-text-quaternary)">{s.noSessions}</div>
|
||||
) : (
|
||||
renderRows(visibleSessions)
|
||||
)}
|
||||
{hiddenCount > 0 &&
|
||||
(isProfileGroup ? (
|
||||
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
|
||||
) : (
|
||||
<WorkspaceShowMoreButton
|
||||
count={nextCount}
|
||||
label={group.label}
|
||||
onClick={() => setVisibleCount(count => count + SIDEBAR_GROUP_PAGE)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SidebarRowStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesGitWorktree } from '@/global'
|
||||
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
baseName,
|
||||
kanbanWorktreeDir,
|
||||
liveSessionProjectId,
|
||||
mergeRepoWorktreeGroups,
|
||||
overlayLiveLanes,
|
||||
overlayLivePreviews,
|
||||
type SidebarProjectTree,
|
||||
type SidebarSessionGroup,
|
||||
sortWorktreeGroups
|
||||
} from './workspace-groups'
|
||||
|
||||
// The grouping itself now lives on the backend (tui_gateway/project_tree.py,
|
||||
// covered by tests/tui_gateway/test_project_tree.py). This file only covers the
|
||||
// thin render helpers the desktop still owns + the VISUAL worktree enhancer.
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
archived: false,
|
||||
cwd,
|
||||
ended_at: null,
|
||||
id: `s${nextId++}`,
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 1_000,
|
||||
message_count: 1,
|
||||
model: 'claude',
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
source: 'cli',
|
||||
started_at: 1_000,
|
||||
title: null,
|
||||
tool_call_count: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const lane = (over: Partial<SidebarSessionGroup> & Pick<SidebarSessionGroup, 'id' | 'label'>): SidebarSessionGroup => ({
|
||||
path: null,
|
||||
sessions: [],
|
||||
...over
|
||||
})
|
||||
|
||||
describe('baseName', () => {
|
||||
it('returns the final path segment, ignoring trailing slashes and separators', () => {
|
||||
expect(baseName('/www/hermes-agent/')).toBe('hermes-agent')
|
||||
expect(baseName('C:\\repos\\app')).toBe('app')
|
||||
expect(baseName('')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('kanbanWorktreeDir', () => {
|
||||
it('matches a kanban task worktree (t_<hex>) and returns its .worktrees dir', () => {
|
||||
expect(kanbanWorktreeDir('/repo/.worktrees/t_aaaaaaaa')).toBe('/repo/.worktrees')
|
||||
})
|
||||
|
||||
it('does NOT match a user-named "New worktree" under .worktrees/ (its own lane)', () => {
|
||||
expect(kanbanWorktreeDir('/repo/.worktrees/test-gui-stuff')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for non-kanban paths', () => {
|
||||
expect(kanbanWorktreeDir('/repo/src')).toBeNull()
|
||||
expect(kanbanWorktreeDir('/repo')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortWorktreeGroups', () => {
|
||||
it('pins trunk to the top, sinks kanban to the bottom, and orders the rest by recency', () => {
|
||||
const at = (t: number) => [makeSession('/x', { last_active: t })]
|
||||
|
||||
const groups = [
|
||||
lane({ id: 'k', label: 'kanban', isKanban: true, sessions: at(999) }),
|
||||
lane({ id: 'stale', label: 'stale-branch', isMain: true, sessions: at(10) }),
|
||||
lane({ id: 'wt', label: 'busy-worktree', isMain: false, sessions: at(500) }),
|
||||
lane({ id: 'main', label: 'main', isMain: true, sessions: at(1) })
|
||||
]
|
||||
|
||||
// main (trunk) first despite being least recent; kanban last despite being
|
||||
// most recent; busy-worktree ahead of stale-branch by activity.
|
||||
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['main', 'busy-worktree', 'stale-branch', 'kanban'])
|
||||
})
|
||||
|
||||
it('pins the live home checkout above trunk, even when it has no sessions yet', () => {
|
||||
const groups = [
|
||||
lane({ id: 'main', label: 'main', isMain: true, sessions: [makeSession('/x', { last_active: 999 })] }),
|
||||
lane({ id: 'home', label: 'bb/projects-paradigm', isMain: true, isHome: true })
|
||||
]
|
||||
|
||||
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['bb/projects-paradigm', 'main'])
|
||||
})
|
||||
|
||||
it('falls back to label order for equally-idle lanes', () => {
|
||||
const groups = [
|
||||
lane({ id: 'b', label: 'beta', isMain: false }),
|
||||
lane({ id: 'a', label: 'alpha', isMain: false })
|
||||
]
|
||||
|
||||
expect(sortWorktreeGroups(groups).map(g => g.label)).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeRepoWorktreeGroups (visual enhancer)', () => {
|
||||
it('injects a linked worktree lane discovered by git that has no sessions yet', () => {
|
||||
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'feature', detached: false, isMain: false, locked: false, path: '/repo-wt-feature' }
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups(repo, discovered)
|
||||
|
||||
expect(merged.map(g => g.label)).toEqual(['main', 'feature'])
|
||||
// The injected lane is empty (visual only — never carries sessions).
|
||||
expect(merged.find(g => g.label === 'feature')?.sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('never spawns a lane per kanban task worktree', () => {
|
||||
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })] }
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'wt/t_aaaaaaaa', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_aaaaaaaa' },
|
||||
{ branch: 'wt/t_bbbbbbbb', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/t_bbbbbbbb' }
|
||||
]
|
||||
|
||||
expect(mergeRepoWorktreeGroups(repo, discovered).map(g => g.label)).toEqual(['main'])
|
||||
})
|
||||
|
||||
it('does not duplicate a lane already present from the backend (by id/path)', () => {
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
|
||||
]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups(repo, discovered)
|
||||
|
||||
expect(merged).toHaveLength(1)
|
||||
// The backend lane keeps its session rows; the enhancer left it untouched.
|
||||
expect(merged[0].sessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is a no-op when git worktree list is unavailable (remote backend)', () => {
|
||||
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo' })]
|
||||
|
||||
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, undefined).map(g => g.label)).toEqual(['main'])
|
||||
})
|
||||
|
||||
it('does not add a second "main" for a linked worktree checked out on main', () => {
|
||||
const groups = [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'main', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/main-mirror' }
|
||||
]
|
||||
|
||||
expect(mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups }, discovered).filter(g => g.label === 'main')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('surfaces a user-named "New worktree" under .worktrees/ as its own lane', () => {
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'hermes/test-gui-stuff', detached: false, isMain: false, locked: false, path: '/repo/.worktrees/test-gui-stuff' }
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups({ id: '/repo', path: '/repo', groups: [] }, discovered)
|
||||
|
||||
expect(merged.map(g => g.label)).toContain('hermes/test-gui-stuff')
|
||||
})
|
||||
|
||||
it('relabels a dir-named linked worktree lane to its live checked-out branch', () => {
|
||||
// Backend labels the lane by the worktree dir (`hermes-agent-ci`); the live
|
||||
// `git worktree list` says HEAD there is `bb/ci-affected-only` → branch wins.
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] }),
|
||||
lane({
|
||||
id: '/repo-ci',
|
||||
label: 'hermes-agent-ci',
|
||||
isMain: false,
|
||||
path: '/repo-ci',
|
||||
sessions: [makeSession('/repo-ci')]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' },
|
||||
{ branch: 'bb/ci-affected-only', detached: false, isMain: false, locked: false, path: '/repo-ci' }
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups(repo, discovered)
|
||||
const ci = merged.find(g => g.id === '/repo-ci')
|
||||
|
||||
expect(ci?.label).toBe('bb/ci-affected-only')
|
||||
// The relabel is label-only — the lane keeps its id, path, and sessions.
|
||||
expect(ci?.path).toBe('/repo-ci')
|
||||
expect(ci?.sessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-anchors a lane whose path drifted from git truth back to its branch path', () => {
|
||||
// The reported bug: a lane is correctly labeled by its branch (`bb/attempts`)
|
||||
// but its stored PATH points at a stale/old worktree dir. git pins a branch
|
||||
// to exactly one worktree, so the lane must follow the branch's real path —
|
||||
// otherwise "reveal in Finder" opens a completely different worktree.
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({
|
||||
id: '/repo/.worktrees/attempts',
|
||||
label: 'bb/attempts',
|
||||
isMain: false,
|
||||
path: '/repo/.worktrees/attempts',
|
||||
sessions: [makeSession('/repo/.worktrees/attempts')]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
// git now has `bb/attempts` at a sibling dir, not the stale `.worktrees` one.
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'bb/attempts', detached: false, isMain: false, locked: false, path: '/repo-pr-attempts' }
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups(repo, discovered)
|
||||
const attempts = merged.filter(g => g.label === 'bb/attempts')
|
||||
|
||||
// Exactly one lane, re-pointed at git's real path (label preserved, sessions
|
||||
// preserved), and NO leftover lane on the stale path.
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(attempts[0].path).toBe('/repo-pr-attempts')
|
||||
expect(attempts[0].sessions).toHaveLength(1)
|
||||
expect(merged.some(g => g.path === '/repo/.worktrees/attempts')).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses a re-anchored lane onto the real lane that already holds that path', () => {
|
||||
// A stale lane (branch label, wrong path) AND the real worktree lane both
|
||||
// exist. Re-anchoring the stale one onto git's path must not leave a twin —
|
||||
// keep the richer (more sessions) lane.
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: 'stale', label: 'bb/feature', isMain: false, path: '/repo/.worktrees/old', sessions: [] }),
|
||||
lane({
|
||||
id: '/repo-feature',
|
||||
label: 'bb/feature',
|
||||
isMain: false,
|
||||
path: '/repo-feature',
|
||||
sessions: [makeSession('/repo-feature'), makeSession('/repo-feature')]
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'bb/feature', detached: false, isMain: false, locked: false, path: '/repo-feature' }
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups(repo, discovered)
|
||||
const feature = merged.filter(g => g.path === '/repo-feature')
|
||||
|
||||
expect(feature).toHaveLength(1)
|
||||
expect(feature[0].sessions).toHaveLength(2)
|
||||
expect(merged.some(g => g.path === '/repo/.worktrees/old')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the dir label for a detached-HEAD worktree (no branch to show)', () => {
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo-ci', label: 'repo-ci', isMain: false, path: '/repo-ci', sessions: [makeSession('/repo-ci')] })
|
||||
]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: null, detached: true, isMain: false, locked: false, path: '/repo-ci' }
|
||||
]
|
||||
|
||||
expect(mergeRepoWorktreeGroups(repo, discovered).find(g => g.id === '/repo-ci')?.label).toBe('repo-ci')
|
||||
})
|
||||
|
||||
it('collapses the main checkout into one home lane labeled by the live branch (off-trunk)', () => {
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })
|
||||
]
|
||||
}
|
||||
|
||||
// The repo root is switched to a feature branch. The historical "main"
|
||||
// sessions fold into ONE home lane labeled by the live branch — no stale
|
||||
// "main" lane lingering beside it.
|
||||
const discovered: HermesGitWorktree[] = [
|
||||
{ branch: 'some-feature', detached: false, isMain: true, locked: false, path: '/repo' }
|
||||
]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups(repo, discovered)
|
||||
const home = merged.find(g => g.isHome)
|
||||
|
||||
expect(merged.filter(g => g.isMain)).toHaveLength(1)
|
||||
expect(home?.label).toBe('some-feature')
|
||||
expect(home?.path).toBe('/repo')
|
||||
expect(home?.sessions).toHaveLength(1)
|
||||
expect(merged.some(g => g.label === 'main')).toBe(false)
|
||||
})
|
||||
|
||||
it('labels the home lane "main" (still home-flagged) when the root is on trunk', () => {
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [{ branch: 'main', detached: false, isMain: true, locked: false, path: '/repo' }]
|
||||
|
||||
const home = mergeRepoWorktreeGroups(repo, discovered).find(g => g.isHome)
|
||||
|
||||
expect(home?.label).toBe('main')
|
||||
expect(home?.isHome).toBe(true)
|
||||
})
|
||||
|
||||
it('folds multiple historical main-checkout branch lanes into the single live home lane', () => {
|
||||
const repo = {
|
||||
id: '/repo',
|
||||
path: '/repo',
|
||||
groups: [
|
||||
lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'a' })] }),
|
||||
lane({ id: '/repo::branch::old', label: 'old-feature', isMain: true, path: '/repo', sessions: [makeSession('/repo', { id: 'b' })] })
|
||||
]
|
||||
}
|
||||
|
||||
const discovered: HermesGitWorktree[] = [{ branch: 'bb/live', detached: false, isMain: true, locked: false, path: '/repo' }]
|
||||
|
||||
const merged = mergeRepoWorktreeGroups(repo, discovered)
|
||||
const home = merged.find(g => g.isHome)
|
||||
|
||||
expect(merged.filter(g => g.isMain)).toHaveLength(1)
|
||||
expect(home?.label).toBe('bb/live')
|
||||
expect(home?.sessions.map(s => s.id).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('leaves main lanes untouched on a remote backend (no git probe)', () => {
|
||||
const repo = { id: '/repo', path: '/repo', groups: [lane({ id: '/repo::branch::main', label: 'main', isMain: true, path: '/repo', sessions: [makeSession('/repo')] })] }
|
||||
|
||||
// No discovered worktrees → no live branch truth → backend label stands.
|
||||
const merged = mergeRepoWorktreeGroups(repo, undefined)
|
||||
|
||||
expect(merged.map(g => g.label)).toEqual(['main'])
|
||||
expect(merged[0].isHome).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
const makeProject = (id: string, folders: string[]): ProjectInfo => ({
|
||||
archived: false,
|
||||
board_slug: null,
|
||||
color: null,
|
||||
created_at: 0,
|
||||
description: null,
|
||||
folders: folders.map((path, i) => ({ added_at: 0, is_primary: i === 0, label: null, path })),
|
||||
icon: null,
|
||||
id,
|
||||
name: id,
|
||||
primary_path: folders[0] ?? null,
|
||||
slug: id
|
||||
})
|
||||
|
||||
const projectNode = (over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree, 'id'>): SidebarProjectTree => ({
|
||||
label: over.id,
|
||||
path: over.id,
|
||||
repos: [],
|
||||
sessionCount: 0,
|
||||
...over
|
||||
})
|
||||
|
||||
describe('liveSessionProjectId', () => {
|
||||
it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => {
|
||||
expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app')
|
||||
})
|
||||
|
||||
it('routes a session under an explicit project folder to that project', () => {
|
||||
const id = liveSessionProjectId(makeSession('/www/app/src', { git_repo_root: '/www/app', git_branch: 'feat' }), [
|
||||
makeProject('p_app', ['/www/app'])
|
||||
])
|
||||
|
||||
expect(id).toBe('p_app')
|
||||
})
|
||||
|
||||
it('skips cwd-less, kanban, and linked-worktree sessions (backend folds those)', () => {
|
||||
expect(liveSessionProjectId(makeSession(null), [])).toBeNull()
|
||||
expect(liveSessionProjectId(makeSession('/repo/.worktrees/t_aaaaaaaa'), [])).toBeNull()
|
||||
expect(liveSessionProjectId(makeSession('/elsewhere/wt', { git_repo_root: '/repo' }), [])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('overlayLiveLanes', () => {
|
||||
it('injects a live session into the matching main lane instantly', () => {
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
isAuto: true,
|
||||
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
|
||||
})
|
||||
|
||||
const live = [makeSession('/www/app', { id: 'fresh', git_branch: 'main' })]
|
||||
|
||||
const overlaid = overlayLiveLanes(project, live)
|
||||
const lane = overlaid.repos[0].groups.find(g => g.label === 'main')
|
||||
|
||||
expect(lane?.sessions.map(session => session.id)).toContain('fresh')
|
||||
expect(overlaid.sessionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('injects a session created in a fresh worktree into that worktree lane (no git_repo_root yet)', () => {
|
||||
// The brand-new session row has only a cwd — no git_repo_root. The entered
|
||||
// project knows its repo root, so the worktree session still lands in its
|
||||
// own lane (not kanban, not skipped) optimistically.
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
isAuto: true,
|
||||
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
|
||||
})
|
||||
|
||||
const live = [makeSession('/www/app/.worktrees/baby', { id: 'fresh' })]
|
||||
|
||||
const overlaid = overlayLiveLanes(project, live)
|
||||
const lane = overlaid.repos[0].groups.find(g => g.id === '/www/app/.worktrees/baby')
|
||||
|
||||
expect(lane?.label).toBe('baby')
|
||||
expect(lane?.sessions.map(s => s.id)).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('folds a kanban-task worktree session into the kanban lane', () => {
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
isAuto: true,
|
||||
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups: [] }]
|
||||
})
|
||||
|
||||
const live = [makeSession('/www/app/.worktrees/t_abc12345', { id: 'k' })]
|
||||
|
||||
const overlaid = overlayLiveLanes(project, live)
|
||||
const lane = overlaid.repos[0].groups.find(g => g.isKanban)
|
||||
|
||||
expect(lane?.id).toBe('/www/app::kanban')
|
||||
expect(lane?.sessions.map(s => s.id)).toEqual(['k'])
|
||||
})
|
||||
|
||||
it('does not duplicate a session already present in a backend lane', () => {
|
||||
const existing = makeSession('/www/app', { id: 'dup', git_branch: 'main' })
|
||||
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
repos: [
|
||||
{
|
||||
id: '/www/app',
|
||||
label: 'app',
|
||||
path: '/www/app',
|
||||
sessionCount: 1,
|
||||
groups: [lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [existing] })]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const overlaid = overlayLiveLanes(project, [existing])
|
||||
|
||||
expect(overlaid.repos[0].groups.flatMap(g => g.sessions.map(s => s.id))).toEqual(['dup'])
|
||||
})
|
||||
|
||||
it('adds a new session to an existing worktree lane keyed by a divergent id (matches by path)', () => {
|
||||
// Backend keyed the worktree lane off a branch-style id (no live git probe),
|
||||
// but the lane PATH is the worktree dir. A new session under that worktree
|
||||
// must join the existing lane, not spawn a twin.
|
||||
const existing = makeSession('/www/app/.worktrees/baby', { id: 'old' })
|
||||
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
repos: [
|
||||
{
|
||||
id: '/www/app',
|
||||
label: 'app',
|
||||
path: '/www/app',
|
||||
sessionCount: 1,
|
||||
groups: [
|
||||
lane({ id: '/www/app::branch::baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [existing] })
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const fresh = makeSession('/www/app/.worktrees/baby', { id: 'fresh' })
|
||||
|
||||
const overlaid = overlayLiveLanes(project, [existing, fresh])
|
||||
const lanes = overlaid.repos[0].groups.filter(g => g.path === '/www/app/.worktrees/baby')
|
||||
|
||||
expect(lanes).toHaveLength(1)
|
||||
expect(lanes[0].sessions.map(s => s.id).sort()).toEqual(['fresh', 'old'])
|
||||
})
|
||||
|
||||
it('places a session into an out-of-tree (sibling) worktree lane by its path', () => {
|
||||
// `hermes-agent-ci` is a linked worktree living BESIDE the repo, not under
|
||||
// it — repo-root nesting fails, but the existing lane carries its real path.
|
||||
const existing = makeSession('/www/app-ci', { id: 'old' })
|
||||
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
repos: [
|
||||
{
|
||||
id: '/www/app',
|
||||
label: 'app',
|
||||
path: '/www/app',
|
||||
sessionCount: 1,
|
||||
groups: [
|
||||
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [] }),
|
||||
lane({ id: '/www/app-ci', label: 'app-ci', path: '/www/app-ci', sessions: [existing] })
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const fresh = makeSession('/www/app-ci', { id: 'fresh' })
|
||||
|
||||
const overlaid = overlayLiveLanes(project, [existing, fresh])
|
||||
const ci = overlaid.repos[0].groups.find(g => g.path === '/www/app-ci')
|
||||
const main = overlaid.repos[0].groups.find(g => g.label === 'main')
|
||||
|
||||
expect(ci?.sessions.map(s => s.id).sort()).toEqual(['fresh', 'old'])
|
||||
expect(main?.sessions ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('places into a visual-only discovered worktree lane after merge', () => {
|
||||
const discovered = [{ path: '/www/app-retry', branch: 'bb/ci-install-retry', isMain: false, detached: false, locked: false }]
|
||||
const groups = mergeRepoWorktreeGroups({ id: '/www/app', path: '/www/app', groups: [] }, discovered)
|
||||
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
repos: [{ id: '/www/app', label: 'app', path: '/www/app', sessionCount: 0, groups }]
|
||||
})
|
||||
|
||||
const fresh = makeSession('/www/app-retry', { id: 'fresh' })
|
||||
|
||||
const overlaid = overlayLiveLanes(project, [fresh])
|
||||
const lane = overlaid.repos[0].groups.find(g => g.path === '/www/app-retry')
|
||||
|
||||
expect(lane?.sessions.map(s => s.id)).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('evicts a deleted/archived snapshot row (and drops the lane once empty)', () => {
|
||||
const a = makeSession('/www/app', { id: 'keep', git_branch: 'main' })
|
||||
const b = makeSession('/www/app/.worktrees/baby', { id: 'gone' })
|
||||
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
repos: [
|
||||
{
|
||||
id: '/www/app',
|
||||
label: 'app',
|
||||
path: '/www/app',
|
||||
sessionCount: 2,
|
||||
groups: [
|
||||
lane({ id: '/www/app::branch::main', label: 'main', isMain: true, path: '/www/app', sessions: [a] }),
|
||||
lane({ id: '/www/app/.worktrees/baby', label: 'baby', path: '/www/app/.worktrees/baby', sessions: [b] })
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// No live rows (both deleted from $sessions); only 'gone' is tombstoned.
|
||||
const overlaid = overlayLiveLanes(project, [a], new Set(['gone']))
|
||||
|
||||
expect(overlaid.repos[0].groups.map(g => g.id)).toEqual(['/www/app::branch::main'])
|
||||
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['keep'])
|
||||
expect(overlaid.sessionCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('overlayLivePreviews', () => {
|
||||
it('merges live sessions into a project preview, live first, capped to the limit', () => {
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
previewSessions: [makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })]
|
||||
})
|
||||
|
||||
const live = [makeSession('/www/app', { id: 'fresh', started_at: 99, last_active: 99 })]
|
||||
|
||||
const previews = overlayLivePreviews([project], live, [], 3)
|
||||
|
||||
expect(previews['/www/app'].map(s => s.id)).toEqual(['fresh', 'old'])
|
||||
})
|
||||
|
||||
it('evicts a deleted session from a project preview (snapshot + live)', () => {
|
||||
const project = projectNode({
|
||||
id: '/www/app',
|
||||
previewSessions: [
|
||||
makeSession('/www/app', { id: 'gone', started_at: 5, last_active: 5 }),
|
||||
makeSession('/www/app', { id: 'old', started_at: 1, last_active: 1 })
|
||||
]
|
||||
})
|
||||
|
||||
const previews = overlayLivePreviews([project], [], [], 3, new Set(['gone']))
|
||||
|
||||
expect(previews['/www/app'].map(s => s.id)).toEqual(['old'])
|
||||
})
|
||||
})
|
||||
577
apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
Normal file
577
apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
Normal file
@@ -0,0 +1,577 @@
|
||||
import type { HermesGitWorktree } from '@/global'
|
||||
import type { ProjectInfo, SessionInfo } from '@/hermes'
|
||||
|
||||
// Session grouping is now computed authoritatively on the backend
|
||||
// (`tui_gateway/project_tree.py`, exposed via `projects.tree` /
|
||||
// `projects.project_sessions`). The desktop is a thin renderer: this module
|
||||
// only holds the render contract (the three tree interfaces) plus a couple of
|
||||
// pure helpers and the VISUAL-ONLY worktree enhancer that injects empty lanes
|
||||
// from `git worktree list`. It never decides session membership.
|
||||
|
||||
export interface SidebarSessionGroup {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
sessions: SessionInfo[]
|
||||
// Profile color for the ALL-profiles view; absent for workspace groups.
|
||||
color?: null | string
|
||||
// True when this group is a repo's main checkout (vs a linked worktree).
|
||||
isMain?: boolean
|
||||
// True for the repo's primary ("home") checkout lane — the single lane that
|
||||
// collapses all main-checkout sessions, labeled by the worktree's LIVE branch
|
||||
// (defaulting to `main`). Renders a home glyph and pins to the top.
|
||||
isHome?: boolean
|
||||
// True for the synthetic lane that collapses all of a repo's kanban task
|
||||
// worktrees (`<repo>/.worktrees/t_*`) into one row, so a heavy board doesn't
|
||||
// spray hundreds of throwaway branch lanes across the sidebar.
|
||||
isKanban?: boolean
|
||||
loadingMore?: boolean
|
||||
mode?: 'profile' | 'source' | 'workspace'
|
||||
onLoadMore?: () => void
|
||||
sourceId?: string
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
/** A repo node: holds its branch/worktree lanes (`repo -> lane -> sessions`). */
|
||||
export interface SidebarWorkspaceTree {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
groups: SidebarSessionGroup[]
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
/** A project node: human-named (or repo-derived), holds its repo subtree. */
|
||||
export interface SidebarProjectTree {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
color?: null | string
|
||||
icon?: null | string
|
||||
archived?: boolean
|
||||
// A git repo root promoted automatically (not a user-created projects.db row).
|
||||
// Deletable = dismissable.
|
||||
isAuto?: boolean
|
||||
// The synthetic "No project" bucket for cwd-less sessions.
|
||||
isNoProject?: boolean
|
||||
repos: SidebarWorkspaceTree[]
|
||||
sessionCount: number
|
||||
// Max activity timestamp across the project's sessions (overview sort key).
|
||||
lastActive?: number
|
||||
// Up to N most-recent sessions for the overview preview (set by `projects.tree`).
|
||||
previewSessions?: SessionInfo[]
|
||||
}
|
||||
|
||||
/** Path split into segments, ignoring trailing slashes and mixed separators. */
|
||||
const segments = (path: string): string[] =>
|
||||
path
|
||||
.replace(/[/\\]+$/, '')
|
||||
.split(/[/\\]/)
|
||||
.filter(Boolean)
|
||||
|
||||
/** A path with trailing separators stripped, for stable equality checks. */
|
||||
const normalizePath = (path: null | string | undefined): string => (path ?? '').replace(/[/\\]+$/, '')
|
||||
|
||||
/** Last path segment. */
|
||||
export const baseName = (path: string): string | undefined => segments(path).pop()
|
||||
|
||||
// The `.worktrees` dir for a KANBAN-TASK worktree path, else null. Only matches
|
||||
// task worktrees (`<repo>/.worktrees/t_<hex>`, the `t_…` id kanban_db mints) so
|
||||
// the many ephemeral task worktrees collapse into one lane — while user-named
|
||||
// "New worktree" dirs (`<repo>/.worktrees/<slug>`) stay as their own lanes.
|
||||
const KANBAN_DIR_RE = /^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$/
|
||||
|
||||
export function kanbanWorktreeDir(path: string): null | string {
|
||||
return path.match(KANBAN_DIR_RE)?.[1] ?? null
|
||||
}
|
||||
|
||||
/** Label for a main-checkout lane whose session recorded no branch. */
|
||||
export const DEFAULT_BRANCH_LABEL = 'main'
|
||||
|
||||
/** The one definition of a main-checkout lane id (must match the backend tree). */
|
||||
export const branchLaneId = (repoRoot: string, branch?: string): string =>
|
||||
`${repoRoot}::branch::${(branch ?? '').trim()}`
|
||||
|
||||
/** A session's recency stamp (last activity, falling back to creation). */
|
||||
export const sessionRecency = (session: SessionInfo): number => session.last_active || session.started_at || 0
|
||||
|
||||
/** Default-branch names that pin to the top and read as the repo's trunk. */
|
||||
const TRUNK_BRANCHES = new Set(['main', 'master', 'trunk', 'develop'])
|
||||
|
||||
const isTrunkLane = (group: SidebarSessionGroup): boolean =>
|
||||
Boolean(group.isMain) && TRUNK_BRANCHES.has(group.label.toLowerCase())
|
||||
|
||||
/** A lane's recency = its most-recently-active session (empty lanes sink). */
|
||||
const laneActivity = (group: SidebarSessionGroup): number =>
|
||||
group.sessions.reduce((max, session) => Math.max(max, sessionRecency(session)), 0)
|
||||
|
||||
// Lane tiers (low sorts first): the repo's primary ("home") checkout pins above
|
||||
// everything (it's "where you are", labeled by its live branch), then trunk,
|
||||
// then ordinary branches/worktrees, then the kanban aggregate.
|
||||
const laneRank = (group: SidebarSessionGroup): number =>
|
||||
group.isHome ? 0 : isTrunkLane(group) ? 1 : group.isKanban ? 3 : 2
|
||||
|
||||
/**
|
||||
* Sort by tier (home → trunk → branches/worktrees → kanban); within a tier, by
|
||||
* most-recent activity (empty lanes fall last), label as the tiebreak.
|
||||
*/
|
||||
function compareWorktreeGroups(a: SidebarSessionGroup, b: SidebarSessionGroup): number {
|
||||
const byRank = laneRank(a) - laneRank(b)
|
||||
|
||||
if (byRank !== 0) {
|
||||
return byRank
|
||||
}
|
||||
|
||||
const byActivity = laneActivity(b) - laneActivity(a)
|
||||
|
||||
return byActivity || a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
export function sortWorktreeGroups(groups: SidebarSessionGroup[]): SidebarSessionGroup[] {
|
||||
return [...groups].sort(compareWorktreeGroups)
|
||||
}
|
||||
|
||||
/**
|
||||
* VISUAL enhancer only: inject empty lanes from a live `git worktree list` so a
|
||||
* repo shows its branches/worktrees even when they have no Hermes sessions yet.
|
||||
* The repo's real session lanes already come fully built from the backend
|
||||
* (`projects.project_sessions`); this never adds or moves session rows, and it
|
||||
* degrades to a no-op on remote backends (where the Electron probe returns
|
||||
* nothing). Lanes already present (by id/path) are left untouched.
|
||||
*/
|
||||
export function mergeRepoWorktreeGroups(
|
||||
repo: Pick<SidebarWorkspaceTree, 'groups' | 'id' | 'path'>,
|
||||
discoveredWorktrees?: HermesGitWorktree[]
|
||||
): SidebarSessionGroup[] {
|
||||
// Branch-primary labels: a linked worktree's identity in every git UI (VS
|
||||
// Code, JetBrains, lazygit, …) is its CHECKED-OUT BRANCH, not the directory it
|
||||
// happens to live in. The backend labels these lanes by dir/slug; relabel them
|
||||
// to the live branch from `git worktree list` so the sidebar matches the
|
||||
// composer's branch strip. Detached worktrees (no branch) keep their dir label.
|
||||
const liveBranchByPath = new Map<string, string>()
|
||||
// Inverse: branch → its ONE live worktree path. git guarantees a branch is
|
||||
// checked out in at most one worktree, so this mapping is a function and can
|
||||
// re-anchor a lane whose stored path has drifted from git truth.
|
||||
const livePathByBranch = new Map<string, string>()
|
||||
|
||||
for (const worktree of discoveredWorktrees ?? []) {
|
||||
const wtPath = normalizePath(worktree.path)
|
||||
const branch = worktree.branch?.trim()
|
||||
|
||||
if (wtPath && branch && !worktree.detached) {
|
||||
liveBranchByPath.set(wtPath, branch)
|
||||
livePathByBranch.set(branch.toLowerCase(), worktree.path.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// The primary ("home") checkout's LIVE branch. A repo dir is only ever on ONE
|
||||
// branch, so every main-checkout session lane (historical branches over the
|
||||
// same root path) collapses into a single home lane labeled by this live
|
||||
// branch, defaulting to `main`. Known only when the local git probe ran;
|
||||
// remote backends keep the backend's recorded-branch main lane untouched.
|
||||
const mainWorktree = (discoveredWorktrees ?? []).find(w => w.isMain)
|
||||
const homeBranch = mainWorktree && !mainWorktree.detached ? mainWorktree.branch?.trim() || DEFAULT_BRANCH_LABEL : ''
|
||||
|
||||
// Reconcile a LINKED worktree lane against git truth so its label AND path
|
||||
// describe the SAME worktree. Two repair directions:
|
||||
// 1. Path git knows → relabel to that path's live branch (git UIs identify a
|
||||
// worktree by its checked-out branch, not the dir it lives in).
|
||||
// 2. Path git DOESN'T know but the label IS a live branch → the lane's path
|
||||
// has gone stale; re-anchor it to that branch's real path, else "reveal"
|
||||
// opens a different, stale checkout. The home checkout is folded
|
||||
// separately (below), never here.
|
||||
const reconcile = (group: SidebarSessionGroup): SidebarSessionGroup => {
|
||||
if (group.isMain || group.isKanban) {
|
||||
return group
|
||||
}
|
||||
|
||||
const branchForPath = liveBranchByPath.get(normalizePath(group.path))
|
||||
|
||||
if (branchForPath) {
|
||||
return branchForPath !== group.label ? { ...group, label: branchForPath } : group
|
||||
}
|
||||
|
||||
const livePath = livePathByBranch.get(group.label.trim().toLowerCase())
|
||||
|
||||
if (livePath && normalizePath(livePath) !== normalizePath(group.path)) {
|
||||
return { ...group, id: livePath, path: livePath }
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
const dedupeById = (sessions: SessionInfo[]): SessionInfo[] => {
|
||||
const byId = new Map<string, SessionInfo>()
|
||||
|
||||
for (const session of sessions) {
|
||||
byId.set(session.id, byId.get(session.id) ?? session)
|
||||
}
|
||||
|
||||
return [...byId.values()]
|
||||
}
|
||||
|
||||
// Fold every main-checkout lane into one home lane labeled by the live branch
|
||||
// (the root dir is only ever on one branch); reconcile the linked worktrees.
|
||||
// Always shown, even with no sessions on the current branch yet. Remote
|
||||
// backends (no probe → no homeBranch) keep their main lanes untouched.
|
||||
const mainGroups = repo.groups.filter(group => group.isMain)
|
||||
const reconciled = repo.groups.filter(group => !group.isMain).map(reconcile)
|
||||
|
||||
if (homeBranch) {
|
||||
reconciled.push({
|
||||
id: branchLaneId(repo.id, homeBranch),
|
||||
label: homeBranch,
|
||||
path: repo.path,
|
||||
isMain: true,
|
||||
isHome: true,
|
||||
sessions: dedupeById(mainGroups.flatMap(group => group.sessions))
|
||||
})
|
||||
} else {
|
||||
reconciled.push(...mainGroups)
|
||||
}
|
||||
|
||||
// Collapse any duplicate a re-anchor produced (a stale lane re-pointed onto a
|
||||
// path a real lane already holds) — keep the richer (more sessions) lane.
|
||||
const byPath = new Map<string, SidebarSessionGroup>()
|
||||
const merged: SidebarSessionGroup[] = []
|
||||
|
||||
for (const group of reconciled) {
|
||||
const key = !group.isMain && group.path ? normalizePath(group.path) : ''
|
||||
const existing = key ? byPath.get(key) : undefined
|
||||
|
||||
if (existing) {
|
||||
if (group.sessions.length > existing.sessions.length) {
|
||||
merged[merged.indexOf(existing)] = group
|
||||
byPath.set(key, group)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (key) {
|
||||
byPath.set(key, group)
|
||||
}
|
||||
|
||||
merged.push(group)
|
||||
}
|
||||
|
||||
const seenIds = new Set(merged.map(group => group.id))
|
||||
const seenPaths = new Set(merged.map(group => group.path).filter((path): path is string => Boolean(path)))
|
||||
// Dedupe by branch label too: a branch shows once even if it's checked out in
|
||||
// a linked worktree AND already has a session lane.
|
||||
const seenLabels = new Set(merged.map(group => group.label.toLowerCase()))
|
||||
|
||||
for (const worktree of discoveredWorktrees ?? []) {
|
||||
const wtPath = worktree.path?.trim()
|
||||
|
||||
if (!wtPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
// The home checkout is already the collapsed home lane (above).
|
||||
if (worktree.isMain && homeBranch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Kanban task worktrees never get their own lane — they fold into the
|
||||
// session-derived `::kanban` bucket. Listing every `git worktree list` entry
|
||||
// here is exactly what blew the sidebar up to hundreds of empty rows.
|
||||
if (!worktree.isMain && kanbanWorktreeDir(wtPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const label = (worktree.isMain ? worktree.branch?.trim() || DEFAULT_BRANCH_LABEL : worktree.branch?.trim()) || baseName(wtPath) || wtPath
|
||||
const id = worktree.isMain ? branchLaneId(repo.id, label) : wtPath
|
||||
|
||||
const alreadySeen =
|
||||
seenIds.has(id) || seenLabels.has(label.toLowerCase()) || (!worktree.isMain && seenPaths.has(wtPath))
|
||||
|
||||
if (alreadySeen) {
|
||||
continue
|
||||
}
|
||||
|
||||
merged.push({ id, isMain: worktree.isMain, label, path: wtPath, sessions: [] })
|
||||
seenIds.add(id)
|
||||
seenPaths.add(wtPath)
|
||||
seenLabels.add(label.toLowerCase())
|
||||
}
|
||||
|
||||
return sortWorktreeGroups(merged)
|
||||
}
|
||||
|
||||
// ── Live session overlay ─────────────────────────────────────────────────────
|
||||
// The backend tree is a snapshot (sessions with >=1 message, refreshed on a
|
||||
// turn boundary). For parity with the flat Recents list — instant insertion of
|
||||
// a freshly-created session and the live "working" arc — we overlay the live
|
||||
// `$sessions` store onto the tree at render time. This is ADDITIVE only: the
|
||||
// backend still owns membership, structure, counts, and history. The overlay
|
||||
// just places rows already present in `$sessions` into the project/lane the
|
||||
// backend would put them in, using the same id scheme. Worktree/kanban folding
|
||||
// needs the backend common-root probe, so those rows are left for the next
|
||||
// tree refresh; the common case (a new main-checkout session) overlays here.
|
||||
|
||||
/** True when `target` equals `folder` or is nested under it (segment-wise). */
|
||||
function isPathUnder(folder: string, target: string): boolean {
|
||||
const f = segments(folder)
|
||||
const t = segments(target)
|
||||
|
||||
if (!f.length || f.length > t.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return f.every((seg, i) => seg === t[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* The project a plain main-checkout live session belongs to (overview
|
||||
* membership) — explicit project by longest-prefix folder, else the repo root
|
||||
* (the auto-project id). Returns null for sessions we can't place without the
|
||||
* backend (cwd-less, kanban, or a linked worktree); those wait for the refresh.
|
||||
*/
|
||||
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
|
||||
const cwd = (session.cwd || '').trim()
|
||||
|
||||
if (!cwd || kanbanWorktreeDir(cwd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// No persisted repo root yet (brand-new session) → the cwd is the root.
|
||||
const repoRoot = (session.git_repo_root || '').trim() || cwd
|
||||
const underRepo = cwd === repoRoot || cwd.startsWith(`${repoRoot}/`) || cwd.startsWith(`${repoRoot}\\`)
|
||||
|
||||
if (!underRepo || cwd.startsWith(`${repoRoot}/.worktrees/`) || cwd.startsWith(`${repoRoot}\\.worktrees\\`)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let projectId = ''
|
||||
let bestLen = -1
|
||||
|
||||
for (const project of explicitProjects) {
|
||||
if (project.archived) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const folder of project.folders) {
|
||||
if (isPathUnder(folder.path, cwd) || isPathUnder(folder.path, repoRoot)) {
|
||||
const len = segments(folder.path).length
|
||||
|
||||
if (len > bestLen) {
|
||||
bestLen = len
|
||||
projectId = project.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return projectId || repoRoot
|
||||
}
|
||||
|
||||
const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] =>
|
||||
[session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => b.started_at - a.started_at)
|
||||
|
||||
/**
|
||||
* The lane a live session belongs to WITHIN a known repo root, by path — the
|
||||
* entered project already knows its repo roots, so we don't need the session's
|
||||
* (often-unset, on a fresh row) git_repo_root. Mirrors the backend's lane ids:
|
||||
* main checkout -> branch lane, `.worktrees/t_<hex>` -> kanban, any other
|
||||
* `.worktrees/<slug>` -> that worktree's own lane.
|
||||
*/
|
||||
function liveLaneForRepo(repoRoot: string, session: SessionInfo): null | SidebarSessionGroup {
|
||||
const cwd = (session.cwd || '').trim()
|
||||
|
||||
if (!cwd || !isPathUnder(repoRoot, cwd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wt = cwd.match(/^(.*[/\\]\.worktrees)[/\\]([^/\\]+)/)
|
||||
|
||||
if (wt) {
|
||||
const [worktreeRoot, worktreesDir, slug] = [wt[0], wt[1], wt[2]]
|
||||
|
||||
return /^t_[0-9a-f]+$/.test(slug)
|
||||
? { id: `${repoRoot}::kanban`, isKanban: true, isMain: false, label: 'kanban', path: worktreesDir, sessions: [] }
|
||||
: { id: worktreeRoot, isMain: false, label: slug, path: worktreeRoot, sessions: [] }
|
||||
}
|
||||
|
||||
const branch = (session.git_branch || '').trim() || DEFAULT_BRANCH_LABEL
|
||||
|
||||
return { id: branchLaneId(repoRoot, branch), isMain: true, label: branch, path: repoRoot, sessions: [] }
|
||||
}
|
||||
|
||||
const NO_REMOVED: ReadonlySet<string> = new Set()
|
||||
|
||||
/**
|
||||
* Reconcile ONE repo's lanes against the live `$sessions` cache: evict
|
||||
* deleted/archived rows (`removed`) and inject freshly-created ones, so a lane
|
||||
* mutates exactly like the flat Recents list. The backend snapshot stays the
|
||||
* datasource for structure and off-page history; this is the optimistic layer
|
||||
* on top (Apollo-style), reconciled away on the next snapshot refresh. Returns
|
||||
* the same repo ref when nothing changes (memo-stable).
|
||||
*/
|
||||
export function overlayRepoLanes(
|
||||
repo: SidebarWorkspaceTree,
|
||||
live: SessionInfo[],
|
||||
removed: ReadonlySet<string> = NO_REMOVED
|
||||
): SidebarWorkspaceTree {
|
||||
const repoRoot = normalizePath(repo.path)
|
||||
let changed = false
|
||||
|
||||
// Snapshot lanes minus anything the user just deleted/archived.
|
||||
const lanes = repo.groups.map(g => {
|
||||
if (!removed.size) {
|
||||
return { ...g, sessions: [...g.sessions] }
|
||||
}
|
||||
|
||||
const kept = g.sessions.filter(s => !removed.has(s.id))
|
||||
|
||||
changed ||= kept.length !== g.sessions.length
|
||||
|
||||
return { ...g, sessions: kept }
|
||||
})
|
||||
|
||||
for (const session of live) {
|
||||
const cwd = (session.cwd || '').trim()
|
||||
|
||||
if (removed.has(session.id) || !cwd) {
|
||||
continue
|
||||
}
|
||||
|
||||
// (1) Join an EXISTING worktree lane by its own path. A linked worktree can
|
||||
// live anywhere on disk (often a repo sibling, e.g. `repo-ci`), so nesting
|
||||
// under the repo root isn't reliable — but the lane carries its real dir.
|
||||
// Longest match wins; skip the root lane so an in-tree `.worktrees/<slug>`
|
||||
// session isn't swallowed by main.
|
||||
let lane: SidebarSessionGroup | undefined
|
||||
let bestLen = -1
|
||||
|
||||
for (const g of lanes) {
|
||||
const lanePath = normalizePath(g.path)
|
||||
|
||||
if (!lanePath || lanePath === repoRoot || !isPathUnder(lanePath, cwd)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const len = segments(lanePath).length
|
||||
|
||||
if (len > bestLen) {
|
||||
bestLen = len
|
||||
lane = g
|
||||
}
|
||||
}
|
||||
|
||||
// (2) Else place under the repo root via a computed lane (main / branch /
|
||||
// in-tree `.worktrees` / kanban). Match by id, then path (the backend may
|
||||
// key a worktree lane off the git-probed root OR a branch-style id), then
|
||||
// the main-lane label; create it when the snapshot lacked it.
|
||||
if (!lane) {
|
||||
const placed = repo.path ? liveLaneForRepo(repo.path, session) : null
|
||||
|
||||
if (!placed) {
|
||||
continue
|
||||
}
|
||||
|
||||
const placedPath = normalizePath(placed.path)
|
||||
|
||||
lane =
|
||||
lanes.find(g => g.id === placed.id) ??
|
||||
(placed.isMain ? lanes.find(g => g.isMain && g.label.toLowerCase() === placed.label.toLowerCase()) : undefined) ??
|
||||
(!placed.isMain && placedPath ? lanes.find(g => normalizePath(g.path) === placedPath) : undefined)
|
||||
|
||||
if (!lane) {
|
||||
lane = { ...placed, sessions: [] }
|
||||
lanes.push(lane)
|
||||
}
|
||||
}
|
||||
|
||||
lane.sessions = upsertSession(lane.sessions, session)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return repo
|
||||
}
|
||||
|
||||
// Drop lanes emptied by eviction (the server only emits non-empty lanes; the
|
||||
// git-worktree enhancer re-adds any still-real worktree as an empty lane).
|
||||
const groups = sortWorktreeGroups(lanes.filter(g => g.sessions.length > 0))
|
||||
|
||||
return { ...repo, groups, sessionCount: groups.reduce((n, g) => n + g.sessions.length, 0) }
|
||||
}
|
||||
|
||||
/** Project-level overlay: {@link overlayRepoLanes} across every repo subtree. */
|
||||
export function overlayLiveLanes(
|
||||
project: SidebarProjectTree,
|
||||
live: SessionInfo[],
|
||||
removed: ReadonlySet<string> = NO_REMOVED
|
||||
): SidebarProjectTree {
|
||||
let changed = false
|
||||
|
||||
const repos = project.repos.map(repo => {
|
||||
const next = overlayRepoLanes(repo, live, removed)
|
||||
|
||||
changed ||= next !== repo
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
return project
|
||||
}
|
||||
|
||||
return { ...project, repos, sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0) }
|
||||
}
|
||||
|
||||
/** Merge live sessions into per-project overview previews, keyed by project path. */
|
||||
export function overlayLivePreviews(
|
||||
projects: SidebarProjectTree[],
|
||||
live: SessionInfo[],
|
||||
explicitProjects: ProjectInfo[],
|
||||
limit: number,
|
||||
removed: ReadonlySet<string> = new Set()
|
||||
): Record<string, SessionInfo[]> {
|
||||
const byProject = new Map<string, SessionInfo[]>()
|
||||
|
||||
for (const session of live) {
|
||||
if (removed.has(session.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const projectId = liveSessionProjectId(session, explicitProjects)
|
||||
|
||||
if (!projectId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const arr = byProject.get(projectId) ?? []
|
||||
arr.push(session)
|
||||
byProject.set(projectId, arr)
|
||||
}
|
||||
|
||||
const out: Record<string, SessionInfo[]> = {}
|
||||
|
||||
for (const node of projects) {
|
||||
if (!node.path) {
|
||||
continue
|
||||
}
|
||||
|
||||
const liveRows = byProject.get(node.id) ?? []
|
||||
const base = (node.previewSessions ?? []).filter(session => !removed.has(session.id))
|
||||
|
||||
if (!liveRows.length && !base.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Live rows take precedence (fresher title/activity/working state).
|
||||
const map = new Map<string, SessionInfo>()
|
||||
|
||||
for (const session of [...liveRows, ...base]) {
|
||||
if (!map.has(session.id)) {
|
||||
map.set(session.id, session)
|
||||
}
|
||||
}
|
||||
|
||||
out[node.path] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
243
apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
Normal file
243
apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import type * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { gitRef } from '@/lib/sanitize'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { copyPath, revealPath, startWorkInRepo } from '@/store/projects'
|
||||
|
||||
import { SidebarCount, SidebarRowLead } from '../chrome'
|
||||
|
||||
// Branch/worktree labels routinely share a long prefix (`bb/coding-context-…`),
|
||||
// so plain end-truncation (`truncate`) hides exactly the suffix that tells two
|
||||
// lanes apart — both render as "bb/coding-context…". Keep the tail pinned and
|
||||
// ellipsize the HEAD instead, so `…context-facts-rpc` and `…context-persona`
|
||||
// stay distinguishable. Falls back to whole-string for short labels.
|
||||
function LaneLabel({ label, title }: { label: string; title?: string }) {
|
||||
const tailLen = Math.min(14, Math.floor(label.length / 2))
|
||||
const head = label.slice(0, label.length - tailLen)
|
||||
const tail = label.slice(label.length - tailLen)
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0" title={title}>
|
||||
<span className="truncate">{head}</span>
|
||||
<span className="shrink-0 whitespace-pre">{tail}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// "+" affordance shared by repo and worktree headers — reveals on header hover.
|
||||
export function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Reveals the next page of already-loaded rows within a workspace/worktree.
|
||||
export function WorkspaceShowMoreButton({ count, label, onClick }: { count: number; label: string; onClick: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const text = t.sidebar.showMoreIn(count, label)
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={text}
|
||||
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.75rem" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Per-worktree actions (linked worktree lanes only), mirroring the session row
|
||||
// and ProjectMenu kebab: reveal in the file manager, copy path, and remove the
|
||||
// worktree (runs a real `git worktree remove` via the caller's confirm dialog).
|
||||
export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const p = t.sidebar.projects
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={p.menu}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
|
||||
onClick={event => event.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.75rem" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
|
||||
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
|
||||
<Codicon name="folder-opened" size="0.875rem" />
|
||||
<span>{p.reveal}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!path} onSelect={() => void copyPath(path)}>
|
||||
<Codicon name="copy" size="0.875rem" />
|
||||
<span>{p.copyPath}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onRemove} variant="destructive">
|
||||
<Codicon name="trash" size="0.875rem" />
|
||||
<span>{`${p.removeWorktree}…`}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// "New worktree": prompt for a branch name, then git spins up a fresh worktree
|
||||
// for that branch under the repo (the lightest way) and we open a new session
|
||||
// inside it. Naming is explicit — no auto-generated `hermes/work-<ts>` trees.
|
||||
export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onStarted: (path: string) => void }) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
const branch = name.trim()
|
||||
|
||||
if (pending || !repoPath || !branch) {
|
||||
return
|
||||
}
|
||||
|
||||
setPending(true)
|
||||
|
||||
try {
|
||||
// Pass the typed value as both the dir slug source and the branch, so the
|
||||
// branch is exactly what the user named (the dir is slugified git-side).
|
||||
const result = await startWorkInRepo(repoPath, { branch, name: branch })
|
||||
|
||||
if (result) {
|
||||
onStarted(result.path)
|
||||
setOpen(false)
|
||||
setName('')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, s.projects.startWorkFailed)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label={s.projects.startWork}
|
||||
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => setOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="git-branch" size="0.75rem" />
|
||||
</button>
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{s.projects.newWorktreeTitle}</DialogTitle>
|
||||
<DialogDescription>{s.projects.newWorktreeDesc}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SanitizedInput
|
||||
autoFocus
|
||||
disabled={pending}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void submit()
|
||||
} else if (event.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={setName}
|
||||
placeholder={s.projects.branchPlaceholder}
|
||||
sanitize={gitRef}
|
||||
value={name}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button disabled={pending} onClick={() => setOpen(false)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={pending || !name.trim()} onClick={() => void submit()} type="button">
|
||||
{s.projects.startWork}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible header shared by the repo (emphasis) and worktree levels: a toggle
|
||||
// button with a leading glyph, plus an optional trailing action (the +).
|
||||
export function WorkspaceHeader({
|
||||
action,
|
||||
count,
|
||||
emphasis = false,
|
||||
icon,
|
||||
label,
|
||||
onToggle,
|
||||
open,
|
||||
title
|
||||
}: {
|
||||
action?: React.ReactNode
|
||||
count: React.ReactNode
|
||||
emphasis?: boolean
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onToggle: () => void
|
||||
open: boolean
|
||||
/** Hover tooltip — the lane's full on-disk path (worktree / repo root). */
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem]',
|
||||
emphasis ? 'font-semibold text-(--ui-text-secondary)' : 'font-medium text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-1.5 bg-transparent text-left',
|
||||
emphasis ? 'hover:text-foreground' : 'hover:text-(--ui-text-secondary)'
|
||||
)}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<SidebarRowLead>{icon}</SidebarRowLead>
|
||||
<LaneLabel label={label} title={title ? `${label}\n${title}` : label} />
|
||||
<span className="shrink-0">
|
||||
<SidebarCount>{count}</SidebarCount>
|
||||
</span>
|
||||
<DisclosureCaret
|
||||
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -77,6 +77,7 @@ interface SessionActions {
|
||||
pinned?: boolean
|
||||
profile?: string
|
||||
onPin?: () => void
|
||||
onBranch?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
@@ -92,7 +93,7 @@ interface ItemSpec {
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onArchive, onDelete }: SessionActions) {
|
||||
function useSessionActions({ sessionId, title, pinned = false, profile, onPin, onBranch, onArchive, onDelete }: SessionActions) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
@@ -130,6 +131,15 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
void exportSession(sessionId, { profile, title })
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onBranch?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'edit',
|
||||
@@ -175,6 +185,7 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
disabled={!sessionId}
|
||||
errorMessage={r.copyIdFailed}
|
||||
iconClassName="size-3.5 text-current"
|
||||
key={r.copyId}
|
||||
label={r.copyId}
|
||||
onCopyError={err => notifyError(err, r.copyIdFailed)}
|
||||
|
||||
@@ -15,14 +15,18 @@ import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
session: SessionInfo
|
||||
/** TUI-style tree stem for branched sessions (`└─ ` / `├─ `). */
|
||||
branchStem?: string
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onBranch?: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@@ -51,10 +55,12 @@ function formatAge(seconds: number, r: Translations['sidebar']['row']): string {
|
||||
|
||||
export function SidebarSessionRow({
|
||||
session,
|
||||
branchStem,
|
||||
isPinned,
|
||||
isSelected,
|
||||
isWorking,
|
||||
onArchive,
|
||||
onBranch,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume,
|
||||
@@ -84,6 +90,7 @@ export function SidebarSessionRow({
|
||||
return (
|
||||
<SessionContextMenu
|
||||
onArchive={onArchive}
|
||||
onBranch={onBranch}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
@@ -91,9 +98,38 @@ export function SidebarSessionRow({
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<div
|
||||
<SidebarRowShell
|
||||
actions={
|
||||
<div className="relative z-2 grid w-[1.375rem] place-items-center">
|
||||
{!isWorking && (
|
||||
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{age}
|
||||
</span>
|
||||
)}
|
||||
<SessionActionsMenu
|
||||
onArchive={onArchive}
|
||||
onBranch={onBranch}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
profile={session.profile}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
aria-label={r.actionsFor(title)}
|
||||
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title={r.sessionActions}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.875rem" />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
</div>
|
||||
}
|
||||
className={cn(
|
||||
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
|
||||
'group relative cursor-pointer transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
|
||||
isSelected && 'bg-(--ui-row-active-background)',
|
||||
isWorking && 'text-foreground',
|
||||
// Opaque surface while lifted so the dragged row erases what's under
|
||||
@@ -123,9 +159,7 @@ export function SidebarSessionRow({
|
||||
{...rest}
|
||||
>
|
||||
{isWorking && !needsInput && <span aria-hidden="true" className="arc-border" />}
|
||||
<button
|
||||
className="z-0 flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left group-hover:pr-12"
|
||||
onClick={event => {
|
||||
<SidebarRowBody className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')} onClick={event => {
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -150,49 +184,25 @@ export function SidebarSessionRow({
|
||||
|
||||
onResume()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{reorderable ? (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
aria-label={handleLabel}
|
||||
className={cn(
|
||||
// Scope the dot↔grabber swap to a local group so the grabber
|
||||
// only reveals when hovering/focusing the handle itself, not
|
||||
// anywhere on the row. Width MUST match the non-reorderable dot
|
||||
// column (w-3.5) so rows don't shift horizontally when reorder is
|
||||
// toggled (e.g. scoped → ALL-profiles view).
|
||||
'group/handle relative -my-0.5 grid w-3.5 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing',
|
||||
// The quest-glow box-shadow extends past the dot; let it bleed
|
||||
// out instead of being clipped by this handle's overflow-hidden.
|
||||
needsInput && 'overflow-visible'
|
||||
)}
|
||||
data-reorder-handle
|
||||
onClick={event => event.stopPropagation()}
|
||||
<SidebarRowGrab
|
||||
ariaLabel={handleLabel}
|
||||
dragging={dragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
leadClassName={needsInput ? 'overflow-visible' : undefined}
|
||||
>
|
||||
<SidebarRowDot
|
||||
<SessionRowLeadDot
|
||||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
isWorking={isWorking}
|
||||
needsInput={needsInput}
|
||||
/>
|
||||
<Codicon
|
||||
className={cn(
|
||||
'absolute text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/handle:opacity-80 group-focus-within/handle:opacity-80 hover:text-(--ui-text-secondary)',
|
||||
dragging && 'text-(--ui-text-secondary) opacity-100'
|
||||
)}
|
||||
name="grabber"
|
||||
size="0.75rem"
|
||||
/>
|
||||
</span>
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'grid w-3.5 shrink-0 place-items-center',
|
||||
needsInput ? 'overflow-visible' : 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||
<SessionRowLeadDot branchStem={branchStem} isWorking={isWorking} needsInput={needsInput} />
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
<Tip label={r.handoffOrigin(handoffLabel)}>
|
||||
@@ -203,41 +213,38 @@ export function SidebarSessionRow({
|
||||
/>
|
||||
</Tip>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative z-2 grid w-[1.375rem] place-items-center">
|
||||
{!isWorking && (
|
||||
<span className="pointer-events-none absolute right-6 top-1/2 min-w-6 -translate-y-1/2 text-right text-[0.625rem] leading-none text-(--ui-text-tertiary) opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{age}
|
||||
</span>
|
||||
)}
|
||||
<SessionActionsMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
profile={session.profile}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
aria-label={r.actionsFor(title)}
|
||||
className="size-5 rounded-[4px] bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
title={r.sessionActions}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
</SessionActionsMenu>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarRowLabel>
|
||||
</SidebarRowBody>
|
||||
</SidebarRowShell>
|
||||
</SessionContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRowLeadDot({
|
||||
branchStem,
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
className
|
||||
}: {
|
||||
branchStem?: string
|
||||
isWorking: boolean
|
||||
needsInput?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5', className)}>
|
||||
{branchStem ? (
|
||||
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
|
||||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRowDot({
|
||||
isWorking,
|
||||
needsInput = false,
|
||||
|
||||
@@ -4,30 +4,35 @@ import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { type FC, useCallback, useRef } from 'react'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
import { type SidebarSessionEntry } from '@/lib/session-branch-tree'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { sessionPinId } from '@/store/session'
|
||||
|
||||
import { SidebarSessionRow } from './session-row'
|
||||
|
||||
interface SessionRowCommonProps {
|
||||
branchStem?: string
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onBranch?: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
reorderable?: boolean
|
||||
}
|
||||
|
||||
interface VirtualSessionListProps {
|
||||
activeSessionId: null | string
|
||||
className?: string
|
||||
entries: SidebarSessionEntry[]
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onBranchSession?: (sessionId: string, profile?: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
pinned: boolean
|
||||
sessions: SessionInfo[]
|
||||
sortable: boolean
|
||||
workingSessionIdSet: Set<string>
|
||||
}
|
||||
@@ -38,21 +43,22 @@ const OVERSCAN_ROWS = 12
|
||||
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
activeSessionId,
|
||||
className,
|
||||
entries,
|
||||
onArchiveSession,
|
||||
onBranchSession,
|
||||
onDeleteSession,
|
||||
onResumeSession,
|
||||
onTogglePin,
|
||||
pinned,
|
||||
sessions,
|
||||
sortable,
|
||||
workingSessionIdSet
|
||||
}) => {
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sessions.length,
|
||||
count: entries.length,
|
||||
estimateSize: () => ROW_ESTIMATE_PX,
|
||||
getItemKey: index => sessions[index]?.id ?? index,
|
||||
getItemKey: index => entries[index]?.session.id ?? index,
|
||||
getScrollElement: () => scrollerRef.current,
|
||||
// jsdom-friendly default; the real rect takes over on first observe.
|
||||
initialRect: { height: 600, width: 240 },
|
||||
@@ -65,23 +71,29 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0))
|
||||
|
||||
const rows = virtualItems.map(virtualItem => {
|
||||
const session = sessions[virtualItem.index]
|
||||
const entry = entries[virtualItem.index]
|
||||
|
||||
if (!session) {
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { branchStem, session } = entry
|
||||
const reorderable = sortable && !branchStem
|
||||
|
||||
const commonProps: SessionRowCommonProps = {
|
||||
branchStem,
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined,
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(sessionPinId(session)),
|
||||
onResume: () => onResumeSession(session.id)
|
||||
onResume: () => onResumeSession(session.id),
|
||||
reorderable
|
||||
}
|
||||
|
||||
return sortable ? (
|
||||
return reorderable ? (
|
||||
<VirtualSortableRow
|
||||
index={virtualItem.index}
|
||||
key={session.id}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesWorktreeInfo } from '@/global'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { uniqueCwds, workspaceGroupsFor, workspaceTreeFor, type WorktreeResolver } from './workspace-groups'
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
archived: false,
|
||||
cwd,
|
||||
ended_at: null,
|
||||
id: `s${nextId++}`,
|
||||
input_tokens: 0,
|
||||
is_active: false,
|
||||
last_active: 1_000,
|
||||
message_count: 1,
|
||||
model: 'claude',
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
source: 'cli',
|
||||
started_at: 1_000,
|
||||
title: null,
|
||||
tool_call_count: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const labels = (sessions: SessionInfo[]) => workspaceGroupsFor(sessions, 'No workspace').map(g => g.label)
|
||||
|
||||
describe('workspaceGroupsFor', () => {
|
||||
it('groups by full cwd, not by basename — same-named folders are separate groups', () => {
|
||||
const groups = workspaceGroupsFor(
|
||||
[makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')],
|
||||
'No workspace'
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('disambiguates colliding basenames by walking up the path', () => {
|
||||
expect(
|
||||
labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')])
|
||||
).toEqual(['hermes-agent/apps/desktop', 'hermes-agent-wt-rtl/apps/desktop'])
|
||||
})
|
||||
|
||||
it('leaves a unique basename as its short label', () => {
|
||||
expect(labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/b/heval-py')])).toEqual([
|
||||
'desktop',
|
||||
'heval-py'
|
||||
])
|
||||
})
|
||||
|
||||
it('grows the prefix past one segment when the parent also collides', () => {
|
||||
expect(labels([makeSession('/x/proj/apps/desktop'), makeSession('/y/proj/apps/desktop')])).toEqual([
|
||||
'x/proj/apps/desktop',
|
||||
'y/proj/apps/desktop'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the synthetic no-workspace group untouched even if a real group shares its label', () => {
|
||||
const groups = workspaceGroupsFor([makeSession(null), makeSession('/a/No workspace')], 'No workspace')
|
||||
const noWorkspace = groups.find(g => g.path === null)
|
||||
|
||||
expect(noWorkspace?.label).toBe('No workspace')
|
||||
})
|
||||
})
|
||||
|
||||
const info = (over: Partial<HermesWorktreeInfo> & Pick<HermesWorktreeInfo, 'repoRoot' | 'worktreeRoot'>): HermesWorktreeInfo => ({
|
||||
branch: null,
|
||||
isMainWorktree: false,
|
||||
...over
|
||||
})
|
||||
|
||||
describe('workspaceTreeFor', () => {
|
||||
it('heuristic nests `<repo>-wt-<branch>` under its sibling repo', () => {
|
||||
const tree = workspaceTreeFor(
|
||||
[makeSession('/www/hermes-agent'), makeSession('/www/hermes-agent-wt-rtl')],
|
||||
'No workspace'
|
||||
)
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].label).toBe('hermes-agent')
|
||||
expect(tree[0].groups.map(g => g.label).sort()).toEqual(['hermes-agent', 'rtl'])
|
||||
})
|
||||
|
||||
it('git metadata is authoritative — worktrees group by repoRoot regardless of directory naming', () => {
|
||||
const resolver: WorktreeResolver = cwd => {
|
||||
if (cwd === '/www/hermes-agent') {
|
||||
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/www/hermes-agent', isMainWorktree: true, branch: 'main' })
|
||||
}
|
||||
|
||||
if (cwd === '/elsewhere/ha-rtl') {
|
||||
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/elsewhere/ha-rtl', branch: 'rtl' })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const tree = workspaceTreeFor(
|
||||
[makeSession('/www/hermes-agent'), makeSession('/elsewhere/ha-rtl')],
|
||||
'No workspace',
|
||||
resolver
|
||||
)
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].label).toBe('hermes-agent')
|
||||
// The main checkout labels by directory (its branch is transient — using it
|
||||
// would misattribute old sessions to the currently checked-out branch);
|
||||
// linked worktrees label by branch.
|
||||
expect(tree[0].groups.map(g => g.label)).toEqual(['hermes-agent', 'rtl'])
|
||||
})
|
||||
|
||||
it('a standalone directory is its own parent (always parent → worktree → sessions)', () => {
|
||||
const tree = workspaceTreeFor([makeSession('/www/heval-node')], 'No workspace')
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].label).toBe('heval-node')
|
||||
expect(tree[0].groups).toHaveLength(1)
|
||||
expect(tree[0].groups[0].label).toBe('heval-node')
|
||||
})
|
||||
|
||||
it('aggregates session counts across a repo’s worktrees', () => {
|
||||
const tree = workspaceTreeFor(
|
||||
[makeSession('/www/ha'), makeSession('/www/ha-wt-x'), makeSession('/www/ha-wt-x')],
|
||||
'No workspace'
|
||||
)
|
||||
|
||||
const parent = tree.find(p => p.label === 'ha')
|
||||
|
||||
expect(parent?.sessionCount).toBe(3)
|
||||
})
|
||||
|
||||
it('no-workspace sessions form their own parent', () => {
|
||||
const tree = workspaceTreeFor([makeSession(null)], 'No workspace')
|
||||
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0].label).toBe('No workspace')
|
||||
expect(tree[0].path).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueCwds', () => {
|
||||
it('dedupes and drops empty/whitespace cwds', () => {
|
||||
expect(uniqueCwds([makeSession('/a'), makeSession('/a'), makeSession(null), makeSession(' ')])).toEqual(['/a'])
|
||||
})
|
||||
})
|
||||
@@ -1,326 +0,0 @@
|
||||
import type { HermesWorktreeInfo } from '@/global'
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
|
||||
export interface SidebarSessionGroup {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
sessions: SessionInfo[]
|
||||
// Profile color for the ALL-profiles view; absent for workspace groups.
|
||||
color?: null | string
|
||||
loadingMore?: boolean
|
||||
mode?: 'profile' | 'source' | 'workspace'
|
||||
onLoadMore?: () => void
|
||||
sourceId?: string
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
const NO_WORKSPACE_ID = '__no_workspace__'
|
||||
|
||||
/** Path split into segments, ignoring trailing slashes and mixed separators. */
|
||||
const segments = (path: string): string[] => path.replace(/[/\\]+$/, '').split(/[/\\]/).filter(Boolean)
|
||||
|
||||
/** Last path segment. */
|
||||
export const baseName = (path: string): string | undefined => segments(path).pop()
|
||||
|
||||
/** The segments above the basename. */
|
||||
const parentSegments = (path: string): string[] => segments(path).slice(0, -1)
|
||||
|
||||
interface Labelable {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Disambiguate groups whose basename collides (worktrees all end in the same
|
||||
* `apps/desktop`, sibling repos share a folder name, etc.) by walking up the
|
||||
* path and prepending parent segments until each colliding label is unique —
|
||||
* e.g. `hermes-agent/desktop` vs `hermes-agent-wt-rtl/desktop`. Groups with a
|
||||
* unique basename keep their short label untouched.
|
||||
*/
|
||||
function disambiguateLabels(groups: Labelable[]): void {
|
||||
const byLabel = new Map<string, Labelable[]>()
|
||||
|
||||
for (const group of groups) {
|
||||
const bucket = byLabel.get(group.label)
|
||||
|
||||
if (bucket) {
|
||||
bucket.push(group)
|
||||
} else {
|
||||
byLabel.set(group.label, [group])
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of byLabel.values()) {
|
||||
if (bucket.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only groups backed by a real path can grow a prefix; the synthetic
|
||||
// "No workspace" group has no path and stays as-is.
|
||||
const pathed = bucket.filter(group => group.path)
|
||||
|
||||
if (pathed.length < 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parents = new Map(pathed.map(group => [group.id, parentSegments(group.path!)]))
|
||||
let depth = 1
|
||||
|
||||
// Grow the prefix one parent segment at a time until every label in the
|
||||
// bucket is distinct, or we run out of parent segments to add.
|
||||
while (depth <= Math.max(...pathed.map(g => parents.get(g.id)!.length))) {
|
||||
const labels = new Map<string, number>()
|
||||
|
||||
for (const group of pathed) {
|
||||
const segs = parents.get(group.id)!
|
||||
const prefix = segs.slice(-depth).join('/')
|
||||
const base = baseName(group.path!) ?? group.path!
|
||||
group.label = prefix ? `${prefix}/${base}` : base
|
||||
labels.set(group.label, (labels.get(group.label) ?? 0) + 1)
|
||||
}
|
||||
|
||||
if ([...labels.values()].every(count => count === 1)) {
|
||||
break
|
||||
}
|
||||
|
||||
depth += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceGroupsFor(
|
||||
sessions: SessionInfo[],
|
||||
noWorkspaceLabel: string,
|
||||
options: { preserveSessionOrder?: boolean } = {}
|
||||
): SidebarSessionGroup[] {
|
||||
const groups = new Map<string, SidebarSessionGroup>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
const id = path || NO_WORKSPACE_ID
|
||||
const label = baseName(path) || path || noWorkspaceLabel
|
||||
|
||||
const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
|
||||
group.sessions.push(session)
|
||||
groups.set(id, group)
|
||||
}
|
||||
|
||||
if (!options.preserveSessionOrder) {
|
||||
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
|
||||
// input, so an active project floats up), but rows *within* a group sort by
|
||||
// creation time so they don't reshuffle every time a message lands — keeps
|
||||
// muscle memory intact.
|
||||
for (const group of groups.values()) {
|
||||
group.sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
}
|
||||
}
|
||||
|
||||
const result = [...groups.values()]
|
||||
disambiguateLabels(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* A worktree's main repo and all its linked worktrees collapse into ONE parent
|
||||
* (keyed by the repo root); each worktree is a child group; sessions hang off
|
||||
* the worktree they ran in. `parent → worktree → sessions`.
|
||||
*/
|
||||
export interface SidebarWorkspaceTree {
|
||||
id: string
|
||||
label: string
|
||||
path: null | string
|
||||
groups: SidebarSessionGroup[]
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
/** Resolves a session cwd to git-worktree identity (from the local fs probe). */
|
||||
export type WorktreeResolver = (cwd: string) => HermesWorktreeInfo | null | undefined
|
||||
|
||||
interface WorkspacePlacement {
|
||||
parentKey: string
|
||||
parentLabel: string
|
||||
parentPath: string
|
||||
worktreeKey: string
|
||||
worktreeLabel: string
|
||||
worktreePath: string
|
||||
}
|
||||
|
||||
/** Replace a path's final segment, preserving its prefix + separators. */
|
||||
const withBaseName = (path: string, name: string): string =>
|
||||
path.replace(/[/\\]+$/, '').replace(/[^/\\]+$/, name)
|
||||
|
||||
/**
|
||||
* Path-only fallback for when git metadata is unavailable (remote backends,
|
||||
* unreadable paths). Mirrors the git layout: a `<repo>-wt-<branch>` directory
|
||||
* nests under its sibling `<repo>`; any other directory is its own repo root.
|
||||
*/
|
||||
function placeByHeuristic(path: string): WorkspacePlacement | null {
|
||||
const base = baseName(path)
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const worktreeMatch = base.match(/^(.+)-wt-(.+)$/)
|
||||
|
||||
if (worktreeMatch) {
|
||||
const repo = worktreeMatch[1]
|
||||
const repoPath = withBaseName(path, repo)
|
||||
|
||||
return {
|
||||
parentKey: repoPath,
|
||||
parentLabel: repo,
|
||||
parentPath: repoPath,
|
||||
worktreeKey: path,
|
||||
worktreeLabel: worktreeMatch[2],
|
||||
worktreePath: path
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parentKey: path,
|
||||
parentLabel: base,
|
||||
parentPath: path,
|
||||
worktreeKey: path,
|
||||
worktreeLabel: base,
|
||||
worktreePath: path
|
||||
}
|
||||
}
|
||||
|
||||
function placeWorkspace(path: string, resolver?: WorktreeResolver): WorkspacePlacement | null {
|
||||
const info = resolver?.(path)
|
||||
|
||||
if (info?.repoRoot && info.worktreeRoot) {
|
||||
const dirLabel = baseName(info.worktreeRoot) || info.worktreeRoot
|
||||
|
||||
return {
|
||||
parentKey: info.repoRoot,
|
||||
parentLabel: baseName(info.repoRoot) ?? info.repoRoot,
|
||||
parentPath: info.repoRoot,
|
||||
worktreeKey: info.worktreeRoot,
|
||||
// The main checkout's branch is transient — it changes as you work, so a
|
||||
// branch label would misattribute every past session to whatever branch
|
||||
// is checked out *now*. Label it by directory. Linked worktrees are
|
||||
// per-branch by construction, so branch is the clearest label there.
|
||||
worktreeLabel: info.isMainWorktree ? dirLabel : info.branch || dirLabel,
|
||||
worktreePath: info.worktreeRoot
|
||||
}
|
||||
}
|
||||
|
||||
return placeByHeuristic(path)
|
||||
}
|
||||
|
||||
/** Unique, non-empty session cwds — the batch to probe for worktree info. */
|
||||
export function uniqueCwds(sessions: SessionInfo[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim()
|
||||
|
||||
if (path) {
|
||||
seen.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `parent → worktree → sessions` tree. Parents keep recency order
|
||||
* (first-seen in the recency-sorted input); worktree groups within a parent do
|
||||
* too, while rows inside a worktree sort by creation time (stable muscle memory,
|
||||
* matching `workspaceGroupsFor`).
|
||||
*/
|
||||
export function workspaceTreeFor(
|
||||
sessions: SessionInfo[],
|
||||
noWorkspaceLabel: string,
|
||||
resolver?: WorktreeResolver,
|
||||
options: { preserveSessionOrder?: boolean } = {}
|
||||
): SidebarWorkspaceTree[] {
|
||||
interface WorktreeEntry {
|
||||
group: SidebarSessionGroup
|
||||
parentKey: string
|
||||
parentLabel: string
|
||||
parentPath: string
|
||||
}
|
||||
|
||||
const worktrees = new Map<string, WorktreeEntry>()
|
||||
const noWorkspace: SessionInfo[] = []
|
||||
|
||||
for (const session of sessions) {
|
||||
const path = session.cwd?.trim() || ''
|
||||
|
||||
if (!path) {
|
||||
noWorkspace.push(session)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const placement = placeWorkspace(path, resolver)
|
||||
|
||||
if (!placement) {
|
||||
noWorkspace.push(session)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let entry = worktrees.get(placement.worktreeKey)
|
||||
|
||||
if (!entry) {
|
||||
entry = {
|
||||
group: { id: placement.worktreeKey, label: placement.worktreeLabel, path: placement.worktreePath, sessions: [] },
|
||||
parentKey: placement.parentKey,
|
||||
parentLabel: placement.parentLabel,
|
||||
parentPath: placement.parentPath
|
||||
}
|
||||
worktrees.set(placement.worktreeKey, entry)
|
||||
}
|
||||
|
||||
entry.group.sessions.push(session)
|
||||
}
|
||||
|
||||
if (!options.preserveSessionOrder) {
|
||||
for (const entry of worktrees.values()) {
|
||||
entry.group.sessions.sort((a, b) => b.started_at - a.started_at)
|
||||
}
|
||||
}
|
||||
|
||||
const parents = new Map<string, SidebarWorkspaceTree>()
|
||||
|
||||
for (const entry of worktrees.values()) {
|
||||
let parent = parents.get(entry.parentKey)
|
||||
|
||||
if (!parent) {
|
||||
parent = { id: entry.parentKey, label: entry.parentLabel, path: entry.parentPath, groups: [], sessionCount: 0 }
|
||||
parents.set(entry.parentKey, parent)
|
||||
}
|
||||
|
||||
parent.groups.push(entry.group)
|
||||
parent.sessionCount += entry.group.sessions.length
|
||||
}
|
||||
|
||||
const result = [...parents.values()]
|
||||
|
||||
if (noWorkspace.length) {
|
||||
result.push({
|
||||
id: NO_WORKSPACE_ID,
|
||||
label: noWorkspaceLabel,
|
||||
path: null,
|
||||
groups: [{ id: NO_WORKSPACE_ID, label: noWorkspaceLabel, path: null, sessions: noWorkspace }],
|
||||
sessionCount: noWorkspace.length
|
||||
})
|
||||
}
|
||||
|
||||
// Parents that collide on basename grow a path prefix; worktree labels that
|
||||
// collide inside a parent do the same.
|
||||
disambiguateLabels(result)
|
||||
|
||||
for (const parent of result) {
|
||||
disambiguateLabels(parent.groups)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { IconBookmark, IconBookmarkFilled, IconDownload, IconTrash } from '@tabler/icons-react'
|
||||
import { type MouseEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons'
|
||||
import { exportSession } from '@/lib/session-export'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
@@ -338,23 +337,23 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
|
||||
title={pinned ? cc.unpinSession : cc.pinSession}
|
||||
>
|
||||
{pinned ? (
|
||||
<IconBookmarkFilled className="size-3.5" />
|
||||
<BookmarkFilled className="size-3.5" />
|
||||
) : (
|
||||
<IconBookmark className="size-3.5" />
|
||||
<Bookmark className="size-3.5" />
|
||||
)}
|
||||
</RowIconButton>
|
||||
<RowIconButton
|
||||
onClick={() => void exportSession(session.id, { session, title: sessionTitle(session) })}
|
||||
title={cc.exportSession}
|
||||
>
|
||||
<IconDownload className="size-3.5" />
|
||||
<Download className="size-3.5" />
|
||||
</RowIconButton>
|
||||
<RowIconButton
|
||||
className="hover:text-destructive"
|
||||
onClick={() => void onDeleteSession(session.id)}
|
||||
title={cc.deleteSession}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
<Trash2 className="size-3.5" />
|
||||
</RowIconButton>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Clock,
|
||||
Cpu,
|
||||
Download,
|
||||
GitBranch,
|
||||
Globe,
|
||||
type IconComponent,
|
||||
Info,
|
||||
@@ -40,8 +41,10 @@ import {
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $repoWorktrees } from '@/store/coding-status'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { requestStartWorkSession } from '@/store/projects'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
@@ -208,6 +211,7 @@ export function CommandPalette() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const bindings = useStore($bindings)
|
||||
const worktrees = useStore($repoWorktrees)
|
||||
const navigate = useNavigate()
|
||||
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -278,6 +282,30 @@ export function CommandPalette() {
|
||||
const settingsTab = (tab: string) => `${SETTINGS_ROUTE}?tab=${tab}`
|
||||
const cc = t.commandCenter
|
||||
|
||||
// The active repo's worktrees → "new conversation in <branch>". This is the
|
||||
// ⌘K-typed "I want to work on <branch>" reflex: each entry seeds a fresh
|
||||
// session anchored to that worktree's checkout (requestStartWorkSession),
|
||||
// so git is the source of truth and edits land in the right tree.
|
||||
const branchGroup: PaletteGroup[] =
|
||||
worktrees.length > 0
|
||||
? [
|
||||
{
|
||||
heading: cc.branches,
|
||||
items: worktrees.map(wt => {
|
||||
const name = wt.branch?.trim() || wt.path.split('/').pop() || wt.path
|
||||
|
||||
return {
|
||||
icon: GitBranch,
|
||||
id: `worktree-${wt.path}`,
|
||||
keywords: ['branch', 'worktree', 'switch', name, wt.path],
|
||||
label: cc.startInBranch(name),
|
||||
run: () => requestStartWorkSession(wt.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
return [
|
||||
{
|
||||
heading: cc.goTo,
|
||||
@@ -339,6 +367,7 @@ export function CommandPalette() {
|
||||
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
||||
]
|
||||
},
|
||||
...branchGroup,
|
||||
{
|
||||
heading: cc.commandCenter,
|
||||
items: [
|
||||
@@ -414,7 +443,7 @@ export function CommandPalette() {
|
||||
]
|
||||
}
|
||||
]
|
||||
}, [go, settingsSectionLabel, t])
|
||||
}, [go, settingsSectionLabel, t, worktrees])
|
||||
|
||||
// The long, granular lists (settings fields, API keys, MCP servers, archived
|
||||
// chats) only surface once the user types — otherwise they'd bury the
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
FILE_BROWSER_MAX_WIDTH,
|
||||
FILE_BROWSER_MIN_WIDTH,
|
||||
pinSession,
|
||||
PREVIEW_PANE_ID,
|
||||
setSidebarOverlayMounted,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
@@ -49,6 +50,8 @@ import {
|
||||
normalizeProfileKey,
|
||||
refreshActiveProfile
|
||||
} from '../store/profile'
|
||||
import { $startWorkSessionRequest, resolveNewSessionCwd } from '../store/projects'
|
||||
import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentCwd,
|
||||
@@ -56,13 +59,14 @@ import {
|
||||
$gatewayState,
|
||||
$messages,
|
||||
$messagingSessions,
|
||||
$resumeFailedSessionId,
|
||||
$resumeExhaustedSessionId,
|
||||
$resumeFailedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
CRON_SECTION_LIMIT,
|
||||
getRecentlySettledSessionIds,
|
||||
getRememberedSessionId,
|
||||
mergeSessionPage,
|
||||
MESSAGING_SECTION_LIMIT,
|
||||
sessionPinId,
|
||||
@@ -77,6 +81,7 @@ import {
|
||||
setMessagingPlatformTotals,
|
||||
setMessagingSessions,
|
||||
setMessagingTruncated,
|
||||
setRememberedSessionId,
|
||||
setSessionProfileTotals,
|
||||
setSessions,
|
||||
setSessionsLoading,
|
||||
@@ -105,6 +110,8 @@ import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { FileActionDialogs } from './right-sidebar/file-actions'
|
||||
import { ReviewPane } from './right-sidebar/review'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
@@ -210,6 +217,7 @@ export function DesktopController() {
|
||||
const previewTarget = useStore($previewTarget)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const reviewOpen = useStore($reviewOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const profileScope = useStore($profileScope)
|
||||
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
|
||||
@@ -278,6 +286,36 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Remember the open chat so a relaunch reopens it instead of an empty new-chat.
|
||||
useEffect(() => {
|
||||
if (routedSessionId) {
|
||||
setRememberedSessionId(routedSessionId)
|
||||
}
|
||||
}, [routedSessionId])
|
||||
|
||||
// Restore that chat once, on cold start only (we're at the new-chat route and
|
||||
// haven't navigated yet). A dead/deleted id self-clears via the exhausted latch
|
||||
// below, so we never boot-loop into an error screen.
|
||||
const restoredLastSessionRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (restoredLastSessionRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
restoredLastSessionRef.current = true
|
||||
const last = getRememberedSessionId()
|
||||
|
||||
if (last && location.pathname === NEW_CHAT_ROUTE) {
|
||||
navigate(sessionRoute(last), { replace: true })
|
||||
}
|
||||
}, [location.pathname, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
|
||||
setRememberedSessionId(null)
|
||||
}
|
||||
}, [resumeExhaustedSessionId])
|
||||
|
||||
// Notification click: the main process already focused the window; jump to its
|
||||
// session. Notifications are tagged with the gateway *runtime* session id, but
|
||||
// the chat route is keyed by the *stored* id — navigating with the runtime id
|
||||
@@ -471,9 +509,9 @@ export function DesktopController() {
|
||||
void refreshMessagingSessions()
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
||||
|
||||
const loadMoreSessions = useCallback(() => {
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
bumpSessionsLimit()
|
||||
void refreshSessions()
|
||||
await refreshSessions()
|
||||
}, [refreshSessions])
|
||||
|
||||
// Another window mutated the shared session list (e.g. a chat started in the
|
||||
@@ -546,7 +584,7 @@ export function DesktopController() {
|
||||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
|
||||
const { refreshProjectBranch } = useCwdActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
|
||||
@@ -662,6 +700,7 @@ export function DesktopController() {
|
||||
const {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
branchStoredSession,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
removeSession,
|
||||
@@ -794,7 +833,10 @@ export function DesktopController() {
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
||||
const target = path?.trim()
|
||||
// A worktree lane carries its own path; the trunk "+" can be path-less (the
|
||||
// main checkout is implicit), so fall back to the active project's root
|
||||
// instead of no-op'ing on null — that was "+ on main does nothing".
|
||||
const target = path?.trim() || resolveNewSessionCwd()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
@@ -813,6 +855,28 @@ export function DesktopController() {
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
// Composer "branch off into a new worktree": the composer already created the
|
||||
// worktree and cleared its draft; open a fresh session anchored to that tree,
|
||||
// then prefill the task that kicked it off. startSessionInWorkspace owns the
|
||||
// reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp
|
||||
// the cwd back to the default), so the prefill is dispatched right after — its
|
||||
// deferred event lands once the fresh composer has remounted and rebound.
|
||||
const startWorkSessionRequest = useStore($startWorkSessionRequest)
|
||||
const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastStartWorkTokenRef.current = startWorkSessionRequest.token
|
||||
startSessionInWorkspace(startWorkSessionRequest.path)
|
||||
|
||||
if (startWorkSessionRequest.draft) {
|
||||
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
|
||||
}
|
||||
}, [startSessionInWorkspace, startWorkSessionRequest])
|
||||
|
||||
const handleSkinCommand = useSkinCommand()
|
||||
|
||||
const {
|
||||
@@ -929,6 +993,7 @@ export function DesktopController() {
|
||||
<ChatSidebar
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onBranchSession={sessionId => void branchStoredSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreMessaging={loadMoreMessagingForPlatform}
|
||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||
@@ -978,6 +1043,7 @@ export function DesktopController() {
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<SessionSwitcher />
|
||||
<FileActionDialogs />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -1077,7 +1143,7 @@ export function DesktopController() {
|
||||
const previewPane = (
|
||||
<Pane
|
||||
disabled={!chatOpen || (!previewTarget && !filePreviewTarget)}
|
||||
id="preview"
|
||||
id={PREVIEW_PANE_ID}
|
||||
key="preview"
|
||||
maxWidth={PREVIEW_RAIL_MAX_WIDTH}
|
||||
minWidth={PREVIEW_RAIL_MIN_WIDTH}
|
||||
@@ -1105,14 +1171,43 @@ export function DesktopController() {
|
||||
side={railSide}
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
{/* Key on the project (cwd) so switching projects unmounts the old tree and
|
||||
mounts a fresh one straight into its skeleton — no stale-then-blip. */}
|
||||
<RightSidebarPane
|
||||
key={currentCwd || 'no-cwd'}
|
||||
onActivateFile={path => composer.insertContextPathInlineRef(path)}
|
||||
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</Pane>
|
||||
)
|
||||
|
||||
const reviewPane = (
|
||||
<Pane
|
||||
defaultOpen
|
||||
// The diff pane only makes sense in a workspace, so force it shut when the
|
||||
// session is detached — "No diffs" then only ever shows inside a project,
|
||||
// never as a second empty panel next to the file browser.
|
||||
// Docked (wide): `reviewOpen` gates it. Narrow: drop `reviewOpen` from the
|
||||
// gate so the pane stays mounted as a collapsed overlay — `toggleReview`
|
||||
// then slides it in/out via the forced-reveal pin, exactly like ⌘B for the
|
||||
// sidebar. Still requires a repo (no diffs to show otherwise).
|
||||
disabled={!chatOpen || !currentCwd.trim() || (!narrowViewport && !reviewOpen)}
|
||||
forceCollapsed={narrowViewport}
|
||||
hoverReveal
|
||||
id={REVIEW_PANE_ID}
|
||||
key="review"
|
||||
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||
minWidth={FILE_BROWSER_MIN_WIDTH}
|
||||
// Mobile overlay sits at its min width — compact, doesn't bury the chat.
|
||||
overlayWidth={FILE_BROWSER_MIN_WIDTH}
|
||||
resizable
|
||||
side={railSide}
|
||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||
>
|
||||
<ReviewPane key={currentCwd || 'no-cwd'} />
|
||||
</Pane>
|
||||
)
|
||||
|
||||
const terminalPane = (
|
||||
<Pane
|
||||
defaultOpen
|
||||
@@ -1205,6 +1300,7 @@ export function DesktopController() {
|
||||
*/}
|
||||
{panesFlipped ? fileBrowserPane : terminalPane}
|
||||
{previewPane}
|
||||
{reviewPane}
|
||||
{panesFlipped ? terminalPane : fileBrowserPane}
|
||||
</AppShell>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
|
||||
import { $repoStatus } from '@/store/coding-status'
|
||||
import { toggleCommandPalette } from '@/store/command-palette'
|
||||
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
|
||||
import {
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
switchToDefaultProfile,
|
||||
toggleShowAllProfiles
|
||||
} from '@/store/profile'
|
||||
import { requestNewWorktree } from '@/store/projects'
|
||||
import { toggleReview } from '@/store/review'
|
||||
import { setModelPickerOpen } from '@/store/session'
|
||||
import {
|
||||
$switcherOpen,
|
||||
@@ -139,6 +142,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
...sessionSlotHandlers,
|
||||
'session.focusSearch': requestSessionSearchFocus,
|
||||
'session.togglePin': deps.toggleSelectedPin,
|
||||
// Only meaningful inside a git repo — a no-op otherwise (the key falls
|
||||
// through instead of silently doing nothing).
|
||||
'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(),
|
||||
|
||||
'view.toggleSidebar': () => {
|
||||
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
|
||||
@@ -154,6 +160,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
'view.toggleReview': toggleReview,
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SanitizedInput } from '@/components/ui/sanitized-input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Pencil, Save, Terminal, Trash2, Users } from '@/lib/icons'
|
||||
import { slug } from '@/lib/sanitize'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -519,12 +520,13 @@ function CreateProfileDialog({
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-name">
|
||||
{p.nameLabel}
|
||||
</label>
|
||||
<Input
|
||||
<SanitizedInput
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
id="new-profile-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
onValueChange={setName}
|
||||
placeholder="my-profile"
|
||||
sanitize={slug}
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
@@ -648,11 +650,12 @@ function RenameProfileDialog({
|
||||
<label className="text-xs font-medium" htmlFor="rename-profile-name">
|
||||
{p.newNameLabel}
|
||||
</label>
|
||||
<Input
|
||||
<SanitizedInput
|
||||
aria-invalid={invalid}
|
||||
autoFocus
|
||||
id="rename-profile-name"
|
||||
onChange={event => setName(event.target.value)}
|
||||
onValueChange={setName}
|
||||
sanitize={slug}
|
||||
value={name}
|
||||
/>
|
||||
<p className={cn('text-[0.66rem] leading-4', invalid ? 'text-destructive' : 'text-muted-foreground')}>
|
||||
|
||||
202
apps/desktop/src/app/right-sidebar/file-actions.tsx
Normal file
202
apps/desktop/src/app/right-sidebar/file-actions.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useRef, useState } from 'react'
|
||||
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
|
||||
import { IS_MAC } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$fileActionDialog,
|
||||
beginInlineRename,
|
||||
cancelInlineRename,
|
||||
closeFileActionDialog,
|
||||
copyFilePath,
|
||||
executeFileDelete,
|
||||
executeFileRename,
|
||||
type FileActionTarget,
|
||||
requestFileDelete,
|
||||
revealFile,
|
||||
toRelativePath
|
||||
} from '@/store/file-actions'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
const IS_WIN = typeof navigator !== 'undefined' && /win/i.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
// F2 starts a rename anywhere; Enter starts one when a row is focused (VS Code).
|
||||
export function isRenameShortcut(event: KeyboardEvent | ReactKeyboardEvent): boolean {
|
||||
return event.key === 'F2' || event.key === 'Enter'
|
||||
}
|
||||
|
||||
/** The platform-appropriate "reveal in file manager" label (Finder / Explorer
|
||||
* / containing folder). Shared so every file menu reads consistently. */
|
||||
export function pickRevealLabel(finder: string, explorer: string, fileManager: string): string {
|
||||
return IS_MAC ? finder : IS_WIN ? explorer : fileManager
|
||||
}
|
||||
|
||||
interface FileEntryContextMenuProps {
|
||||
children: ReactNode
|
||||
isDirectory: boolean
|
||||
/** Display name (basename). */
|
||||
name: string
|
||||
/** Absolute path on disk. */
|
||||
path: string
|
||||
/** Base dir for "Copy Relative Path" (the cwd / repo root). Omit to hide it. */
|
||||
relativeTo?: null | string
|
||||
}
|
||||
|
||||
/** Right-click menu shared by both file trees (browser + review/git). */
|
||||
export function FileEntryContextMenu({ children, isDirectory, name, path, relativeTo }: FileEntryContextMenuProps) {
|
||||
const { t } = useI18n()
|
||||
const m = t.fileMenu
|
||||
// Reveal / rename / delete need the local filesystem; hide them on a remote
|
||||
// backend (copy-path still works everywhere).
|
||||
const localFs = !isDesktopFsRemoteMode()
|
||||
const target: FileActionTarget = { isDirectory, name, path }
|
||||
const revealLabel = pickRevealLabel(m.revealFinder, m.revealExplorer, m.revealFileManager)
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
{/* Don't restore focus to the row on close: "Rename" mounts an autofocused
|
||||
inline input, and the default focus-return would blur it immediately. */}
|
||||
<ContextMenuContent onCloseAutoFocus={event => event.preventDefault()}>
|
||||
{localFs && (
|
||||
<>
|
||||
<ContextMenuItem onSelect={() => void revealFile(path)}>{revealLabel}</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem onSelect={() => void copyFilePath(path)}>{m.copyPath}</ContextMenuItem>
|
||||
{relativeTo && (
|
||||
<ContextMenuItem onSelect={() => void copyFilePath(toRelativePath(path, relativeTo))}>
|
||||
{m.copyRelativePath}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{localFs && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => beginInlineRename(path)}>{m.rename}</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => requestFileDelete(target)} variant="destructive">
|
||||
{m.delete}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mounted once near the app root: the delete confirm dialog for shared file
|
||||
* actions. Rename is inline (see {@link InlineRenameInput}). */
|
||||
export function FileActionDialogs() {
|
||||
const { t } = useI18n()
|
||||
const dialog = useStore($fileActionDialog)
|
||||
const deleting = dialog?.kind === 'delete'
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
confirmLabel={t.fileMenu.delete}
|
||||
description={t.fileMenu.deleteBody}
|
||||
destructive
|
||||
onClose={closeFileActionDialog}
|
||||
onConfirm={() => {
|
||||
if (deleting) {
|
||||
return executeFileDelete(dialog.path)
|
||||
}
|
||||
}}
|
||||
open={deleting}
|
||||
title={deleting ? t.fileMenu.deleteTitle(dialog.name) : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface InlineRenameInputProps {
|
||||
className?: string
|
||||
/** Display name (basename) to seed the editor. */
|
||||
name: string
|
||||
/** Absolute path being renamed. */
|
||||
path: string
|
||||
}
|
||||
|
||||
/** The in-row rename editor (VS Code style): seeded with the name (stem
|
||||
* pre-selected), commits on Enter/blur, cancels on Esc. Render it in place of a
|
||||
* row's label when `$renamingPath === path`. */
|
||||
export function InlineRenameInput({ className, name, path }: InlineRenameInputProps) {
|
||||
const [value, setValue] = useState(name)
|
||||
// Enter then the resulting blur must not both commit; latch on first finish.
|
||||
const done = useRef(false)
|
||||
// Focus churn right after mount (context-menu close, arborist refocus, the
|
||||
// fall-through click on the row) would blur→commit→cancel instantly; ignore
|
||||
// blurs in this window and grab focus back instead.
|
||||
const mountedAt = useRef(Date.now())
|
||||
|
||||
const finish = async (commit: boolean) => {
|
||||
if (done.current) {
|
||||
return
|
||||
}
|
||||
|
||||
done.current = true
|
||||
const next = value.trim()
|
||||
|
||||
if (commit && next && next !== name) {
|
||||
try {
|
||||
await executeFileRename(path, next)
|
||||
} catch (error) {
|
||||
notifyError(error, translateNow('errors.genericFailure'))
|
||||
}
|
||||
}
|
||||
|
||||
cancelInlineRename()
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
aria-label={translateNow('fileMenu.renameLabel')}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'min-w-0 flex-1 rounded-sm border border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-(--ui-bg-elevated) px-1 py-0 text-xs text-foreground outline-none',
|
||||
className
|
||||
)}
|
||||
onBlur={event => {
|
||||
if (Date.now() - mountedAt.current < 250) {
|
||||
event.currentTarget.focus()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void finish(true)
|
||||
}}
|
||||
onChange={event => setValue(event.target.value)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
onDoubleClick={event => event.stopPropagation()}
|
||||
onFocus={event => {
|
||||
const dot = event.currentTarget.value.lastIndexOf('.')
|
||||
event.currentTarget.setSelectionRange(0, dot > 0 ? dot : event.currentTarget.value.length)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void finish(true)
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
void finish(false)
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
|
||||
import { ALWAYS_EXCLUDED } from '@/lib/excluded-paths'
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
@@ -68,7 +69,7 @@ async function gitRootFor(start: string) {
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = desktopGitRoot(start)
|
||||
cached = desktopGitRoot(clean(start))
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
|
||||
}
|
||||
|
||||
const result = await readDesktopDir(dirPath)
|
||||
const entries = result?.entries ?? []
|
||||
const entries = (result?.entries ?? []).filter(entry => !ALWAYS_EXCLUDED.has(entry.name))
|
||||
|
||||
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { type NodeApi, type NodeRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type NodeApi, type NodeRendererProps, type RowRendererProps, Tree, type TreeApi } from 'react-arborist'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { TreeSkeleton } from '@/components/chat/skeletons'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $repoChangeByPath, type RepoChangeKind } from '@/store/coding-status'
|
||||
import { $renamingPath, beginInlineRename } from '@/store/file-actions'
|
||||
import { $revealInTreeRequest } from '@/store/layout'
|
||||
|
||||
import { FileEntryContextMenu, InlineRenameInput, isRenameShortcut } from '../file-actions'
|
||||
|
||||
import { getFileTreeDndManager } from './dnd-manager'
|
||||
import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 22
|
||||
const INDENT = 10
|
||||
/** Base inset for every row; react-arborist owns paddingLeft for depth indent. */
|
||||
const TREE_ROW_INSET = 12
|
||||
/** Fixed base inset (`px-6.5`) layered on top of arborist's depth indent. */
|
||||
const TREE_ROW_INSET = '17px'
|
||||
|
||||
function withTreeInset(paddingLeft: number | string | undefined): string {
|
||||
if (typeof paddingLeft === 'number') {
|
||||
return `calc(${paddingLeft}px + ${TREE_ROW_INSET})`
|
||||
}
|
||||
|
||||
if (!paddingLeft) {
|
||||
return TREE_ROW_INSET
|
||||
}
|
||||
|
||||
return `calc(${paddingLeft} + ${TREE_ROW_INSET})`
|
||||
}
|
||||
|
||||
interface ProjectTreeProps {
|
||||
collapseNonce: number
|
||||
@@ -41,6 +58,7 @@ export function ProjectTree({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||
const [size, setSize] = useState({ height: 0, width: 0 })
|
||||
const changeByPath = useStore($repoChangeByPath)
|
||||
|
||||
const syncTreeSize = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
@@ -79,17 +97,85 @@ export function ProjectTree({
|
||||
[onLoadChildren, onNodeOpenChange]
|
||||
)
|
||||
|
||||
// "Reveal in side bar": expand each ancestor folder top-down (lazy-loading its
|
||||
// children first so the node exists), then select + scroll to the target. The
|
||||
// pane is opened by the caller; this drives the tree to the file.
|
||||
const revealNode = useCallback(
|
||||
async (absPath: string) => {
|
||||
const root = cwd.replace(/[\\/]+$/, '')
|
||||
const target = absPath.replace(/[\\/]+$/, '')
|
||||
const rel = target.startsWith(root) ? target.slice(root.length).replace(/^[\\/]+/, '') : ''
|
||||
const segments = rel.split(/[\\/]/).filter(Boolean)
|
||||
|
||||
let acc = root
|
||||
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
acc = `${acc}/${segments[i]}`
|
||||
const node = treeRef.current?.get(acc)
|
||||
|
||||
if (node?.data?.isDirectory && node.data.children === undefined) {
|
||||
await onLoadChildren(acc)
|
||||
}
|
||||
|
||||
onNodeOpenChange(acc, true)
|
||||
treeRef.current?.open(acc)
|
||||
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)))
|
||||
}
|
||||
|
||||
treeRef.current?.select(target)
|
||||
// 'start' lands the file at/near the top (instant — arborist sets scrollTop
|
||||
// directly, no smooth scroll).
|
||||
treeRef.current?.scrollTo(target, 'start')
|
||||
},
|
||||
[cwd, onLoadChildren, onNodeOpenChange]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
$revealInTreeRequest.subscribe(path => {
|
||||
if (!path) {
|
||||
return
|
||||
}
|
||||
|
||||
$revealInTreeRequest.set(null)
|
||||
void revealNode(path)
|
||||
}),
|
||||
[revealNode]
|
||||
)
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(node: NodeApi<TreeNode>) => {
|
||||
if (node.data && !node.data.isDirectory) {
|
||||
// arborist fires onActivate on click/dblclick/Enter — independent of the
|
||||
// row's own handlers. Suppress it for the row being renamed so the
|
||||
// context-menu "Rename" (and its fall-through) can't open the preview.
|
||||
if (node.data && !node.data.isDirectory && $renamingPath.get() !== node.data.id) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
},
|
||||
[onPreviewFile]
|
||||
)
|
||||
|
||||
// F2 / Enter on the selected row begins an inline rename. Capture-phase so it
|
||||
// beats arborist's own Enter-to-activate; skipped while an edit is in progress
|
||||
// (the editor input owns Enter/Esc then) and for placeholder rows.
|
||||
const handleRenameShortcut = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isRenameShortcut(event) || $renamingPath.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
const node = treeRef.current?.selectedNodes?.[0]
|
||||
|
||||
if (!node?.data || node.data.placeholder) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
beginInlineRename(node.data.id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
|
||||
<div className="min-h-0 flex-1 overflow-hidden" onKeyDownCapture={handleRenameShortcut} ref={containerRef}>
|
||||
{size.height > 0 && size.width > 0 ? (
|
||||
<Tree<TreeNode>
|
||||
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
|
||||
@@ -107,15 +193,18 @@ export function ProjectTree({
|
||||
openByDefault={false}
|
||||
padding={0}
|
||||
ref={treeRef}
|
||||
renderRow={ProjectTreeRowContainer}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
width={size.width}
|
||||
>
|
||||
{props => (
|
||||
<ProjectTreeRow
|
||||
{...props}
|
||||
changeKind={props.node.data ? changeByPath.get(props.node.data.id) : undefined}
|
||||
onAttachFile={onActivateFile}
|
||||
onAttachFolder={onActivateFolder}
|
||||
onPreviewFile={onPreviewFile}
|
||||
relativeTo={cwd}
|
||||
/>
|
||||
)}
|
||||
</Tree>
|
||||
@@ -127,23 +216,51 @@ export function ProjectTree({
|
||||
}
|
||||
|
||||
function TreeSizingState() {
|
||||
const { t } = useI18n()
|
||||
return <TreeSkeleton />
|
||||
}
|
||||
|
||||
return <PageLoader aria-label={t.rightSidebar.loadingFiles} className="min-h-24 px-3" />
|
||||
// arborist's default row hardcodes `min-width: max-content` (so a highlight can
|
||||
// span horizontally-scrolled content), which grows the row to its full name
|
||||
// width and defeats the inner `truncate`. We don't scroll sideways — pin the row
|
||||
// to the viewport so long names ellipsize instead of clipping at the pane edge.
|
||||
function ProjectTreeRowContainer({ attrs, children, innerRef, node }: RowRendererProps<TreeNode>) {
|
||||
return (
|
||||
<div
|
||||
{...attrs}
|
||||
onClick={node.handleClick}
|
||||
onFocus={e => e.stopPropagation()}
|
||||
ref={innerRef}
|
||||
style={{ ...attrs.style, minWidth: 0, width: '100%' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CHANGE_TINT: Record<RepoChangeKind, string> = {
|
||||
added: 'text-(--ui-green)',
|
||||
conflicted: 'text-(--ui-red)',
|
||||
modified: 'text-(--ui-yellow)'
|
||||
}
|
||||
|
||||
function ProjectTreeRow({
|
||||
changeKind,
|
||||
dragHandle,
|
||||
node,
|
||||
onAttachFile,
|
||||
onAttachFolder,
|
||||
onPreviewFile,
|
||||
relativeTo,
|
||||
style
|
||||
}: NodeRendererProps<TreeNode> & {
|
||||
changeKind?: RepoChangeKind
|
||||
onAttachFile: (path: string) => void
|
||||
onAttachFolder: (path: string) => void
|
||||
onPreviewFile?: (path: string) => void
|
||||
relativeTo?: null | string
|
||||
}) {
|
||||
const renamingPath = useStore($renamingPath)
|
||||
|
||||
if (!node.data) {
|
||||
return <div style={style} />
|
||||
}
|
||||
@@ -151,21 +268,25 @@ function ProjectTreeRow({
|
||||
const isFolder = node.data.isDirectory
|
||||
const isPlaceholder = Boolean(node.data.placeholder)
|
||||
const isErrorPlaceholder = node.data.placeholder === 'error'
|
||||
const editing = !isPlaceholder && renamingPath === node.data.id
|
||||
|
||||
return (
|
||||
const row = (
|
||||
<div
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
aria-selected={node.isSelected}
|
||||
className={cn(
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors hover:bg-(--ui-row-hover-background) hover:text-foreground',
|
||||
'group/row flex h-full cursor-pointer select-none items-center gap-1 border border-transparent px-3 text-xs font-normal leading-(--file-tree-row-height) text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
|
||||
node.isSelected && 'bg-(--ui-row-active-background) text-foreground',
|
||||
isPlaceholder && 'pointer-events-none italic text-muted-foreground/70'
|
||||
)}
|
||||
draggable={!isPlaceholder}
|
||||
draggable={!isPlaceholder && !editing}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (isPlaceholder) {
|
||||
// Read the rename atom LIVE (not the render closure): the fall-through
|
||||
// click from a context-menu close can fire before the editing re-render
|
||||
// commits, so a stale closure would still select/activate and yank focus.
|
||||
if (isPlaceholder || $renamingPath.get() === node.data.id) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -184,12 +305,12 @@ function ProjectTreeRow({
|
||||
onDoubleClick={event => {
|
||||
event.stopPropagation()
|
||||
|
||||
if (!isFolder && !isPlaceholder) {
|
||||
if (!isFolder && !isPlaceholder && $renamingPath.get() !== node.data.id) {
|
||||
onPreviewFile?.(node.data.id)
|
||||
}
|
||||
}}
|
||||
onDragStart={event => {
|
||||
if (isPlaceholder) {
|
||||
if (isPlaceholder || $renamingPath.get() === node.data.id) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
@@ -204,11 +325,9 @@ function ProjectTreeRow({
|
||||
ref={dragHandle}
|
||||
style={{
|
||||
...style,
|
||||
paddingLeft:
|
||||
(typeof style.paddingLeft === 'number'
|
||||
? style.paddingLeft
|
||||
: Number.parseFloat(String(style.paddingLeft ?? 0)) || 0) + TREE_ROW_INSET
|
||||
paddingLeft: withTreeInset(style.paddingLeft)
|
||||
}}
|
||||
title={node.data.id}
|
||||
>
|
||||
{/* No chevron column — the folder icon (open/closed) already carries the
|
||||
expand state, so the extra glyph was pure noise. */}
|
||||
@@ -223,7 +342,23 @@ function ProjectTreeRow({
|
||||
<Codicon name="file" size="0.875rem" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{node.data.name}</span>
|
||||
{editing ? (
|
||||
<InlineRenameInput name={node.data.name} path={node.data.id} />
|
||||
) : (
|
||||
// Git decoration (VS Code-style): tint changed files; the explicit color
|
||||
// wins over the row's hover/selected text color, so it persists.
|
||||
<span className={cn('min-w-0 flex-1 truncate', changeKind && CHANGE_TINT[changeKind])}>{node.data.name}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isPlaceholder) {
|
||||
return row
|
||||
}
|
||||
|
||||
return (
|
||||
<FileEntryContextMenu isDirectory={isFolder} name={node.data.name} path={node.data.id} relativeTo={relativeTo}>
|
||||
{row}
|
||||
</FileEntryContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { atom } from 'nanostores'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
import { $workspaceChangeTick } from '@/store/workspace-events'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
@@ -219,6 +220,52 @@ export function resetProjectTreeState() {
|
||||
clearProjectDirCache()
|
||||
}
|
||||
|
||||
// Non-destructive refresh: re-read every currently-loaded directory and merge
|
||||
// entries (add new files/folders, drop deleted ones) while preserving expansion
|
||||
// and already-loaded subtrees. Unlike `loadRoot({force})` this never collapses
|
||||
// the tree, so it's safe to run live as the agent edits — and because node ids
|
||||
// (absolute paths) stay stable across merges, rows can animate in/out.
|
||||
async function revalidateTree(cwd: string): Promise<void> {
|
||||
const state = $projectTree.get()
|
||||
|
||||
if (!cwd || state.cwd !== cwd || !state.loaded) {
|
||||
return
|
||||
}
|
||||
|
||||
const rootPath = state.resolvedCwd || cwd
|
||||
clearProjectDirCache()
|
||||
|
||||
const reconcile = async (dirPath: string, existing: TreeNode[]): Promise<TreeNode[]> => {
|
||||
const { entries, error } = await readProjectDir(dirPath, rootPath)
|
||||
|
||||
if (error) {
|
||||
return existing // keep the last-known children on a transient read error
|
||||
}
|
||||
|
||||
const byId = new Map(existing.filter(node => !node.placeholder).map(node => [node.id, node]))
|
||||
const merged: TreeNode[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const prev = byId.get(entry.path)
|
||||
|
||||
if (prev?.isDirectory && prev.children) {
|
||||
// Loaded folder: recurse so deep edits surface without a re-expand.
|
||||
merged.push({ ...prev, children: await reconcile(prev.id, prev.children) })
|
||||
} else if (prev) {
|
||||
merged.push(prev)
|
||||
} else {
|
||||
merged.push(makeNode(entry.path, entry.name, entry.isDirectory))
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const nextData = await reconcile(rootPath, state.data)
|
||||
|
||||
setProjectTree(latest => (latest.cwd === cwd && latest.loaded ? { ...latest, data: nextData } : latest))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-loads a directory tree rooted at `cwd`. Children are fetched on first
|
||||
* expand and cached in this feature-owned atom so unrelated chat rerenders or
|
||||
@@ -229,6 +276,7 @@ export function resetProjectTreeState() {
|
||||
export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
const state = useStore($projectTree)
|
||||
const connection = useStore($connection)
|
||||
const workspaceTick = useStore($workspaceChangeTick)
|
||||
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
|
||||
|
||||
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
|
||||
@@ -308,6 +356,14 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
[cwd]
|
||||
)
|
||||
|
||||
// Live, non-destructive refresh when the agent touches the tree (skip the
|
||||
// very first render: tick 0 is the initial value, not a real change).
|
||||
useEffect(() => {
|
||||
if (workspaceTick > 0) {
|
||||
void revalidateTree(cwd)
|
||||
}
|
||||
}, [workspaceTick, cwd])
|
||||
|
||||
useEffect(() => {
|
||||
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
|
||||
lastConnectionKey = connectionKey
|
||||
|
||||
@@ -9,32 +9,17 @@ import { resetProjectTreeState } from './files/use-project-tree'
|
||||
import { RightSidebarPane } from './index'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
const selectPaths = vi.fn()
|
||||
|
||||
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function installBridge() {
|
||||
;(
|
||||
window as unknown as {
|
||||
hermesDesktop: {
|
||||
readDir: typeof readDir
|
||||
selectPaths: typeof selectPaths
|
||||
}
|
||||
}
|
||||
).hermesDesktop = { readDir, selectPaths }
|
||||
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
|
||||
}
|
||||
|
||||
describe('RightSidebarPane', () => {
|
||||
beforeEach(() => {
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
setCurrentCwd('/repo')
|
||||
readDir.mockReset()
|
||||
selectPaths.mockReset()
|
||||
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
|
||||
selectPaths.mockResolvedValue(['/repo-next'])
|
||||
readDir.mockResolvedValue({ entries: [{ isDirectory: false, name: 'README.md', path: '/repo/README.md' }] })
|
||||
installBridge()
|
||||
})
|
||||
|
||||
@@ -46,30 +31,27 @@ describe('RightSidebarPane', () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('refreshes the current tree without opening the folder picker', async () => {
|
||||
const onChangeCwd = vi.fn()
|
||||
it('renders the tree whenever the session has a working dir (repo or not) — no picker', async () => {
|
||||
setCurrentCwd('/repo')
|
||||
|
||||
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
|
||||
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
|
||||
const refresh = await screen.findByRole('button', { name: 'Refresh tree' })
|
||||
|
||||
readDir.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
|
||||
|
||||
fireEvent.click(refresh)
|
||||
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
|
||||
// The freeform folder picker is retired.
|
||||
expect(screen.queryByRole('button', { name: 'Open folder' })).toBeNull()
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(selectPaths).toHaveBeenCalledWith({
|
||||
defaultPath: '/repo',
|
||||
directories: true,
|
||||
multiple: false,
|
||||
title: 'Change working directory'
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
|
||||
it('shows no tree for a detached chat (no working dir)', async () => {
|
||||
setCurrentCwd('')
|
||||
|
||||
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} />)
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Refresh tree' })).toBeNull())
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { TreeSkeleton } from '@/components/chat/skeletons'
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { useDelayedTrue } from '@/hooks/use-delayed-true'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -23,15 +23,19 @@ import { useProjectTree } from './files/use-project-tree'
|
||||
interface RightSidebarPaneProps {
|
||||
onActivateFile: (path: string) => void
|
||||
onActivateFolder: (path: string) => void
|
||||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||
export function RightSidebarPane({ onActivateFile, onActivateFolder }: RightSidebarPaneProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
// The file tree is simply "browse the session's working directory". If the
|
||||
// session has a cwd — a repo, a sibling worktree, or any folder — show it. A
|
||||
// bare/detached chat (resolveNewSessionCwd → '') has none, so it shows the
|
||||
// empty hint instead of whatever dir Hermes happens to run from.
|
||||
const hasWorkspace = Boolean(currentCwd)
|
||||
|
||||
const {
|
||||
collapseAll,
|
||||
@@ -44,30 +48,16 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
rootError,
|
||||
rootLoading,
|
||||
setNodeOpen
|
||||
} = useProjectTree(currentCwd)
|
||||
} = useProjectTree(hasWorkspace ? currentCwd : '')
|
||||
|
||||
const cwdName = hasCwd
|
||||
? (effectiveCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? effectiveCwd)
|
||||
: r.noFolderSelected
|
||||
const cwdName =
|
||||
effectiveCwd
|
||||
.split(/[\\/]+/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? effectiveCwd
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await selectDesktopPaths({
|
||||
defaultPath: hasCwd ? effectiveCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
title: r.changeCwdTitle
|
||||
})
|
||||
|
||||
if (selected?.[0]) {
|
||||
await onChangeCwd(selected[0])
|
||||
}
|
||||
}
|
||||
|
||||
const previewFile = async (path: string) => {
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(path, effectiveCwd || undefined)
|
||||
@@ -101,11 +91,10 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
hasWorkspace={hasWorkspace}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
@@ -120,8 +109,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
canCollapse: boolean
|
||||
cwdName: string
|
||||
hasCwd: boolean
|
||||
onChangeFolder: () => Promise<void> | void
|
||||
hasWorkspace: boolean
|
||||
onCollapseAll: () => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
@@ -140,11 +128,10 @@ function FilesystemTab({
|
||||
cwdName,
|
||||
data,
|
||||
error,
|
||||
hasCwd,
|
||||
hasWorkspace,
|
||||
loading,
|
||||
onActivateFile,
|
||||
onActivateFolder,
|
||||
onChangeFolder,
|
||||
onCollapseAll,
|
||||
onLoadChildren,
|
||||
onNodeOpenChange,
|
||||
@@ -155,22 +142,22 @@ function FilesystemTab({
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
|
||||
// No working directory (a bare/detached chat) → no tree, just a terse hint.
|
||||
// Switching workspace is a project/worktree action, never a raw folder picker.
|
||||
if (!hasWorkspace) {
|
||||
return <PaneEmptyState label={r.noProjectOpen} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<button
|
||||
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
type="button"
|
||||
>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={!hasCwd || loading}
|
||||
disabled={loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
title={r.refreshTree}
|
||||
@@ -178,20 +165,10 @@ function FilesystemTab({
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.openFolder}
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon-xs"
|
||||
title={r.openFolder}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
disabled={!canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
title={r.collapseAll}
|
||||
@@ -218,8 +195,12 @@ function FilesystemTab({
|
||||
)
|
||||
}
|
||||
|
||||
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
|
||||
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
|
||||
export function RightSidebarSectionHeader({ children, className, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div className={cn('group/project-header flex h-7 shrink-0 items-center px-2.5', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
@@ -255,6 +236,9 @@ function FileTreeBody({
|
||||
}: FileTreeBodyProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
// Stay blank for a beat, then skeleton — so a fast project switch doesn't
|
||||
// flash a jarring loading state.
|
||||
const showSkeleton = useDelayedTrue(loading && data.length === 0)
|
||||
|
||||
if (!cwd) {
|
||||
return <EmptyState body={r.noProjectBody} title={r.noProjectTitle} />
|
||||
@@ -278,7 +262,7 @@ function FileTreeBody({
|
||||
}
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return <FileTreeLoadingState />
|
||||
return showSkeleton ? <FileTreeLoadingState /> : <div className="min-h-0 flex-1" />
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
@@ -321,23 +305,30 @@ function FileTreeLoadingState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div aria-label={t.rightSidebar.loadingTree} className="grid min-h-0 flex-1 place-items-center px-3" role="status">
|
||||
<Loader
|
||||
aria-hidden="true"
|
||||
className="size-8 text-(--ui-text-tertiary)"
|
||||
pathSteps={180}
|
||||
role="presentation"
|
||||
strokeScale={0.68}
|
||||
type="spiral-search"
|
||||
/>
|
||||
<div aria-label={t.rightSidebar.loadingTree} className="min-h-0 flex-1" role="status">
|
||||
<TreeSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ body, title }: { body: string; title: string }) {
|
||||
// Terse pane empty state ("No files" / "No diffs"): the panel label itself —
|
||||
// same uppercase/tracking + dither dot — just muted instead of theme-primary,
|
||||
// centered. Shared by the file tree and review panes so both read identically.
|
||||
export function PaneEmptyState({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-4">
|
||||
<SidebarPanelLabel className="pl-0 text-(--ui-text-quaternary)">{label}</SidebarPanelLabel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Richer empty/error state (title + body) for the file tree's read failures.
|
||||
export function EmptyState({ body, title }: { body: string; title?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1 px-4 text-center">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
|
||||
{title && (
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.07em] text-muted-foreground/75">{title}</div>
|
||||
)}
|
||||
<div className="text-[0.68rem] leading-relaxed text-muted-foreground/65">{body}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
59
apps/desktop/src/app/right-sidebar/review/churn-bar.tsx
Normal file
59
apps/desktop/src/app/right-sidebar/review/churn-bar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { HermesReviewFile } from '@/global'
|
||||
import { $reviewMaxChurn } from '@/store/review'
|
||||
|
||||
// Per-row "digital rain" churn bar: a right-anchored, clipped stream of
|
||||
// Matrix-ish glyphs whose width is the file's churn relative to the biggest
|
||||
// changed file. Not wired in — drop `<ChurnBar file={file} />` into a review row
|
||||
// (which must be `relative isolate overflow-hidden`) to revive it.
|
||||
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテナニヌノハヒフヘホマミムメモヤユヨラリレワ0123456789:=*+<>¦'
|
||||
|
||||
const MASK = 'linear-gradient(to left, #000 45%, transparent)'
|
||||
|
||||
// Deterministic glyph run (FNV-1a seed → xorshift) so a file's rain is stable
|
||||
// across renders instead of reshuffling every paint.
|
||||
function rain(seed: string, len: number): string {
|
||||
let h = 2166136261
|
||||
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = Math.imul(h ^ seed.charCodeAt(i), 16777619)
|
||||
}
|
||||
|
||||
let out = ''
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
h ^= h << 13
|
||||
h ^= h >>> 17
|
||||
h ^= h << 5
|
||||
out += GLYPHS[Math.abs(h) % GLYPHS.length]
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function ChurnBar({ file }: { file: HermesReviewFile }) {
|
||||
const max = useStore($reviewMaxChurn)
|
||||
const fill = useMemo(() => rain(file.path, 200), [file.path])
|
||||
const width = max > 0 ? ((file.added + file.removed) / max) * 100 : 0
|
||||
|
||||
if (width <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-0 -z-10 block overflow-hidden text-right font-mono text-[0.7rem] leading-6 tracking-tight whitespace-nowrap opacity-30 dark:opacity-40"
|
||||
style={{
|
||||
WebkitMaskImage: MASK,
|
||||
color: `var(--ui-${file.added >= file.removed ? 'green' : 'red'})`,
|
||||
maskImage: MASK,
|
||||
width: `${width}%`
|
||||
}}
|
||||
>
|
||||
{fill}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
443
apps/desktop/src/app/right-sidebar/review/file-tree.tsx
Normal file
443
apps/desktop/src/app/right-sidebar/review/file-tree.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import type { HermesReviewFile } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions'
|
||||
import { $sidebarWorkspaceCollapsedIds, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import {
|
||||
$reviewFiles,
|
||||
$reviewLoading,
|
||||
$reviewOpen,
|
||||
$reviewSelectedPath,
|
||||
$reviewTreeMode,
|
||||
requestRevert,
|
||||
selectReviewFile,
|
||||
stageReviewFile,
|
||||
unstageReviewFile
|
||||
} from '@/store/review'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { pickRevealLabel } from '../file-actions'
|
||||
|
||||
import { buildReviewFlatList, buildReviewTree, type ReviewTreeNode } from './tree-data'
|
||||
|
||||
const INDENT = 12
|
||||
|
||||
// Per git status letter: a tinted diff codicon so the file's nature reads at a
|
||||
// glance (added / modified / deleted / renamed / untracked).
|
||||
const STATUS_GLYPH: Record<string, { icon: string; tone: string }> = {
|
||||
A: { icon: 'diff-added', tone: 'text-(--ui-green)' },
|
||||
C: { icon: 'diff-added', tone: 'text-(--ui-green)' },
|
||||
D: { icon: 'diff-removed', tone: 'text-(--ui-red)' },
|
||||
M: { icon: 'diff-modified', tone: 'text-amber-500/85' },
|
||||
R: { icon: 'diff-renamed', tone: 'text-sky-500/85' },
|
||||
U: { icon: 'warning', tone: 'text-(--ui-red)' },
|
||||
'?': { icon: 'diff-added', tone: 'text-muted-foreground/60' }
|
||||
}
|
||||
|
||||
// Review paths are repo-relative; the composer drop expects absolute paths, so
|
||||
// join against the active session cwd (the repo we probed).
|
||||
function absolutePath(relative: string): string {
|
||||
if (/^([a-zA-Z]:[\\/]|\/)/.test(relative)) {
|
||||
return relative
|
||||
}
|
||||
|
||||
const cwd = $currentCwd
|
||||
.get()
|
||||
?.trim()
|
||||
.replace(/[\\/]+$/, '')
|
||||
|
||||
return cwd ? `${cwd}/${relative}` : relative
|
||||
}
|
||||
|
||||
// Fast, layout-aware row: `layout` slides siblings when one is inserted/removed
|
||||
// (a new file at index N pushes the rest down), AnimatePresence fades the
|
||||
// enter/exit. A tight, near-critically-damped spring keeps it crisp (quick
|
||||
// settle, no bounce) so adds/deletes read as snappy, not floaty.
|
||||
const ROW_TRANSITION = { type: 'spring', stiffness: 1100, damping: 48, mass: 0.32 } as const
|
||||
|
||||
// Instant (no animation) — used while the pane is settling open so the initial
|
||||
// batch of rows doesn't fly in.
|
||||
const ROW_INSTANT = { duration: 0 } as const
|
||||
|
||||
// Past this many changed files, drop the per-row motion (AnimatePresence +
|
||||
// layout springs on every node is the heaviest cost) and lean on CSS
|
||||
// content-visibility so off-screen rows skip layout/paint.
|
||||
const HEAVY_LIST_CAP = 60
|
||||
|
||||
// Reserve a stable row height (h-6 = 1.5rem) so the scrollbar stays correct
|
||||
// while off-screen rows are skipped.
|
||||
const ROW_CV_STYLE: CSSProperties = { containIntrinsicSize: 'auto 1.5rem', contentVisibility: 'auto' }
|
||||
|
||||
export function ReviewFileTree() {
|
||||
const files = useStore($reviewFiles)
|
||||
const open = useStore($reviewOpen)
|
||||
const loading = useStore($reviewLoading)
|
||||
const mode = useStore($reviewTreeMode)
|
||||
|
||||
const tree = useMemo(
|
||||
() => (mode === 'tree' ? buildReviewTree(files) : buildReviewFlatList(files)),
|
||||
[files, mode]
|
||||
)
|
||||
|
||||
const heavy = tree.length > HEAVY_LIST_CAP
|
||||
|
||||
// The Pane keeps this tree mounted while collapsed, so opening it doesn't
|
||||
// remount (AnimatePresence `initial={false}` can't help). The first refresh
|
||||
// after opening can also surface a batch of edits made while it was closed.
|
||||
// Suppress row enter/exit until that first post-open refresh settles; real
|
||||
// edits made while the pane stays open then animate normally.
|
||||
const [animate, setAnimate] = useState(false)
|
||||
const armed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
armed.current = false
|
||||
setAnimate(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !loading && !armed.current) {
|
||||
armed.current = true
|
||||
const id = requestAnimationFrame(() => setAnimate(true))
|
||||
|
||||
return () => cancelAnimationFrame(id)
|
||||
}
|
||||
}, [open, loading])
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1 py-1" data-suppress-pane-reveal-side="">
|
||||
<ReviewNodeList animate={animate && !heavy} depth={0} motion={!heavy} nodes={tree} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewNodeList({
|
||||
animate,
|
||||
depth,
|
||||
motion: useMotion,
|
||||
nodes
|
||||
}: {
|
||||
animate: boolean
|
||||
depth: number
|
||||
motion: boolean
|
||||
nodes: ReviewTreeNode[]
|
||||
}) {
|
||||
// Heavy lists: plain rows + content-visibility, no motion.
|
||||
if (!useMotion) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map(node => (
|
||||
<div key={node.id} style={ROW_CV_STYLE}>
|
||||
{node.isDir ? (
|
||||
<ReviewDirRow animate={false} depth={depth} motion={useMotion} node={node} />
|
||||
) : (
|
||||
<ReviewFileRow depth={depth} node={node} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{nodes.map(node => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -2 }}
|
||||
initial={animate ? { opacity: 0, y: -4 } : false}
|
||||
key={node.id}
|
||||
layout="position"
|
||||
transition={animate ? ROW_TRANSITION : ROW_INSTANT}
|
||||
>
|
||||
{node.isDir ? (
|
||||
<ReviewDirRow animate={animate} depth={depth} motion={useMotion} node={node} />
|
||||
) : (
|
||||
<ReviewFileRow depth={depth} node={node} />
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
// Depth-0 rows align their icon to the panel header's dither glyph: the tree
|
||||
// body has px-1 (4px) and the header glyph sits at px-2.5 (10px) + the label's
|
||||
// pl-2 (8px) = 18px, so the base inset is 18 − 4 = 14px.
|
||||
const ROW_BASE_INSET = 14
|
||||
|
||||
function rowStyle(depth: number): CSSProperties {
|
||||
return { paddingLeft: `${depth * INDENT + ROW_BASE_INSET}px` }
|
||||
}
|
||||
|
||||
function ReviewDirRow({
|
||||
animate,
|
||||
depth,
|
||||
motion: useMotion,
|
||||
node
|
||||
}: {
|
||||
animate: boolean
|
||||
depth: number
|
||||
motion: boolean
|
||||
node: ReviewTreeNode
|
||||
}) {
|
||||
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
|
||||
const id = `review:${node.id}`
|
||||
const open = !collapsed.includes(id)
|
||||
const toggle = () => toggleWorkspaceNodeCollapsed(id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none"
|
||||
onClick={toggle}
|
||||
style={rowStyle(depth)}
|
||||
>
|
||||
<Codicon
|
||||
className="shrink-0 text-(--ui-text-tertiary)"
|
||||
name={open ? 'folder-opened' : 'folder'}
|
||||
size="0.8rem"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate" title={node.name}>
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
{open && node.children && (
|
||||
<ReviewNodeList animate={animate} depth={depth + 1} motion={useMotion} nodes={node.children} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewFileRow({ node, depth }: { node: ReviewTreeNode; depth: number }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.statusStack.coding
|
||||
const selectedPath = useStore($reviewSelectedPath)
|
||||
const file = node.file!
|
||||
const selected = file.path === selectedPath
|
||||
const glyph = STATUS_GLYPH[file.status] ?? STATUS_GLYPH.M
|
||||
const dragPath = absolutePath(file.path)
|
||||
const cwd = useStore($currentCwd)
|
||||
|
||||
// Single-click shows the inline diff; double-click opens the file in the main
|
||||
// preview pane (matching the file browser). They're mutually exclusive: defer
|
||||
// the single-click select briefly so a double-click can cancel it, otherwise a
|
||||
// double-click would fire BOTH (inline diff + main preview = two previews).
|
||||
const clickTimer = useRef<null | ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (clickTimer.current != null) {
|
||||
clearTimeout(clickTimer.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
// A file-browser rename of the same path is active → ignore the fall-through
|
||||
// click so it doesn't open the diff / steal focus from that editor.
|
||||
if ($renamingPath.get() === dragPath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (clickTimer.current != null) {
|
||||
clearTimeout(clickTimer.current)
|
||||
}
|
||||
|
||||
clickTimer.current = setTimeout(() => {
|
||||
clickTimer.current = null
|
||||
void selectReviewFile(file)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const openInPreview = () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const preview = await normalizeOrLocalPreviewTarget(dragPath)
|
||||
|
||||
if (preview) {
|
||||
setCurrentSessionPreviewTarget(preview, 'file-browser', dragPath)
|
||||
}
|
||||
} catch (error) {
|
||||
notifyError(error, t.rightSidebar.previewUnavailable)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (clickTimer.current != null) {
|
||||
clearTimeout(clickTimer.current)
|
||||
clickTimer.current = null
|
||||
}
|
||||
|
||||
openInPreview()
|
||||
}
|
||||
|
||||
return (
|
||||
<ReviewFileContextMenu
|
||||
cwd={cwd}
|
||||
dragPath={dragPath}
|
||||
file={file}
|
||||
onOpenChanges={() => void selectReviewFile(file)}
|
||||
onOpenFile={openInPreview}
|
||||
>
|
||||
<div
|
||||
aria-selected={selected}
|
||||
className={cn(
|
||||
'group/review-row flex h-6 cursor-pointer select-none items-center gap-1.5 rounded-md pr-1.5 text-xs text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:text-foreground hover:transition-none',
|
||||
selected && 'bg-(--ui-row-active-background) text-foreground'
|
||||
)}
|
||||
draggable
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onDragStart={event => {
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
event.dataTransfer.setData(
|
||||
'application/x-hermes-paths',
|
||||
JSON.stringify([{ isDirectory: false, path: dragPath }])
|
||||
)
|
||||
event.dataTransfer.setData('text/plain', dragPath)
|
||||
}}
|
||||
style={rowStyle(depth)}
|
||||
title={dragPath}
|
||||
>
|
||||
<Codicon className={cn('shrink-0', glyph.tone)} name={glyph.icon} size="0.8rem" />
|
||||
{/* Dir collapses first (huge shrink); the name only ellipsizes once the
|
||||
dir is gone — either way neither runs into the diff count. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span className="min-w-0 shrink truncate" title={node.name}>
|
||||
{node.name}
|
||||
</span>
|
||||
{node.dir && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-[0.68rem] text-(--ui-text-tertiary)" title={node.dir}>
|
||||
{node.dir}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="hidden shrink-0 items-center gap-0.5 group-hover/review-row:flex">
|
||||
<Tip label={file.staged ? c.unstage : c.stage}>
|
||||
<Button
|
||||
aria-label={file.staged ? c.unstage : c.stage}
|
||||
className="size-4 rounded text-muted-foreground/70 hover:text-foreground"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
void (file.staged ? unstageReviewFile(file.path) : stageReviewFile(file.path))
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={file.staged ? 'remove' : 'add'} size="0.7rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.revert}>
|
||||
<Button
|
||||
aria-label={c.revert}
|
||||
className="size-4 rounded text-muted-foreground/70 hover:text-(--ui-red)"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
requestRevert(file.path)
|
||||
}}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="discard" size="0.7rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</span>
|
||||
|
||||
<DiffCount
|
||||
added={node.added}
|
||||
className="text-[0.64rem] leading-4 group-hover/review-row:hidden"
|
||||
removed={node.removed}
|
||||
/>
|
||||
{file.staged && (
|
||||
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-(--ui-green)/70" title={c.staged} />
|
||||
)}
|
||||
</div>
|
||||
</ReviewFileContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Git-specific right-click menu for a changed file (VS Code's SCM menu shape):
|
||||
// open changes / open file, stage·unstage, discard, then reveal / copy path. No
|
||||
// rename or delete here — those belong to the file browser; this tree just
|
||||
// reflects the working-tree state.
|
||||
function ReviewFileContextMenu({
|
||||
children,
|
||||
cwd,
|
||||
dragPath,
|
||||
file,
|
||||
onOpenChanges,
|
||||
onOpenFile
|
||||
}: {
|
||||
children: ReactNode
|
||||
cwd: null | string
|
||||
dragPath: string
|
||||
file: HermesReviewFile
|
||||
onOpenChanges: () => void
|
||||
onOpenFile: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.statusStack.coding
|
||||
const m = t.fileMenu
|
||||
const localFs = !isDesktopFsRemoteMode()
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={onOpenChanges}>{c.openChanges}</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onOpenFile}>{c.openFile}</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
void (file.staged ? unstageReviewFile(file.path) : stageReviewFile(file.path)).catch(err =>
|
||||
notifyError(err, file.staged ? c.unstage : c.stage)
|
||||
)
|
||||
}
|
||||
>
|
||||
{file.staged ? c.unstage : c.stage}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => requestRevert(file.path)} variant="destructive">
|
||||
{c.revert}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => revealFileInTree(dragPath)}>{m.revealInSidebar}</ContextMenuItem>
|
||||
{localFs && (
|
||||
<ContextMenuItem onSelect={() => void revealFile(dragPath)}>
|
||||
{pickRevealLabel(m.revealFinder, m.revealExplorer, m.revealFileManager)}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => void copyFilePath(dragPath)}>{m.copyPath}</ContextMenuItem>
|
||||
{cwd && (
|
||||
<ContextMenuItem onSelect={() => void copyFilePath(toRelativePath(dragPath, cwd))}>
|
||||
{m.copyRelativePath}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
241
apps/desktop/src/app/right-sidebar/review/index.tsx
Normal file
241
apps/desktop/src/app/right-sidebar/review/index.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { DiffSkeleton, TreeSkeleton } from '@/components/chat/skeletons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { DiffCount } from '@/components/ui/diff-count'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useDelayedTrue } from '@/hooks/use-delayed-true'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$reviewDiff,
|
||||
$reviewDiffLoading,
|
||||
$reviewFiles,
|
||||
$reviewIsRepo,
|
||||
$reviewLoading,
|
||||
$reviewRevertTarget,
|
||||
$reviewSelectedPath,
|
||||
$reviewTreeMode,
|
||||
cancelRevert,
|
||||
clearReviewSelection,
|
||||
closeReview,
|
||||
confirmRevert,
|
||||
refreshReview,
|
||||
requestRevert,
|
||||
stageReviewFile,
|
||||
toggleReviewTreeMode,
|
||||
unstageReviewFile
|
||||
} from '@/store/review'
|
||||
|
||||
import { SidebarPanelLabel } from '../../shell/sidebar-label'
|
||||
import { PaneEmptyState, RightSidebarSectionHeader } from '../index'
|
||||
|
||||
import { ReviewFileTree } from './file-tree'
|
||||
import { ReviewShipBar } from './ship-bar'
|
||||
|
||||
// Compact header/diff action buttons — micro hit targets packed tight, matching
|
||||
// the rest of the app's icon-action rows.
|
||||
const ACTION_BTN = 'size-5'
|
||||
|
||||
export function ReviewPane() {
|
||||
const { t } = useI18n()
|
||||
const c = t.statusStack.coding
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const files = useStore($reviewFiles)
|
||||
const loading = useStore($reviewLoading)
|
||||
const isRepo = useStore($reviewIsRepo)
|
||||
const selectedPath = useStore($reviewSelectedPath)
|
||||
const diff = useStore($reviewDiff)
|
||||
const diffLoading = useStore($reviewDiffLoading)
|
||||
const revertTarget = useStore($reviewRevertTarget)
|
||||
const treeMode = useStore($reviewTreeMode)
|
||||
|
||||
const selectedFile = files.find(file => file.path === selectedPath)
|
||||
const hasFiles = files.length > 0
|
||||
// `{ path: null }` → revert all; `{ path: '…' }` → revert one file.
|
||||
const revertingAll = revertTarget?.path == null
|
||||
// Delay the skeletons so fast loads (most project switches) just blank → content
|
||||
// instead of flashing a jarring loading state.
|
||||
const showTreeSkeleton = useDelayedTrue(loading && !hasFiles)
|
||||
const showDiffSkeleton = useDelayedTrue(diffLoading)
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={c.review}
|
||||
className={cn(
|
||||
'before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary)',
|
||||
panesFlipped
|
||||
? 'border-r shadow-[inset_-0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
{(loading || isRepo) && (
|
||||
<RightSidebarSectionHeader data-suppress-pane-reveal-side="">
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
|
||||
</div>
|
||||
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
|
||||
<Button
|
||||
aria-label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}
|
||||
className={ACTION_BTN}
|
||||
disabled={!hasFiles}
|
||||
onClick={toggleReviewTreeMode}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={treeMode === 'tree' ? 'list-flat' : 'list-tree'} size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.stageAll}>
|
||||
<Button
|
||||
aria-label={c.stageAll}
|
||||
className={ACTION_BTN}
|
||||
disabled={!hasFiles}
|
||||
onClick={() => void stageReviewFile(null).catch(err => notifyError(err, c.stageAll))}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.revertAll}>
|
||||
<Button
|
||||
aria-label={c.revertAll}
|
||||
className={ACTION_BTN}
|
||||
disabled={!hasFiles}
|
||||
onClick={() => requestRevert(null)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="discard" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={t.rightSidebar.refreshTree}>
|
||||
<Button
|
||||
aria-label={t.rightSidebar.refreshTree}
|
||||
className={ACTION_BTN}
|
||||
onClick={() => void refreshReview()}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.close}>
|
||||
<Button aria-label={c.close} className={ACTION_BTN} onClick={closeReview} size="icon-xs" variant="ghost">
|
||||
<Codicon name="close" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</RightSidebarSectionHeader>
|
||||
)}
|
||||
|
||||
{loading || isRepo ? (
|
||||
hasFiles ? (
|
||||
<ReviewFileTree />
|
||||
) : showTreeSkeleton ? (
|
||||
<TreeSkeleton />
|
||||
) : loading ? (
|
||||
<div className="min-h-0 flex-1" />
|
||||
) : (
|
||||
<PaneEmptyState label={t.rightSidebar.noDiffs} />
|
||||
)
|
||||
) : (
|
||||
// No repo at all → same terse empty state, just without the chrome.
|
||||
<PaneEmptyState label={t.rightSidebar.noDiffs} />
|
||||
)}
|
||||
|
||||
{/* Selected file's diff — reuses the shiki-highlighted FileDiffPanel. */}
|
||||
{selectedFile && (
|
||||
<div className="flex max-h-[55%] shrink-0 flex-col border-t border-(--ui-stroke-secondary)">
|
||||
<div className="flex items-center gap-1 px-2.5 py-1.5" data-suppress-pane-reveal-side="">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[0.66rem] text-(--ui-text-secondary)"
|
||||
title={selectedFile.path}
|
||||
>
|
||||
{selectedFile.path}
|
||||
</span>
|
||||
<DiffCount added={selectedFile.added} className="text-[0.64rem] leading-4" removed={selectedFile.removed} />
|
||||
<Tip label={selectedFile.staged ? c.unstage : c.stage}>
|
||||
<Button
|
||||
aria-label={selectedFile.staged ? c.unstage : c.stage}
|
||||
className={ACTION_BTN}
|
||||
onClick={() =>
|
||||
void (
|
||||
selectedFile.staged ? unstageReviewFile(selectedFile.path) : stageReviewFile(selectedFile.path)
|
||||
).catch(err => notifyError(err, c.stage))
|
||||
}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={selectedFile.staged ? 'remove' : 'add'} size="0.8rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.close}>
|
||||
<Button
|
||||
aria-label={c.close}
|
||||
className={ACTION_BTN}
|
||||
onClick={clearReviewSelection}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.8rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-1 pb-1">
|
||||
{diffLoading ? (
|
||||
showDiffSkeleton ? (
|
||||
<DiffSkeleton />
|
||||
) : null
|
||||
) : diff ? (
|
||||
<FileDiffPanel diff={diff} path={selectedFile.path} />
|
||||
) : (
|
||||
<div className="py-6 text-center text-[0.66rem] text-muted-foreground/60">{c.noDiff}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReviewShipBar />
|
||||
|
||||
<Dialog onOpenChange={open => !open && cancelRevert()} open={revertTarget !== undefined}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{revertingAll ? c.revertAll : c.revert}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{revertingAll ? c.revertAllConfirm : c.revertConfirm}
|
||||
{!revertingAll && revertTarget?.path && (
|
||||
<span
|
||||
className="mt-2 block truncate font-mono text-[0.7rem] text-(--ui-text-secondary)"
|
||||
title={revertTarget.path}
|
||||
>
|
||||
{revertTarget.path}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={cancelRevert} variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={() => void confirmRevert().catch(err => notifyError(err, c.revert))} variant="destructive">
|
||||
{revertingAll ? c.revertAll : c.revert}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
154
apps/desktop/src/app/right-sidebar/review/ship-bar.tsx
Normal file
154
apps/desktop/src/app/right-sidebar/review/ship-bar.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { requestComposerSubmit } from '@/app/chat/composer/focus'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { SplitButton } from '@/components/ui/split-button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$reviewCommitDefault,
|
||||
$reviewCommitMsgBusy,
|
||||
$reviewFiles,
|
||||
$reviewShipBusy,
|
||||
$reviewShipInfo,
|
||||
cancelCommitMessage,
|
||||
type CommitAction,
|
||||
commitChanges,
|
||||
createOrOpenPr,
|
||||
generateCommitMessage
|
||||
} from '@/store/review'
|
||||
|
||||
// One size for every glyph in the bar so the row reads as a set of peers.
|
||||
const ICON = '0.85rem'
|
||||
|
||||
// The commit / push / PR action bar at the bottom of the review pane. Supports
|
||||
// both paths: the user drives it directly, OR hands the whole thing to the agent
|
||||
// with one click (requestComposerSubmit sends it a task through the composer).
|
||||
export function ReviewShipBar() {
|
||||
const { t } = useI18n()
|
||||
const c = t.statusStack.coding
|
||||
const files = useStore($reviewFiles)
|
||||
const ship = useStore($reviewShipInfo)
|
||||
const busy = useStore($reviewShipBusy)
|
||||
const generating = useStore($reviewCommitMsgBusy)
|
||||
const commitDefault = useStore($reviewCommitDefault)
|
||||
const [message, setMessage] = useState('')
|
||||
const prLabel = ship.pr?.url ? c.openPr : c.createPr
|
||||
|
||||
const hasFiles = files.length > 0
|
||||
const canCommit = hasFiles && message.trim().length > 0 && !busy
|
||||
const canGenerate = hasFiles && !generating && !busy
|
||||
|
||||
// Nothing to commit → no ship bar at all; the pane just shows the tree /
|
||||
// "No changes" state.
|
||||
if (!hasFiles) {
|
||||
return null
|
||||
}
|
||||
|
||||
const runCommit = (action: CommitAction) => {
|
||||
if (!canCommit) {
|
||||
return
|
||||
}
|
||||
|
||||
void commitChanges(message, { push: action === 'commitPush' })
|
||||
.then(() => setMessage(''))
|
||||
.catch(err => notifyError(err, c.commit))
|
||||
}
|
||||
|
||||
// Draft the commit message off-thread (VS Code style); pass the current text
|
||||
// so a re-press regenerates instead of returning the same thing.
|
||||
const runGenerate = () => {
|
||||
if (!canGenerate) {
|
||||
return
|
||||
}
|
||||
|
||||
void generateCommitMessage(message)
|
||||
.then(text => text && setMessage(text))
|
||||
.catch(err => notifyError(err, c.generateCommitMessage))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col gap-1.5 p-2" data-suppress-pane-reveal-side="">
|
||||
{/* Auto-growing message field (CSS field-sizing); generate/stop action
|
||||
fills the right edge on one row, then sticks to the top as it grows. */}
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
className="field-sizing-content max-h-40 min-h-0 resize-none pr-9"
|
||||
disabled={generating}
|
||||
onChange={event => setMessage(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
runCommit(commitDefault)
|
||||
}
|
||||
}}
|
||||
placeholder={c.commitPlaceholder}
|
||||
rows={1}
|
||||
size="sm"
|
||||
value={message}
|
||||
/>
|
||||
<GenerateButton
|
||||
className="absolute top-px right-px h-6 w-8 rounded-l-none rounded-r-[2px]"
|
||||
disabled={!canGenerate}
|
||||
generating={generating}
|
||||
generatingLabel={c.stopGenerating}
|
||||
iconSize={ICON}
|
||||
label={c.generateCommitMessage}
|
||||
onCancel={cancelCommitMessage}
|
||||
onGenerate={runGenerate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Commit split (VS Code style). */}
|
||||
<div className="flex min-w-0">
|
||||
<SplitButton
|
||||
actions={[
|
||||
{ id: 'commit', label: c.commit },
|
||||
{ id: 'commitPush', label: c.commitAndPush }
|
||||
]}
|
||||
className="min-w-0 flex-1"
|
||||
disabled={!canCommit}
|
||||
onTrigger={id => runCommit(id as CommitAction)}
|
||||
onValueChange={id => $reviewCommitDefault.set(id as CommitAction)}
|
||||
primaryIcon={<Codicon name="check" size={ICON} />}
|
||||
value={commitDefault}
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hand it to the agent (one click sends a commit+PR task to the composer).
|
||||
The PR button floats on the right (out of flow) so the label centers on
|
||||
the whole bar; px-7 reserves the icon's width on both sides. */}
|
||||
<div className="relative flex min-w-0 items-center">
|
||||
<Button
|
||||
className="min-w-0 flex-1 justify-center px-7 text-[0.7rem] text-muted-foreground/85 hover:text-foreground"
|
||||
disabled={!hasFiles}
|
||||
onClick={() => requestComposerSubmit(c.agentShipPrompt, { target: 'main' })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="truncate underline underline-offset-2">{c.agentShip}</span>
|
||||
</Button>
|
||||
<Tip label={ship.ghReady ? prLabel : c.ghMissing}>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center">
|
||||
<Button
|
||||
aria-label={prLabel}
|
||||
className="size-7 text-muted-foreground/80 hover:text-foreground"
|
||||
disabled={!ship.ghReady || busy}
|
||||
onClick={() => void createOrOpenPr().catch(err => notifyError(err, prLabel))}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="git-pull-request" size={ICON} />
|
||||
</Button>
|
||||
</span>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
apps/desktop/src/app/right-sidebar/review/tree-data.test.ts
Normal file
45
apps/desktop/src/app/right-sidebar/review/tree-data.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesReviewFile } from '@/global'
|
||||
|
||||
import { buildReviewTree } from './tree-data'
|
||||
|
||||
const file = (path: string, added = 1, removed = 0): HermesReviewFile => ({
|
||||
path,
|
||||
added,
|
||||
removed,
|
||||
status: 'M',
|
||||
staged: false
|
||||
})
|
||||
|
||||
describe('buildReviewTree', () => {
|
||||
it('nests files under their folders and sorts dirs before files', () => {
|
||||
const tree = buildReviewTree([file('src/a.ts'), file('readme.md'), file('src/b.ts')], false)
|
||||
|
||||
expect(tree.map(n => n.name)).toEqual(['src', 'readme.md'])
|
||||
const src = tree[0]
|
||||
expect(src.isDir).toBe(true)
|
||||
expect(src.children?.map(n => n.name)).toEqual(['a.ts', 'b.ts'])
|
||||
})
|
||||
|
||||
it('aggregates +/- onto directories', () => {
|
||||
const tree = buildReviewTree([file('src/a.ts', 5, 2), file('src/b.ts', 3, 1)], false)
|
||||
|
||||
expect(tree[0].added).toBe(8)
|
||||
expect(tree[0].removed).toBe(3)
|
||||
})
|
||||
|
||||
it('compacts single-child directory chains', () => {
|
||||
const tree = buildReviewTree([file('a/b/c/deep.ts')], true)
|
||||
|
||||
expect(tree[0].name).toBe('a/b/c')
|
||||
expect(tree[0].children?.[0].name).toBe('deep.ts')
|
||||
})
|
||||
|
||||
it('does not compact when a directory has multiple children', () => {
|
||||
const tree = buildReviewTree([file('a/b/one.ts'), file('a/other.ts')], true)
|
||||
|
||||
expect(tree[0].name).toBe('a')
|
||||
expect(tree[0].children?.map(n => n.name).sort()).toEqual(['b', 'other.ts'])
|
||||
})
|
||||
})
|
||||
126
apps/desktop/src/app/right-sidebar/review/tree-data.ts
Normal file
126
apps/desktop/src/app/right-sidebar/review/tree-data.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { HermesReviewFile } from '@/global'
|
||||
|
||||
// A node in the review changed-files tree. Directories aggregate their
|
||||
// descendants' +/- so a collapsed folder still shows its total churn (Codex's
|
||||
// folder hierarchy view).
|
||||
export interface ReviewTreeNode {
|
||||
id: string
|
||||
name: string
|
||||
isDir: boolean
|
||||
added: number
|
||||
removed: number
|
||||
/** For a flat-list file row: the parent dir (relative), shown dimmed. */
|
||||
dir?: string
|
||||
file?: HermesReviewFile
|
||||
children?: ReviewTreeNode[]
|
||||
}
|
||||
|
||||
// Flat changed-file list (VS Code's default SCM "List" view): one row per file,
|
||||
// filename + a dimmed parent-dir path, sorted by path. No folder nodes.
|
||||
export function buildReviewFlatList(files: HermesReviewFile[]): ReviewTreeNode[] {
|
||||
return [...files]
|
||||
.sort((a, b) => a.path.localeCompare(b.path))
|
||||
.map(file => {
|
||||
const segments = file.path.split('/').filter(Boolean)
|
||||
const name = segments.pop() ?? file.path
|
||||
|
||||
return {
|
||||
id: file.path,
|
||||
name,
|
||||
dir: segments.join('/'),
|
||||
isDir: false,
|
||||
added: file.added,
|
||||
removed: file.removed,
|
||||
file
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface MutableDir {
|
||||
id: string
|
||||
name: string
|
||||
added: number
|
||||
removed: number
|
||||
dirs: Map<string, MutableDir>
|
||||
files: ReviewTreeNode[]
|
||||
}
|
||||
|
||||
const makeDir = (id: string, name: string): MutableDir => ({
|
||||
id,
|
||||
name,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
dirs: new Map(),
|
||||
files: []
|
||||
})
|
||||
|
||||
// Build a folder hierarchy from the flat changed-file list. With `compact`,
|
||||
// single-child directory chains collapse into one row (`a/b/c`), the way VS Code
|
||||
// and Codex render sparse trees.
|
||||
export function buildReviewTree(files: HermesReviewFile[], compact = true): ReviewTreeNode[] {
|
||||
const root = makeDir('', '')
|
||||
|
||||
for (const file of files) {
|
||||
const segments = file.path.split('/').filter(Boolean)
|
||||
const fileName = segments.pop() ?? file.path
|
||||
let dir = root
|
||||
|
||||
dir.added += file.added
|
||||
dir.removed += file.removed
|
||||
|
||||
let prefix = ''
|
||||
|
||||
for (const segment of segments) {
|
||||
prefix = prefix ? `${prefix}/${segment}` : segment
|
||||
let child = dir.dirs.get(segment)
|
||||
|
||||
if (!child) {
|
||||
child = makeDir(prefix, segment)
|
||||
dir.dirs.set(segment, child)
|
||||
}
|
||||
|
||||
child.added += file.added
|
||||
child.removed += file.removed
|
||||
dir = child
|
||||
}
|
||||
|
||||
dir.files.push({
|
||||
id: file.path,
|
||||
name: fileName,
|
||||
isDir: false,
|
||||
added: file.added,
|
||||
removed: file.removed,
|
||||
file
|
||||
})
|
||||
}
|
||||
|
||||
const finalize = (dir: MutableDir): ReviewTreeNode[] => {
|
||||
const dirNodes: ReviewTreeNode[] = [...dir.dirs.values()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(child => {
|
||||
let node: ReviewTreeNode = {
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
isDir: true,
|
||||
added: child.added,
|
||||
removed: child.removed,
|
||||
children: finalize(child)
|
||||
}
|
||||
|
||||
// Compact a chain: a folder whose only child is one folder merges into
|
||||
// `parent/child` so deep sparse paths read on one row.
|
||||
while (compact && node.children?.length === 1 && node.children[0].isDir) {
|
||||
const only = node.children[0]
|
||||
node = { ...only, name: `${node.name}/${only.name}` }
|
||||
}
|
||||
|
||||
return node
|
||||
})
|
||||
|
||||
const fileNodes = [...dir.files].sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
return [...dirNodes, ...fileNodes]
|
||||
}
|
||||
|
||||
return finalize(root)
|
||||
}
|
||||
@@ -35,7 +35,9 @@ import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import { followActiveSessionCwd } from '@/store/projects'
|
||||
import {
|
||||
$currentCwd,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setCurrentUsage,
|
||||
setSessions,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
@@ -52,6 +55,7 @@ import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { setSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
@@ -338,6 +342,9 @@ export function useMessageStream({
|
||||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||
const compactedTurnRef = useRef<Set<string>>(new Set())
|
||||
// Last session we applied a session.info cwd for — lets us tell an agent
|
||||
// relocating the SAME session (follow it) from a session switch (don't yank).
|
||||
const lastCwdInfoSessionRef = useRef<null | string>(null)
|
||||
|
||||
const flushQueuedDeltas = useCallback(
|
||||
(sessionId?: string) => {
|
||||
@@ -745,7 +752,20 @@ export function useMessageStream({
|
||||
}
|
||||
|
||||
if (typeof payload?.cwd === 'string') {
|
||||
// The active session's agent can relocate itself (new repo/worktree
|
||||
// via the terminal). When the SAME active session's cwd actually
|
||||
// moves, follow it — refresh the project tree + scope so the sidebar
|
||||
// tracks the live thread. A fresh selection (different session id)
|
||||
// is a switch, not a move, so it refreshes data without yanking scope.
|
||||
const cwdMoved = payload.cwd !== $currentCwd.get()
|
||||
const sameSession = !!sessionId && sessionId === lastCwdInfoSessionRef.current
|
||||
|
||||
lastCwdInfoSessionRef.current = sessionId
|
||||
setCurrentCwd(payload.cwd)
|
||||
|
||||
if (cwdMoved && sameSession) {
|
||||
void followActiveSessionCwd(payload.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
@@ -900,6 +920,16 @@ export function useMessageStream({
|
||||
if (payload?.usage) {
|
||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||
}
|
||||
} else if (event.type === 'session.title') {
|
||||
// Live auto-title push (titler runs async, after the turn's refresh).
|
||||
const storedId = typeof payload?.session_id === 'string' ? payload.session_id : ''
|
||||
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
|
||||
|
||||
if (storedId && nextTitle) {
|
||||
setSessions(prev =>
|
||||
prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
|
||||
)
|
||||
}
|
||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
@@ -927,6 +957,13 @@ export function useMessageStream({
|
||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
|
||||
}
|
||||
|
||||
// A file-mutating tool just finished — nudge the git-mirroring surfaces
|
||||
// (coding rail, review pane, file tree) to refresh. Event-driven, not
|
||||
// polled: fires exactly when the agent touches the tree.
|
||||
if (payload && toolMayMutateFiles(payload)) {
|
||||
notifyWorkspaceChanged()
|
||||
}
|
||||
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
|
||||
if (sessionId && payload && !sessionInterrupted(sessionId)) {
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
|
||||
@@ -120,31 +120,7 @@ describe('usePreviewRouting', () => {
|
||||
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers structured tool-result preview targets', async () => {
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
handleEvent = handler
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { path: './dist/index.html' },
|
||||
session_id: 'session-1',
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('./dist/index.html')
|
||||
})
|
||||
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toContain('./dist/index.html')
|
||||
})
|
||||
|
||||
it('registers html previews from edit inline diffs', async () => {
|
||||
it('does not auto-open a preview from tool results', async () => {
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
@@ -160,9 +136,9 @@ describe('usePreviewRouting', () => {
|
||||
type: 'tool.complete'
|
||||
})
|
||||
)
|
||||
act(() => handleEvent({ payload: { path: './dist/index.html' }, session_id: 'session-1', type: 'tool.complete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()?.source).toBe('preview-demo.html')
|
||||
})
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
expect(window.localStorage.getItem('hermes.desktop.sessionPreviews.v1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
getSessionPreviewRecord,
|
||||
progressPreviewServerRestart,
|
||||
requestPreviewReload,
|
||||
setPreviewTarget,
|
||||
setSessionPreviewTarget
|
||||
setPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
@@ -40,53 +39,6 @@ function activePreviewSessionId(
|
||||
return selectedStoredSessionId || routedSessionId || activeSessionIdRef.current || ''
|
||||
}
|
||||
|
||||
function looksLikePreviewTarget(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), '')
|
||||
}
|
||||
|
||||
function htmlPathFromInlineDiff(value: string): string {
|
||||
const cleaned = stripAnsi(value).replace(/^\s*┊\s*review diff\s*\n/i, '')
|
||||
|
||||
for (const match of cleaned.matchAll(/(?:^|\s)(?:[ab]\/)?([^\s]+\.html?)(?=\s|$)/gi)) {
|
||||
const candidate = match[1]?.trim()
|
||||
|
||||
if (candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function structuredPreviewCandidate(payload: unknown): string {
|
||||
const record = asRecord(payload)
|
||||
const fields = ['url', 'target', 'path', 'file', 'filepath', 'preview']
|
||||
|
||||
for (const field of fields) {
|
||||
const value = record[field]
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const target = value.trim()
|
||||
|
||||
if (target && looksLikePreviewTarget(target)) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineDiff = record.inline_diff
|
||||
|
||||
if (typeof inlineDiff === 'string') {
|
||||
return htmlPathFromInlineDiff(inlineDiff)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function usePreviewRouting({
|
||||
activeSessionIdRef,
|
||||
baseHandleGatewayEvent,
|
||||
@@ -99,6 +51,10 @@ export function usePreviewRouting({
|
||||
const previewRegistry = useStore($sessionPreviewRegistry)
|
||||
const previewSessionId = activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId)
|
||||
|
||||
// Restore a *user-opened* preview when its session becomes active. Tool
|
||||
// results no longer auto-register/open a preview — the inline preview card in
|
||||
// the tool row is the only entry point, so HTML artifacts never pop the rail
|
||||
// open on their own.
|
||||
useEffect(() => {
|
||||
if (currentView !== 'chat' || !previewSessionId) {
|
||||
setPreviewTarget(null)
|
||||
@@ -111,53 +67,6 @@ export function usePreviewRouting({
|
||||
setPreviewTarget(record?.normalized ?? null)
|
||||
}, [currentView, previewRegistry, previewSessionId])
|
||||
|
||||
const registerStructuredPreview = useCallback(
|
||||
async (event: RpcEvent) => {
|
||||
if (
|
||||
event.session_id &&
|
||||
event.session_id !== activeSessionIdRef.current &&
|
||||
event.session_id !== previewSessionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.type.startsWith('tool.')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!previewSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = structuredPreviewCandidate(event.payload)
|
||||
|
||||
if (!candidate) {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.normalizePreviewTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = previewSessionId
|
||||
const cwd = currentCwd || ''
|
||||
const target = await desktop.normalizePreviewTarget(candidate, cwd || undefined).catch(() => null)
|
||||
|
||||
if (
|
||||
!target ||
|
||||
sessionId !== activePreviewSessionId(activeSessionIdRef, routedSessionId, selectedStoredSessionId) ||
|
||||
$currentCwd.get() !== cwd
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setSessionPreviewTarget(sessionId, target, 'tool-result', candidate)
|
||||
},
|
||||
[activeSessionIdRef, currentCwd, previewSessionId, routedSessionId, selectedStoredSessionId]
|
||||
)
|
||||
|
||||
const restartPreviewServer = useCallback(
|
||||
async (url: string, context?: string) => {
|
||||
const sessionId = activeSessionIdRef.current
|
||||
@@ -210,13 +119,14 @@ export function usePreviewRouting({
|
||||
return
|
||||
}
|
||||
|
||||
void registerStructuredPreview(event)
|
||||
|
||||
// Only refresh an already-open live preview when a file changes; never
|
||||
// open one unprompted. (Preview links are surfaced from the tool row into
|
||||
// the status stack — see tool-fallback.tsx.)
|
||||
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
|
||||
requestPreviewReload()
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, baseHandleGatewayEvent, registerStructuredPreview]
|
||||
[activeSessionIdRef, baseHandleGatewayEvent]
|
||||
)
|
||||
|
||||
return { handleDesktopGatewayEvent, restartPreviewServer }
|
||||
|
||||
@@ -44,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
restoreToMessage: (messageId: string) => Promise<void>
|
||||
restoreToMessage: (
|
||||
messageId: string,
|
||||
target?: { text?: string; userOrdinal?: number | null }
|
||||
) => Promise<void>
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
@@ -642,17 +645,45 @@ describe('usePromptActions restoreToMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-user targets and unknown ids without touching the gateway', async () => {
|
||||
it('rejects non-user targets and unknown ids without touching the gateway', async () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.restoreToMessage('a1')
|
||||
await handle!.restoreToMessage('missing')
|
||||
await expect(handle!.restoreToMessage('a1')).rejects.toThrow('Could not find the message to restore.')
|
||||
await expect(handle!.restoreToMessage('missing')).rejects.toThrow('Could not find the message to restore.')
|
||||
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the clicked runtime user ordinal when the rendered message id is stale', async () => {
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let lastState: Record<string, unknown> = {}
|
||||
let handle: HarnessHandle | null = null
|
||||
render(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => (lastState = state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
seedMessages={$messages.get()}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.restoreToMessage('runtime-user-id-not-in-store', {
|
||||
text: 'first prompt',
|
||||
userOrdinal: 0
|
||||
})
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'first prompt',
|
||||
truncate_before_user_ordinal: 0
|
||||
})
|
||||
expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions file attachment sync', () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$busy,
|
||||
@@ -58,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
BrowserManageResponse,
|
||||
ClientSessionState,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
@@ -153,6 +154,13 @@ async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// Hard guard: at most one prompt.submit in flight per session. Every submit
|
||||
// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
|
||||
// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
|
||||
// session whose first call hangs) let the SAME prompt launch several real turns
|
||||
// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
|
||||
const _submitInFlight = new Set<string>()
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
@@ -380,6 +388,31 @@ function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): numb
|
||||
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
|
||||
}
|
||||
|
||||
function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number {
|
||||
let ordinal = 0
|
||||
|
||||
for (let index = 0; index < messages.length; index += 1) {
|
||||
const message = messages[index]
|
||||
|
||||
if (message.role !== 'user' || message.hidden) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (ordinal === targetOrdinal) {
|
||||
return index
|
||||
}
|
||||
|
||||
ordinal += 1
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
interface RestoreMessageTarget {
|
||||
text?: string
|
||||
userOrdinal?: number | null
|
||||
}
|
||||
|
||||
export function usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -585,6 +618,23 @@ export function usePromptActions({
|
||||
return false
|
||||
}
|
||||
|
||||
// One submit in flight per session — drop any concurrent re-fire so a
|
||||
// stalled turn can't stack the same prompt into multiple real turns.
|
||||
const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__'
|
||||
|
||||
if (_submitInFlight.has(submitLockKey)) {
|
||||
return false
|
||||
}
|
||||
|
||||
_submitInFlight.add(submitLockKey)
|
||||
let submitLockReleased = false
|
||||
const releaseSubmitLock = () => {
|
||||
if (!submitLockReleased) {
|
||||
submitLockReleased = true
|
||||
_submitInFlight.delete(submitLockKey)
|
||||
}
|
||||
}
|
||||
|
||||
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
const buildUserMessage = (): ChatMessage => ({
|
||||
@@ -595,6 +645,7 @@ export function usePromptActions({
|
||||
})
|
||||
|
||||
const releaseBusy = () => {
|
||||
releaseSubmitLock()
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
@@ -736,6 +787,10 @@ export function usePromptActions({
|
||||
clearComposerAttachments()
|
||||
}
|
||||
|
||||
// Submit landed — the turn now runs (busy stays true), but the submit
|
||||
// window is closed, so release the lock for the next (sequential) send.
|
||||
releaseSubmitLock()
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
releaseBusy()
|
||||
@@ -1588,61 +1643,85 @@ export function usePromptActions({
|
||||
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
|
||||
// user turn and everything after it from the session history, then the same
|
||||
// text is submitted as a fresh turn. Callers confirm before invoking; errors
|
||||
// are rethrown so the confirmation dialog can surface them inline.
|
||||
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
|
||||
// can fire while a turn is streaming, interrupt the live turn first — the
|
||||
// cooperative interrupt takes a beat, so the shared busy-retry rides it out.
|
||||
// are rethrown so callers can surface failures. Idle rewinds submit directly:
|
||||
// interrupting an idle agent can leave a stale interrupt flag that cancels the
|
||||
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
|
||||
// response interrupts + retries through the shared busy gate.
|
||||
const submitRewindPrompt = useCallback(
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
|
||||
if (wasRunning) {
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
|
||||
const interrupt = async () => {
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} catch {
|
||||
// Best-effort — the busy-retry below still gates the submit.
|
||||
// Best-effort. The submit path still gates on the gateway state.
|
||||
}
|
||||
}
|
||||
|
||||
await withSessionBusyRetry(() =>
|
||||
const submit = () =>
|
||||
requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
)
|
||||
|
||||
if (interruptFirst) {
|
||||
await interrupt()
|
||||
}
|
||||
|
||||
try {
|
||||
await submit()
|
||||
} catch (err) {
|
||||
if (!isSessionBusyError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
await interrupt()
|
||||
await withSessionBusyRetry(submit)
|
||||
}
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const restoreToMessage = useCallback(
|
||||
async (messageId: string) => {
|
||||
async (messageId: string, target?: RestoreMessageTarget) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
throw new Error('No active session to restore.')
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const sourceIndex = messages.findIndex(m => m.id === messageId)
|
||||
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
|
||||
|
||||
const fallbackIndex =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? -1
|
||||
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
|
||||
|
||||
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
|
||||
const source = messages[sourceIndex]
|
||||
|
||||
if (!source || source.role !== 'user') {
|
||||
return
|
||||
throw new Error('Could not find the message to restore.')
|
||||
}
|
||||
|
||||
const text = chatMessageText(source).trim()
|
||||
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
|
||||
|
||||
if (!text) {
|
||||
return
|
||||
throw new Error('Cannot restore an empty message.')
|
||||
}
|
||||
|
||||
const wasRunning = $busy.get()
|
||||
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
|
||||
const truncateBeforeUserOrdinal =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? visibleUserOrdinal(messages, sourceIndex)
|
||||
: target.userOrdinal
|
||||
|
||||
// The turns we're discarding may have spawned todos and background
|
||||
// processes; they belong to the abandoned timeline, so wipe their status
|
||||
// rows (and kill the live processes) before the fresh run repopulates.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
||||
clearNotifications()
|
||||
setMutableRef(busyRef, true)
|
||||
@@ -1659,12 +1738,21 @@ export function usePromptActions({
|
||||
}))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
|
||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
|
||||
} catch (err) {
|
||||
// The rewind never landed (e.g. the gateway stayed busy past the retry
|
||||
// deadline). Roll the optimistic truncation back to the full original
|
||||
// history so the UI doesn't desync from what's persisted — leaving it
|
||||
// truncated is what made subsequent sends look duplicative.
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
messages
|
||||
}))
|
||||
throw err
|
||||
}
|
||||
},
|
||||
@@ -1690,9 +1778,8 @@ export function usePromptActions({
|
||||
}
|
||||
|
||||
// Sending an edit is a revert: rewind to this prompt and re-run with the
|
||||
// new text. It can fire mid-turn, so capture the live state — the submit
|
||||
// helper interrupts first when a turn is running.
|
||||
const wasRunning = $busy.get()
|
||||
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
|
||||
// first, so a live turn is wound down before the resubmit.
|
||||
|
||||
// Failed turn: optimistic user msg never reached the gateway, so truncating
|
||||
// by ordinal would 422. Submit as a plain resend instead.
|
||||
@@ -1705,6 +1792,7 @@ export function usePromptActions({
|
||||
// processes) before the re-run repopulates them.
|
||||
clearSessionTodos(sessionId)
|
||||
resetSessionBackground(sessionId)
|
||||
clearPreviewArtifacts(sessionId)
|
||||
|
||||
clearNotifications()
|
||||
setMutableRef(busyRef, true)
|
||||
@@ -1724,7 +1812,12 @@ export function usePromptActions({
|
||||
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
|
||||
await submitRewindPrompt(
|
||||
sessionId,
|
||||
text,
|
||||
isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex),
|
||||
busyRef.current || $busy.get()
|
||||
)
|
||||
} catch (err) {
|
||||
let surfaced = err
|
||||
|
||||
@@ -1739,10 +1832,13 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
|
||||
// Roll the optimistic edit/truncation back to the original history so the
|
||||
// UI stays in sync with what's persisted instead of stranding a partial
|
||||
// timeline.
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false, messages }))
|
||||
notifyError(surfaced, copy.editFailed)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
|
||||
import {
|
||||
$currentCwd,
|
||||
$currentFastMode,
|
||||
@@ -175,20 +176,37 @@ function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages:
|
||||
})
|
||||
}
|
||||
|
||||
interface BranchMessage {
|
||||
content: string
|
||||
role: ChatMessage['role']
|
||||
source: ChatMessage
|
||||
}
|
||||
|
||||
// The copyable spine of a branch: user/assistant turns that carry text.
|
||||
const toBranchMessages = (messages: ChatMessage[]): BranchMessage[] =>
|
||||
messages
|
||||
.map(message => ({ content: chatMessageText(message), role: message.role, source: message }))
|
||||
.filter(({ content, role }) => content.trim() && (role === 'assistant' || role === 'user'))
|
||||
|
||||
function upsertOptimisticSession(
|
||||
created: SessionCreateResponse,
|
||||
id: string,
|
||||
title: string | null = null,
|
||||
preview: string | null = null
|
||||
preview: string | null = null,
|
||||
parentSessionId: string | null = null,
|
||||
lastActive?: number
|
||||
) {
|
||||
const now = Date.now() / 1000
|
||||
const now = lastActive ?? Date.now() / 1000
|
||||
// Stamp the profile the session was just created on (= the live gateway's
|
||||
// profile) so the scoped sidebar shows the new row immediately instead of
|
||||
// filtering it out as "default" until the aggregator re-fetches.
|
||||
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
const session: SessionInfo = {
|
||||
cwd: created.info?.cwd ?? null,
|
||||
// Seed cwd so the grouped sidebar can place the new row in its repo/worktree
|
||||
// lane immediately (the overlay groups by path); fall back to the workspace
|
||||
// the session was just started in when the create response omits it.
|
||||
cwd: created.info?.cwd ?? ($currentCwd.get().trim() || null),
|
||||
ended_at: null,
|
||||
id,
|
||||
input_tokens: 0,
|
||||
@@ -198,6 +216,7 @@ function upsertOptimisticSession(
|
||||
message_count: created.message_count ?? created.messages?.length ?? 0,
|
||||
model: created.info?.model ?? null,
|
||||
output_tokens: 0,
|
||||
parent_session_id: parentSessionId,
|
||||
preview,
|
||||
profile: profileKey,
|
||||
source: 'tui',
|
||||
@@ -372,6 +391,16 @@ function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string }
|
||||
setCurrentPersonality('')
|
||||
}
|
||||
|
||||
// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped /
|
||||
// rotated backend) — the REST transcript 404s with `Session not found`. Distinct
|
||||
// from a transient/wedged backend (ECONNREFUSED, timeout), which must still
|
||||
// retry rather than discard the id.
|
||||
function isSessionGoneError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err ?? '')
|
||||
|
||||
return message.includes('404') || /session not found/i.test(message)
|
||||
}
|
||||
|
||||
export function useSessionActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
@@ -421,7 +450,10 @@ export function useSessionActions({
|
||||
// is cleared.
|
||||
setCurrentServiceTier('')
|
||||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
// In a project → the repo's default-branch (main worktree) checkout; not in
|
||||
// a project → detached. So cmd-n "knows" the project instead of inheriting
|
||||
// whatever linked worktree the last session drifted into.
|
||||
setCurrentCwd(resolveNewSessionCwd())
|
||||
setCurrentBranch('')
|
||||
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
||||
setFreshDraftReady(true)
|
||||
@@ -788,6 +820,8 @@ export function useSessionActions({
|
||||
// empty transcript. That is the exact state the thread loader latches on
|
||||
// forever (messagesEmpty && !activeSessionId) with no recovery path —
|
||||
// the "open in new window stays stuck loading, even after a nap" bug.
|
||||
let fallbackError: unknown = null
|
||||
|
||||
try {
|
||||
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
@@ -796,14 +830,31 @@ export function useSessionActions({
|
||||
}
|
||||
|
||||
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Fallback also failed: nothing to paint. Leave whatever messages are
|
||||
// already shown and fall through to arm the resume-failure latch so
|
||||
// use-route-resume re-attempts the resume on the next render / window
|
||||
// focus / gateway reconnect instead of stranding the loader.
|
||||
fallbackError = e
|
||||
}
|
||||
|
||||
if (isCurrentResume() && $messages.get().length === 0) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
}
|
||||
|
||||
// The session is genuinely gone (deleted, or a stale id from a wiped /
|
||||
// rotated backend): the resume RPC and the authoritative REST transcript
|
||||
// both 404. There's nothing to recover — silently drop to a fresh draft
|
||||
// instead of toasting an error and hot-looping the bounded retry on a
|
||||
// permanently-dead id. (Booting straight into a no-longer-existent
|
||||
// last-session id is the common trigger.)
|
||||
if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) {
|
||||
startFreshSessionDraft(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if ($messages.get().length === 0) {
|
||||
// Arm the self-heal ONLY when the window is still empty: the gateway
|
||||
// resume rejected AND the REST fallback failed to paint a transcript.
|
||||
// That is the exact stranded state the loader latches on
|
||||
@@ -832,93 +883,53 @@ export function useSessionActions({
|
||||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
startFreshSessionDraft,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
const branchCurrentSession = useCallback(
|
||||
async (messageId?: string): Promise<boolean> => {
|
||||
const sourceSessionId = activeSessionIdRef.current
|
||||
|
||||
if (!sourceSessionId) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: copy.nothingToBranch,
|
||||
message: copy.branchNeedsChat
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (busyRef.current) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: copy.sessionBusy,
|
||||
message: copy.branchStopCurrent
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Shared fork: create a child session seeded with `branchMessages`, linked to
|
||||
// `parentStoredId` so it nests under its parent, then make it the active chat.
|
||||
const forkBranch = useCallback(
|
||||
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const currentMessages = $messages.get()
|
||||
|
||||
const targetIndex = messageId
|
||||
? currentMessages.findIndex(message => message.id === messageId)
|
||||
: currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
|
||||
|
||||
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
|
||||
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
|
||||
|
||||
const branchMessages = currentMessages
|
||||
.slice(branchStart, branchEnd)
|
||||
.map(message => ({
|
||||
content: chatMessageText(message),
|
||||
source: message,
|
||||
role: message.role
|
||||
}))
|
||||
.filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role))
|
||||
|
||||
if (!branchMessages.length) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: copy.nothingToBranch,
|
||||
message: copy.branchNoText
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
clearNotifications()
|
||||
|
||||
const cwd = $currentCwd.get().trim()
|
||||
|
||||
// No title: the backend auto-names the branch from its parent's lineage.
|
||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||
title: copy.branchTitle
|
||||
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||
})
|
||||
|
||||
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
||||
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
|
||||
// Draft until submit: nest under the parent at the parent's recency so it
|
||||
// doesn't bubble to the top until a real message lands (backend persists
|
||||
// + auto-names it then). The selected row survives refreshes (sessionsToKeep).
|
||||
const rows = $sessions.get()
|
||||
const parent = parentStoredId ? rows.find(session => sessionMatchesStoredId(session, parentStoredId)) : null
|
||||
const siblings = parentStoredId
|
||||
? rows.filter(session => session.parent_session_id?.trim() === parentStoredId).length
|
||||
: 0
|
||||
|
||||
setFreshDraftReady(false)
|
||||
upsertOptimisticSession(branched, routedSessionId, copy.branchTitle, preview)
|
||||
upsertOptimisticSession(
|
||||
branched,
|
||||
routedSessionId,
|
||||
copy.branchTitle(siblings + 1).toLowerCase(),
|
||||
preview,
|
||||
parentStoredId,
|
||||
parent ? parent.last_active || parent.started_at : undefined
|
||||
)
|
||||
ensureSessionState(branched.session_id, routedSessionId)
|
||||
setActiveSessionId(branched.session_id)
|
||||
activeSessionIdRef.current = branched.session_id
|
||||
updateSessionState(
|
||||
branched.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
messages: branchMessages.map(({ source }) => source),
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
}),
|
||||
state => ({ ...state, messages: branchMessages.map(({ source }) => source), busy: false, awaitingResponse: false }),
|
||||
routedSessionId
|
||||
)
|
||||
setSelectedStoredSessionId(routedSessionId)
|
||||
@@ -926,7 +937,6 @@ export function useSessionActions({
|
||||
navigate(sessionRoute(routedSessionId))
|
||||
|
||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||
|
||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||
|
||||
if (runtimeInfo) {
|
||||
@@ -944,17 +954,74 @@ export function useSessionActions({
|
||||
}, 0)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
copy,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
]
|
||||
[activeSessionIdRef, copy, creatingSessionRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
// Branch the open chat — optionally from a specific message — off its live transcript.
|
||||
const branchCurrentSession = useCallback(
|
||||
async (messageId?: string): Promise<boolean> => {
|
||||
if (!activeSessionIdRef.current) {
|
||||
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNeedsChat })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (busyRef.current) {
|
||||
notify({ kind: 'warning', title: copy.sessionBusy, message: copy.branchStopCurrent })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const messages = $messages.get()
|
||||
const at = messageId
|
||||
? messages.findIndex(message => message.id === messageId)
|
||||
: messages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
|
||||
const start = at >= 0 ? at : Math.max(messages.length - 1, 0)
|
||||
const end = at >= 0 ? at + 1 : messages.length
|
||||
const branchMessages = toBranchMessages(messages.slice(start, end))
|
||||
|
||||
if (!branchMessages.length) {
|
||||
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
clearNotifications()
|
||||
|
||||
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim())
|
||||
},
|
||||
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
// Branch any listed session, not just the open one. Reads the target's stored
|
||||
// transcript directly (no resume/active-session dependency), so it works on
|
||||
// right-click and nests under its parent.
|
||||
const branchStoredSession = useCallback(
|
||||
async (storedSessionId: string, sessionProfile?: string | null): Promise<boolean> => {
|
||||
clearNotifications()
|
||||
|
||||
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||
const profile = sessionProfile ?? stored?.profile
|
||||
|
||||
try {
|
||||
await ensureGatewayProfile(profile)
|
||||
const { messages } = await getSessionMessages(storedSessionId, profile)
|
||||
const branchMessages = toBranchMessages(toChatMessages(messages))
|
||||
|
||||
if (!branchMessages.length) {
|
||||
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim())
|
||||
} catch (err) {
|
||||
notifyError(err, copy.branchFailed)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[copy, forkBranch]
|
||||
)
|
||||
|
||||
const removeSession = useCallback(
|
||||
@@ -971,6 +1038,10 @@ export function useSessionActions({
|
||||
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
||||
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
// Evict from the project tree's optimistic layer too (the backend snapshot
|
||||
// still lists it until its next refresh), so grouped + flat views drop the
|
||||
// row in lockstep.
|
||||
tombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
@@ -999,6 +1070,7 @@ export function useSessionActions({
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
untombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
||||
if (wasSelected) {
|
||||
@@ -1053,6 +1125,7 @@ export function useSessionActions({
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||
tombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
@@ -1078,6 +1151,7 @@ export function useSessionActions({
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
untombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
notifyError(err, copy.archiveFailed)
|
||||
}
|
||||
@@ -1088,6 +1162,7 @@ export function useSessionActions({
|
||||
return {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
branchStoredSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { BrandMark } from '@/components/brand-mark'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$desktopVersion,
|
||||
@@ -117,7 +118,7 @@ export function AboutSettings() {
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{statusTone === 'available' ? (
|
||||
<Sparkles className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<Codicon className="mt-0.5 size-4 shrink-0 text-primary" name="cloud-download" size="1rem" />
|
||||
) : statusTone === 'error' ? null : (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
|
||||
239
apps/desktop/src/app/settings/computer-use-panel.tsx
Normal file
239
apps/desktop/src/app/settings/computer-use-panel.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getActionStatus, getComputerUseStatus, grantComputerUsePermissions } from '@/hermes'
|
||||
import { AlertTriangle, Check, ExternalLink, Loader2, RefreshCw, X } from '@/lib/icons'
|
||||
import { upsertDesktopActionTask } from '@/store/activity'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ComputerUseStatus } from '@/types/hermes'
|
||||
|
||||
import { Pill } from './primitives'
|
||||
|
||||
interface ComputerUsePanelProps {
|
||||
/** Re-read the parent toolset list after a permission/install change so the
|
||||
* "Configured / Needs keys" pill stays in sync. */
|
||||
onConfiguredChange?: () => void
|
||||
}
|
||||
|
||||
// Per-OS one-liner shown when there's no TCC grant flow (Windows/Linux). macOS
|
||||
// drives the permission rows instead, so it has no entry here.
|
||||
const PLATFORM_NOTE: Record<string, string> = {
|
||||
linux: 'Drives your desktop via the X11/XWayland accessibility stack — no permission prompt.',
|
||||
win32: 'First run may trigger a Windows SmartScreen prompt for the cua-driver UIAccess worker — allow it.'
|
||||
}
|
||||
|
||||
function tone(granted: boolean | null) {
|
||||
return granted === true ? 'primary' : 'muted'
|
||||
}
|
||||
|
||||
function GrantIcon({ granted }: { granted: boolean | null }) {
|
||||
const Icon = granted === true ? Check : granted === false ? X : AlertTriangle
|
||||
|
||||
return <Icon className="size-3" />
|
||||
}
|
||||
|
||||
function PermissionRow({ granted, label, hint }: { granted: boolean | null; label: string; hint: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
<Pill tone={tone(granted)}>
|
||||
<GrantIcon granted={granted} />
|
||||
{granted === true ? 'Granted' : granted === false ? 'Not granted' : 'Unknown'}
|
||||
</Pill>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-platform Computer Use preflight card.
|
||||
*
|
||||
* cua-driver runs on macOS, Windows, and Linux, but readiness differs: macOS
|
||||
* needs two TCC grants (Accessibility + Screen Recording) that attach to
|
||||
* cua-driver's own `com.trycua.driver` identity — not Hermes — and are
|
||||
* requested via `cua-driver permissions grant` (dialog attributed to
|
||||
* CuaDriver). Windows/Linux have no TCC toggles, so readiness is driver health
|
||||
* from `cua-driver doctor`. The backend folds both into one `ready` signal.
|
||||
*
|
||||
* Binary install/upgrade stays in the cua-driver provider's post-setup runner
|
||||
* below this card (the generic ToolsetConfigPanel).
|
||||
*/
|
||||
export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) {
|
||||
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [granting, setGranting] = useState(false)
|
||||
const activeRef = useRef(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setStatus(await getComputerUseStatus())
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not read Computer Use status')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true
|
||||
void refresh()
|
||||
|
||||
return () => void (activeRef.current = false)
|
||||
}, [refresh])
|
||||
|
||||
const grant = useCallback(async () => {
|
||||
setGranting(true)
|
||||
|
||||
try {
|
||||
const started = await grantComputerUsePermissions()
|
||||
|
||||
if (!started.ok) {
|
||||
notifyError(new Error('spawn failed'), 'Could not request permissions')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Approve in System Settings',
|
||||
message: 'macOS will show a permission dialog attributed to CuaDriver. Approve it, then return here.'
|
||||
})
|
||||
|
||||
// The driver waits for the user to flip the switch — poll until it exits.
|
||||
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1500))
|
||||
|
||||
if (!activeRef.current) {
|
||||
break
|
||||
}
|
||||
|
||||
const polled = await getActionStatus(started.name, 200)
|
||||
upsertDesktopActionTask(polled)
|
||||
|
||||
if (!polled.running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (activeRef.current) {
|
||||
await refresh()
|
||||
onConfiguredChange?.()
|
||||
}
|
||||
} catch (err) {
|
||||
if (activeRef.current) {
|
||||
notifyError(err, 'Could not request permissions')
|
||||
}
|
||||
} finally {
|
||||
if (activeRef.current) {
|
||||
setGranting(false)
|
||||
}
|
||||
}
|
||||
}, [onConfiguredChange, refresh])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking Computer Use status…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!status.platform_supported) {
|
||||
return (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
Computer Use isn't supported on this platform ({status.platform}).
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status.installed) {
|
||||
return (
|
||||
<p className="mt-3 px-1 text-xs text-muted-foreground">
|
||||
Install the cua-driver backend below to drive this machine.
|
||||
{status.can_grant && ' Then grant Accessibility and Screen Recording here.'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const failingChecks = status.checks.filter(c => c.status !== 'ok')
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<div className="min-w-0">
|
||||
{status.can_grant ? (
|
||||
<p className="text-[0.72rem] text-muted-foreground">
|
||||
Grants attach to CuaDriver's own identity (com.trycua.driver), not Hermes — so the dialog is
|
||||
attributed to the process that drives your Mac.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[0.72rem] text-muted-foreground">{PLATFORM_NOTE[status.platform] ?? ''}</p>
|
||||
)}
|
||||
{status.version && <p className="text-[0.68rem] text-muted-foreground/80">{status.version}</p>}
|
||||
</div>
|
||||
<Button onClick={() => void refresh()} size="sm" variant="text">
|
||||
<RefreshCw className="size-3.5" />
|
||||
Recheck
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{status.can_grant ? (
|
||||
<>
|
||||
<PermissionRow
|
||||
granted={status.accessibility}
|
||||
hint="Lets cua-driver post clicks, keystrokes, and read the accessibility tree."
|
||||
label="Accessibility"
|
||||
/>
|
||||
<PermissionRow
|
||||
granted={status.screen_recording}
|
||||
hint="Lets cua-driver capture screenshots of app windows."
|
||||
label="Screen Recording"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<span className="text-sm font-medium">Driver health</span>
|
||||
<Pill tone={tone(status.ready)}>
|
||||
<GrantIcon granted={status.ready} />
|
||||
{status.ready === true ? 'Ready' : status.ready === false ? 'Not ready' : 'Unknown'}
|
||||
</Pill>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failingChecks.map(c => (
|
||||
<p className="px-1 text-[0.7rem] text-muted-foreground" key={c.label}>
|
||||
<AlertTriangle className="mr-1 inline size-3" />
|
||||
{c.label}: {c.message}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{status.error && (
|
||||
<p className="px-1 text-[0.7rem] text-muted-foreground">
|
||||
<AlertTriangle className="mr-1 inline size-3" />
|
||||
{status.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status.ready ? (
|
||||
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
|
||||
<Check className="size-3.5" />
|
||||
Computer Use is ready. Ask the agent to capture an app and click around.
|
||||
</div>
|
||||
) : (
|
||||
status.can_grant && (
|
||||
<Button disabled={granting} onClick={() => void grant()} size="sm">
|
||||
{granting ? <Loader2 className="size-3.5 animate-spin" /> : <ExternalLink className="size-3.5" />}
|
||||
{granting ? 'Waiting for approval…' : 'Grant permissions'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
|
||||
import { fieldCopyForSchemaKey } from './field-copy'
|
||||
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
|
||||
import { MemoryConnect } from './memory/connect'
|
||||
import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import { ProviderConfigPanel } from './provider-config-panel'
|
||||
@@ -31,7 +32,8 @@ function ConfigField({
|
||||
value,
|
||||
enumOptions,
|
||||
optionLabels,
|
||||
onChange
|
||||
onChange,
|
||||
descriptionExtra
|
||||
}: {
|
||||
schemaKey: string
|
||||
schema: ConfigFieldSchema
|
||||
@@ -39,6 +41,7 @@ function ConfigField({
|
||||
enumOptions?: string[]
|
||||
optionLabels?: Record<string, string>
|
||||
onChange: (value: unknown) => void
|
||||
descriptionExtra?: ReactNode
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
@@ -64,8 +67,17 @@ function ConfigField({
|
||||
? rawDescription
|
||||
: undefined
|
||||
|
||||
const descriptionNode: ReactNode = descriptionExtra ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{description}
|
||||
{descriptionExtra}
|
||||
</span>
|
||||
) : (
|
||||
description
|
||||
)
|
||||
|
||||
const row = (action: ReactNode, wide = false) => (
|
||||
<ListRow action={action} description={description} title={label} wide={wide} />
|
||||
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
|
||||
)
|
||||
|
||||
if (schema.type === 'boolean') {
|
||||
@@ -358,6 +370,11 @@ export function ConfigSettings({
|
||||
{fields.map(([key, field]) => (
|
||||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
<ConfigField
|
||||
descriptionExtra={
|
||||
key === 'memory.provider' && Boolean(getNested(config, key)) ? (
|
||||
<MemoryConnect provider={String(getNested(config, key))} />
|
||||
) : undefined
|
||||
}
|
||||
enumOptions={
|
||||
key === 'tts.elevenlabs.voice_id'
|
||||
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Brain,
|
||||
type IconComponent,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Wrench
|
||||
} from '@/lib/icons'
|
||||
@@ -501,7 +501,7 @@ export const SECTIONS: DesktopConfigSection[] = [
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
icon: Sparkles,
|
||||
icon: codiconIcon('hubot'),
|
||||
keys: ['model_context_length', 'fallback_providers']
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { codiconIcon } from '@/components/ui/codicon'
|
||||
import { Archive, Bell, Download, Globe, Info, KeyRound, RefreshCw, Settings2, Upload, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -120,7 +120,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
<OverlayNavItem
|
||||
active={providerView === 'accounts'}
|
||||
icon={Sparkles}
|
||||
icon={codiconIcon('account')}
|
||||
label={t.settings.nav.providerAccounts}
|
||||
nested
|
||||
onClick={() => openProviderView('accounts')}
|
||||
@@ -186,7 +186,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<div className="mt-auto flex items-center gap-1 pt-2">
|
||||
<Tip label={t.settings.exportConfig}>
|
||||
<OverlayIconButton onClick={() => void exportConfig()}>
|
||||
<IconDownload className="size-3.5" />
|
||||
<Download className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.importConfig}>
|
||||
@@ -196,7 +196,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
importInputRef.current?.click()
|
||||
}}
|
||||
>
|
||||
<IconUpload className="size-3.5" />
|
||||
<Upload className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
<Tip label={t.settings.resetToDefaults}>
|
||||
@@ -207,7 +207,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
void resetConfig()
|
||||
}}
|
||||
>
|
||||
<IconRefresh className="size-3.5" />
|
||||
<RefreshCw className="size-3.5" />
|
||||
</OverlayIconButton>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
162
apps/desktop/src/app/settings/memory/connect.tsx
Normal file
162
apps/desktop/src/app/settings/memory/connect.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes'
|
||||
import { Check, ExternalLink, Loader2 } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderOAuthStatus } from '@/types/hermes'
|
||||
|
||||
const POLL_MS = 1500
|
||||
const POLL_TIMEOUT_MS = 120_000
|
||||
|
||||
// Small connect affordance rendered under the provider dropdown. Capability is
|
||||
// backend-driven: the status route 404s for providers without an oauth_flow
|
||||
// module, so non-OAuth providers render nothing.
|
||||
export function MemoryConnect({ provider }: { provider: string }) {
|
||||
const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown')
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [auth, setAuth] = useState<MemoryProviderOAuthStatus['auth']>(null)
|
||||
const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle')
|
||||
const [detail, setDetail] = useState('')
|
||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const deadline = useRef(0)
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (timer.current !== null) {
|
||||
clearInterval(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setCapable('unknown')
|
||||
getMemoryProviderOAuthStatus(provider)
|
||||
.then(s => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
setCapable('yes')
|
||||
setConnected(s.connected)
|
||||
setAuth(s.auth)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setCapable('no')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
stop()
|
||||
}
|
||||
}, [provider, stop])
|
||||
|
||||
// An error message isn't sticky — it clears back to the steady state
|
||||
// (Connect link, plus the connected badge if a credential is stored).
|
||||
useEffect(() => {
|
||||
if (phase !== 'error') {
|
||||
return
|
||||
}
|
||||
|
||||
const t = setTimeout(() => {
|
||||
setPhase('idle')
|
||||
setDetail('')
|
||||
}, 6000)
|
||||
|
||||
return () => clearTimeout(t)
|
||||
}, [phase])
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
setPhase('pending')
|
||||
|
||||
try {
|
||||
await startMemoryProviderOAuth(provider)
|
||||
} catch (err) {
|
||||
setPhase('error')
|
||||
setDetail('Could not start the connection.')
|
||||
notifyError(err, 'Failed to start connection')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
deadline.current = Date.now() + POLL_TIMEOUT_MS
|
||||
stop()
|
||||
timer.current = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getMemoryProviderOAuthStatus(provider)
|
||||
|
||||
if (next.state === 'pending') {
|
||||
if (Date.now() > deadline.current) {
|
||||
stop()
|
||||
setPhase('error')
|
||||
setDetail('Timed out — try again.')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
stop()
|
||||
setConnected(next.connected)
|
||||
setAuth(next.auth)
|
||||
|
||||
if (next.state === 'error') {
|
||||
setPhase('error')
|
||||
setDetail(next.detail || 'Connection failed.')
|
||||
} else {
|
||||
setPhase('idle')
|
||||
}
|
||||
} catch {
|
||||
// Transient poll failure — keep trying until the deadline.
|
||||
}
|
||||
})()
|
||||
}, POLL_MS)
|
||||
}, [provider, stop])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
stop()
|
||||
setPhase('idle')
|
||||
}, [stop])
|
||||
|
||||
if (capable !== 'yes') {
|
||||
return null
|
||||
}
|
||||
|
||||
const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect'
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
{phase === 'idle' && connected && (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Check className="size-3" />
|
||||
{auth === 'apikey' ? 'api key set' : 'oauth set'}
|
||||
</span>
|
||||
)}
|
||||
{phase === 'pending' ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Waiting for browser consent…
|
||||
</span>
|
||||
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="h-auto gap-1 p-0 text-xs"
|
||||
onClick={() => void connect()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{connectLabel}
|
||||
</Button>
|
||||
)}
|
||||
{phase === 'error' && detail && <span className="text-destructive">{detail}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { untombstoneSessions } from '@/store/projects'
|
||||
import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
@@ -62,7 +63,9 @@ export function SessionsSettings() {
|
||||
try {
|
||||
await setSessionArchived(session.id, false, session.profile)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
// Surface it again in the sidebar without waiting for a full refresh.
|
||||
// Surface it again in the sidebar without waiting for a full refresh, and
|
||||
// lift any optimistic eviction so the grouped tree shows it again too.
|
||||
untombstoneSessions([session.id, session._lineage_root_id])
|
||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||
triggerHaptic('selection')
|
||||
notify({ durationMs: 2_000, kind: 'success', message: s.restored })
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { IconLayoutDashboard } from '@tabler/icons-react'
|
||||
|
||||
import { StatusDot, type StatusTone } from '@/components/status-dot'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Activity, AlertCircle } from '@/lib/icons'
|
||||
import { Activity, AlertCircle, LayoutDashboard } from '@/lib/icons'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
@@ -88,7 +86,7 @@ export function GatewayMenuPanel({
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<IconLayoutDashboard />
|
||||
<LayoutDashboard />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
import {
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
Command,
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Zap,
|
||||
ZapFilled
|
||||
@@ -337,7 +337,7 @@ export function useStatusbarItems({
|
||||
) : bgRunning > 0 || subagentsRunning > 0 ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-3" />
|
||||
<Codicon name="hubot" size="0.75rem" />
|
||||
),
|
||||
id: 'agents',
|
||||
label: copy.agents,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user