diff --git a/.agents/commands/deslop.md b/.agents/commands/deslop.md index 4c2806c8621..0621bba170e 100644 --- a/.agents/commands/deslop.md +++ b/.agents/commands/deslop.md @@ -17,10 +17,14 @@ Apply clean code philosophy to the code, with special emphasis on comments: **Keep or add comments only when:** - Explaining *why* a non-obvious decision was made +- Documenting design intent or architectural decisions (e.g., "shared by X and Y paths", "deferred because Z") - Documenting external constraints or business rules not evident from code - Warning about non-intuitive behavior or edge cases +- Noting the origin of a pattern when it aids future maintenance (e.g., "VS Code pattern") - Required for public API documentation (JSDoc, docstrings) +**When in doubt, keep the comment.** Removing a comment that captured intent is destructive — the reasoning is lost and cannot be recovered from code alone. Only remove a comment when you are confident the code *fully* communicates the same information. + ## Code Simplification - Rename variables/functions to be self-documenting instead of adding comments diff --git a/.agents/commands/pr/create-pr.md b/.agents/commands/pr/create-pr.md new file mode 100644 index 00000000000..52aa0c46672 --- /dev/null +++ b/.agents/commands/pr/create-pr.md @@ -0,0 +1,89 @@ +--- +description: Create a pull request for the current branch (agent-driven, one-click) +argumentHint: "[--draft]" +--- + +# Goal + +Create a pull request for the current branch in one pass. The user +clicked the Create PR button in the diff-editor sidebar — they expect +the PR to be created without further prompting. + +An attachment named `pr-context.md` is included with this turn. It +contains: + +- Current branch and base branch +- Whether the branch is published (has upstream) +- Commits ahead/behind upstream +- Whether there are uncommitted changes +- Required preconditions the user's branch must satisfy before + `gh pr create` will succeed + +Read `pr-context.md` first. Use it as ground truth instead of re-deriving +the state yourself. + +# Arguments + +- `--draft` — create the PR as a draft. Pass `--draft` through to the + `gh pr create` call. + +Parse the arguments from the user's prompt (everything after the skill +name). Do not ask the user to confirm the draft flag — it came from +their button click. + +# Workflow + +## 1. Satisfy preconditions + +In the order listed in `pr-context.md` under "Required preconditions": + +- **Uncommitted changes**: generate a commit message from the staged + diff (use `git diff --cached` and `git status`). If nothing is + staged, `git add -A`. Then `git commit -m ""`. Keep the + message short and specific — do not write a PR-body-style + description here. +- **Unpublished branch**: `git push -u origin -- ""` — quote `` + to avoid shell injection on names with metacharacters. +- **Unpushed commits on a published branch**: `git push`. +- **Behind upstream**: stop. Report to the user that they should sync + first. Do not force-push. Do not rebase without asking. + +If any push fails non-fast-forward, stop and report — never +force-push. + +## 2. Draft the PR body + +Use `git log "..HEAD"` to read the commits, `git diff "...HEAD"` +for the scope of changes. Produce: + +- **Title**: short, imperative, derived from the most recent commit + message or the scope of the diff. +- **Body**: concise. Summary + a short Test Plan checklist. Skip + sections that have nothing meaningful to say — do not pad. + +## 3. Create the PR + +``` +gh pr create \ + --base \ + --title "" \ + --body "<body>" +``` + +If `--draft` was passed, add `--draft`. + +## 4. Report back + +Print the PR URL as a plain link on its own line. One short sentence +above it summarizing what you did (e.g. "Published `feature-x` and +opened draft PR."). Do not paste the full body back. + +# Guardrails + +- Never force-push. +- Never skip pre-commit hooks (`--no-verify`) or signing. +- If a hook fails, report the failure; do not retry with `--no-verify`. +- Do not open a browser — the caller handles that. +- Do not run a full `AGENTS.md` standards review in this skill. The + button is a fast path; use `/create-pr` (the general-purpose skill) + for the gated review flow. diff --git a/.bun-version b/.bun-version new file mode 100644 index 00000000000..17e63e7affd --- /dev/null +++ b/.bun-version @@ -0,0 +1 @@ +1.3.11 diff --git a/.cursor/mcp.json b/.cursor/mcp.json deleted file mode 120000 index c67157dc4ab..00000000000 --- a/.cursor/mcp.json +++ /dev/null @@ -1 +0,0 @@ -../.mcp.json \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..12d315aab3d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +**/node_modules +**/.next +**/dist +**/build +**/.turbo +**/.cache +.git +.github +.vscode +.idea +*.log +.DS_Store diff --git a/.env.example b/.env.example index 464107ea1a4..e1ac0be790b 100644 --- a/.env.example +++ b/.env.example @@ -106,3 +106,7 @@ QSTASH_NEXT_SIGNING_KEY= # MCP API Key for Claude Code SUPERSET_MCP_API_KEY= + +# Relay service URL (the v2 tunnel proxy that forwards cloud API calls +# to host-service instances on user devices). Local dev: http://localhost:4734 +RELAY_URL= diff --git a/.github/prompts/generate-changelog.md b/.github/prompts/generate-changelog.md index cbcedc5c56e..00933afc56b 100644 --- a/.github/prompts/generate-changelog.md +++ b/.github/prompts/generate-changelog.md @@ -53,7 +53,10 @@ Brief description of the feature and its benefit to users. --- -**Bug fixes:** Fixed issue with X <PRBadge url="https://github.com/superset-sh/superset/pull/NUMBER" />, resolved Y problem <PRBadge url="https://github.com/superset-sh/superset/pull/NUMBER" /> +**Bug fixes** + +- Fixed issue with X <PRBadge url="https://github.com/superset-sh/superset/pull/NUMBER" /> +- Resolved Y problem <PRBadge url="https://github.com/superset-sh/superset/pull/NUMBER" /> ``` 6. **Important formatting rules** @@ -61,7 +64,8 @@ Brief description of the feature and its benefit to users. - MDX comments (`{/* ... */}`) must come AFTER the frontmatter, not before - Set `image:` in frontmatter to `/changelog/IMAGE_PLACEHOLDER.png` - reviewers will replace this - Add TODO comments for features that would benefit from screenshots - - Use a horizontal rule (`---`) before the bug fixes footnote + - Use a horizontal rule (`---`) before the bug fixes section + - Bug fixes should use bullet points, one fix per line, same as Improvements 7. **Writing style** - **Be brief** - Users scan changelogs, they don't read every word @@ -77,7 +81,7 @@ Brief description of the feature and its benefit to users. | New user-facing feature | Own section with heading, 1-2 sentences + bullets | | Significant improvement | Own section or grouped under "Improvements" | | Small enhancement | One line under "Improvements" | -| Bug fix | One-liner in footnote section at bottom | +| Bug fix | Bullet point in footnote section at bottom | | Internal/refactor | Skip entirely unless user-visible | ## Reference Examples diff --git a/.github/prompts/triage-issue.md b/.github/prompts/triage-issue.md index 100da30c7c0..8f4f109368b 100644 --- a/.github/prompts/triage-issue.md +++ b/.github/prompts/triage-issue.md @@ -22,8 +22,9 @@ You are triaging GitHub issue `$ISSUE_NUMBER`. Your goal is to: - Run any nearby targeted tests needed to validate the fix. - If a safe fix is not clear, keep this as reproduction-only and continue to step 6A. -6. **Open exactly one PR** — Run `bun run lint:fix`, then commit, push, and create a PR: - - **6A: Reproduction-only PR (reproducible but not solved)** +6. **Open exactly one PR** — Run `bun run lint:fix`, then commit, push, and create a PR. + **Default to draft** (`gh pr create --draft`) unless the issue is high priority (labeled `priority: high` or `priority: critical`, or the issue describes data loss, security, or a production outage) **and** you are highly confident in the fix (clear root cause, minimal scoped change, all relevant tests pass). Only in that case, create as ready for review (omit `--draft`). + - **6A: Reproduction-only PR (reproducible but not solved)** — always draft. - Title: `test: reproduce #$ISSUE_NUMBER — <short bug description>` - Body should include: - What the bug is (in your own words, based on the issue) diff --git a/.github/templates/preview-comment.md b/.github/templates/preview-comment.md index a1bd37c3d40..f555706ecd5 100644 --- a/.github/templates/preview-comment.md +++ b/.github/templates/preview-comment.md @@ -14,11 +14,6 @@ <td>$DATABASE_LINK</td> </tr> <tr> -<td><img src="https://fly.io/phx/ui/images/favicon/favicon-595d1312b35dfe32838befdf8505515e.ico" width="20" height="20" alt="Fly.io"> <strong>Electric (Fly.io)</strong></td> -<td align="center">$ELECTRIC_STATUS</td> -<td>$ELECTRIC_LINK</td> -</tr> -<tr> <td><img src="https://vercel.com/favicon.ico" width="20" height="20" alt="Vercel"> <strong>API (Vercel)</strong></td> <td align="center">$API_STATUS</td> <td>$API_LINK</td> diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml new file mode 100644 index 00000000000..8f2acaca814 --- /dev/null +++ b/.github/workflows/build-cli.yml @@ -0,0 +1,79 @@ +name: Build CLI Distribution + +on: + push: + tags: ["cli-v*"] + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: darwin-arm64 + - os: ubuntu-latest + target: linux-x64 + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Setup Node.js (for native addon compilation) + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: 22 + + - name: Install dependencies + run: bun install --frozen + + - name: Build distribution + working-directory: packages/cli + env: + RELAY_URL: https://relay.superset.sh + CLOUD_API_URL: https://api.superset.sh + run: bun run build:dist --target=${{ matrix.target }} + + - name: Upload tarball + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: superset-${{ matrix.target }} + path: packages/cli/dist/superset-${{ matrix.target }}.tar.gz + if-no-files-found: error + + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/cli-v') + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + + - name: Download all artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + path: release-artifacts + pattern: superset-* + merge-multiple: true + + - name: Create Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + release-artifacts/*.tar.gz \ + --title "Superset CLI ${{ github.ref_name }}" \ + --generate-notes \ + --draft diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 18ff1594119..c65877b64d9 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -41,16 +41,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: "1.3.2" + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.bun/install/cache @@ -104,6 +104,7 @@ jobs: NEXT_PUBLIC_ELECTRIC_URL: ${{ secrets.NEXT_PUBLIC_ELECTRIC_URL }} SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + RELAY_URL: ${{ secrets.RELAY_URL }} SUPERSET_WORKSPACE_NAME: superset run: bun run compile:app @@ -135,7 +136,7 @@ jobs: } - name: Upload DMG artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-mac-${{ matrix.arch }}-dmg path: apps/desktop/release/*.dmg @@ -143,7 +144,7 @@ jobs: if-no-files-found: error - name: Upload ZIP artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-mac-${{ matrix.arch }}-zip path: apps/desktop/release/*.zip @@ -151,7 +152,7 @@ jobs: if-no-files-found: error - name: Upload auto-update manifest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-mac-${{ matrix.arch }}-update-manifest path: apps/desktop/release/*-mac.yml @@ -165,16 +166,16 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: "1.3.2" + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.bun/install/cache @@ -185,6 +186,9 @@ jobs: - name: Install dependencies run: bun install --frozen --ignore-scripts + - name: Install Linux native build deps + run: sudo apt-get update && sudo apt-get install -y libx11-dev libxkbfile-dev + - name: Install desktop native dependencies working-directory: apps/desktop run: bun run install:deps @@ -226,6 +230,7 @@ jobs: NEXT_PUBLIC_ELECTRIC_URL: ${{ secrets.NEXT_PUBLIC_ELECTRIC_URL }} SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + RELAY_URL: ${{ secrets.RELAY_URL }} SUPERSET_WORKSPACE_NAME: superset run: bun run compile:app @@ -247,7 +252,7 @@ jobs: } - name: Upload AppImage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-linux-appimage path: apps/desktop/release/*.AppImage @@ -255,7 +260,7 @@ jobs: if-no-files-found: error - name: Upload Linux auto-update manifest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ inputs.artifact_prefix }}-linux-update-manifest path: apps/desktop/release/*-linux.yml diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml new file mode 100644 index 00000000000..42ce5f8fea4 --- /dev/null +++ b/.github/workflows/bump-homebrew.yml @@ -0,0 +1,131 @@ +name: Bump Homebrew Formula + +# Triggers when a CLI release (tag matching cli-v*) is published, computes +# SHA256 for each platform tarball, and pushes an updated formula to the +# superset-sh/homebrew-tap repository. + +on: + release: + types: [published] + +# Serialize runs so two concurrent releases can't race and drop a bump. +concurrency: + group: homebrew-tap-formula-bump + cancel-in-progress: false + +jobs: + bump: + if: startsWith(github.event.release.tag_name, 'cli-v') + runs-on: ubuntu-latest + steps: + - name: Extract version from tag + id: version + env: + TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + # Validate tag format: cli-v<semver>. Rejects tags with shell metacharacters. + if ! printf '%s' "$TAG" | grep -Eq '^cli-v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$'; then + echo "::error::Invalid tag format: $TAG" + exit 1 + fi + VERSION="${TAG#cli-v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Compute SHA256 for each tarball + id: shas + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + for target in darwin-arm64 linux-x64; do + url="https://github.com/superset-sh/superset/releases/download/${TAG}/superset-${target}.tar.gz" + echo "Fetching SHA for $url" + tmp=$(mktemp) + if ! curl -fsSL -o "$tmp" "$url"; then + echo "::error::Failed to download $url" + rm -f "$tmp" + exit 1 + fi + sha=$(shasum -a 256 "$tmp" | awk '{print $1}') + rm -f "$tmp" + echo "${target//-/_}_sha=$sha" >> "$GITHUB_OUTPUT" + done + + - name: Checkout homebrew-tap + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + repository: superset-sh/homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: homebrew-tap + + - name: Render formula + env: + VERSION: ${{ steps.version.outputs.version }} + DARWIN_ARM64_SHA: ${{ steps.shas.outputs.darwin_arm64_sha }} + LINUX_X64_SHA: ${{ steps.shas.outputs.linux_x64_sha }} + run: | + set -euo pipefail + python3 - <<'PYEOF' > homebrew-tap/Formula/superset.rb + import os + template = '''class Superset < Formula + desc "CLI and host-service for Superset" + homepage "https://superset.sh" + version "{version}" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/superset-sh/superset/releases/download/cli-v#{{version}}/superset-darwin-arm64.tar.gz" + sha256 "{darwin_arm64}" + end + end + + on_linux do + on_intel do + url "https://github.com/superset-sh/superset/releases/download/cli-v#{{version}}/superset-linux-x64.tar.gz" + sha256 "{linux_x64}" + end + end + + def install + libexec.install Dir["*"] + bin.install_symlink libexec/"bin/superset" + bin.install_symlink libexec/"bin/superset-host" + end + + test do + assert_match "superset", shell_output("#{{bin}}/superset --version") + end + end + ''' + print(template.format( + version=os.environ["VERSION"], + darwin_arm64=os.environ["DARWIN_ARM64_SHA"], + linux_x64=os.environ["LINUX_X64_SHA"], + ), end="") + PYEOF + + - name: Commit and push + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + cd homebrew-tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/superset.rb + if git diff --cached --quiet; then + echo "No changes to formula" + exit 0 + fi + git commit -m "superset ${VERSION}" + + # Retry once on non-fast-forward in case the remote moved between + # checkout and push (shouldn't happen with the concurrency group, + # but belt-and-suspenders). + if ! git push; then + git pull --rebase + git push + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e49ecd36af7..8e2dc1ff84d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,16 +12,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.6 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -37,16 +37,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.6 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -62,16 +62,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.6 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -79,11 +79,16 @@ jobs: - name: Install dependencies run: bun install --frozen --ignore-scripts + - name: Install Linux native build deps + run: sudo apt-get update && sudo apt-get install -y libx11-dev libxkbfile-dev + - name: Install desktop native dependencies working-directory: apps/desktop run: bun run install:deps - name: Test + env: + RELAY_URL: https://relay.superset.sh run: bun run test typecheck: @@ -91,16 +96,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.6 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -116,16 +121,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.6 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -133,9 +138,14 @@ jobs: - name: Install dependencies run: bun install --frozen --ignore-scripts + - name: Install Linux native build deps + run: sudo apt-get update && sudo apt-get install -y libx11-dev libxkbfile-dev + - name: Install desktop native dependencies working-directory: apps/desktop run: bun run install:deps - name: Build Desktop + env: + RELAY_URL: https://relay.superset.sh run: bun turbo run build --filter=@superset/desktop diff --git a/.github/workflows/cleanup-preview.yml b/.github/workflows/cleanup-preview.yml index e7b4e732e26..9f87b92173f 100644 --- a/.github/workflows/cleanup-preview.yml +++ b/.github/workflows/cleanup-preview.yml @@ -15,34 +15,22 @@ jobs: steps: - name: Delete Neon branch id: neon-cleanup - uses: neondatabase/delete-branch-action@v3 + uses: neondatabase/delete-branch-action@4468d825d5a88ef4012f1705a82f02ec3072f776 # v3.2.1 continue-on-error: true with: project_id: ${{ vars.NEON_PROJECT_ID }} branch: ${{ github.event.pull_request.head.ref }} api_key: ${{ secrets.NEON_API_KEY }} - - name: Setup Fly CLI - uses: superfly/flyctl-actions/setup-flyctl@master - - - name: Delete Electric Fly.io app - id: electric-cleanup - continue-on-error: true - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - run: | - flyctl apps destroy "superset-electric-pr-${{ github.event.pull_request.number }}" --yes - - name: Update comment if: always() - uses: thollander/actions-comment-pull-request@v3 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 with: message: | ## 🧹 Preview Cleanup Complete The following preview resources have been cleaned up: - ${{ steps.neon-cleanup.outcome == 'success' && '✅' || '⚠️' }} Neon database branch - - ${{ steps.electric-cleanup.outcome == 'success' && '✅' || '⚠️' }} Electric Fly.io app Thank you for your contribution! 🎉 comment-tag: "🚀-preview-deployment" diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 1b52e24ce79..772641b28b5 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -18,7 +18,6 @@ env: MARKETING_ALIAS: marketing-pr-${{ github.event.pull_request.number }}-superset.vercel.app ADMIN_ALIAS: admin-pr-${{ github.event.pull_request.number }}-superset.vercel.app DOCS_ALIAS: docs-pr-${{ github.event.pull_request.number }}-superset.vercel.app - ELECTRIC_URL: https://superset-electric-pr-${{ github.event.pull_request.number }}.fly.dev/v1/shape jobs: deploy-database: @@ -28,16 +27,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -47,7 +46,7 @@ jobs: - name: Create Neon branch id: create-branch - uses: neondatabase/create-branch-action@v6 + uses: neondatabase/create-branch-action@fb620d43d4c565abaf088b848a4e28e5c4ea4d9c # 6.3.1 with: project_id: ${{ vars.NEON_PROJECT_ID }} branch_name: ${{ github.head_ref }} @@ -73,56 +72,11 @@ jobs: EOF - name: Upload database status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: database-status path: database-status.env - deploy-electric: - name: Deploy Electric (Fly.io) - runs-on: ubuntu-latest - needs: deploy-database - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download database info - uses: actions/download-artifact@v4 - with: - name: database-status - - - name: Load database URL - run: | - source database-status.env - echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV - - - name: Deploy Electric to Fly.io - uses: superfly/fly-pr-review-apps@1.3.0 - with: - name: superset-electric-pr-${{ github.event.pull_request.number }} - region: iad - org: ${{ vars.FLY_ORG }} - config: fly.toml - secrets: | - DATABASE_URL=${{ env.DATABASE_URL_UNPOOLED }} - ELECTRIC_SECRET=${{ secrets.ELECTRIC_SECRET_PREVIEW }} - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - - name: Save Electric status - run: | - cat > electric-status.env << EOF - ELECTRIC_STATUS="✅" - ELECTRIC_LINK="<a href=\"https://fly.io/apps/superset-electric-pr-${{ github.event.pull_request.number }}\">View App</a>" - EOF - - - name: Upload Electric status - uses: actions/upload-artifact@v4 - with: - name: electric-status - path: electric-status.env - deploy-api: name: Deploy API runs-on: ubuntu-latest @@ -131,16 +85,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Download database info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: database-status @@ -151,7 +105,7 @@ jobs: echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -203,10 +157,9 @@ jobs: GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} + QSTASH_URL: ${{ secrets.QSTASH_URL }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} - ELECTRIC_URL: ${{ env.ELECTRIC_URL }} - ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET_PREVIEW }} DURABLE_STREAMS_URL: ${{ secrets.DURABLE_STREAMS_URL }} DURABLE_STREAMS_SECRET: ${{ secrets.DURABLE_STREAMS_SECRET }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -217,6 +170,7 @@ jobs: SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} + RELAY_URL: ${{ secrets.RELAY_URL }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -255,10 +209,9 @@ jobs: --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ --env QSTASH_TOKEN=$QSTASH_TOKEN \ + --env QSTASH_URL=$QSTASH_URL \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ - --env ELECTRIC_URL=$ELECTRIC_URL \ - --env ELECTRIC_SECRET=$ELECTRIC_SECRET \ --env DURABLE_STREAMS_URL=$DURABLE_STREAMS_URL \ --env DURABLE_STREAMS_SECRET=$DURABLE_STREAMS_SECRET \ --env STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY \ @@ -268,7 +221,8 @@ jobs: --env STRIPE_ENTERPRISE_YEARLY_PRICE_ID=$STRIPE_ENTERPRISE_YEARLY_PRICE_ID \ --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY \ - --env TAVILY_API_KEY=$TAVILY_API_KEY) + --env TAVILY_API_KEY=$TAVILY_API_KEY \ + --env RELAY_URL=$RELAY_URL) vercel alias $VERCEL_URL ${{ env.API_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT @@ -281,7 +235,7 @@ jobs: EOF - name: Upload API status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: api-status path: api-status.env @@ -296,22 +250,22 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} - name: Download database info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: database-status @@ -399,7 +353,7 @@ jobs: EOF - name: Upload web status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: web-status path: web-status.env @@ -412,16 +366,27 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Download database info + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - bun-version: 1.3.3 + name: database-status + + - name: Load database URL + run: | + source database-status.env + echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV + echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -438,6 +403,8 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} + DATABASE_URL: ${{ env.DATABASE_URL }} + DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} @@ -465,6 +432,8 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --archive=tgz --token=$VERCEL_TOKEN \ + --env DATABASE_URL=$DATABASE_URL \ + --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ @@ -498,7 +467,7 @@ jobs: EOF - name: Upload marketing status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: marketing-status path: marketing-status.env @@ -511,16 +480,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Download database info - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: database-status @@ -531,7 +500,7 @@ jobs: echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -616,7 +585,7 @@ jobs: EOF - name: Upload admin status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: admin-status path: admin-status.env @@ -629,16 +598,27 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Download database info + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - bun-version: 1.3.3 + name: database-status + + - name: Load database URL + run: | + source database-status.env + echo "DATABASE_URL=$DATABASE_URL" >> $GITHUB_ENV + echo "DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED" >> $GITHUB_ENV - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -655,6 +635,8 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_DOCS_PROJECT_ID }} + DATABASE_URL: ${{ env.DATABASE_URL }} + DATABASE_URL_UNPOOLED: ${{ env.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: https://${{ env.API_ALIAS }} NEXT_PUBLIC_WEB_URL: https://${{ env.WEB_ALIAS }} NEXT_PUBLIC_MARKETING_URL: https://${{ env.MARKETING_ALIAS }} @@ -668,6 +650,8 @@ jobs: vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN VERCEL_URL=$(vercel deploy --prebuilt --archive=tgz --token=$VERCEL_TOKEN \ + --env DATABASE_URL=$DATABASE_URL \ + --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ --env NEXT_PUBLIC_SENTRY_DSN_DOCS=$NEXT_PUBLIC_SENTRY_DSN_DOCS \ @@ -684,7 +668,7 @@ jobs: EOF - name: Upload docs status - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: docs-status path: docs-status.env @@ -693,17 +677,17 @@ jobs: name: Post Deployment Comment runs-on: ubuntu-latest if: always() - needs: [deploy-database, deploy-electric, deploy-api, deploy-web, deploy-marketing, deploy-admin, deploy-docs] + needs: [deploy-database, deploy-api, deploy-web, deploy-marketing, deploy-admin, deploy-docs] permissions: contents: read pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Download all status artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: "*-status" merge-multiple: true @@ -712,8 +696,6 @@ jobs: run: | DATABASE_STATUS="❌" DATABASE_LINK="Failed to create" - ELECTRIC_STATUS="❌" - ELECTRIC_LINK="Failed to deploy" API_STATUS="❌" API_LINK="Failed to deploy" WEB_STATUS="❌" @@ -729,10 +711,6 @@ jobs: source database-status.env fi - if [[ "${{ needs.deploy-electric.result }}" == "success" ]]; then - source electric-status.env - fi - if [[ "${{ needs.deploy-api.result }}" == "success" ]]; then source api-status.env fi @@ -753,11 +731,11 @@ jobs: source docs-status.env fi - export DATABASE_STATUS DATABASE_LINK ELECTRIC_STATUS ELECTRIC_LINK API_STATUS API_LINK WEB_STATUS WEB_LINK MARKETING_STATUS MARKETING_LINK ADMIN_STATUS ADMIN_LINK DOCS_STATUS DOCS_LINK + export DATABASE_STATUS DATABASE_LINK API_STATUS API_LINK WEB_STATUS WEB_LINK MARKETING_STATUS MARKETING_LINK ADMIN_STATUS ADMIN_LINK DOCS_STATUS DOCS_LINK envsubst < .github/templates/preview-comment.md > final-comment.md - name: Post final deployment comment - uses: thollander/actions-comment-pull-request@v3 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 with: file-path: final-comment.md comment-tag: "🚀-preview-deployment" diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 685a95164e0..23efd918d66 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -16,16 +16,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -48,16 +48,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -108,10 +108,9 @@ jobs: GH_APP_PRIVATE_KEY: ${{ secrets.GH_APP_PRIVATE_KEY }} GH_WEBHOOK_SECRET: ${{ secrets.GH_WEBHOOK_SECRET }} QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} + QSTASH_URL: ${{ secrets.QSTASH_URL }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} QSTASH_NEXT_SIGNING_KEY: ${{ secrets.QSTASH_NEXT_SIGNING_KEY }} - ELECTRIC_URL: ${{ secrets.ELECTRIC_URL }} - ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET }} DURABLE_STREAMS_URL: ${{ secrets.DURABLE_STREAMS_URL }} DURABLE_STREAMS_SECRET: ${{ secrets.DURABLE_STREAMS_SECRET }} STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} @@ -122,6 +121,7 @@ jobs: SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} + RELAY_URL: ${{ secrets.RELAY_URL }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -160,10 +160,9 @@ jobs: --env GH_APP_PRIVATE_KEY="$GH_APP_PRIVATE_KEY" \ --env GH_WEBHOOK_SECRET="$GH_WEBHOOK_SECRET" \ --env QSTASH_TOKEN=$QSTASH_TOKEN \ + --env QSTASH_URL=$QSTASH_URL \ --env QSTASH_CURRENT_SIGNING_KEY=$QSTASH_CURRENT_SIGNING_KEY \ --env QSTASH_NEXT_SIGNING_KEY=$QSTASH_NEXT_SIGNING_KEY \ - --env ELECTRIC_URL=$ELECTRIC_URL \ - --env ELECTRIC_SECRET=$ELECTRIC_SECRET \ --env DURABLE_STREAMS_URL=$DURABLE_STREAMS_URL \ --env DURABLE_STREAMS_SECRET=$DURABLE_STREAMS_SECRET \ --env STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY \ @@ -173,7 +172,8 @@ jobs: --env STRIPE_ENTERPRISE_YEARLY_PRICE_ID=$STRIPE_ENTERPRISE_YEARLY_PRICE_ID \ --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY \ - --env TAVILY_API_KEY=$TAVILY_API_KEY + --env TAVILY_API_KEY=$TAVILY_API_KEY \ + --env RELAY_URL=$RELAY_URL deploy-web: name: Deploy Web to Vercel @@ -183,16 +183,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -272,16 +272,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -297,6 +297,8 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_MARKETING_PROJECT_ID }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }} @@ -324,6 +326,8 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --archive=tgz --token=$VERCEL_TOKEN \ + --env DATABASE_URL=$DATABASE_URL \ + --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET \ --env NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ --env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \ @@ -355,16 +359,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -438,33 +442,6 @@ jobs: --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY \ --env ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY - deploy-electric: - name: Deploy Electric to Fly.io - runs-on: ubuntu-latest - environment: production - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Fly CLI - uses: superfly/flyctl-actions/setup-flyctl@master - - - name: Stage secrets - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - run: | - flyctl secrets set \ - DATABASE_URL="${{ secrets.DATABASE_URL_UNPOOLED }}" \ - ELECTRIC_SECRET="${{ secrets.ELECTRIC_SECRET }}" \ - --app superset-electric \ - --stage - - - name: Deploy to Fly.io - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - run: flyctl deploy . --config fly.toml --remote-only - deploy-electric-proxy: name: Deploy Electric Proxy to Cloudflare runs-on: ubuntu-latest @@ -472,16 +449,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -504,16 +481,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} @@ -529,6 +506,8 @@ jobs: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_DOCS_PROJECT_ID }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_MARKETING_URL: ${{ secrets.NEXT_PUBLIC_MARKETING_URL }} @@ -542,6 +521,8 @@ jobs: vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN vercel deploy --prod --prebuilt --archive=tgz --token=$VERCEL_TOKEN \ + --env DATABASE_URL=$DATABASE_URL \ + --env DATABASE_URL_UNPOOLED=$DATABASE_URL_UNPOOLED \ --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ --env NEXT_PUBLIC_SENTRY_DSN_DOCS=$NEXT_PUBLIC_SENTRY_DSN_DOCS \ diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index 2fd67028e10..3e09f040b09 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -16,18 +16,18 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/release-desktop-canary.yml b/.github/workflows/release-desktop-canary.yml index bc408fbe2f7..89d7ef1a5e0 100644 --- a/.github/workflows/release-desktop-canary.yml +++ b/.github/workflows/release-desktop-canary.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -87,10 +87,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: release-artifacts pattern: desktop-canary-* @@ -137,7 +137,7 @@ jobs: git push origin :refs/tags/desktop-canary || true - name: Create Canary Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: desktop-canary name: "Superset Desktop Canary" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 717b1a3806d..91be7e1cadb 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -52,7 +52,7 @@ jobs: echo "Previous tag: ${PREVIOUS_TAG:-none}" - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: release-artifacts pattern: desktop-* diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml index 02e40df0a7a..0457c711311 100644 --- a/.github/workflows/triage-issue.yml +++ b/.github/workflows/triage-issue.yml @@ -26,16 +26,16 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.6 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 11930eab257..cbcf109f42a 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -16,18 +16,18 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Setup Bun id: setup-bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.3 + bun-version-file: .bun-version - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.bun/install/cache key: ${{ runner.os }}-bun-${{ steps.setup-bun.outputs.bun-revision }}-${{ hashFiles('bun.lock') }} diff --git a/.gitignore b/.gitignore index 6220f7db587..2c81080d953 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ yarn.lock # Env variables .env .env.* +!.bun-version mise.toml @@ -84,3 +85,11 @@ superset-dev-data/ !.codex/config.toml !.codex/commands !.codex/prompts +.amp/* + +# MCP config (contains per-user server URLs/tokens) +.mcp.json +.cursor/mcp.json + +# Local-only plans (not tracked) +plans/local/ diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index 3651aa27461..00000000000 --- a/.mcp.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "mcpServers": { - "superset": { - "type": "http", - "url": "https://api.superset.sh/api/agent/mcp" - }, - "expo-mcp": { - "type": "http", - "url": "https://mcp.expo.dev/mcp", - "enabled": false - }, - "maestro": { - "command": "maestro", - "args": ["mcp"] - }, - "neon": { - "type": "http", - "url": "https://mcp.neon.tech/mcp" - }, - "linear": { - "type": "http", - "url": "https://mcp.linear.app/mcp" - }, - "sentry": { - "type": "http", - "url": "https://mcp.sentry.dev/mcp" - }, - "posthog": { - "type": "http", - "url": "https://mcp.posthog.com/mcp" - }, - "desktop-automation": { - "command": "bun", - "args": ["run", "packages/desktop-mcp/src/bin.ts"] - } - } -} diff --git a/.superset/lib/setup/main.sh b/.superset/lib/setup/main.sh index 681cc21ef17..3ad4e6cfa03 100644 --- a/.superset/lib/setup/main.sh +++ b/.superset/lib/setup/main.sh @@ -36,7 +36,12 @@ setup_main() { step_failed "Seed local DB" fi - # Step 5: Seed auth token into superset-dev-data/ + # Step 5: Seed host-service DBs into superset-dev-data/host/ + if ! step_seed_host_dbs; then + step_failed "Seed host-service DBs" + fi + + # Step 6: Seed auth token into superset-dev-data/ if ! step_seed_auth_token; then step_failed "Seed auth token" fi diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 51ac6f0760f..8ae07bfff20 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -259,6 +259,7 @@ step_start_electric() { if ! docker run -d \ --name "$ELECTRIC_CONTAINER" \ + --restart on-failure:5 \ $port_flag \ -e DATABASE_URL="$DIRECT_URL" \ -e ELECTRIC_SECRET="$ELECTRIC_SECRET" \ @@ -440,6 +441,7 @@ step_write_env() { local CODE_INSPECTOR_PORT=$((BASE + 11)) local DESKTOP_AUTOMATION_PORT=$((BASE + 12)) local WRANGLER_PORT=$((BASE + 13)) + local RELAY_PORT=$((BASE + 14)) echo "" echo "# Workspace Ports (allocated from SUPERSET_PORT_BASE=$BASE, range=20)" @@ -458,6 +460,7 @@ step_write_env() { write_env_var "CODE_INSPECTOR_PORT" "$CODE_INSPECTOR_PORT" write_env_var "DESKTOP_AUTOMATION_PORT" "$DESKTOP_AUTOMATION_PORT" write_env_var "WRANGLER_PORT" "$WRANGLER_PORT" + write_env_var "RELAY_PORT" "$RELAY_PORT" echo "" echo "# Cross-app URLs (overrides from root .env)" write_env_var "NEXT_PUBLIC_API_URL" "http://localhost:$API_PORT" @@ -468,6 +471,8 @@ step_write_env() { write_env_var "NEXT_PUBLIC_DESKTOP_URL" "http://localhost:$DESKTOP_VITE_PORT" write_env_var "EXPO_PUBLIC_WEB_URL" "http://localhost:$WEB_PORT" write_env_var "EXPO_PUBLIC_API_URL" "http://localhost:$API_PORT" + write_env_var "RELAY_URL" "http://localhost:$RELAY_PORT" + write_env_var "SUPERSET_WEB_URL" "http://localhost:$WEB_PORT" echo "" echo "# Streams URLs (overrides from root .env)" write_env_var "PORT" "$STREAMS_PORT" @@ -487,7 +492,12 @@ step_write_env() { # Generate Caddyfile for HTTP/2 reverse proxy (avoids browser 6-connection limit with Electric SSE streams) # Caddy proxies to the local Wrangler worker, which handles auth and forwards upstream appropriately. + # auto_https disable_redirects keeps Caddy off port 80 — we only need HTTPS on the allocated port. cat > Caddyfile <<-CADDYEOF + { + auto_https disable_redirects + } + https://localhost:{\$CADDY_ELECTRIC_PORT} { reverse_proxy localhost:{\$WRANGLER_PORT} { flush_interval -1 @@ -599,6 +609,96 @@ step_seed_auth_token() { return 0 } +step_seed_host_dbs() { + echo "🛰️ Seeding host-service DBs into superset-dev-data/host/..." + + local source_root="$HOME/.superset/host" + local dev_data_dir="superset-dev-data" + local dest_root="$dev_data_dir/host" + local force_overwrite="$FORCE_OVERWRITE_DATA" + + if [ ! -d "$source_root" ]; then + warn "No host-service DBs found at $source_root — skipping (host-service will create fresh DBs per org)" + step_skipped "Seed host-service DBs (no source dir)" + return 0 + fi + + local org_dirs=() + for org_dir in "$source_root"/*/; do + [ -d "$org_dir" ] || continue + local org_id + org_id="$(basename "$org_dir")" + if [ -f "${org_dir}host.db" ]; then + org_dirs+=("$org_id") + fi + done + + if [ ${#org_dirs[@]} -eq 0 ]; then + warn "No host.db files under $source_root — skipping" + step_skipped "Seed host-service DBs (no host.db files)" + return 0 + fi + + mkdir -p "$dest_root" + chmod 700 "$dev_data_dir" "$dest_root" + + local seeded=0 + local skipped=0 + for org_id in "${org_dirs[@]}"; do + local source_db="$source_root/$org_id/host.db" + local dest_org_dir="$dest_root/$org_id" + local dest_db="$dest_org_dir/host.db" + + if [ -f "$dest_db" ] && [ "$force_overwrite" != "1" ]; then + warn "Host DB already exists at $dest_db — skipping (use -f/--force)" + skipped=$((skipped + 1)) + continue + fi + + mkdir -p "$dest_org_dir" + chmod 700 "$dest_org_dir" + + # Clear stale WAL siblings when overwriting so we don't mix old WAL + # data with a freshly-copied DB (their page pointers won't match). + if [ "$force_overwrite" = "1" ]; then + rm -f "$dest_db" "${dest_db}-shm" "${dest_db}-wal" + fi + + # Copy all SQLite files so WAL data isn't lost when source is held open. + local copy_failed=0 + for ext in "" "-shm" "-wal"; do + local source_file="${source_db}${ext}" + local dest_file="${dest_db}${ext}" + + if [ -f "$source_file" ]; then + if ! cp "$source_file" "$dest_file"; then + error "Failed to copy $source_file to $dest_file" + copy_failed=1 + break + fi + chmod 600 "$dest_file" + fi + done + + if [ "$copy_failed" = "1" ]; then + # A lone host.db without its -wal/-shm siblings would make the next + # non-force run think this org is already seeded and skip it. + rm -f "$dest_db" "${dest_db}-shm" "${dest_db}-wal" + return 1 + fi + + # Checkpoint the copy's WAL (no lock contention since nothing else has it open). + if command -v sqlite3 &> /dev/null; then + sqlite3 "$dest_db" "PRAGMA wal_checkpoint(TRUNCATE);" &> /dev/null || true + fi + + seeded=$((seeded + 1)) + done + + success "Host-service DBs seeded ($seeded copied, $skipped skipped) from $source_root" + return 0 +} + step_seed_local_db() { echo "💾 Seeding local DB into superset-dev-data/..." diff --git a/AGENTS.md b/AGENTS.md index aa82ff4921f..adce133d3ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,9 @@ # Superset Monorepo Guide +## Question Tool + +When you need to ask the user ANY question — including simple yes/no, confirmations, and clarifications — ALWAYS use the `ask_user` tool. Never ask questions in plain text. The Superset UI renders `ask_user` calls as an interactive overlay with clickable option buttons; plain-text questions will not be surfaced to the user in the same way. + Guidelines for agents and developers working in this repository. ## Structure @@ -71,7 +75,8 @@ bun run clean:workspaces # Clean all workspace node_modules 2. **Prefer `gh` CLI** - when performing git operations (PRs, issues, checkout, etc.), prefer the GitHub CLI (`gh`) over raw `git` commands where possible 3. **Shared command source** - keep command definitions in `.agents/commands/` only. `.claude/commands` and `.cursor/commands` should be symlinks to `../.agents/commands`. (`packages/chat` discovers slash commands from `.claude/commands`.) 4. **Workspace MCP config** - keep shared MCP servers in `.mcp.json`; `.cursor/mcp.json` should link to `../.mcp.json`. Codex uses `.codex/config.toml` (run with `CODEX_HOME=.codex codex ...`). OpenCode uses `opencode.json` and should mirror the same MCP set using OpenCode's `remote`/`local` schema. -5. **Mastracode fork workflow** - for Superset's internal `mastracode` fork bundle and release process, follow `docs/mastracode-fork-workflow.md`. +5. **Mastra dependencies** - use the published upstream `mastracode` and `@mastra/*` packages. Do not add fork tarball overrides or custom patch steps unless explicitly requested. +6. **Plan & doc placement** - implementation plans go in `plans/` (cross-cutting) or `apps/<app>/plans/` (app-scoped); shipped plans move to `plans/done/`. Architecture/reference docs go in `<app>/docs/`. Never drop `*_PLAN.md` at an app root or inside `src/`. --- diff --git a/README.md b/README.md index 5bf89fa00c4..e1d935232ec 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Superset works with any CLI-based coding agent, including: | Agent | Status | |:------|:-------| +| [Amp Code](https://ampcode.com/) | Fully supported | | [Claude Code](https://github.com/anthropics/claude-code) | Fully supported | | [OpenAI Codex CLI](https://github.com/openai/codex) | Fully supported | | [Cursor Agent](https://docs.cursor.com/agent) | Fully supported | @@ -113,6 +114,10 @@ echo 'SKIP_ENV_VALIDATION=1' >> .env ```bash # Install caddy: brew install caddy (macOS) or see https://caddyserver.com/docs/install cp Caddyfile.example Caddyfile + +# Without this, Chromium rejects https://localhost:* with ERR_CERT_AUTHORITY_INVALID. +# Prompts for sudo once. +caddy trust ``` **4. Install dependencies and run** @@ -203,9 +208,9 @@ Scripts have access to environment variables: - `SUPERSET_WORKSPACE_NAME` — Name of the workspace - `SUPERSET_ROOT_PATH` — Path to the main repository -## Internal Dependency Overrides +## Mastra Dependencies -For the internal `mastracode` fork/bundle workflow used by this repo, see [docs/mastracode-fork-workflow.md](docs/mastracode-fork-workflow.md). +This repo uses the published upstream `mastracode` and `@mastra/*` packages directly. Avoid adding custom tarball overrides unless there is a repo-specific blocker. ## Tech Stack diff --git a/apps/admin/package.json b/apps/admin/package.json index a9b8e58cbf0..8c6bec93071 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -23,9 +23,9 @@ "@trpc/client": "^11.7.1", "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", - "better-auth": "1.4.18", + "better-auth": "1.6.5", "date-fns": "^4.1.0", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "import-in-the-middle": "2.0.1", "next": "^16.0.10", "next-themes": "^0.4.6", diff --git a/apps/api/MCP_TOOLS.md b/apps/api/MCP_TOOLS.md index f6074babcf0..142469c8ec9 100644 --- a/apps/api/MCP_TOOLS.md +++ b/apps/api/MCP_TOOLS.md @@ -161,12 +161,10 @@ const listTaskStatusesOutput = z.object({ These tools write to `agent_commands` table and poll for results. #### `list_devices` -List online devices in the organization. +List registered devices in the organization. ```typescript -const listDevicesInput = z.object({ - includeOffline: z.boolean().default(false).describe("Include recently offline devices"), -}); +const listDevicesInput = z.object({}); const listDevicesOutput = z.object({ devices: z.array(z.object({ @@ -176,7 +174,6 @@ const listDevicesOutput = z.object({ ownerId: z.string().uuid().describe("User who owns this device"), ownerName: z.string().describe("Name of device owner"), lastSeenAt: z.string().datetime(), - isOnline: z.boolean(), })), }); ``` diff --git a/apps/api/package.json b/apps/api/package.json index 860aa8f649e..06419d3d03a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,9 +12,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.78.0", - "@better-auth/oauth-provider": "1.4.18", - "@durable-streams/client": "^0.2.1", - "@electric-sql/client": "1.5.13", + "@better-auth/oauth-provider": "1.6.5", + "@durable-streams/client": "^0.2.3", "@linear/sdk": "^68.1.0", "@modelcontextprotocol/sdk": "^1.26.0", "@octokit/app": "^16.1.2", @@ -35,9 +34,9 @@ "@upstash/ratelimit": "^2.0.4", "@upstash/redis": "^1.34.3", "@vercel/blob": "^2.0.0", - "better-auth": "1.4.18", + "better-auth": "1.6.5", "date-fns": "^4.1.0", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "import-in-the-middle": "2.0.1", "jose": "^6.1.3", "lodash.chunk": "^4.2.0", @@ -46,6 +45,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "require-in-the-middle": "8.0.1", + "rrule": "^2.8.1", "stripe": "^20.2.0", "zod": "^4.3.5" }, diff --git a/apps/api/src/app/.well-known/oauth-protected-resource/[...path]/route.ts b/apps/api/src/app/.well-known/oauth-protected-resource/[...path]/route.ts index b5e8b532600..1db0c83d84e 100644 --- a/apps/api/src/app/.well-known/oauth-protected-resource/[...path]/route.ts +++ b/apps/api/src/app/.well-known/oauth-protected-resource/[...path]/route.ts @@ -1,19 +1,33 @@ -import { env } from "@/env"; +import { auth } from "@superset/auth/server"; +import { buildProtectedResourceMetadata } from "@/lib/oauth-metadata"; -function getPublicOrigin(req: Request): string { - const host = req.headers.get("x-forwarded-host") ?? new URL(req.url).host; - const proto = - req.headers.get("x-forwarded-proto") ?? - new URL(req.url).protocol.replace(":", ""); - return `${proto}://${host}`; +interface RouteContext { + params: Promise<{ + path: string[]; + }>; } -export function GET(req: Request) { +export async function GET( + request: Request, + { params }: RouteContext, +): Promise<Response> { + const { path } = await params; + const authServerMetadata = await auth.api.getOAuthServerConfig({ + headers: request.headers, + }); + const resourcePath = `/${path.join("/")}`; + return Response.json( - { - resource: getPublicOrigin(req), - authorization_servers: [env.NEXT_PUBLIC_API_URL], - }, + buildProtectedResourceMetadata(request, resourcePath, { + authorizationServerUrl: + typeof authServerMetadata.issuer === "string" + ? authServerMetadata.issuer + : undefined, + resourceName: "Superset MCP Server", + scopesSupported: Array.isArray(authServerMetadata.scopes_supported) + ? authServerMetadata.scopes_supported + : undefined, + }), { headers: { "Access-Control-Allow-Origin": "*", diff --git a/apps/api/src/app/.well-known/oauth-protected-resource/route.ts b/apps/api/src/app/.well-known/oauth-protected-resource/route.ts index b5e8b532600..1e370eeb4c8 100644 --- a/apps/api/src/app/.well-known/oauth-protected-resource/route.ts +++ b/apps/api/src/app/.well-known/oauth-protected-resource/route.ts @@ -1,19 +1,22 @@ -import { env } from "@/env"; +import { auth } from "@superset/auth/server"; +import { buildProtectedResourceMetadata } from "@/lib/oauth-metadata"; -function getPublicOrigin(req: Request): string { - const host = req.headers.get("x-forwarded-host") ?? new URL(req.url).host; - const proto = - req.headers.get("x-forwarded-proto") ?? - new URL(req.url).protocol.replace(":", ""); - return `${proto}://${host}`; -} +export async function GET(request: Request): Promise<Response> { + const authServerMetadata = await auth.api.getOAuthServerConfig({ + headers: request.headers, + }); -export function GET(req: Request) { return Response.json( - { - resource: getPublicOrigin(req), - authorization_servers: [env.NEXT_PUBLIC_API_URL], - }, + buildProtectedResourceMetadata(request, "/", { + authorizationServerUrl: + typeof authServerMetadata.issuer === "string" + ? authServerMetadata.issuer + : undefined, + resourceName: "Superset MCP Server", + scopesSupported: Array.isArray(authServerMetadata.scopes_supported) + ? authServerMetadata.scopes_supported + : undefined, + }), { headers: { "Access-Control-Allow-Origin": "*", diff --git a/apps/api/src/app/api/agent/[transport]/auth-flow.ts b/apps/api/src/app/api/agent/[transport]/auth-flow.ts new file mode 100644 index 00000000000..6dcda29e11c --- /dev/null +++ b/apps/api/src/app/api/agent/[transport]/auth-flow.ts @@ -0,0 +1,274 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import type { createMcpServer } from "@superset/mcp"; +import type { McpContext } from "@superset/mcp/auth"; +import type { verifyAccessToken as verifyOAuthAccessToken } from "better-auth/oauth2"; +import { getOAuthProtectedResourceMetadataUrl } from "@/lib/oauth-metadata"; + +interface SessionUser { + id: string; +} + +interface SessionRecord { + activeOrganizationId?: string | null; +} + +interface SessionResponse { + session?: SessionRecord | null; + user: SessionUser; +} + +interface VerifiedApiKey { + referenceId?: string | null; + metadata?: unknown; +} + +interface VerifyApiKeyResponse { + valid: boolean; + key: VerifiedApiKey | null; +} + +export interface McpRequestDeps { + apiUrl: string; + authApi: { + getSession(args: { + headers: Headers; + }): Promise<SessionResponse | null | undefined>; + verifyApiKey(args: { + body: { key: string }; + }): Promise<VerifyApiKeyResponse>; + }; + createServer: typeof createMcpServer; + createTransport: () => WebStandardStreamableHTTPServerTransport; + verifyAccessToken: typeof verifyOAuthAccessToken; +} + +function getBearerToken(req: Request): string | undefined { + const authorization = req.headers.get("authorization"); + const match = authorization?.match(/^Bearer\s+(.+)$/i); + return match?.[1]; +} + +export function isApiKeyBearerToken(token: string): boolean { + return token.startsWith("sk_live_"); +} + +function normalizeApiUrl(apiUrl: string): string { + return apiUrl.replace(/\/+$/, ""); +} + +function getSafeAuthErrorName(error: unknown): string { + if (error && typeof error === "object") { + const errorName = "name" in error ? error.name : undefined; + if (typeof errorName === "string" && errorName.length > 0) { + return errorName; + } + + const errorCode = "code" in error ? error.code : undefined; + if (typeof errorCode === "string" && errorCode.length > 0) { + return errorCode; + } + } + + return "AuthVerificationError"; +} + +function looksLikeJwt(token: string): boolean { + const parts = token.split("."); + return parts.length === 3 && parts.every(Boolean); +} + +function buildSessionAuthInfo(session: SessionResponse): AuthInfo | undefined { + const organizationId = session.session?.activeOrganizationId; + + if (!organizationId) { + console.error("[mcp/auth] Session missing activeOrganizationId"); + return undefined; + } + + return { + token: "session", + clientId: "session", + scopes: ["mcp:full"], + extra: { + mcpContext: { + userId: session.user.id, + organizationId, + } satisfies McpContext, + }, + }; +} + +function parseApiKeyMetadata( + metadata: unknown, +): Record<string, unknown> | null { + if (!metadata) { + return null; + } + + if (typeof metadata === "string") { + try { + const parsed = JSON.parse(metadata); + return parsed && typeof parsed === "object" + ? (parsed as Record<string, unknown>) + : null; + } catch (error) { + console.error("[mcp/auth] Failed to parse API key metadata:", error); + return null; + } + } + + return typeof metadata === "object" + ? (metadata as Record<string, unknown>) + : null; +} + +function buildApiKeyAuthInfo(apiKey: VerifiedApiKey): AuthInfo | undefined { + const userId = apiKey.referenceId; + + if (!userId) { + console.error("[mcp/auth] API key missing referenceId"); + return undefined; + } + + const metadata = parseApiKeyMetadata(apiKey.metadata); + const organizationId = + typeof metadata?.organizationId === "string" + ? metadata.organizationId + : undefined; + + if (!organizationId) { + console.error("[mcp/auth] API key missing organizationId in metadata"); + return undefined; + } + + return { + token: "api-key", + clientId: "api-key", + scopes: ["mcp:full"], + extra: { + mcpContext: { + userId, + organizationId, + } satisfies McpContext, + }, + }; +} + +function buildOAuthAuthInfo( + bearerToken: string, + payload: Record<string, unknown>, +): AuthInfo | undefined { + if ( + typeof payload.sub !== "string" || + typeof payload.organizationId !== "string" + ) { + console.error( + "[mcp/auth] Access token missing sub or organizationId claim", + ); + return undefined; + } + + const scopes = Array.isArray(payload.scope) + ? (payload.scope as string[]) + : typeof payload.scope === "string" + ? payload.scope.split(" ") + : []; + + return { + token: bearerToken, + clientId: typeof payload.azp === "string" ? payload.azp : "mcp-client", + scopes, + extra: { + mcpContext: { + userId: payload.sub, + organizationId: payload.organizationId, + } satisfies McpContext, + }, + }; +} + +export async function verifyToken( + req: Request, + deps: McpRequestDeps, +): Promise<AuthInfo | undefined> { + const bearerToken = getBearerToken(req); + const apiUrl = normalizeApiUrl(deps.apiUrl); + let oauthVerificationError: unknown; + + if (bearerToken) { + if (isApiKeyBearerToken(bearerToken)) { + try { + const result = await deps.authApi.verifyApiKey({ + body: { key: bearerToken }, + }); + + if (result.valid && result.key) { + return buildApiKeyAuthInfo(result.key); + } + } catch (error) { + console.error("[mcp/auth] API key verification failed", { + errorName: getSafeAuthErrorName(error), + }); + } + + return undefined; + } + + if (looksLikeJwt(bearerToken)) { + try { + const payload = (await deps.verifyAccessToken(bearerToken, { + jwksUrl: `${apiUrl}/api/auth/jwks`, + verifyOptions: { + issuer: apiUrl, + audience: [apiUrl, `${apiUrl}/`, `${apiUrl}/api/agent/mcp`], + }, + })) as Record<string, unknown>; + + return buildOAuthAuthInfo(bearerToken, payload); + } catch (error) { + oauthVerificationError = error; + } + } + } + + const session = await deps.authApi.getSession({ headers: req.headers }); + if (session?.session) { + return buildSessionAuthInfo(session); + } + + if (oauthVerificationError) { + console.error("[mcp/auth] Access token verification failed", { + errorName: getSafeAuthErrorName(oauthVerificationError), + }); + } + + return undefined; +} + +export function unauthorizedResponse(req: Request): Response { + return new Response("Unauthorized", { + status: 401, + headers: { + "WWW-Authenticate": `Bearer resource_metadata="${getOAuthProtectedResourceMetadataUrl( + req, + )}"`, + }, + }); +} + +export async function handleMcpRequest( + req: Request, + deps: McpRequestDeps, +): Promise<Response> { + const authInfo = await verifyToken(req, deps); + if (!authInfo) { + return unauthorizedResponse(req); + } + + const transport = deps.createTransport(); + const server = deps.createServer(); + await server.connect(transport); + + return transport.handleRequest(req, { authInfo }); +} diff --git a/apps/api/src/app/api/agent/[transport]/route.ts b/apps/api/src/app/api/agent/[transport]/route.ts index 0bf00ad8f49..676971bb153 100644 --- a/apps/api/src/app/api/agent/[transport]/route.ts +++ b/apps/api/src/app/api/agent/[transport]/route.ts @@ -1,150 +1,20 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { auth } from "@superset/auth/server"; import { createMcpServer } from "@superset/mcp"; -import type { McpContext } from "@superset/mcp/auth"; import { verifyAccessToken } from "better-auth/oauth2"; import { env } from "@/env"; +import { handleMcpRequest, type McpRequestDeps } from "./auth-flow"; -async function verifyToken(req: Request): Promise<AuthInfo | undefined> { - const authorization = req.headers.get("authorization"); - const bearerToken = authorization?.startsWith("Bearer ") - ? authorization.slice(7) - : undefined; - - // 1. Try session auth (for desktop/web app) - const session = await auth.api.getSession({ headers: req.headers }); - if (session?.session) { - const extendedSession = session.session as { - activeOrganizationId?: string; - }; - if (!extendedSession.activeOrganizationId) { - console.error("[mcp/auth] Session missing activeOrganizationId"); - return undefined; - } - return { - token: "session", - clientId: "session", - scopes: ["mcp:full"], - extra: { - mcpContext: { - userId: session.user.id, - organizationId: extendedSession.activeOrganizationId, - } satisfies McpContext, - }, - }; - } - - // 2. Try API key verification (for sk_live_ tokens) - if (bearerToken) { - try { - const result = await auth.api.verifyApiKey({ - body: { key: bearerToken }, - }); - if (result.valid && result.key) { - const userId = result.key.userId; - if (!userId) { - console.error("[mcp/auth] API key missing userId"); - return undefined; - } - const metadata = - typeof result.key.metadata === "string" - ? JSON.parse(result.key.metadata) - : result.key.metadata; - const organizationId = metadata?.organizationId as string | undefined; - if (!organizationId) { - console.error( - "[mcp/auth] API key missing organizationId in metadata", - ); - return undefined; - } - return { - token: "api-key", - clientId: "api-key", - scopes: ["mcp:full"], - extra: { - mcpContext: { - userId, - organizationId, - } satisfies McpContext, - }, - }; - } - } catch (error) { - console.error("[mcp/auth] API key verification failed:", error); - } - } - - // 3. Try OAuth access token verification via JWKS - if (bearerToken) { - try { - const payload = await verifyAccessToken(bearerToken, { - jwksUrl: `${env.NEXT_PUBLIC_API_URL}/api/auth/jwks`, - verifyOptions: { - issuer: env.NEXT_PUBLIC_API_URL, - audience: [env.NEXT_PUBLIC_API_URL, `${env.NEXT_PUBLIC_API_URL}/`], - }, - }); - if (!payload?.sub || !payload.organizationId) { - console.error( - "[mcp/auth] Access token missing sub or organizationId claim", - ); - return undefined; - } - - const scopes = Array.isArray(payload.scope) - ? (payload.scope as string[]) - : typeof payload.scope === "string" - ? payload.scope.split(" ") - : []; - - return { - token: bearerToken, - clientId: (payload.azp as string) ?? "mcp-client", - scopes, - extra: { - mcpContext: { - userId: payload.sub, - organizationId: payload.organizationId as string, - } satisfies McpContext, - }, - }; - } catch (error) { - console.error("[mcp/auth] Access token verification failed:", error); - return undefined; - } - } - - return undefined; -} - -function getResourceMetadataUrl(req: Request): string { - const host = req.headers.get("x-forwarded-host") ?? new URL(req.url).host; - const proto = - req.headers.get("x-forwarded-proto") ?? - new URL(req.url).protocol.replace(":", ""); - return `${proto}://${host}/.well-known/oauth-protected-resource`; -} - -function unauthorizedResponse(req: Request): Response { - const metadataUrl = getResourceMetadataUrl(req); - return new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"`, - }, - }); -} +const deps: McpRequestDeps = { + apiUrl: env.NEXT_PUBLIC_API_URL, + authApi: auth.api, + createServer: createMcpServer, + createTransport: () => new WebStandardStreamableHTTPServerTransport(), + verifyAccessToken, +}; async function handleRequest(req: Request): Promise<Response> { - const authInfo = await verifyToken(req); - if (!authInfo) return unauthorizedResponse(req); - - const transport = new WebStandardStreamableHTTPServerTransport(); - const server = createMcpServer(); - await server.connect(transport); - - return transport.handleRequest(req, { authInfo }); + return handleMcpRequest(req, deps); } export { handleRequest as GET, handleRequest as POST, handleRequest as DELETE }; diff --git a/apps/api/src/app/api/automations/dispatch/[id]/route.ts b/apps/api/src/app/api/automations/dispatch/[id]/route.ts new file mode 100644 index 00000000000..409776d53fa --- /dev/null +++ b/apps/api/src/app/api/automations/dispatch/[id]/route.ts @@ -0,0 +1,69 @@ +import { dbWs } from "@superset/db/client"; +import { automations } from "@superset/db/schema"; +import { dispatchAutomation } from "@superset/trpc/automation-dispatch"; +import { Receiver } from "@upstash/qstash"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { env } from "@/env"; + +export const maxDuration = 60; +export const dynamic = "force-dynamic"; + +const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, +}); + +const payloadSchema = z.object({ + automationId: z.string().uuid(), + scheduledFor: z.string().datetime(), +}); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +): Promise<Response> { + const body = await request.text(); + const signature = request.headers.get("upstash-signature"); + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const { id } = await params; + const valid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/automations/dispatch/${id}`, + }); + if (!valid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const parsed = payloadSchema.safeParse(JSON.parse(body)); + if (!parsed.success) { + console.error("[automations/dispatch] invalid payload", parsed.error); + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + const [automation] = await dbWs + .select() + .from(automations) + .where(eq(automations.id, parsed.data.automationId)) + .limit(1); + + if (!automation) { + return Response.json({ ok: true, skipped: "deleted" }); + } + if (!automation.enabled) { + return Response.json({ ok: true, skipped: "disabled" }); + } + + const outcome = await dispatchAutomation({ + automation, + scheduledFor: new Date(parsed.data.scheduledFor), + relayUrl: env.RELAY_URL, + }); + + return Response.json({ ok: true, outcome }); +} diff --git a/apps/api/src/app/api/automations/evaluate/route.ts b/apps/api/src/app/api/automations/evaluate/route.ts new file mode 100644 index 00000000000..f1c6d7f5a85 --- /dev/null +++ b/apps/api/src/app/api/automations/evaluate/route.ts @@ -0,0 +1,126 @@ +import { dbWs } from "@superset/db/client"; +import { automations, subscriptions } from "@superset/db/schema"; +import { ACTIVE_SUBSCRIPTION_STATUSES } from "@superset/shared/billing"; +import { nextOccurrenceAfter } from "@superset/shared/rrule"; +import { Client, Receiver } from "@upstash/qstash"; +import { and, eq, exists, inArray, lte, ne, sql } from "drizzle-orm"; + +import { env } from "@/env"; + +export const dynamic = "force-dynamic"; + +const qstash = new Client({ + token: env.QSTASH_TOKEN, + baseUrl: env.QSTASH_URL, +}); +const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, +}); + +const BATCH_SIZE = 2000; + +function bucketToMinute(d: Date): Date { + const copy = new Date(d.getTime()); + copy.setUTCSeconds(0, 0); + return copy; +} + +export async function POST(request: Request): Promise<Response> { + const body = await request.text(); + const signature = request.headers.get("upstash-signature"); + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const valid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/automations/evaluate`, + }); + if (!valid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + const now = new Date(); + const due = await dbWs + .select() + .from(automations) + .where( + and( + eq(automations.enabled, true), + lte(automations.nextRunAt, now), + // Catches orgs that downgraded with automations still enabled — the + // create-time gate only blocks new ones. + exists( + dbWs + .select({ one: sql`1` }) + .from(subscriptions) + .where( + and( + eq(subscriptions.referenceId, automations.organizationId), + inArray(subscriptions.status, ACTIVE_SUBSCRIPTION_STATUSES), + ne(subscriptions.plan, "free"), + ), + ), + ), + ), + ) + .orderBy(automations.nextRunAt) + .limit(BATCH_SIZE); + + if (due.length === 0) { + return Response.json({ enqueued: 0 }); + } + + await qstash.batchJSON( + due.map((automation) => { + const scheduledFor = bucketToMinute(automation.nextRunAt); + return { + url: `${env.NEXT_PUBLIC_API_URL}/api/automations/dispatch/${automation.id}`, + body: { + automationId: automation.id, + scheduledFor: scheduledFor.toISOString(), + }, + deduplicationId: `${automation.id}_${scheduledFor.getTime()}`, + retries: 2, + failureCallback: `${env.NEXT_PUBLIC_API_URL}/api/automations/run-failed`, + }; + }), + ); + + const advanceResults = await Promise.allSettled( + due.map((automation) => { + const next = nextOccurrenceAfter({ + rrule: automation.rrule, + dtstart: automation.dtstart, + timezone: automation.timezone, + after: automation.nextRunAt, + }); + return dbWs + .update(automations) + .set(next ? { nextRunAt: next } : { enabled: false }) + .where(eq(automations.id, automation.id)); + }), + ); + + // next_run_at advance failures are recoverable (next tick re-enqueues and + // QStash dedup absorbs the duplicate), but a persistent failure would + // hide itself without this log. + const advanceFailures = advanceResults.flatMap((result, index) => { + if (result.status !== "rejected") return []; + const automation = due[index]; + return [{ automationId: automation?.id, reason: result.reason }]; + }); + if (advanceFailures.length > 0) { + console.error( + "[automations/evaluate] advanceNextRun failures", + advanceFailures, + ); + } + + return Response.json({ + enqueued: due.length, + advanceFailed: advanceFailures.length, + }); +} diff --git a/apps/api/src/app/api/automations/run-failed/route.ts b/apps/api/src/app/api/automations/run-failed/route.ts new file mode 100644 index 00000000000..e782e49a21e --- /dev/null +++ b/apps/api/src/app/api/automations/run-failed/route.ts @@ -0,0 +1,121 @@ +import * as Sentry from "@sentry/nextjs"; +import { dbWs } from "@superset/db/client"; +import { automationRuns, automations } from "@superset/db/schema"; +import { Receiver } from "@upstash/qstash"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { env } from "@/env"; + +export const dynamic = "force-dynamic"; + +const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, +}); + +const failurePayloadSchema = z.object({ + sourceMessageId: z.string(), + sourceBody: z.string(), + status: z.number(), + error: z.string().optional(), + retried: z.number().optional(), +}); + +const sourceBodySchema = z.object({ + automationId: z.string().uuid(), + scheduledFor: z.string().datetime(), +}); + +export async function POST(request: Request): Promise<Response> { + const body = await request.text(); + const signature = request.headers.get("upstash-signature"); + if (!signature) { + return Response.json({ error: "Missing signature" }, { status: 401 }); + } + + const valid = await receiver.verify({ + body, + signature, + url: `${env.NEXT_PUBLIC_API_URL}/api/automations/run-failed`, + }); + if (!valid) { + return Response.json({ error: "Invalid signature" }, { status: 401 }); + } + + let rawBody: unknown; + try { + rawBody = JSON.parse(body); + } catch (err) { + console.error("[automations/run-failed] invalid JSON", err); + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = failurePayloadSchema.safeParse(rawBody); + if (!parsed.success) { + console.error("[automations/run-failed] invalid payload", parsed.error); + return Response.json({ error: "Invalid payload" }, { status: 400 }); + } + + let decoded: unknown; + try { + decoded = JSON.parse( + Buffer.from(parsed.data.sourceBody, "base64").toString("utf-8"), + ); + } catch (err) { + console.error("[automations/run-failed] invalid sourceBody JSON", err); + return Response.json({ error: "Invalid sourceBody JSON" }, { status: 400 }); + } + const source = sourceBodySchema.safeParse(decoded); + if (!source.success) { + console.error("[automations/run-failed] invalid sourceBody", source.error); + return Response.json({ error: "Invalid sourceBody" }, { status: 400 }); + } + + const { automationId, scheduledFor } = source.data; + + const [automation] = await dbWs + .select({ + organizationId: automations.organizationId, + name: automations.name, + }) + .from(automations) + .where(eq(automations.id, automationId)) + .limit(1); + + if (!automation) { + return Response.json({ ok: true, skipped: "deleted" }); + } + + const errorText = `delivery failed after retries (status ${parsed.data.status}): ${parsed.data.error ?? "unknown"}`; + + await dbWs + .insert(automationRuns) + .values({ + automationId, + organizationId: automation.organizationId, + title: automation.name, + scheduledFor: new Date(scheduledFor), + status: "dispatch_failed", + error: errorText, + }) + .onConflictDoUpdate({ + target: [automationRuns.automationId, automationRuns.scheduledFor], + set: { status: "dispatch_failed", error: errorText }, + }); + + Sentry.captureException( + new Error(`automation dispatch failed: ${automationId}`), + { + tags: { feature: "automations" }, + extra: { + automationId, + scheduledFor, + sourceMessageId: parsed.data.sourceMessageId, + status: parsed.data.status, + }, + }, + ); + + return Response.json({ ok: true }); +} diff --git a/apps/api/src/app/api/cli/create-code/route.ts b/apps/api/src/app/api/cli/create-code/route.ts new file mode 100644 index 00000000000..37cb2c305e8 --- /dev/null +++ b/apps/api/src/app/api/cli/create-code/route.ts @@ -0,0 +1,47 @@ +import { randomBytes } from "node:crypto"; +import { auth } from "@superset/auth/server"; +import { db } from "@superset/db/client"; +import { members } from "@superset/db/schema"; +import { Redis } from "@upstash/redis"; +import { and, eq } from "drizzle-orm"; +import { env } from "@/env"; + +const redis = new Redis({ + url: env.KV_REST_API_URL, + token: env.KV_REST_API_TOKEN, +}); + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const body = (await request.json()) as { organizationId?: string }; + const { organizationId } = body; + if (!organizationId) { + return Response.json({ error: "organizationId required" }, { status: 400 }); + } + + const membership = await db.query.members.findFirst({ + where: and( + eq(members.userId, session.user.id), + eq(members.organizationId, organizationId), + ), + }); + if (!membership) { + return Response.json( + { error: "Not a member of this organization" }, + { status: 403 }, + ); + } + + const code = randomBytes(24).toString("base64url"); + await redis.set( + `cli:code:${code}`, + { userId: session.user.id, organizationId }, + { ex: 300 }, + ); + + return Response.json({ code }); +} diff --git a/apps/api/src/app/api/cli/exchange/route.ts b/apps/api/src/app/api/cli/exchange/route.ts new file mode 100644 index 00000000000..7270274acc0 --- /dev/null +++ b/apps/api/src/app/api/cli/exchange/route.ts @@ -0,0 +1,51 @@ +import { auth } from "@superset/auth/server"; +import { Redis } from "@upstash/redis"; +import { env } from "@/env"; + +const redis = new Redis({ + url: env.KV_REST_API_URL, + token: env.KV_REST_API_TOKEN, +}); + +interface CodePayload { + userId: string; + organizationId: string; +} + +export async function POST(request: Request) { + const body = (await request.json()) as { code?: string }; + const { code } = body; + if (!code) { + return Response.json({ error: "code required" }, { status: 400 }); + } + + const key = `cli:code:${code}`; + const payload = await redis.get<CodePayload>(key); + if (!payload) { + return Response.json({ error: "Invalid or expired code" }, { status: 400 }); + } + + await redis.del(key); + + if (!payload.userId || !payload.organizationId) { + return Response.json({ error: "Malformed code data" }, { status: 500 }); + } + + const context = await auth.$context; + const session = await context.internalAdapter.createSession( + payload.userId, + false, + { activeOrganizationId: payload.organizationId }, + ); + if (!session) { + return Response.json( + { error: "Failed to create session" }, + { status: 500 }, + ); + } + + return Response.json({ + token: session.token, + expiresAt: session.expiresAt.toISOString(), + }); +} diff --git a/apps/api/src/app/api/desktop/version/route.ts b/apps/api/src/app/api/desktop/version/route.ts index 01751c8d186..2e6dff66c7e 100644 --- a/apps/api/src/app/api/desktop/version/route.ts +++ b/apps/api/src/app/api/desktop/version/route.ts @@ -1,4 +1,4 @@ -const MINIMUM_DESKTOP_VERSION = "0.0.48"; +const MINIMUM_DESKTOP_VERSION = "1.5.0"; /** * Used to force the desktop app to update, in cases where we can't support diff --git a/apps/api/src/app/api/electric/[...path]/route.ts b/apps/api/src/app/api/electric/[...path]/route.ts deleted file mode 100644 index 21e552bfcab..00000000000 --- a/apps/api/src/app/api/electric/[...path]/route.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"; -import { auth } from "@superset/auth/server"; -import { env } from "@/env"; -import { buildWhereClause } from "./utils"; - -interface AuthInfo { - userId: string; - organizationIds: string[]; -} - -async function authenticate(request: Request): Promise<AuthInfo | null> { - const bearer = request.headers.get("Authorization"); - if (bearer?.startsWith("Bearer ")) { - const token = bearer.slice(7); - try { - const { payload } = await auth.api.verifyJWT({ body: { token } }); - if (payload?.sub && Array.isArray(payload.organizationIds)) { - return { - userId: payload.sub, - organizationIds: payload.organizationIds as string[], - }; - } - } catch {} - } - - const sessionData = await auth.api.getSession({ headers: request.headers }); - if (!sessionData?.user) return null; - return { - userId: sessionData.user.id, - organizationIds: sessionData.session.organizationIds ?? [], - }; -} - -export async function GET(request: Request): Promise<Response> { - const authInfo = await authenticate(request); - if (!authInfo) { - return new Response("Unauthorized", { status: 401 }); - } - - const url = new URL(request.url); - - const organizationId = url.searchParams.get("organizationId"); - - if (organizationId && !authInfo.organizationIds.includes(organizationId)) { - return new Response("Not a member of this organization", { status: 403 }); - } - - const originUrl = new URL(env.ELECTRIC_URL); - originUrl.searchParams.set("secret", env.ELECTRIC_SECRET); - - url.searchParams.forEach((value, key) => { - if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { - originUrl.searchParams.set(key, value); - } - }); - - const tableName = url.searchParams.get("table"); - if (!tableName) { - return new Response("Missing table parameter", { status: 400 }); - } - - const whereClause = await buildWhereClause( - tableName, - organizationId ?? "", - authInfo.userId, - ); - if (!whereClause) { - return new Response(`Unknown table: ${tableName}`, { status: 400 }); - } - - originUrl.searchParams.set("table", tableName); - originUrl.searchParams.set("where", whereClause.fragment); - whereClause.params.forEach((value, index) => { - originUrl.searchParams.set(`params[${index + 1}]`, String(value)); - }); - - if (tableName === "auth.apikeys") { - originUrl.searchParams.set( - "columns", - "id,name,start,created_at,last_request", - ); - } - - if (tableName === "integration_connections") { - originUrl.searchParams.set( - "columns", - "id,organization_id,connected_by_user_id,provider,token_expires_at,external_org_id,external_org_name,config,created_at,updated_at", - ); - } - - const response = await fetch(originUrl.toString()); - - const headers = new Headers(response.headers); - if (headers.get("content-encoding")) { - headers.delete("content-encoding"); - headers.delete("content-length"); - } - - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts deleted file mode 100644 index 81d5dabcb7f..00000000000 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { db } from "@superset/db/client"; -import { - agentCommands, - chatSessions, - devicePresence, - githubPullRequests, - githubRepositories, - integrationConnections, - invitations, - members, - organizations, - projects, - sessionHosts, - subscriptions, - taskStatuses, - tasks, - v2DevicePresence, - v2Devices, - v2Projects, - v2UsersDevices, - v2Workspaces, - workspaces, -} from "@superset/db/schema"; -import { eq, inArray, sql } from "drizzle-orm"; -import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; -import { QueryBuilder } from "drizzle-orm/pg-core"; - -export type AllowedTable = - | "tasks" - | "task_statuses" - | "projects" - | "v2_devices" - | "v2_device_presence" - | "v2_projects" - | "v2_users_devices" - | "v2_workspaces" - | "auth.members" - | "auth.organizations" - | "auth.users" - | "auth.invitations" - | "auth.apikeys" - | "device_presence" - | "agent_commands" - | "integration_connections" - | "subscriptions" - | "workspaces" - | "chat_sessions" - | "session_hosts" - | "github_repositories" - | "github_pull_requests"; - -interface WhereClause { - fragment: string; - params: unknown[]; -} - -function build(table: PgTable, column: PgColumn, id: string): WhereClause { - const whereExpr = eq(sql`${sql.identifier(column.name)}`, id); - const qb = new QueryBuilder(); - const { sql: query, params } = qb - .select() - .from(table) - .where(whereExpr) - .toSQL(); - const fragment = query.replace(/^select .* from .* where\s+/i, ""); - return { fragment, params }; -} - -export async function buildWhereClause( - tableName: string, - organizationId: string, - userId: string, -): Promise<WhereClause | null> { - switch (tableName) { - case "tasks": - return build(tasks, tasks.organizationId, organizationId); - - case "task_statuses": - return build(taskStatuses, taskStatuses.organizationId, organizationId); - - case "projects": - return build(projects, projects.organizationId, organizationId); - - case "v2_projects": - return build(v2Projects, v2Projects.organizationId, organizationId); - - case "v2_devices": - return build(v2Devices, v2Devices.organizationId, organizationId); - - case "v2_device_presence": - return build( - v2DevicePresence, - v2DevicePresence.organizationId, - organizationId, - ); - - case "v2_users_devices": - return build( - v2UsersDevices, - v2UsersDevices.organizationId, - organizationId, - ); - - case "v2_workspaces": - return build(v2Workspaces, v2Workspaces.organizationId, organizationId); - - case "auth.members": - return build(members, members.organizationId, organizationId); - - case "auth.invitations": - return build(invitations, invitations.organizationId, organizationId); - - case "auth.organizations": { - // Use the authenticated user's ID to find their organizations - const userMemberships = await db.query.members.findMany({ - where: eq(members.userId, userId), - columns: { organizationId: true }, - }); - - if (userMemberships.length === 0) { - return { fragment: "1 = 0", params: [] }; - } - - const orgIds = [...new Set(userMemberships.map((m) => m.organizationId))]; - const whereExpr = inArray( - sql`${sql.identifier(organizations.id.name)}`, - orgIds, - ); - const qb = new QueryBuilder(); - const { sql: query, params } = qb - .select() - .from(organizations) - .where(whereExpr) - .toSQL(); - const fragment = query.replace(/^select .* from .* where\s+/i, ""); - return { fragment, params }; - } - - case "auth.users": { - const fragment = `$1 = ANY("organization_ids")`; - return { fragment, params: [organizationId] }; - } - - case "device_presence": - return build( - devicePresence, - devicePresence.organizationId, - organizationId, - ); - - case "agent_commands": - return build(agentCommands, agentCommands.organizationId, organizationId); - - case "auth.apikeys": { - const fragment = `"metadata" LIKE '%"organizationId":"' || $1 || '"%'`; - return { fragment, params: [organizationId] }; - } - - case "integration_connections": - return build( - integrationConnections, - integrationConnections.organizationId, - organizationId, - ); - - case "subscriptions": - return build(subscriptions, subscriptions.referenceId, organizationId); - - case "workspaces": - return build(workspaces, workspaces.organizationId, organizationId); - - case "chat_sessions": - return build(chatSessions, chatSessions.organizationId, organizationId); - - case "session_hosts": - return build(sessionHosts, sessionHosts.organizationId, organizationId); - - case "github_repositories": - return build( - githubRepositories, - githubRepositories.organizationId, - organizationId, - ); - - case "github_pull_requests": - return build( - githubPullRequests, - githubPullRequests.organizationId, - organizationId, - ); - - default: - return null; - } -} diff --git a/apps/api/src/app/api/github/jobs/initial-sync/route.ts b/apps/api/src/app/api/github/jobs/initial-sync/route.ts index 8b38d03cee1..8c16bb386fc 100644 --- a/apps/api/src/app/api/github/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/github/jobs/initial-sync/route.ts @@ -223,6 +223,7 @@ export async function POST(request: Request) { checks, mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + updatedAt: new Date(pr.updated_at), }) .onConflictDoUpdate({ target: [ @@ -240,7 +241,7 @@ export async function POST(request: Request) { mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, closedAt: pr.closed_at ? new Date(pr.closed_at) : null, lastSyncedAt: new Date(), - updatedAt: new Date(), + updatedAt: new Date(pr.updated_at), }, }); } diff --git a/apps/api/src/app/api/github/sync/route.ts b/apps/api/src/app/api/github/sync/route.ts index 63fbf2ba370..06d6a4d29a7 100644 --- a/apps/api/src/app/api/github/sync/route.ts +++ b/apps/api/src/app/api/github/sync/route.ts @@ -191,6 +191,7 @@ export async function POST(request: Request) { checks, mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + updatedAt: new Date(pr.updated_at), }) .onConflictDoUpdate({ target: [ @@ -208,7 +209,7 @@ export async function POST(request: Request) { mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, closedAt: pr.closed_at ? new Date(pr.closed_at) : null, lastSyncedAt: new Date(), - updatedAt: new Date(), + updatedAt: new Date(pr.updated_at), }, }); } diff --git a/apps/api/src/app/api/github/webhook/webhooks.ts b/apps/api/src/app/api/github/webhook/webhooks.ts index 6e5fb2d0dd9..c93a2e8e931 100644 --- a/apps/api/src/app/api/github/webhook/webhooks.ts +++ b/apps/api/src/app/api/github/webhook/webhooks.ts @@ -143,8 +143,10 @@ function upsertPullRequest( changed_files?: number; merged_at: string | null; closed_at: string | null; + updated_at: string; }, ) { + const upstreamUpdatedAt = new Date(pr.updated_at); return db .insert(githubPullRequests) .values({ @@ -167,6 +169,7 @@ function upsertPullRequest( checksStatus: "none", mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, closedAt: pr.closed_at ? new Date(pr.closed_at) : null, + updatedAt: upstreamUpdatedAt, }) .onConflictDoUpdate({ target: [githubPullRequests.repositoryId, githubPullRequests.prNumber], @@ -184,7 +187,7 @@ function upsertPullRequest( mergedAt: pr.merged_at ? new Date(pr.merged_at) : null, closedAt: pr.closed_at ? new Date(pr.closed_at) : null, lastSyncedAt: new Date(), - updatedAt: new Date(), + updatedAt: upstreamUpdatedAt, }, }); } @@ -290,7 +293,7 @@ webhooks.on( .set({ reviewDecision, lastSyncedAt: new Date(), - updatedAt: new Date(), + updatedAt: new Date(pr.updated_at), }) .where( and( @@ -405,7 +408,6 @@ webhooks.on( checks: currentChecks, checksStatus, lastSyncedAt: new Date(), - updatedAt: new Date(), }) .where(eq(githubPullRequests.id, currentPr.id)); } diff --git a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts index 33ee354fb61..86467136eef 100644 --- a/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts +++ b/apps/api/src/app/api/integrations/slack/events/utils/run-agent/run-agent.ts @@ -370,7 +370,7 @@ async function fetchAgentContext({ mcpClient.callTool({ name: "list_task_statuses", arguments: {} }), mcpClient.callTool({ name: "list_devices", - arguments: { includeOffline: true }, + arguments: {}, }), ]); @@ -409,13 +409,12 @@ async function fetchAgentContext({ deviceName: string | null; ownerName: string | null; ownerEmail: string; - isOnline: boolean; }[]; } | null; if (devicesData?.devices?.length) { const lines = devicesData.devices.map( (d) => - `- ${d.deviceName ?? "Unknown"} (id: ${d.deviceId}, owner: ${d.ownerName ?? d.ownerEmail}, status: ${d.isOnline ? "online" : "offline"})`, + `- ${d.deviceName ?? "Unknown"} (id: ${d.deviceId}, owner: ${d.ownerName ?? d.ownerEmail})`, ); sections.push(`Devices:\n${lines.join("\n")}`); } diff --git a/apps/api/src/app/api/trpc/[trpc]/route.ts b/apps/api/src/app/api/trpc/[trpc]/route.ts index 96d09181859..89838aab968 100644 --- a/apps/api/src/app/api/trpc/[trpc]/route.ts +++ b/apps/api/src/app/api/trpc/[trpc]/route.ts @@ -2,6 +2,8 @@ import { appRouter } from "@superset/trpc"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { createContext } from "@/trpc/context"; +export const maxDuration = 60; + const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", diff --git a/apps/api/src/bun-test.d.ts b/apps/api/src/bun-test.d.ts new file mode 100644 index 00000000000..93f10d284ea --- /dev/null +++ b/apps/api/src/bun-test.d.ts @@ -0,0 +1,25 @@ +declare module "bun:test" { + export interface MockResult { + calls: unknown[][]; + } + + export type Mock<T extends (...args: never[]) => unknown> = T & { + mock: MockResult; + }; + + export function describe( + name: string, + callback: () => void | Promise<void>, + ): void; + + export function it(name: string, callback: () => void | Promise<void>): void; + + export function mock<T extends (...args: never[]) => unknown>(fn: T): Mock<T>; + + export function expect<T>(actual: T): { + toBe(expected: unknown): void; + toEqual(expected: unknown): void; + toBeUndefined(): void; + toHaveBeenCalledTimes(expected: number): void; + }; +} diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 9c778d7d818..117ed25046a 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -10,10 +10,6 @@ export const env = createEnv({ server: { DATABASE_URL: z.string(), DATABASE_URL_UNPOOLED: z.string(), - ELECTRIC_URL: z.string().url(), - ELECTRIC_SECRET: z.string().min(16), - ELECTRIC_SOURCE_ID: z.string().optional(), - ELECTRIC_SOURCE_SECRET: z.string().optional(), BLOB_READ_WRITE_TOKEN: z.string(), GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), @@ -31,6 +27,7 @@ export const env = createEnv({ SLACK_SIGNING_SECRET: z.string(), ANTHROPIC_API_KEY: z.string(), QSTASH_TOKEN: z.string().min(1), + QSTASH_URL: z.string().url(), QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), QSTASH_NEXT_SIGNING_KEY: z.string().min(1), RESEND_API_KEY: z.string(), @@ -47,6 +44,7 @@ export const env = createEnv({ DURABLE_STREAMS_URL: z.string().url(), DURABLE_STREAMS_SECRET: z.string().min(1), TAVILY_API_KEY: z.string().optional(), + RELAY_URL: z.string().url(), }, client: { NEXT_PUBLIC_API_URL: z.string().url(), diff --git a/apps/api/src/lib/oauth-metadata.ts b/apps/api/src/lib/oauth-metadata.ts new file mode 100644 index 00000000000..c8f838a4691 --- /dev/null +++ b/apps/api/src/lib/oauth-metadata.ts @@ -0,0 +1,63 @@ +export interface ProtectedResourceMetadataOptions { + authorizationServerUrl?: string; + resourceName?: string; + resourceDocumentation?: string; + scopesSupported?: string[]; +} + +function getFirstForwardedValue(value: string | null): string | undefined { + return value + ?.split(",") + .map((part) => part.trim()) + .find(Boolean); +} + +export function getRequestOrigin(req: Request): string { + const requestUrl = new URL(req.url); + const host = + getFirstForwardedValue(req.headers.get("x-forwarded-host")) ?? + requestUrl.host; + const proto = + getFirstForwardedValue(req.headers.get("x-forwarded-proto")) ?? + requestUrl.protocol.replace(":", ""); + + return `${proto}://${host}`; +} + +export function normalizeResourcePath(pathname: string): string { + if (!pathname || pathname === "/") { + return ""; + } + + return pathname.startsWith("/") ? pathname : `/${pathname}`; +} + +export function getOAuthProtectedResourceMetadataUrl(req: Request): string { + const requestUrl = new URL(req.url); + return `${getRequestOrigin(req)}/.well-known/oauth-protected-resource${normalizeResourcePath( + requestUrl.pathname, + )}`; +} + +export function buildProtectedResourceMetadata( + req: Request, + resourcePath: string, + options: ProtectedResourceMetadataOptions, +): Record<string, unknown> { + const origin = getRequestOrigin(req); + const normalizedResourcePath = normalizeResourcePath(resourcePath); + + return { + resource: `${origin}${normalizedResourcePath}`, + ...(options.authorizationServerUrl + ? { authorization_servers: [options.authorizationServerUrl] } + : {}), + ...(options.scopesSupported?.length + ? { scopes_supported: options.scopesSupported } + : {}), + ...(options.resourceName ? { resource_name: options.resourceName } : {}), + ...(options.resourceDocumentation + ? { resource_documentation: options.resourceDocumentation } + : {}), + }; +} diff --git a/apps/api/src/proxy.ts b/apps/api/src/proxy.ts index 8e28b5829e2..6451123950c 100644 --- a/apps/api/src/proxy.ts +++ b/apps/api/src/proxy.ts @@ -29,15 +29,8 @@ function getCorsHeaders(origin: string | null, deploymentOrigin: string) { "Access-Control-Allow-Origin": isAllowed ? origin : "", "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", "Access-Control-Allow-Headers": - "Content-Type, Authorization, x-trpc-source, trpc-accept, X-Electric-Backend, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Closed", + "Content-Type, Authorization, x-trpc-source, trpc-accept, Producer-Id, Producer-Epoch, Producer-Seq, Stream-Closed", "Access-Control-Expose-Headers": [ - // Electric sync headers - "electric-offset", - "electric-handle", - "electric-schema", - "electric-cursor", - "electric-chunk-last-offset", - "electric-up-to-date", // Durable stream headers "Stream-Next-Offset", "Stream-Cursor", diff --git a/apps/desktop/BUILDING.md b/apps/desktop/BUILDING.md index 74347999166..8c03ba9eb56 100644 --- a/apps/desktop/BUILDING.md +++ b/apps/desktop/BUILDING.md @@ -6,7 +6,7 @@ Run the dev server without env validation or auth: SKIP_ENV_VALIDATION=1 bun run dev ``` -This skips environment variable validation and the sign-in screen, useful for local development without credentials. +This skips environment variable validation and the sign-in screen. Desktop chat also falls back to local-only session bootstrap in this mode, so you can test chat/streaming without the cloud API as long as you have local model credentials configured. # Release @@ -36,4 +36,4 @@ ls -la release/*.AppImage ls -la release/*-linux.yml ``` -If both files exist, packaging produced the Linux artifact + updater metadata that `electron-updater` expects. \ No newline at end of file +If both files exist, packaging produced the Linux artifact + updater metadata that `electron-updater` expects. diff --git a/apps/desktop/HOST_SERVICE_ARCHITECTURE.md b/apps/desktop/HOST_SERVICE_ARCHITECTURE.md new file mode 100644 index 00000000000..528479413f0 --- /dev/null +++ b/apps/desktop/HOST_SERVICE_ARCHITECTURE.md @@ -0,0 +1,62 @@ +# Host Service Architecture + +What a host service is, how it's layered, and what needs to change. + +## What is a host service? + +A process that runs workspaces on a machine — laptop or remote server. It clones repos, runs terminals, watches filesystems, runs AI chat, and registers itself with the cloud as a **host**. + +A **device** is anything that connects (phone, browser, desktop app). A **host** is something that runs workspaces. A MacBook is both. A phone is only a device. A remote server is only a host. + +The host service must be deployable standalone with zero Electron awareness. + +## Layering + +``` +┌──────────────────────────────────────────────────────────────┐ +│ ELECTRON DESKTOP (apps/desktop) │ +│ │ +│ Owns: │ +│ - Spawning / adopting / releasing host service processes │ +│ - Desktop-specific credential providers │ +│ - Session config (auth token, cloud API URL) │ +│ - System tray UI │ +│ - Quit flow (release vs stop) │ +│ - Manifest files (on-disk persistence for process adoption) │ +│ │ +│ Does NOT own: │ +│ - Workspace CRUD, host registration, terminal sessions │ +│ - Organization metadata (the host service knows its own) │ +│ - Any business logic a remote host would also need │ +├──────────────────────────────────────────────────────────────┤ +│ HOST SERVICE (packages/host-service) │ +│ │ +│ Owns: │ +│ - Workspace lifecycle (create, delete, list) │ +│ - Host registration with the cloud │ +│ - Terminal PTY management │ +│ - Filesystem watching │ +│ - Git operations │ +│ - AI chat runtime │ +│ - Its own identity and metadata (host.info endpoint) │ +│ │ +│ Does NOT own: │ +│ - How it was started (Electron vs systemd vs docker) │ +│ - Credential discovery (keychain, ~/.claude, git cred mgr) │ +│ - Default paths like ~/.superset/host.db │ +│ - Electron concepts (resourcesPath, manifests, etc.) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Host vs Device + +Rename in host service context: +- `deviceClientId` → `hostId` (generated internally from machine identity) +- `deviceName` → `hostName` (generated internally from `os.hostname()`) +- `device.ensureV2Host` → `host.register` + +Host identity is intrinsic — the host service generates it at startup, not passed in as config. + +--- + +For API shapes, boundaries, and concrete migration steps, see [HOST_SERVICE_BOUNDARIES.md](./HOST_SERVICE_BOUNDARIES.md). diff --git a/apps/desktop/HOST_SERVICE_BOUNDARIES.md b/apps/desktop/HOST_SERVICE_BOUNDARIES.md new file mode 100644 index 00000000000..21340029654 --- /dev/null +++ b/apps/desktop/HOST_SERVICE_BOUNDARIES.md @@ -0,0 +1,397 @@ +# Host Service Boundaries + +API shapes and boundaries between the host service, the Electron desktop layer, and the tray. + +--- + +## 1. Host Service (`packages/host-service`) + +### `createApp()` — the sole entry point + +```ts +createApp({ + config: { + dbPath: string, // where the SQLite database lives + cloudApiUrl: string, // where the cloud API is + migrationsPath: string, // where Drizzle migration files live + allowedOrigins: string[], // CORS allowlist + }, + providers: { + auth: ApiAuthProvider, // outbound: how to authenticate with the cloud API + hostAuth: HostAuthProvider, // inbound: how to validate requests to this service + credentials: GitCredentialProvider, // how to get git/GitHub credentials + modelResolver: ModelProviderResolver, // how to resolve AI model credentials + }, +}); +``` + +All fields required. No optional fields. No defaults that assume a desktop environment. + +**Config** = static values (strings, paths, URLs). **Providers** = injectable behavior (interfaces with different implementations per deployment). + +**Not config, not providers:** + +- `hostId` / `hostName` — generated internally by the host service from machine identity +- Version — the service reads its own version from package.json, not from a passed-in string. + +### Provider interfaces + +```ts +interface ApiAuthProvider { + getHeaders(): Promise<Record<string, string>>; +} + +interface HostAuthProvider { + validate(request: Request): Promise<boolean>; + validateToken(token: string): Promise<boolean>; +} + +interface GitCredentialProvider { + getToken(host: string): Promise<string | null>; +} + +interface ModelProviderResolver { + resolve(cwd: string): Promise<RuntimeEnv>; + // Returns env vars — does NOT mutate process.env +} +``` + +### tRPC endpoints + +**Unauthenticated (liveness probes):** + +```ts +health.check → { status: "ok" } +``` + +**Authenticated (PSK) — host identity and metadata:** + +This is how the tray gets the information it needs. `host.info` is the single source of truth for "who is this host" — no metadata passed through the Electron layer. + +```ts +host.info → { + hostId: string, + hostName: string, + organization: { + id: string, + name: string, + slug: string, + }, + version: string, // from package.json + platform: string, + uptime: number, +} +``` + +**Authenticated (PSK) — workspace and project management:** + +```ts +workspace.create → ... +workspace.delete → ... +workspace.list → ... +project.remove → ... // renamed from removeFromDevice +``` + +**Authenticated (PSK) — WebSocket routes:** + +```ts +terminal/* → WebSocket +filesystem/* → WebSocket +``` + +### What the host service is NOT + +`createApp()` is a factory — it wires config + providers into a Hono server and returns it. There is no "host service manager" inside the package. The complexity of the current `createApp()` (~150 lines) is just plumbing: create DB, create git factory, create API client, register routes. Provider construction is one-liners (`new PskHostAuthProvider(secret)`, etc.) — the callers are simple. + +--- + +## 2. Electron Coordinator (`apps/desktop`) + +Manages host service child processes. This is the only complex piece on the Electron side. + +### Interface + +```ts +interface HostServiceCoordinator { + // Lifecycle + start(organizationId: string, config: SpawnConfig): Promise<{ port: number; secret: string }>; + stop(organizationId: string): void; + restart(organizationId: string, config: SpawnConfig): Promise<{ port: number; secret: string }>; + stopAll(): void; + releaseAll(): void; + + // Discovery + discoverAll(): Promise<void>; // scan manifests, adopt running services + + // Queries + getConnection(organizationId: string): { port: number; secret: string } | null; + getProcessStatus(organizationId: string): ProcessStatus; + getActiveOrganizationIds(): string[]; + hasActiveInstances(): boolean; + + // Events + on(event: "status-changed", handler: (e: StatusEvent) => void): void; +} + +interface SpawnConfig { + authToken: string; + cloudApiUrl: string; + dbPath: string; + migrationsPath: string; + allowedOrigins: string[]; +} + +type ProcessStatus = "starting" | "running" | "degraded" | "restarting" | "stopped"; + +interface StatusEvent { + organizationId: string; + status: ProcessStatus; + previousStatus: ProcessStatus | null; +} +``` + +### Per-instance state + +After a service is running (whether spawned or adopted), the coordinator holds: + +```ts +{ + pid: number, // the OS process ID — used for liveness checks and SIGTERM + port: number, // from ready message (spawned) or manifest (adopted) + secret: string, // PSK for authenticating with this instance +} +``` + +That's the steady-state. During spawn, the coordinator picks a free port, passes it to the host service as config (env var), then polls `health.check` on that port until the service is up. No Node IPC channel needed — the host service just starts on the port it's told. Once healthy, the coordinator records the pid/port/secret and discards the `ChildProcess` handle (`unref`'d so it survives app quit). From that point, spawned and adopted processes are treated identically: just a PID to check liveness and signal, a port to connect to, and a secret to authenticate. + +### Where the complexity lives + +The coordinator is ~500 lines. This is irreducible complexity from managing processes that survive app restarts: + +| Concern | Why it's unavoidable | +|---------|---------------------| +| Spawn + health poll | Must start the child, poll health.check until ready, handle timeout | +| Adoption from manifests | Must read disk, health-check the process, verify it's reachable | +| Liveness polling | Adopted processes have no exit event — must poll PID | +| Restart with backoff | Crashed services need exponential backoff, not immediate retry | +| Pending start dedup | Concurrent `start()` calls for the same org must coalesce | +| Release vs stop | Quit flow needs to either detach or kill each service | + +The current 800-line manager mixes these with org metadata, session config, display formatting, compatibility checks, and version tracking. The coordinator drops all of that — it only manages processes. The ~300 lines saved aren't from removing complexity; they're from removing concerns that don't belong. + +### What the coordinator does NOT hold + +| Data | Where it lives instead | +|------|----------------------| +| Organization name/metadata | Host service (`host.info` endpoint) | +| Auth token, cloud API URL | Passed per-call as `SpawnConfig`, not stored | +| Service version | Host service (`host.info` endpoint) | +| Uptime | Host service (`host.info` endpoint) | +| Compatibility / pending restart | Derived at query time by comparing `host.info` version vs app version | + +### Config passing + +```ts +// Before (mutate-then-call anti-pattern) +manager.setAuthToken(token); +manager.setCloudApiUrl(url); +manager.setOrganizationName(organizationId, name); +await manager.start(organizationId); + +// After (pass config per-call) +await coordinator.start(organizationId, { + authToken: token, + cloudApiUrl: url, + dbPath: path.join(orgDir, "host.db"), + migrationsPath: getMigrationsPath(), + allowedOrigins: [`http://localhost:${vitePort}`], +}); +``` + +--- + +## 3. Tray (`apps/desktop`) + +Pure view. Reads from two sources, writes to coordinator. + +### Data sources + +``` +From host.info (HTTP to each service, authenticated with PSK): + - organization.name → menu section header + - version → display label + - uptime → display label + +From coordinator (in-process): + - status → "Running" / "Starting..." / "Degraded" + - hasActiveInstances → controls quit menu options +``` + +### Actions + +``` +Restart → coordinator.restart(organizationId, config) +Stop → coordinator.stop(organizationId) +Quit (keep services) → coordinator.releaseAll() + app.exit() +Quit (stop services) → coordinator.stopAll() + app.exit() +``` + +### Menu structure + +``` +Host Service (N) +├── <org name> ← from host.info +│ ├── Running (v1.2.3) ← status from coordinator, version from host.info +│ ├── Uptime: 2h 15m ← from host.info +│ ├── Restart +│ └── Stop +├── ───────── +├── <another org> +│ └── ... +├── ───────── +├── Open Superset +├── Settings +├── Check for Updates +├── ───────── +├── Quit (Keep Services Running) ← only if hasActiveInstances +└── Quit & Stop Services ← only if hasActiveInstances +``` + +--- + +## 4. Renderer HostServiceProvider (`apps/desktop`) + +Queries the coordinator for connection info, then talks directly to host services over HTTP/WS. + +```ts +// From coordinator (via tRPC IPC) +const { port, secret } = await trpc.hostService.getConnection.query({ organizationId }); + +// Direct to host service (HTTP/WS) +const client = createHostServiceClient(port, secret); +await client.workspace.list.query(); +``` + +The provider maintains `Map<organizationId, { port, url, client }>` — just connection info. No metadata caching. + +--- + +## 5. Manifest (`apps/desktop` — Electron-only concept) + +On-disk JSON file per org. Written by the coordinator once the spawned service reports it's ready (pid, port). Read by the coordinator for adoption on next app launch. The host service itself has no knowledge of manifests. + +```ts +interface Manifest { + pid: number, + endpoint: string, // e.g. "http://127.0.0.1:4832" + authToken: string, // PSK secret for this instance + startedAt: number, + organizationId: string, +} +``` + +Minimal — just enough to reconnect. No version or protocol fields; the coordinator queries `host.info` after adoption for metadata if needed. + +Lives at `~/.superset/host/<organizationId>/manifest.json`. The coordinator writes and reads it. Remote deployments don't use manifests. + +--- + +## 6. What moves where + +### Out of `packages/host-service` + +| Item | Current location | Moves to | Reason | +| --- | --- | --- | --- | +| `process.resourcesPath` / `ELECTRON_RUN_AS_NODE` | `db.ts` | Electron entry point | `migrationsPath` is now required config | +| `ORGANIZATION_ID` from `process.env` | `health.ts` | Removed | Org info served via `host.info`, fetched from cloud at registration | +| `LocalModelProvider` as default | `app.ts` | Injected by caller | `modelResolver` is required, no default | +| `LocalGitCredentialProvider` as default | `app.ts` | Injected by caller | `credentials` is required, no default | +| Default `~/.superset/host.db` | `app.ts` | Injected by caller | `dbPath` is required, no default | +| `~/.superset/chat-anthropic-env.json` | `anthropic-runtime-env.ts` | Moves with `LocalModelProvider` | Desktop-only path | +| macOS Keychain reads | `resolveAnthropicCredential.ts` | Moves with `LocalModelProvider` | macOS-only | +| `~/.claude/` credential reads | `resolveAnthropicCredential.ts` | Moves with `LocalModelProvider` | Claude Desktop-only | +| `project.removeFromDevice` | `project.ts` | Rename to `project.remove` | "Device" framing is wrong | +| `process.env` mutations in `applyRuntimeEnv()` | `runtime-env.ts` | Model providers return env, don't mutate | Dangerous in multi-tenant context | +| `health.info` (current combined endpoint) | `health.ts` | Split into `health.check` + `host.info` | Liveness vs metadata are different concerns | + +### Stays in `packages/host-service` + +| Item | Why | +| --- | --- | +| Workspace CRUD | Core host responsibility | +| Host registration (renamed from device) | Host registers itself as a network node | +| Terminal PTY management | Core host responsibility | +| Filesystem watching | Core host responsibility | +| Git operations | Core host responsibility | +| AI chat runtime | Core host responsibility | +| `health.check` (liveness only) | Every service needs this | +| `host.info` (new, authenticated) | Host is the source of truth for its own identity | +| `PskHostAuthProvider` | Pure validation, works everywhere | +| `CloudGitCredentialProvider` / `CloudModelProvider` | Cloud-backed, environment-agnostic | +| Shell resolution (`process.platform` in terminal) | Terminals inherently need to know the OS | +| `terminal_sessions` table | Session tracking is host-service state | + +### Gaps to fix in standalone `serve.ts` + +| Gap | Fix | +| --- | --- | +| `auth` / `cloudApiUrl` not passed | Make required — standalone needs cloud connectivity | +| `credentials` defaults to `LocalGitCredentialProvider` | Use `CloudGitCredentialProvider` | +| `modelResolver` defaults to `LocalModelProvider` | Use `CloudModelProvider` | +| No terminal session reconciliation at startup | Mark orphaned `"active"` sessions as `"disposed"` on boot | +| `health.info` unauthenticated | Move metadata to `host.info` behind PSK auth | + +--- + +## 7. Entry point examples + +### Electron + +```ts +// apps/desktop/src/main/host-service/index.ts +import { createApp, PskHostAuthProvider, JwtApiAuthProvider } from "@superset/host-service"; +import { LocalGitCredentialProvider } from "@superset/host-service/providers/desktop"; +import { LocalModelProvider } from "@superset/host-service/providers/desktop"; + +createApp({ + config: { + dbPath: path.join(orgDir, "host.db"), + cloudApiUrl: env.CLOUD_API_URL, + migrationsPath: app.isPackaged + ? path.join(process.resourcesPath, "resources/host-migrations") + : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), + allowedOrigins: [`http://localhost:${desktopVitePort}`], + }, + providers: { + auth: new JwtApiAuthProvider(authToken), + hostAuth: new PskHostAuthProvider(secret), + credentials: new LocalGitCredentialProvider(), + modelResolver: new LocalModelProvider(), + }, +}); +``` + +### Standalone + +```ts +// packages/host-service/src/serve.ts +import { createApp, PskHostAuthProvider, JwtApiAuthProvider, + CloudGitCredentialProvider, CloudModelProvider } from "./index"; + +createApp({ + config: { + dbPath: env.HOST_DB_PATH, + cloudApiUrl: env.CLOUD_API_URL, + migrationsPath: join(import.meta.dirname, "../../drizzle"), + allowedOrigins: env.CORS_ORIGINS, + }, + providers: { + auth: new JwtApiAuthProvider(env.AUTH_TOKEN), + hostAuth: new PskHostAuthProvider(env.HOST_SERVICE_SECRET), + credentials: new CloudGitCredentialProvider(), + modelResolver: new CloudModelProvider(), + }, +}); +``` + +No `if (process.resourcesPath)`. No `if (platform() === "darwin")`. No `~/.superset` defaults. The host service is a pure server; the caller decides how it's configured. diff --git a/apps/desktop/HOST_SERVICE_LIFECYCLE.md b/apps/desktop/HOST_SERVICE_LIFECYCLE.md new file mode 100644 index 00000000000..3daf0d05dc4 --- /dev/null +++ b/apps/desktop/HOST_SERVICE_LIFECYCLE.md @@ -0,0 +1,75 @@ +# Host Service Lifecycle + +## Architecture + +Electron main owns app lifecycle, tray, and host-service management. Host-services run as child processes that can outlive the app via manifest-based adoption. + +``` +┌─────────────────────────────────────────────────────┐ +│ Electron Main Process │ +│ │ +│ ┌──────────┐ ┌──────────────────────┐ ┌───────┐ │ +│ │ Tray │ │ HostServiceManager │ │Windows│ │ +│ │ (macOS) │ │ │ │ │ │ +│ │ │◄─┤ status events │ │ hide/ │ │ +│ │ restart │ │ start/stop/adopt │ │ show │ │ +│ │ stop │ │ per org │ │ │ │ +│ │ quit ────┼──┼──► requestQuit(mode) │ │ │ │ +│ └──────────┘ └──────┬───────────────┘ └───────┘ │ +└───────────────────────┼─────────────────────────────┘ + │ IPC + stdio + ┌─────────────┼─────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌────────────┐ + │host-service│ │host-service│ │host-service│ + │ (org A) │ │ (org B) │ │ (org C) │ + │ │ │ │ │ │ + │ HTTP/tRPC │ │ HTTP/tRPC │ │ HTTP/tRPC │ + │ port:rand │ │ port:rand │ │ port:rand │ + │ │ │ │ │ │ + │ writes │ │ writes │ │ writes │ + │ manifest │ │ manifest │ │ manifest │ + └────────────┘ └────────────┘ └────────────┘ + │ │ │ + ▼ ▼ ▼ + ~/.superset/host/{orgId}/manifest.json +``` + +### Quit modes + +All quit paths use a single `QuitMode` (`"release" | "stop"`): + +- **release** — detach from services, they keep running for re-adoption on next launch +- **stop** — SIGTERM all services, then exit +- **implicit** (Cmd+Q with active services on macOS) — hide windows to tray + +### Manifest adoption + +Each host-service child writes `~/.superset/host/{orgId}/manifest.json` on startup (pid, endpoint, authToken, version). It's a pidfile extended with connection info. + +- **Release quit** — children keep running, manifests stay on disk +- **Next launch** — `discoverAndAdoptAll()` scans manifests, health-checks each pid/endpoint, reconnects if healthy, removes and respawns if not +- **Stop quit** — SIGTERM children, they remove their own manifests on shutdown + +``` +App Launch App Quit (release) Next Launch +───────── ────────────────── ─────────── +spawn child ──► child writes parent detaches scan manifests + manifest.json manifests stay on disk health-check pid/endpoint + {pid, endpoint, child keeps running ├─ healthy → reconnect + authToken, ...} └─ dead/bad → remove, respawn +``` + +### v1 vs v2 terminal paths + +v1 terminals run on a separate **terminal-host daemon** (`src/main/terminal-host/`) — a persistent background process that owns PTYs over a Unix domain socket. It has its own survival and reconnection model independent of host-service. + +v2 terminals run through **host-service** child processes. The quit/adopt/tray lifecycle described here only applies to host-service instances. + +### Design decisions + +- **No supervisor process.** Electron main owns everything. Simpler while v1 and v2 coexist. +- **No tray on Windows/Linux.** Services still survive quit and are re-adopted, but there's no persistent UI to manage them. +- **Tray calls `requestQuit(mode)`.** One function, one codepath — no setter chains or flag mutation. +- **Manifest handling is single-sourced.** Both parent and child use `host-service-manifest.ts`. Files are written with 0o600 permissions. diff --git a/apps/desktop/V2_WORKSPACE_CREATION.md b/apps/desktop/V2_WORKSPACE_CREATION.md new file mode 100644 index 00000000000..ad97547384a --- /dev/null +++ b/apps/desktop/V2_WORKSPACE_CREATION.md @@ -0,0 +1,435 @@ +# V2 Workspace Creation — Design + +Umbrella design for the v2 "new workspace" flow: branch discovery, the three creation intents (fork / checkout / adopt), the pending-page dispatch, and the delete counterpart that ships in a follow-up PR. Cross-cutting patterns for git-ref handling live in `packages/host-service/GIT_REFS.md`. + +## Scope + +The v2 modal at `/_authenticated/.../DashboardNewWorkspaceModal/` lets users pick a branch and turn it into a workspace. Three workspace-creating intents, one branch picker, one pending page, one cloud+host coordination contract. The delete flow is the inverse of the creation path and follows the same coordination model. + +Not in scope here: v1 (`NewWorkspaceModal`, deliberately diverged), chat session migration, cross-host cleanup. + +--- + +# 1. Branch discovery + +## Data shape + +One procedure — `workspaceCreation.searchBranches` — returns everything the picker needs per row: + +```ts +input: { + projectId: string; + query?: string; // server-side substring match + cursor?: string; // opaque (base64-encoded offset) + limit?: number; // default 50, max 200 + refresh?: boolean; // triggers `git fetch --prune`, TTL-gated + filter?: "branch" | "worktree"; // server-side filter; default = "branch" +} + +output: { + defaultBranch: string | null; + items: BranchRow[]; + nextCursor: string | null; +} + +type BranchRow = { + name: string; + lastCommitDate: number; + isLocal: boolean; + isRemote: boolean; + recency: number | null; // reflog ordinal, 0 = most recent + worktreePath: string | null; // only Superset worktrees under <repo>/.worktrees/ + hasWorkspace: boolean; // workspaces row exists for (project, branch) on this host + isCheckedOut: boolean; // true if in any git worktree (incl. primary) +}; +``` + +One procedure with rich metadata rather than two (branches + worktrees) because worktrees aren't a separate searchable surface — they're a filter + decoration on the same branch list. Single source of truth, one invalidation trigger, no flicker from two queries arriving out of order. + +## Server flow + +Executed on every `searchBranches` call: + +1. If `refresh` is set and the 30s per-project TTL has elapsed, `git fetch --prune --quiet --no-tags`. The TTL prevents keystroke-level thrash. +2. `git for-each-ref --sort=-committerdate refs/heads/ refs/remotes/origin/` — one call, both namespaces, ~20ms on 10k refs. +3. `git worktree list --porcelain` → two maps: `worktreeMap` (Superset-managed only, under `.worktrees/<branch>/`) and `checkedOutBranches` (every worktree incl. primary). +4. `git log -g --pretty=%gs --grep-reflog=checkout: -n 500` → reflog recency ordinals per branch. +5. Parse refs using the **full** refname prefix (`refs/heads/` vs `refs/remotes/origin/`) — a structural namespace that can't appear inside a branch name. Short-name prefixes like `origin/` are unsafe because a local branch can legitimately be named `origin/foo`. See `GIT_REFS.md`. +6. Collapse local+remote pairs by name; attach worktree + recency + hasWorkspace flags. +7. Apply `filter`: `branch` excludes worktree'd rows (`!worktreeMap.has(name)`), `worktree` includes only them. +8. Apply `query` substring (case-insensitive). +9. Sort: default branch first, then reflog-recent ascending, then everything else by `committerdate` desc. +10. Slice `[offset, offset + limit)`; return `nextCursor` if more. + +Cursor is opaque — currently `base64(JSON.stringify({ offset }))`. We don't cache between calls because `for-each-ref` is cheap enough; if profiling ever shows it, memoize per `(projectId, query, generation)`. + +## Client flow + +`useBranchContext` wraps `useInfiniteQuery` keyed by `(projectId, hostUrl, query, filter)`. First page (`pageParam === undefined`) sends `refresh: true`; subsequent pages don't. Types (`BranchFilter`, `BranchRow`) are derived from the server schema via `inferRouterInputs<AppRouter>` / `inferRouterOutputs<AppRouter>` — single source of truth, no duplicate enums. + +The picker is a popover with: +- Search input (server-side, substring). +- 2-tab strip (Branch / Worktree) bound to the `filter` input. +- Infinite-scroll list with an IntersectionObserver sentinel; the callback has an `inFlight` guard so a small page on a tall viewport can't cascade-load all remaining pages. +- Per-row hover-reveal action buttons (see §2). + +--- + +# 2. Actions per row + +The picker dispatches one of four actions based on which tab and what the row's state is: + +| Tab | Row state | Click row body | Hover-reveal action | +|----------|-----------|----------------|---------------------| +| Branch | (no worktree) | Set as base branch → submit prompt → **Fork** | **Check out** (disabled if `isCheckedOut`) | +| Worktree | Has cloud workspace row | Set as base branch → Fork from this worktree's branch | **Open** — navigate to existing workspace | +| Worktree | Orphan (worktree on disk, no cloud row) | Set as base branch → Fork | **Create** — adopt the orphan | + +**Why click ≠ action button.** Click preserves today's prompt-driven fork flow; the user types and submits. Action buttons commit immediately — one click, skip the prompt dance — when the user's intent is clear. + +**Why "Check out" instead of "Create" on Branch tab.** Both paths create a workspace; the distinguishing axis is branch-level: Check out reuses the existing branch; Fork forks a new one from it. The labels signal the right distinction. + +**Authority for `hasWorkspace`**: the picker calls `hasWorkspaceForBranch(name)` which queries the cloud-synced `v2Workspaces` collection, NOT the server's `hasWorkspace` field. The server field is a host-side snapshot that stays `true` after a cloud delete until host cleanup catches up; the client collection reflects real-time cloud state. Side benefit: orphan worktrees (disk dir without cloud row) correctly get "Create" and clicking it resurrects them. + +**Disabled "Check out" state**: `aria-disabled="true"` only, never the native `disabled` attribute. Native `disabled` blocks pointer events, which makes the Radix Tooltip explaining "already checked out" unreachable. Click handler `stopPropagation()`s to prevent action. + +--- + +# 3. Workspace creation flow + +Three intents (`fork` / `checkout` / `adopt`), one unified path. + +## The path + +Modal inserts a `pendingWorkspaces` row tagged with `intent` and navigates to `/pending/<id>`. The pending page owns the mutation, loading/error UX, and retry. Three buttons in the UI (Submit / Check out / Create), one code path. + +Previously only fork went through this; checkout and adopt were fire-and-forget (silent failures, no recovery). Now all three share: +- Pending-row persistence (localStorage via the `pendingWorkspaces` collection). +- Progress UI (either host-service `getProgress` steps or a generic spinner for adopt). +- Retry on failure. +- Warning surfacing (`result.warnings[]`). + +## Pending row schema + +`pendingWorkspaceSchema` in `providers/CollectionsProvider/dashboardSidebarLocal/schema.ts`: + +```ts +{ + // Shared + id, projectId, hostTarget, intent, name, branchName, status, error, + workspaceId, warnings, terminals, createdAt; + + // Fork-only (default-empty for checkout/adopt) + prompt, baseBranch, baseBranchSource, linkedIssues, linkedPR, attachmentCount; + + // fork + checkout (irrelevant for adopt) + runSetupScript; +} +``` + +`hostTarget`, `linkedIssues`, `linkedPR` are structured zod shapes (discriminatedUnion, typed objects) — not `z.unknown()`. Malformed rows fail zod parse at the collection boundary instead of crashing a downstream consumer. + +## Pending page dispatch + +`useFireIntent` in `_dashboard/pending/$pendingId/page.tsx`: + +```ts +switch (pending.intent) { + case "fork": result = await createWorkspace(buildForkPayload(pendingId, pending, attachments)); + case "checkout": result = await checkoutWorkspace(buildCheckoutPayload(pendingId, pending)); + case "adopt": result = await adoptWorktree(buildAdoptPayload(pending)); +} +``` + +Payload builders live in `buildIntentPayload.ts` — pure functions, no React, no IO. Contract-tested in `buildIntentPayload.test.ts` (11 cases covering every input shape + edge cases: empty prompts, orphan hostTarget kinds, linkedIssue filtering). + +Guards on the page: +- **`firedRef`** ensures the mutation fires once per pending page. Resets when `pendingId` changes under a mounted component (user navigates from one pending page to another). +- **`workspaceSynced`** is a live-query against `v2Workspaces` — the page waits for the newly-created cloud row to arrive via Electric before navigating to `/v2-workspace/<id>`, with a 3s timeout fallback. Fast intents (adopt) would otherwise beat sync and land on "workspace not found." +- **`navigatedRef`** prevents double-navigation; also reset on `pendingId` change. + +## Intent-specific UI + +- **Fork / Checkout**: polls `workspaceCreation.getProgress` for step labels (`ensuring_repo` → `creating_worktree` → `registering`) at 500ms. +- **Adopt**: server doesn't instrument progress (it's DB ops only, typically <100ms), so the page renders a generic spinner. The `workspaceSynced` gate adds enough wait for the cloud row to arrive — no "workspace not found" flash. + +## The three mutations + +The router entrypoint is `packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts`; the mutation implementations live under `packages/host-service/src/trpc/router/workspace-creation/procedures/`: + +**`create`** (fork from a base branch): +1. Ensure local project (clone if missing). +2. Resolve start point — either `buildStartPointFromHint(baseBranch, baseBranchSource)` when the picker supplied a hint, or `resolveStartPoint(git, baseBranch)` probing full refnames. Both return a `ResolvedRef`. +3. If remote-tracking, `git fetch origin <branch>` for freshness. +4. `git worktree add --no-track -b <newBranch> <path> <startPoint>` — `--no-track` since the new branch is intentionally untethered. +5. `ensureV2Host` → cloud `v2Workspace.create` → rollback worktree on cloud failure. +6. Insert local `workspaces` row. +7. Optionally spawn setup terminal (`.superset/setup.sh`). + +**`checkout`** (reuse an existing branch): +1. Same project-ensure prelude. +2. `resolveRef(git, branch)` → switch on `kind`. Tags rejected. +3. For remote-tracking: `git fetch origin <branch>` then `git worktree add --track -b <branch> <path> origin/<branch>`. The `--track -b` is essential — bare `git worktree add <path> origin/<branch>` produces a detached HEAD. +4. Same cloud + local registration as `create`. + +**`adopt`** (register an existing worktree as a workspace): +1. Project-ensure. +2. `listWorktreeBranches` → find the existing `.worktrees/<branch>/` directory. +3. `ensureV2Host` → cloud `v2Workspace.create`. Always creates a fresh cloud row — **no local-idempotency shortcut**: previously we returned the existing `workspaces.id` without calling cloud, which echoed a phantom id when the original cloud row had been hard-deleted. +4. Replace any stale local `workspaces` row for this (project, branch) with the new cloud id. +5. No git ops, no setup script (worktree already exists). + +All three return `{ workspace, terminals, warnings }`. + +## The `baseBranchSource` hint + +The picker already knows whether each row is local or remote-only (`isLocal` / `isRemote`). It passes that knowledge through the whole chain: + +``` +picker row (isLocal: true) + → onSelectCompareBaseBranch(name, "local") + → draft.baseBranchSource = "local" + → pendingWorkspaces row carries baseBranchSource + → createWorkspace composer includes baseBranchSource + → server buildStartPointFromHint → { kind: "local", ... } + → git worktree add ... <shortName> +``` + +Server falls back to `resolveStartPoint` only when no hint is given (legacy pending rows, non-picker callers). Benefit: the server never has to re-resolve, so stale cached remote refs can't silently win and produce `git worktree add` failures like `fatal: invalid reference: origin/<branch>`. + +This is the "classify at the boundary, carry the tag" principle applied at the API layer. Same shape as `ResolvedRef` — but for the API contract instead of the ref type. + +## UX per row + +Default state (no hover): +``` +⎇ feature-foo [remote] 3d ago [✓ when selected] +``` + +On hover/focus (keyboard users get this via `group-focus-within`): +``` +⎇ feature-foo [remote] 3d ago [Check out] ← Branch tab +⎇ feature-bar 1h ago [Open] ← Worktree tab, hasWorkspace +⎇ feature-baz 2h ago [Create] ← Worktree tab, orphan +``` + +--- + +# 4. Workspace delete (follow-up PR) + +**Not implemented here.** The creation flow left host-side state leaking on cloud delete — the picker handles this defensively today via the client-collection `hasWorkspace` check and Create-adopts-orphans. Full cleanup lives in a follow-up PR with the design below. + +## Principle + +**The side that owns harder-to-reverse state orchestrates.** Host-service owns the git worktree, PTYs, and local sqlite. The cloud row is bookkeeping *about* the workspace. If host-service commits to the delete, the cloud follows. If cloud delete fails after disk is gone, the user has a consistent view (nothing to open) and the cloud row reconciles on next sync. + +Delete inverts the same order as create: host does `git worktree remove` → calls `v2Workspace.delete` → deletes host row. + +## Procedure shape + +```ts +workspaceCleanup.destroy: protectedProcedure + .input(z.object({ + workspaceId: z.string(), + deleteBranch: z.boolean().default(false), + force: z.boolean().default(false), + })) + .mutation(async ({ ctx, input }) => { + // 1. Kill PTYs for this workspaceId. + // 2. Run .superset/teardown.sh if it exists, 60s timeout, SIGKILL on timeout, + // capture stdout/stderr tail. On failure (and no `force`), throw TEARDOWN_FAILED + // typed so renderer can prompt "delete anyway" → re-call with force: true. + // 3. `git worktree remove <path>` (add --force if input.force). Throws CONFLICT + // if dirty without force — renderer prompts. + // 4. If deleteBranch: `git branch -d <branch>` (or -D with force). + // 5. Cloud delete (`v2Workspace.delete`). Failures logged + warned; disk is + // already clean, cloud self-heals. + // 6. Delete host-sqlite `workspaces` row. + return { warnings }; + }); +``` + +## Renderer flow + +```ts +try { + await destroy({ workspaceId, deleteBranch: false, force: false }); +} catch (err) { + if (err.code === "CONFLICT" || err.code === "TEARDOWN_FAILED") { + const confirmed = await showDeleteConfirm({ ... }); + if (confirmed.deleteAnyway) { + await destroy({ workspaceId, deleteBranch: confirmed.deleteBranch, force: true }); + } + } +} +``` + +Fast path: one click for clean worktree, no branch delete. Confirm only on dirty / teardown-fail / deleteBranch: true. + +## What this replaces + +- Direct calls to `v2Workspace.delete.mutate` from the renderer. Cloud endpoint locks down to require a host-service service token after cutover. +- The picker's "stale hasWorkspace" defensive fix stays — belt-and-suspenders against partial-failure modes. +- Host-side `workspaces` table leak fixed by step 6. + +## Open decisions for the delete PR + +- **Soft-delete vs hard-delete** of the cloud row. Hard-delete is today's behavior and orphans chat sessions (`chatSessions.v2WorkspaceId` is `onDelete: "set null"`). Soft-delete (add `deletedAt`, filter in Electric subscription) would preserve chat continuity on re-adopt. Decision owned by the delete PR. +- **Remote-host cleanup** (workspace on remote host deleted from this device). Same pattern, separate PR. +- **Bulk delete / trash bin**: future composition over `destroy`. + +--- + +# 5. Invariants + enforcement + +## Authority decisions + +| Question | Authoritative source | Why | +|----------|---------------------|-----| +| "Does a git ref exist?" / "Is it local or remote?" | `resolveRef` probe against full refnames | Full refname prefixes are structural; short forms are ambiguous (see GIT_REFS.md). | +| "Does a workspace row exist right now?" | Cloud-synced `v2Workspaces` collection | Electric reflects cloud state; host-side cache can be stale after delete. | +| "What branches are on disk?" | `git for-each-ref` + `git worktree list --porcelain` | Git is the source of truth for repo state. | +| "What was the user's picker intent?" | `baseBranchSource` hint + `intent` discriminator | Picker already knows; carry the tag through the API boundary. | +| "Is the worktree deletion safe?" | Host-service checks git state directly | Host owns disk; cloud can't verify. | + +## Git ref handling + +See `packages/host-service/GIT_REFS.md` for the pattern. Key rules: +- Never `.startsWith("origin/")`. Always probe full refnames. +- `ResolvedRef` discriminated union with template-literal `fullRef` types; `switch` on `kind` with `never` exhaustiveness default. +- Fields that belong to one variant only (e.g., `remote` on `remote-tracking`) live in that variant, not as `?: string`. + +## Tests + +- `packages/host-service/src/runtime/git/refs.test.ts` — 12 tests, contract suite for every input shape. +- `packages/host-service/src/trpc/router/workspace-creation/utils/resolve-start-point.test.ts` — 11 tests, including regression for local-named-`origin/foo` branches. +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts` — 11 tests, intent payload construction. + +## Lint + +- `scripts/check-git-ref-strings.sh` bans `.startsWith("origin/")` and `.replace("origin/", ...)` outside `refs.ts`. Exit-code-aware: distinguishes rg-not-found from rg-error so a broken scan fails loudly. +- V1 desktop tRPC routers excluded (they have pre-existing instances; v1 cleanup is a separate concern per GIT_REFS.md). + +## Type guarantees + +- `ResolvedRef` kind → compiler enforces narrowing before accessing variant-only fields. +- `BranchFilter`, `BranchRow`, `CreateWorkspaceInput`, `CheckoutWorkspaceInput`, `AdoptWorktreeInput` are all inferred from server schemas — renaming a field on either side fails compilation at every call site. +- Pending row's `hostTarget` / `linkedIssues` / `linkedPR` are zod-typed, not `z.unknown()`. No `as` casts at read sites. + +--- + +# Deferred (tracked but not in this design) + +- **Prompt carry-over on Open-existing**. Infra design notes captured during PR review; needs a zustand seed store + wiring the v2 ChatPane's `initialLaunchConfig`. Pure UX improvement, not a correctness issue. +- **Recency section headers** ("Recent" / "Other") in the picker. Server already emits in recency order; cosmetic rendering change. +- **V1 migration to ResolvedRef** in `apps/desktop/src/lib/trpc/routers/**`. V1 predates this pattern; excluded from the lint rule. Port during the v1 sunset or leave until v1 dies with v2 launch. +- **Virtualization of branch rows** via `@tanstack/react-virtual`. `useInfiniteQuery` keeps fetched pages in memory; only add windowing if repos with thousands of *shown* branches become a real performance issue. +- **Multi-remote support** in `resolveRef`. Today it's hardcoded to `origin`; the discriminated union already encodes `remote: string` for future enumeration. + +## Cross-reference + +For the foundational git-ref handling pattern that underpins `create` / `checkout` / `resolveStartPoint`, see [`packages/host-service/GIT_REFS.md`](../../packages/host-service/GIT_REFS.md). + +--- + +# Appendix: prior art for `resolveStartPoint` + +Background research that informed the start-point resolution + targeted-fetch approach in §3's `create` mutation. Useful when revisiting whether to keep the current strategy, switch to a background-fetcher model, or add multi-remote support. + +## How other apps solve "where do I branch off from?" + +### VS Code (Copilot worktree creation) + +`chatSessionWorktreeServiceImpl.ts:79-92` — resolves the branch's **upstream tracking ref** via `getBranch()`: + +```ts +if (isAgentSessionsWorkspace && baseBranch) { + const branchDetails = await gitService.getBranch(repo, baseBranch); + if (branchDetails?.upstream?.remote && branchDetails.upstream?.name) { + baseBranch = `${branchDetails.upstream.remote}/${branchDetails.upstream.name}`; + } +} +// Then: git worktree add -b <newBranch> --no-track <path> <baseBranch> +``` + +Properties: works with non-`origin` remotes via tracking config. No-op when tracking isn't configured (freshly cloned repos). No fetch before creation — relies on last background fetch. Always passes `--no-track`. + +### T3Code (worktree creation) + +`GitCore.ts:1896-1917` — passes `baseBranch` straight through `createWorktree`. The chain lives in `resolveBaseBranchForNoUpstream` (line 1068): + +``` +1. git config: branch.<name>.gh-merge-base +2. git symbolic-ref refs/remotes/<remote>/HEAD (remote default branch) +3. Candidates ["main", "master"] — check local refs/heads/ then remote refs/remotes/ +``` + +Has a **15-second cache-based upstream refresh** (`git fetch --quiet --no-tags`) for status checks, separate from worktree creation. Resolves primary remote dynamically (`origin` → first remote → error). + +### GitHub Desktop (branch creation) + +Multi-layered resolution with a `StartPoint` enum: + +`findDefaultBranch` (`find-default-branch.ts:21-68`): +``` +1. git symbolic-ref refs/remotes/<remote>/HEAD (what remote considers default) +2. git config init.defaultBranch (local git config) +3. Hardcoded "main" +``` + +Then finds the best local representation in priority order: +``` +1. Local branch that TRACKS the remote default (e.g., local main tracking origin/main) +2. Local branch with same NAME as remote default (e.g., local main) +3. Remote-tracking branch itself (e.g., origin/main) +``` + +Branch creation (`create-branch.ts:1-49`): +- `StartPoint.UpstreamDefaultBranch` → `upstream/main`, `--no-track` +- `StartPoint.DefaultBranch` → `main` (local) +- `StartPoint.CurrentBranch` / `Head` → current HEAD +- Fallback chain: Upstream → Default → Current → Head + +Freshness: background fetcher every ~1 hour (min 5 min). After each fetch, `git remote set-head -a <remote>` to refresh the remote HEAD symref. No fetch at branch creation time. + +### Superset v1 + +`workspace-init.ts:217-273` — `resolveLocalStartPoint`: +``` +1. origin/<branch> (git rev-parse --verify --quiet) +2. <branch> locally +3. Scan common branches: main, master, develop, trunk (both origin/ and local) +``` + +Fast: `rev-parse` is local I/O only (<5ms). No network calls. + +## Comparison + +| | VS Code | T3Code | GitHub Desktop | Superset v1 | **Superset v2** | +|--|---------|--------|----------------|-------------|-----------------| +| **Strategy** | Upstream tracking lookup | Config → symbolic-ref → candidates | Symbolic-ref → config → "main" + local/remote search | `origin/<branch>` prefix → local → scan | **Local-first** + symbolic-ref default + `origin/<branch>` fallback → HEAD | +| **Prefers remote ref?** | Yes (via upstream) | Yes (when only remote exists) | Prefers local that tracks remote | Yes (`origin/` first) | **No — local-first** (avoids stale remote refs) | +| **Handles non-origin remotes?** | Yes | Yes | Yes | No | No (origin hardcoded today) | +| **Default branch detection** | N/A | `symbolic-ref refs/remotes/<remote>/HEAD` | symbolic-ref + `init.defaultBranch` + `"main"` | Hardcoded `"main"` | `symbolic-ref refs/remotes/origin/HEAD` → `"main"` | +| **Fetches before creation?** | No | No (15s cache for status) | No (background hourly) | No | **Yes — targeted single-ref fetch** when remote-tracking | +| **`--no-track`?** | Yes always | No | Only for upstream default | No (`^{commit}` instead) | Yes always | +| **Complexity** | Low | High (Effect services, caches) | Medium (enum + multi-layer) | Low | Low | + +## What we picked and why + +**Local-first with `symbolic-ref` default detection + targeted single-ref fetch on remote-tracking.** + +- **Over VS Code's upstream-tracking lookup**: silently no-ops when tracking isn't configured (freshly cloned repos, branches set up with `--no-track`). Direct probe is more reliable. +- **Over T3Code's `gh-merge-base` config + GitHub CLI calls**: too heavy for a request-driven hot path; T3Code can amortize via long-lived services, host-service can't. +- **Over GitHub Desktop's pre-resolved state + background fetcher**: great for a long-running GUI app; host-service is request-driven and shouldn't carry that infrastructure. +- **Over v1's common-branch scan**: unnecessary when `symbolic-ref` is authoritative for the actual default branch name. Scanning `master`/`develop`/`trunk` is a guess. +- **Over remote-first** (which earlier versions of this PR used): a stale cached `refs/remotes/origin/<branch>` (one-off push, missed prune) silently won and produced `git worktree add` failures like `fatal: invalid reference: origin/<branch>`. Local-first matches user intent — the user picks branches from a list they see locally. + +## Future: periodic background fetch + +Host-service is long-running, so a T3Code/GitHub Desktop-style background fetch would keep `origin/*` refs fresh without per-request cost: + +- **Periodic fetch**: `git fetch --quiet --no-tags origin` every N minutes per repo (T3Code uses 15s for status, GitHub Desktop uses ~1hr). +- **Cache with TTL**: track last-fetch time per repo, only fetch if stale. + +The picker's `refresh: true` on modal-open already does a TTL-gated full fetch (30s) — covers the most common freshness need. Move to background-fetch infrastructure only if branch listings start showing visibly stale state in practice. diff --git a/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md b/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md new file mode 100644 index 00000000000..d0db24298c3 --- /dev/null +++ b/apps/desktop/V2_WORKSPACE_MODAL_GAPS.md @@ -0,0 +1,132 @@ +# V2 Workspace Creation Modal — Gap Analysis vs V1 + +> Generated 2026-04-11. Last updated 2026-04-12. Compares V2 (`DashboardNewWorkspaceModal`) against V1 (`NewWorkspaceModal`). + +## Status Summary + +| # | Gap | Status | +|---|-----|--------| +| 1 | Project Picker — Open/New project actions | Open | +| 2 | Branch Picker — Worktree awareness | Open | +| 3 | AI Branch Name Generation | Open | +| 4 | GitHub Issue Content Auto-Fetching | Open | +| 5 | Agent Launch Request Building | Open | +| 6 | Dedicated "Create from PR" Flow | Open | +| 7 | PR URL Parsing and Cross-Repo Validation | ✅ Resolved (PR #3356) — extended to issues | + +## File References + +| | Path | +|---|---| +| **V1 PromptGroup** | `src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx` | +| **V2 PromptGroup** | `src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx` | +| **V2 Submit Hook** | `…/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts` | +| **V2 Draft Context** | `…/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx` | + +--- + +## Gaps + +### 1. Project Picker — "Open project" / "New project" actions + +**V1**: Project picker includes a separator and two extra items — "Open project" (`onImportRepo`) and "New project" (`onNewProject`). + +**V2**: Only lists existing projects with search. No way to import a repo or create a new project from within the modal. + +**V1 ref**: `PromptGroup.tsx:246-268` +**V2 ref**: `ProjectPickerPill.tsx` (entire file) + +--- + +### 2. Branch Picker — Worktree awareness and Open/Create actions + +**V1** has a fully worktree-aware branch picker (`CompareBaseBranchPickerInline`) with: +- All / Worktrees filter toggle (tabs with counts) +- Differentiated icons: local branch, remote branch, openable worktree, active workspace +- "external" badge for external worktrees +- Active workspace detection with `GoArrowUpRight` icon +- Hover actions: "Open" button to navigate to existing workspace/worktree, "Create" button to create alongside an existing one +- Keyboard hint labels (Enter / Cmd+Enter) + +**V2** has a simplified `CompareBaseBranchPicker` — flat list of branches with `GoGitBranch` icons, "default" and "workspace" badges. No open/create actions, no worktree filter toggle, no differentiated icons. + +**V1 ref**: `PromptGroup.tsx:275-530` (`CompareBaseBranchPickerInline`) +**V2 ref**: `CompareBaseBranchPicker/CompareBaseBranchPicker.tsx` + +--- + +### 3. AI Branch Name Generation + +**V1**: On submit, calls `electronTrpc.workspaces.generateBranchName.mutateAsync()` with a 30-second timeout. Falls back to random name on timeout/failure with toast feedback. Shows "generating-branch" pending status. + +**V2**: `resolveNames()` resolves names synchronously. No AI generation call. + +**V1 ref**: `PromptGroup.tsx:600-608, 759-809` +**V2 ref**: `useSubmitWorkspace.ts` → `resolveNames()` + +--- + +### 4. GitHub Issue Content Auto-Fetching + +**V1**: On submit, fetches full GitHub issue content via `projects.getIssueContent.query()`, sanitizes it (HTML entity escaping, URL validation, 50KB body limit), converts to markdown, and attaches as file attachments alongside user-uploaded files. + +**V2**: Only sends `githubIssueUrls` as string URLs in `linkedContext`. Does not fetch or attach issue content. + +**V1 ref**: `PromptGroup.tsx:832-943` +**V2 ref**: `useSubmitWorkspace.ts` → `mapLinkedContext()` + +--- + +### 5. Agent Launch Request Building + +**V1**: Builds a full `AgentLaunchRequest` via `buildPromptAgentLaunchRequest()` with agent config, prompt, converted files, and task slug. Passes this to `createWorkspace.mutateAsyncWithPendingSetup()`. + +**V2**: No `AgentLaunchRequest` is built. The prompt is sent as part of `composer` but agent config resolution, file bundling, and task slug mapping are missing. + +**V1 ref**: `PromptGroup.tsx:695-708, 945-959` +**V2 ref**: `useSubmitWorkspace.ts:96-110` + +--- + +### 6. Dedicated "Create from PR" Flow + +**V1**: When a PR is linked, submit uses a separate code path — `createFromPr.mutateAsyncWithSetup()` — that creates the workspace from the PR's branch and metadata. + +**V2**: Sends `linkedPrUrl` as part of `linkedContext`. The PR is treated as context rather than driving branch creation. No separate mutation. + +**V1 ref**: `PromptGroup.tsx:963-982` +**V2 ref**: `useSubmitWorkspace.ts:79` → `mapLinkedContext()` + +--- + +### 7. PR URL Parsing and Cross-Repo Validation — ✅ Resolved (PR #3356) + +**V1**: `PRLinkCommand` parses pasted GitHub PR URLs (`github.com/:owner/:repo/pull/:number`), detects cross-repository links, and shows an error ("PR URL must match {repo}") for mismatched repos. + +**V2 (resolved)**: Server-side `normalizeGitHubQuery` in host-service handles URL parsing, `#123` / bare-number shorthand, and cross-repo validation. Response returns `{ repoMismatch: "owner/repo" }` and client shows "PR URL must match owner/repo." Same normalization also extended to `searchGitHubIssues`. Debounce-gap loading state (`isPendingDebounce`) added to prevent empty-state flash. + +**Resolved by**: PR #3356 (merged 2026-04-11) +**Refs**: +- `packages/host-service/src/trpc/router/workspace-creation/normalize-github-query.ts` +- `…/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx` +- `…/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx` + +--- + +## Not a Gap (V2 advantage) + +**Branch name preview**: V2 shows a live `branchPreview` in the branch name input placeholder using `slugifyForBranch(trimmedPrompt)`. V1 shows a static `"branch name"` placeholder. V2 is better here. + +--- + +## Priority Assessment (remaining) + +| # | Gap | Impact | Effort | +|---|-----|--------|--------| +| 5 | Agent launch request building | High — agents won't receive full config/prompt/files | Medium | +| 3 | AI branch name generation | High — branch names won't be meaningful | Low | +| 4 | GitHub issue content fetching | Medium — issues linked as URLs only, not rich context | Medium | +| 6 | Dedicated "create from PR" flow | Medium — PR workspaces may not set up branches properly | Medium | +| 2 | Branch picker worktree awareness | Medium — can't discover/open existing worktrees | High | +| 1 | Project picker open/new actions | Low — can do this outside the modal | Low | +| ~~7~~ | ~~PR URL parsing / cross-repo validation~~ | ✅ Resolved by #3356 | — | diff --git a/apps/desktop/docs/EXTERNAL_FILES.md b/apps/desktop/docs/EXTERNAL_FILES.md index 22980e1e6c3..5cc01ad97cb 100644 --- a/apps/desktop/docs/EXTERNAL_FILES.md +++ b/apps/desktop/docs/EXTERNAL_FILES.md @@ -17,6 +17,7 @@ This separation prevents multiple instances from interfering with each other. | File | Purpose | |------|---------| +| `amp` | Wrapper for Amp CLI that preserves Superset terminal context | | `claude` | Wrapper for Claude Code CLI that injects notification hooks | | `codex` | Wrapper for Codex CLI that injects notification hooks | | `droid` | Wrapper for Factory Droid CLI that preserves Superset hook integration | @@ -41,13 +42,16 @@ its hook entries into these files while preserving user-defined entries: | File | Purpose | |------|---------| | `~/.claude/settings.json` | Claude Code hook registration merge | -| `~/.codex/hooks.json` | Codex fallback hook registration merge (`SessionStart`, `Stop`) | +| `~/.codex/hooks.json` | Codex hook registration merge (`SessionStart`, `UserPromptSubmit`, `Stop`) | | `~/.factory/settings.json` | Factory Droid hook registration (`UserPromptSubmit`, `Notification`, `PostToolUse`, `Stop`) | -For Codex specifically, this global `hooks.json` is a fallback path only. The -primary Superset integration is the wrapper in `~/.superset[-{workspace}]/bin/codex`, -which injects `notify` and watches the Codex session log for richer lifecycle -events without mutating project-local `.codex/` state. +For Codex specifically, Superset now relies on native `~/.codex/hooks.json` +registration for durable prompt/tool lifecycle events, while the wrapper in +`~/.superset[-{workspace}]/bin/codex` still injects `notify` and keeps the +session-log watcher as a best-effort compatibility bridge for older Codex +releases. On startup, Superset rewrites only its own managed entries in +`~/.codex/hooks.json` to point at the current environment's `notify.sh`, while +preserving any user-defined Codex hooks. ### `zsh/` and `bash/` - Shell Integration diff --git a/apps/desktop/docs/KEYBOARD_SYSTEM.md b/apps/desktop/docs/KEYBOARD_SYSTEM.md new file mode 100644 index 00000000000..e2c89af83a2 --- /dev/null +++ b/apps/desktop/docs/KEYBOARD_SYSTEM.md @@ -0,0 +1,194 @@ +# Keyboard Shortcut System + +Layout-aware hotkey dispatch + display for the desktop renderer. + +## What it does + +- 50+ shipped default shortcuts (`apps/desktop/src/renderer/hotkeys/registry.ts`). +- User-customizable via Settings → Keyboard, persisted in `localStorage`. +- Each binding can match by **physical key position** (`event.code`) or **printed character** (`event.key`) so users on Dvorak / AZERTY / QWERTZ get shortcuts that follow the labels on their keyboard. +- Display refreshes on the fly when the user switches input source (macOS menu-bar picker, Cmd+Space). +- Terminal forwarding: app hotkeys bubble through xterm; `Ctrl+C/D/Z/S/Q/\` are reserved for the PTY. + +## Public API + +Everything consumers need is re-exported from `renderer/hotkeys`: + +```ts +import { + // dispatch + useHotkey, // register a callback for a HotkeyId + // read + useBinding, getBinding, // current binding (string | v2 object) + getDispatchChord, // imperative event.code-form chord (use for synthesizing KeyboardEvents) + // display + useHotkeyDisplay, // formatted "⌘⇧P" for a HotkeyId + useFormatBinding, // formatted display for a binding shape (e.g. recording UI) + HotkeyLabel, // <Kbd>-rendering component + // recorder + useRecordHotkeys, // capture flow for the Settings page + // registry + HOTKEYS, HotkeyId, PLATFORM, +} from "renderer/hotkeys"; +``` + +Stay out of `stores/keyboardLayoutStore` and `utils/binding.ts` internals unless you're extending the system. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Electron Main process │ +│ │ +│ native-keymap (npm, Microsoft) │ +│ ├─ getKeyMap() → IKeyboardMapping │ +│ ├─ getCurrentKeyboardLayout() → IKeyboardLayoutInfo │ +│ └─ onDidChangeKeyboardLayout(cb) │ +│ └─ macOS: kTISNotifySelectedKeyboardInputSourceChanged │ +│ │ +│ apps/desktop/src/main/lib/keyboardLayout.ts │ +│ └─ EventEmitter wrapping native-keymap, lazy-init │ +│ │ +│ apps/desktop/src/lib/trpc/routers/keyboardLayout.ts │ +│ └─ get query + changes observable │ +└──────────────────┬──────────────────────────────────────┘ + │ tRPC subscription (observable per AGENTS.md) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Electron Renderer │ +│ │ +│ hotkeys/stores/keyboardLayoutStore.ts │ +│ └─ Zustand store: { map, layoutId } │ +│ Self-restarting on subscription error │ +│ │ +│ hotkeys/utils/binding.ts → bindingToDispatchChord() │ +│ └─ Single source of truth for translating logical │ +│ bindings to event.code form. Used by: │ +│ - useHotkey (react-hotkeys-hook registration) │ +│ - useHotkeyDisplay / useFormatBinding (rendering) │ +│ - useRecordHotkeys (cross-mode conflict detection) │ +│ - resolveHotkeyFromEvent (terminal forwarding) │ +│ │ +│ hotkeys/display.ts → formatHotkeyDisplay() │ +│ └─ Looks up event.code in layoutMap for printable │ +│ keys; falls back to KEY_DISPLAY (US-ANSI) for │ +│ special keys and when map is null │ +└─────────────────────────────────────────────────────────┘ +``` + +## Binding model + +Each binding is a `ShortcutBinding`: + +```ts +type ShortcutBinding = + | string // legacy / shipped default — implicitly physical + | { version: 2; mode: BindingMode; chord: string }; + +type BindingMode = "physical" | "logical" | "named"; +``` + +| Mode | Match against | Stored chord | Use | +|---|---|---|---| +| `physical` | `event.code` | scan-code-canonical (`"meta+p"` = physical KeyP) | Shipped registry defaults; preserves QWERTY muscle memory | +| `logical` | the produced character | the literal character (`"meta+p"` = the key labeled P) | Default for new user-recorded printable bindings; follows the printed letter across layouts | +| `named` | `event.code` (stable for named keys) | `"meta+enter"`, `"meta+arrowup"`, `"f5"` | Auto-applied to Enter/arrows/F-keys regardless of mode preference | + +**Storage compactness**: physical-mode bindings serialize to bare strings (matches legacy shape, keeps the registry terse). Logical and named modes serialize to the v2 object form. + +## Layout-aware translation + +The single function that bridges modes is `bindingToDispatchChord(binding, layoutMap)`. For every consumer that needs the chord react-hotkeys-hook actually matches against, route through this function: + +``` +binding.mode === "physical" → return chord unchanged +binding.mode === "named" → return chord unchanged (event.code is stable) +binding.mode === "logical" → translateLogicalChord(chord, layoutMap) + → find scan code whose unshifted glyph + matches the chord's letter, + return chord with key replaced. + Falls back to literal chord (US-correct) + when layoutMap is null. +``` + +Example: a logical `meta+z` binding on German QWERTZ resolves to `meta+y` (because German's KeyY position prints "z"), so react-hotkeys-hook fires when the user presses the key labeled Z — same letter, different physical position. + +## Recording flow + +`useRecordHotkeys` captures both `event.code` (codeChord) and `event.key` (keyChord) on each keystroke, plus a classification: + +- **fkey** / **named** → mode forced to `named` regardless of preference. +- **printable** → caller's `preferredMode` (default `"logical"`) decides; `+` falls back to physical to avoid colliding with the chord separator. + +The Settings page passes `preferredMode: "logical"`. Conflict detection compares dispatch chords (post-translation), so logical and physical bindings that collide on the current layout are flagged. + +## Cross-cutting guards + +| Concern | Where | Why | +|---|---|---| +| **AltGr** (Linux/Windows) | `eventToChord` and `useHotkey.shouldIgnoreEvent` | Chromium reports AltGr as ctrlKey+altKey — without suppression, AltGr-typed printables on non-US layouts (`AltGr+E = €` on German) would false-trigger any `ctrl+alt+e` binding. | +| **IME composition** (CJK / dead keys) | `eventToChord` and `useHotkey.shouldIgnoreEvent` | `event.isComposing` and Safari's `keyCode === 229` short-circuit matching. Modifier+letter chords bypass IME on macOS by OS design. | +| **Terminal-reserved chords** | `TERMINAL_RESERVED_CHORDS` set | `Ctrl+C/D/Z/S/Q/\` always go to PTY; recorder rejects them with an error. | + +## Migration + +The v1→v2 hotkey storage migration was shipped April 2026 and removed in commit `16f0da83e` (3 months later, after every active user had the `hotkey-overrides-migrated-v2` marker). If a user genuinely hasn't launched the app since April, they see default bindings instead of their v1 customizations; v1 overrides remain in main-process state via the `uiState.hotkeys.get` tRPC endpoint and could be recovered if anyone asks. + +## Decision history (brief) + +- **April 2026** — Initial refactor. Unified everything on `event.code` (recorder, dispatch, terminal forwarding). Preserved the bare-string storage shape. See `plans/done/20260412-keyboard-recorder-ctrl-binding-fix.md`. +- **April 27, 2026** — Layout audit and Phase 0–2 plan. Briefly tried `navigator.keyboard.getLayoutMap()` to avoid the native-keymap dep; switched back after discovering Chromium's `layoutchange` event doesn't fire for macOS input-source switches. native-keymap hooks `kTISNotifySelectedKeyboardInputSourceChanged` directly, which fires reliably. See `plans/done/20260427-keyboard-layout-plan.md`. +- **April 28, 2026** — Phase 1 (native-keymap) + Phase 2 (dual-mode bindings) shipped. v1 migration removed. + +## Known gaps / future work + +| Item | Status | +|---|---| +| **Menu accelerator sync** — `main/lib/menu.ts` hardcodes `CmdOrCtrl+R/,//Shift+Q`; they shadow user rebinds | Demand-driven. The single concrete user-visible gap. | +| **v1 terminal handler** uses catch-all `ctrl/meta` skip → starves TUIs of unbound chords like Ctrl+R | Tracked in `plans/20260409-tui-hotkey-forwarding.md`; v2 already correct. | +| **AltGr first-class binding token** | Reserved but never wired. AltGr is suppressed at match time, but a user can't *record* `AltGr+E` as their own chord. Drop or implement on demand. | +| **Numpad / Digit disambiguation** | Collapsed: `Numpad1` and `Digit1` both canonicalize to `"1"`. No current need for separate bindings. | +| **Shifted-layer display** | We use the unshifted glyph + ⇧ symbol convention (macOS). `native-keymap` exposes `withShift` / `withAltGr` data we don't read. | +| **Physical/logical mode toggle in Settings UI** | Backend supports both modes; UI defaults new printable recordings to logical with no opt-in to physical. Add a toggle if a user requests it. | +| **Layout-id telemetry** | `layoutId` is in the store but never reported. Cheap if product wants the data. | +| **Multi-stroke chords** (`Ctrl+K Ctrl+S`) | No demand. | +| **When-clauses / context system** | No demand; per-component `useHotkey` registration is sufficient. | + +## Out of scope + +- VSCode-style `KeybindingResolver` / context engine. +- `globalShortcut` (system-wide hotkeys). +- Per-extension keybinding contributions. +- Vendoring VSCode's static layout files (only if `native-keymap` ever proves insufficient). + +## Key files + +``` +apps/desktop/src/main/lib/keyboardLayout.ts # native-keymap wrapper +apps/desktop/src/lib/trpc/routers/keyboardLayout.ts # tRPC bridge +apps/desktop/src/renderer/hotkeys/ +├── registry.ts # shipped defaults +├── types.ts # ShortcutBinding, BindingMode +├── display.ts # formatHotkeyDisplay, glyphForCode +├── stores/ +│ ├── hotkeyOverridesStore.ts # localStorage user overrides +│ └── keyboardLayoutStore.ts # tRPC mirror with retry +├── hooks/ +│ ├── useBinding/ # binding + dispatch chord +│ ├── useHotkey/ # register a callback +│ ├── useHotkeyDisplay/ # formatted display +│ └── useRecordHotkeys/ # Settings recording flow +├── utils/ +│ ├── binding.ts # parse / serialize / translate +│ └── resolveHotkeyFromEvent.ts # canonicalization, terminal index +└── components/HotkeyLabel/ # <Kbd>-rendering component + +apps/desktop/src/main/lib/menu.ts # ⚠ hardcoded; see "Known gaps" +apps/desktop/src/renderer/lib/terminal/ # terminal forwarding integration +``` + +## References + +- VSCode keyboardLayoutMainService: https://github.com/microsoft/vscode/blob/main/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts +- `native-keymap`: https://github.com/microsoft/node-native-keymap +- MDN `KeyboardEvent.code`: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values diff --git a/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md b/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md new file mode 100644 index 00000000000..bf40a83d511 --- /dev/null +++ b/apps/desktop/docs/OPTIMISTIC_ELECTRIC_UPDATES.md @@ -0,0 +1,111 @@ +# Optimistic Electric Updates + +Desktop uses TanStack DB collections backed by Electric shapes for task and workspace data. The default write model is **optimistic online**, not offline-first. + +## Decision + +Use TanStack DB collection mutations for routine server-backed writes that already have stable local identity: + +1. The UI calls `collection.insert`, `collection.update`, or `collection.delete`. +2. TanStack DB applies optimistic state immediately. +3. The collection handler persists through our API. +4. The API returns the PostgreSQL `txid` from the same database transaction as the write. +5. Electric streams that transaction back to the client. +6. TanStack DB drops the optimistic overlay or rolls it back if persistence fails. + +This matches the documented TanStack DB mutation lifecycle and Electric collection txid strategy: + +- TanStack DB mutations: https://tanstack.com/db/latest/docs/guides/mutations +- Electric collection txid matching: https://tanstack.com/db/latest/docs/collections/electric-collection + +## Scope + +Optimistic online is the right default for task edits, status changes, assignment changes, priority changes, title/description edits, and soft deletes. These are simple, single-record writes where immediate feedback matters and rollback is acceptable if the server rejects the write. + +New task creation can remain server-confirmed while it relies on server-generated slugs, default status seeding, and navigation to the canonical record. Move creation to optimistic insert only after the client can provide all stable identity and routing fields up front. + +Do not treat this as offline-first. If the API call cannot run, the transaction should fail, TanStack DB should roll back the optimistic state, and the UI should show a failure toast. We are not adding a durable outbox, replay queue, conflict resolver, or persisted collection state in this pass. + +## Desktop Collection Matrix + +Desktop currently has three write categories. + +### Server-backed Electric writes + +These collections have Electric mutation handlers in `CollectionsProvider/collections.ts`: + +| Collection | Handlers | Current write surface | Behavior | +| --- | --- | --- | --- | +| `tasks` | insert, update, delete | `useOptimisticCollectionActions().tasks` for update/delete; create dialog still uses `task.createFromUi` directly | Optimistic for task edits/deletes; collection handlers return `{ txid }`. | +| `v2Projects` | update | `useOptimisticCollectionActions().v2Projects` for rename/repository updates | Optimistic for project row edits; create/delete remain API-confirmed. | +| `v2Workspaces` | update | `useOptimisticCollectionActions().v2Workspaces` for rename-style updates | Optimistic for workspace row edits; create/delete remain host-service sagas. | +| `chatSessions` | delete | `useOptimisticCollectionActions().chatSessions` for chat session deletion | Optimistic delete; create remains server-confirmed because the chat runtime coordinates session creation. | +| `agentCommands` | update | `useCommandWatcher` | Background optimistic update; caller awaits `tx.isPersisted.promise` and retries on failure. | + +### Read-only Electric collections + +These are Electric-backed in the renderer but have no collection mutation handlers and no direct renderer `collection.insert/update/delete` calls: + +- `organizations` +- `taskStatuses` +- `projects` +- `v2Hosts` +- `v2Clients` +- `v2UsersHosts` +- `workspaces` +- `members` +- `users` +- `invitations` +- `integrationConnections` +- `subscriptions` +- `apiKeys` +- `sessionHosts` +- `githubRepositories` +- `githubPullRequests` +- `automations` +- `automationRuns` + +Workspace create/delete flows do not use `collections.v2Workspaces.insert/delete`. They go through host-service or tRPC APIs and then Electric streams the confirmed row back: + +- workspace create/checkout/adopt writes a local `pendingWorkspaces` row, then the pending page calls host-service +- workspace delete calls host-service `workspaceCleanup.destroy`; the sidebar hides the row through `DeletingWorkspacesProvider` while the saga runs + +Workspace rename does use `collections.v2Workspaces.update` via `useOptimisticCollectionActions().v2Workspaces`, backed by `v2Workspace.update` returning `{ txid }` from the same Postgres transaction. + +### LocalStorage collections + +These are client-local TanStack DB collections. They are synchronous local persistence, not Electric/Postgres optimistic writes: + +- `v2SidebarProjects` — sidebar project order/collapse/default app +- `v2WorkspaceLocalState` — sidebar placement, pane layout, viewed files, changes tab +- `v2SidebarSections` — user-created sidebar sections and ordering +- `v2TerminalPresets` — local terminal presets +- `pendingWorkspaces` — durable local bus for workspace creation progress and launch handoff +- `v2UserPreferences` — local v2 preferences such as link behavior and delete-branch default + +LocalStorage mutations can still throw for schema/storage errors, but they do not have remote persistence confirmation or Electric rollback semantics. + +## Implementation Contract + +Collection handlers must return `{ txid }` for server-backed Electric writes. The txid must come from `pg_current_xact_id()` inside the same transaction that performs the mutation. A txid captured before or after the write can leave `tx.isPersisted.promise` waiting for a transaction that Electric will never stream. + +Feature code should not scatter direct server-backed collection mutations. Use `useOptimisticCollectionActions` and the relevant grouped action surface, such as `.tasks`, `.v2Workspaces`, `.v2Projects`, or `.chatSessions`, so every call site gets the same behavior: + +- apply the optimistic collection mutation immediately +- attach a rejection handler to `tx.isPersisted.promise` +- show a user-visible error when persistence fails +- let TanStack DB own rollback + +Use `{ optimistic: false }` only for exceptional flows where the UI must wait for server confirmation before revealing the result, such as a workflow that depends on a server-generated identifier or a multi-step server-side effect. + +## Offline-First Boundary + +Offline-first needs more than optimistic state. It needs durable local persistence, queued transactions, replay ordering, idempotency, and conflict handling. If we decide to support offline task writes later, design it as a separate feature with: + +- a durable transaction queue +- client-generated stable IDs for created records +- idempotent API mutations +- explicit conflict policy per write type +- UI for pending and failed replays + +Until then, Electric is our read/sync confirmation path and the API remains the write authority. diff --git a/apps/desktop/docs/ROUTING_REFACTOR_ANALYSIS.md b/apps/desktop/docs/ROUTING_REFACTOR_ANALYSIS.md index 2e6edcbef6a..ff628f1daed 100644 --- a/apps/desktop/docs/ROUTING_REFACTOR_ANALYSIS.md +++ b/apps/desktop/docs/ROUTING_REFACTOR_ANALYSIS.md @@ -2,7 +2,7 @@ **Date:** 2026-01-09 **Status:** Analysis Complete -**Related:** ROUTING_REFACTOR_PLAN.md +**Related:** ../plans/done/ROUTING_REFACTOR_PLAN.md This document analyzes the concrete, specific benefits and tradeoffs of migrating from the current view-switching pattern to **TanStack Router with Next.js conventions**, based on the actual codebase. @@ -302,5 +302,5 @@ The only real loss is **#3 (back behavior)** - may need a `navigateBackToWorkspa - Next.js-style conventions via `indexToken` and `routeToken` - Better DX, more modern -Estimated effort: 8-13 hours (per ROUTING_REFACTOR_PLAN.md) +Estimated effort: 8-13 hours (per ../plans/done/ROUTING_REFACTOR_PLAN.md) Risk: Low - incremental migration possible, comprehensive testing at each phase diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md new file mode 100644 index 00000000000..c6bdc95385c --- /dev/null +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md @@ -0,0 +1,326 @@ +# V2 Workspace Launch Context + +Status as of PR #3467 (branch `v2-modal-agent-launch`). See +`plans/v2-workspace-context-composition.md` for the full design. + +## What's implemented (phase 1) + +V2 "fork" workspaces now compose a full agent launch from prompt + linked +issue/PR/task metadata + attachments. Closes Gaps 4 and 5 in +`V2_WORKSPACE_MODAL_GAPS.md`; Gaps 3 and 6 remain open. + +### Pipeline (composition) + +``` +draft (modal) + → PendingWorkspaceRow + → buildForkAgentLaunch (pending page) + ├─ buildLaunchSourcesFromPending → LaunchSource[] + ├─ buildLaunchContext → LaunchContext + ├─ buildLaunchSpec → AgentLaunchSpec + └─ consumer picks chat vs terminal based on the selected agent's kind +``` + +## Dispatch architecture (pending-row-as-bus) + +Launch dispatch uses the **pending row as the transport** between the +pending page (producer) and the V2 workspace page (consumer). **Zero V1 +primitives.** Same pattern V2 preset execution uses +(`useV2PresetExecution`): live-query a record, open a pane in the V2 +`@superset/panes` store, and pass any terminal startup command as transient +pane data. `TerminalPane` attaches the PTY through the terminal WebSocket. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Pending page │ +│ │ +│ 1. host.workspaceCreation.create → workspace exists │ +│ │ +│ 2. buildForkAgentLaunch(pending, attachments, configs) │ +│ uses the real workspaceId now that create resolved. │ +│ │ +│ 3. Dispatch per agent kind: │ +│ │ +│ kind == "terminal": │ +│ • for each attachment: workspaceTrpc.filesystem │ +│ .writeFile → <worktree>/.superset/attachments/… │ +│ • pendingRow.terminalLaunch = { command, name } │ +│ │ +│ kind == "chat": │ +│ • pendingRow.chatLaunch = { │ +│ initialPrompt, initialFiles, model, taskSlug, │ +│ } │ +│ │ +│ 4. Navigate to /v2-workspace/<workspaceId> │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ V2 workspace page mount: useConsumePendingLaunch() │ +│ │ +│ live-query pendingRow by workspaceId │ +│ │ +│ if row.terminalLaunch: │ +│ store.addTab({ panes: [{ kind:"terminal", … }] }) │ +│ TerminalPane mounts → WebSocket open → initialCommand │ +│ update(row, { terminalLaunch: null }) │ +│ │ +│ if row.chatLaunch: │ +│ store.addChatTab({ initialPrompt, initialFiles, model }) │ +│ ChatPane auto-sends on mount (existing V2 chat runtime) │ +│ update(row, { chatLaunch: null }) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Why pending-row-as-bus + +- **Durable**: pending row lives in the `pendingWorkspaces` collection. + Intent survives renderer restarts; the user can close and reopen the + app and the dispatch still fires the next time the workspace is + visited. +- **Already tied to the workspace**: `pendingRow.workspaceId` is the + natural key. No new zustand slice. +- **Producer/consumer decoupled**: pending page never touches the V2 + workspace store directly; workspace page never does the spec-build. + Each side owns its own concern. +- **Consistent with V2 preset execution** — same "stash a record, live- + query from the workspace page, open a pane" pattern is how + `useV2PresetExecution` ships preset commands. +- **Path to host-owned dispatch** (phase 5): pending page stops + populating `row.terminalLaunch`; instead passes the spec into + `host.workspaceCreation.create`. Host returns the already-running + terminal in `terminals[]`. Workspace page consumer stays — it now + reads the host-returned terminal via live query instead of the + pending row. Migration is local to the producer side; consumer never + changes. Chat stays client-driven (chat runtime is in the renderer). + +### Why not the V1 `WorkspaceInitEffects` bus + +V1's dispatcher (`WorkspaceInitEffects` → `launchAgentSession` → +`terminal-adapter`) is hard-coded to V1's `useTabsStore` in the +orchestrator's default tabs adapter. V2 workspaces render panes from a +separate `@superset/panes` store, so launches dispatched through V1 +land in a store V2 never reads — the command runs but no pane appears. +**V2 must own its launch dispatch.** + +### Files (composition, stable) + +- `shared/context/types.ts` — `LaunchSource`, `ContentPart`, `ContextSection`, `LaunchContext`, `AgentLaunchSpec`. +- `shared/context/composer.ts` — `buildLaunchContext` (parallel resolve, dedup, failure-tolerant). +- `shared/context/contributors/*` — one per source kind: `userPrompt`, `githubIssue`, `githubPr`, `internalTask`, `attachment`. +- `shared/context/buildLaunchSpec.ts` — agent-aware template rendering, inline-multimodal preservation. +- `routes/.../pending/$pendingId/buildForkAgentLaunch.ts` — pure helper that runs the composer + buildLaunchSpec from a `PendingWorkspaceRow`. + +### Files (dispatch, to be reworked per the "pending-row-as-bus" plan) + +The first wire-up attempt shipped through V1's `useWorkspaceInitStore` + +`WorkspaceInitEffects`. That path is being ripped out because V1's +orchestrator uses V1's `useTabsStore`, which V2 doesn't render from. + +- `shared/context/buildAgentLaunchRequest.ts` — **deprecated once dispatch migrates.** Still useful as a reference for the V1 shape if we ever need it; otherwise removable after the pending-row-as-bus rewrite. +- `renderer/hooks/useEnqueueAgentLaunch/*` — **to be removed.** V1-bus primitive. +- `routes/.../pending/$pendingId/page.tsx` (the `enqueueAgentLaunch` call) — **to be replaced** by the kind-split described under "Dispatch architecture" above. + +### Files (dispatch, to be added) + +- `pendingWorkspaceSchema` in `providers/.../schema.ts` — gain `terminalLaunch?` and `chatLaunch?` optional fields. +- `routes/.../v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/*` — mount-effect hook that live-queries the pending row, opens a pane via V2 `@superset/panes` store, writes the command via `workspaceTrpc`, clears the field. + +### Agent templates + +Both system and user templates are Mustache-rendered via +`renderPromptTemplate`. Variables: `{{userPrompt}}`, `{{tasks}}`, +`{{issues}}`, `{{prs}}`, `{{attachments}}`. System default is empty +(harnesses discover their own `AGENTS.md` / `CLAUDE.md`). User default +is markdown with the pre-rendered kind-blocks dropped in order. Users +can override per-agent in settings. + +## Test plan + +### Local manual smoke + +1. `bun dev`, open the desktop app. +2. Create a V2 project if needed, ensure Claude (or another terminal + agent) is enabled in Settings → Agents. +3. Open the V2 new-workspace modal (dashboard). + +#### Scenarios + +- [ ] **Prompt only**. Type "add a README". Submit. Workspace opens; Claude's terminal receives the prompt as an argv. +- [ ] **Prompt + attachment**. Drop a small text file. Submit. File lands at `<worktree>/.superset/attachments/<filename>`; prompt includes `- .superset/attachments/<filename>`. +- [ ] **Prompt + linked GitHub issue**. Link an issue via `@` mention. Submit. Prompt includes `# <issue title>`. (Body is empty — see known gaps.) +- [ ] **Prompt + linked task**. Link an internal task. Submit. Prompt includes `# Task <id> — <title>`; `taskSlug` in launch request matches task slug. +- [ ] **Prompt + linked PR**. Link a PR. Submit. Prompt includes `# <PR title>`. +- [ ] **Multiple sources** (prompt + task + issue + PR + attachment). Submit. All sections appear in the prompt in order. `taskSlug` = first internal-task slug. +- [ ] **Retry on failure**. Disable network, submit, fail; re-enable, hit retry button. Second attempt re-enqueues correctly (no stale setup lingers). + +### Automated + +- `bun test apps/desktop/src/shared/context/ apps/desktop/src/renderer/hooks/useEnqueueAgentLaunch/ apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/\$pendingId/` — **113 tests**, including composer dedup/ordering/failure, contributor 404-null semantics, Claude/codex snapshot rendering, bridge base64 encoding + filename dedup, pending-page source mapping, and the V1 fallback path. +- `bunx tsc --noEmit -p apps/desktop/tsconfig.json` — clean in the new surface area. + +### Demo script + +`apps/desktop/scripts/demo-launch-spec.ts` renders `AgentLaunchSpec` +across scenarios for any built-in agent. Run: +```bash +bun run scripts/demo-launch-spec.ts # claude + codex + cursor-agent +bun run scripts/demo-launch-spec.ts claude # just claude +``` + +## Known phase 1 gaps + +- **Issue / PR / task bodies are not injected.** Host-service has no + `getIssueContent` / `getPullRequestContent` / `getInternalTaskContent` + endpoint yet, and the renderer refuses to fall back to the existing + Electron procedure (we don't want Electron IPC in V2). The resolver + stubs return empty bodies; agents see title + URL + task-slug only. +- **No agent picker in the V2 modal.** `getFallbackAgentId` chooses + (prefers Claude, falls back to first enabled). Settings-level + overrides are respected. +- **Remote hosts** (`hostTarget.kind === "remote"`) — launch enqueue + still runs client-side via `useWorkspaceInitStore`. Remote terminals + are out of scope for phase 1; no regression because V2 doesn't + support remote agent launch today. +- **Base64 round-trip on attachments.** IndexedDB store → data URL → + `Uint8Array` (V2 pipeline) → base64 data URL (V1 wire). Functional + but wasteful; bytes-over-IPC is a later optimization. +- **No host-service-side launch.** Phase 1 launches via V1 renderer + adapters. For remote host support, host-service needs its own + `executeAgentLaunch` mirror. + +## Known footguns to revisit (post-testing cleanup) + +Caught during manual testing, not currently biting us, but worth +fixing before the dispatch rewrite is considered done: + +1. **Deep solve for binary transport.** Current fix for the + `PromptInput` blob-URL revoke race (commit 33730ff01) honors the + library's contract — uses the `message.files` passed into + `onSubmit` (already converted to data URLs) instead of re-reading + provider state. Works correctly but still transports bytes as + base64 strings across layers. The deep solve is to flow `File` / + `Blob` objects end-to-end; URLs stay pure UI preview concerns. + Library-level change to `@superset/ui/ai-elements/prompt-input` + (`FileUIPart & { file: File }` through the provider) + downstream + `ChatLaunchConfig.initialFiles: { file: Blob, ... }[]` + bytes + branch for `workspaceTrpc.filesystem.writeFile`. Touches V1, V2, + chat, and every consumer — deliberate staged PR, not a quick fix. + +2. **Reload-mid-launch can create a new terminal ID.** + `consumeTerminalLaunch` calls `crypto.randomUUID()` for `terminalId` + each time it fires. If the user reloads the app between + `terminalLaunch` being applied to the pending row and the consume + clearing it, the fresh consume can generate a new terminal ID. Fix: + store the `terminalId` on `PendingTerminalLaunch` itself (generate + once in `dispatchForkLaunch`). + +3. **Silent failure in the consume hook.** `addTab` failures + `console.warn` and return — user sees no pane open and no error UI. + Wrap in try/toast with the error message. Low urgency while + `[v2-launch]` debug logs are present; becomes visible when those are + removed. + +4. **`joinPath` assumes POSIX separators.** Fine on Mac/Linux hosts + where the worktree paths come from. When remote-host launch lands + (phase 5) and we get Windows hosts, this breaks. Swap for a + proper cross-platform join (or just use `path-browserify`). + +5. **Schema coupling between old and new IDB stores.** Dexie opened + the hand-rolled store's existing DB (`superset-pending-attachments`, + version 1) transparently. Any future schema change (indices, + migration) requires bumping the Dexie version and writing a + migration step. + +6. **`PendingTerminalLaunch.attachmentNames` is populated but never + read by the consume hook.** Currently informational. Either drop + the field, or use it for a UI "files attached" hint in the + workspace-creation success toast. + +7. **Remove the `[v2-launch]` debug logs** from `dispatchForkLaunch`, + `useConsumePendingLaunch`, and `useSubmitWorkspace` once the + end-to-end flow is stable. Replace with a single structured + `captureEvent` call at the pane-opened milestone. + +## Follow-ups (roughly in priority order) + +0. **Rewrite dispatch to pending-row-as-bus** (blocking phase-1 ship — + current V1-bus dispatch is broken for V2). See "Dispatch architecture" + above. Mirrors `useV2PresetExecution`. Estimated 3-4 hours: + - Schema: `terminalLaunch?` + `chatLaunch?` on `pendingWorkspaceSchema`. + - Producer: pending page populates one of those fields after `create` + resolves, writes attachments via `workspaceTrpc.filesystem`. + - Consumer: new `useConsumePendingLaunch(workspaceId, store)` mount + effect on the V2 workspace page. Opens pane in V2 store, writes + command via `workspaceTrpc.terminal`, clears the field. + - Rip out: `useEnqueueAgentLaunch` + its call. `buildAgentLaunchRequest` + stays for now as a reference but is no longer imported. +1. **Host-service body endpoints** (`getIssueContent` / + `getPullRequestContent` / `getInternalTaskContent`). Swap the + resolver stubs in `buildForkAgentLaunch.ts` → contributors emit real + body markdown → agents see full context. Unblocks full Gap 4. +2. **Gap 3: AI branch name generation.** `workspaces.generateBranchName` + call before submit; 30s timeout; fallback to slug preview. +3. **Gap 6: create-from-PR flow.** Detect `github-pr` source and route + to a different host-service mutation that creates the workspace from + the PR's head branch. Today the PR is treated as context only. +4. **V2 modal agent picker.** Minimum: a display pill showing the + default agent with a click-through to settings. Full: a picker + inline in the modal matching V1's UX. +5. **Bytes transport.** IndexedDB stores `Blob`; pipeline passes + `Uint8Array` over IPC via SuperJSON; adapters gain + `filesystem.writeFile({kind:"bytes"})`. Eliminates the base64 + round-trip. +6. **Anthropic Files API** for chat agents only. Upload once, reference + by file ID across launches. Smaller payloads, server-side caching. + Requires chat-runtime changes; does not apply to CLI agents. +7. **Remote host launch.** Host-service-side `executeAgentLaunch` so + workspaces on remote hosts can launch agents without renderer + involvement. Unblocks remote-first workflows. +8. **Per-kind XML wrapping for Claude** (optional). Extend + `renderPromptTemplate` with Mustache-style conditional sections + (`{{#issues}}...{{/issues}}`) and ship a Claude-XML default that + wraps non-empty blocks in tags. Currently defaults are plain + markdown; users can override in settings. + +## File layout reference + +``` +apps/desktop/src/ + shared/context/ + types.ts + composer.ts composer.integration.test.ts + composer.test.ts + buildLaunchSpec.ts buildLaunchSpec.test.ts + buildAgentLaunchRequest.ts buildAgentLaunchRequest.test.ts + __fixtures__/ + attachment.logs-txt.ts + githubIssue.auth-middleware.ts + githubPr.auth-rewrite.ts + internalTask.refactor-auth.ts + launchContext.multi-source.ts + launchContext.prompt-only.ts + index.ts + contributors/ + userPrompt.ts userPrompt.test.ts + attachment.ts attachment.test.ts + githubIssue.ts githubIssue.test.ts + githubPr.ts githubPr.test.ts + internalTask.ts internalTask.test.ts + index.ts + renderer/hooks/useEnqueueAgentLaunch/ + useEnqueueAgentLaunch.ts useEnqueueAgentLaunch.test.ts + index.ts + renderer/routes/_authenticated/_dashboard/pending/$pendingId/ + page.tsx (wires enqueue) + buildForkAgentLaunch.ts buildForkAgentLaunch.test.ts + +packages/shared/src/ + agent-definition.ts (contextPromptTemplateSystem/User fields) + agent-catalog.ts (builtin chat agent defaults) + agent-prompt-template.ts (renderPromptTemplate + context vars + defaults) + builtin-terminal-agents.ts (builtin terminal agent defaults) + +packages/local-db/src/schema/ + zod.ts (contextPromptTemplate* in preset + custom schemas) +``` diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md new file mode 100644 index 00000000000..dda5cc097e2 --- /dev/null +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT_GAPS.md @@ -0,0 +1,202 @@ +# V2 Launch Context — Body-Fetching Gaps + +Companion to `V2_LAUNCH_CONTEXT.md`. Tracks remaining work to make +linked issues / PRs / tasks useful to the agent. + +## Current state (2026-04-15) + +Claude receives titles only — no bodies: + +``` +<user prompt> + +# <task title> + +# <issue title> + +# PR #<n> — <pr title> +Branch `<branch>` is checked out in this workspace — commits you make continue this PR. + +# Attached files +... +- .superset/attachments/<file> +``` + +Bodies are empty because `buildResolveCtxFromPending` stubs return +empty strings. The pipeline otherwise works end-to-end. + +## Design decisions (locked) + +1. **Inline in prompt.** Bodies go directly into the prompt via + `{{issues}}` / `{{prs}}` / `{{tasks}}` template variables. No file + writes for linked context. Only user-uploaded attachments write to + `.superset/attachments/`. +2. **PR checkout is true.** The fork-from-PR flow checks out the PR's + head branch. Prompt says so. +3. **No body truncation** (or very high cap, e.g. 200 KB/source). Modern + context windows are large. Don't cap aggressively. +4. **No sanitization.** Prompt goes into a heredoc with a random + delimiter (no shell injection). Agent reads raw text, no HTML parser + downstream. V1's entity escaping was unnecessary. +5. **Attachments framing.** The `{{attachments}}` block includes a short + header cueing the agent to read the files. Just paths; agent handles + the rest. +6. **Issue/PR comments.** Defer. Note in the follow-ups. +7. **Per-agent framing.** Don't over-engineer. Give the path; agent + figures it out. + +## Work plan + +### 1. Host-service `getIssueContent` + +Add to `workspaceCreation` router (same GitHub auth path as +`searchGitHubIssues`): + +```ts +getIssueContent: protectedProcedure + .input(z.object({ projectId: z.string(), issueNumber: z.number() })) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + const octokit = await ctx.github(); + const { data } = await octokit.issues.get({ + owner: repo.owner, repo: repo.name, issue_number: input.issueNumber, + }); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.html_url, + state: data.state, + author: data.user?.login ?? null, + }; + }), +``` + +### 2. Host-service `getPullRequestContent` + +Same router, wraps `octokit.pulls.get`: + +```ts +getPullRequestContent: protectedProcedure + .input(z.object({ projectId: z.string(), prNumber: z.number() })) + .query(async ({ ctx, input }) => { + const repo = await resolveGithubRepo(ctx, input.projectId); + const octokit = await ctx.github(); + const { data } = await octokit.pulls.get({ + owner: repo.owner, repo: repo.name, pull_number: input.prNumber, + }); + return { + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.html_url, + state: data.state, + branch: data.head.ref, + baseBranch: data.base.ref, + author: data.user?.login ?? null, + }; + }), +``` + +### 3. Internal-task body source + +Find the API for task details. V1 uses Electron IPC; V2 has +collections in the task view (live-query from cloud). Options: + +- `apiTrpcClient.tasks.get.query({ id })` if such a procedure exists. +- Read from the existing `collections.tasks` live-query data (already + in renderer memory from the task view). +- Host-service proxies the Superset API. + +Need to inspect the task view's data source to find the right shape. +The pending row already has `{ id, slug, title }` from the picker; +the missing field is `description` (and potentially +`acceptanceCriteria`, `comments`, `labels`). + +### 4. Swap stubs in `buildResolveCtxFromPending` + +`apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts` + +Replace the three fake fetchers in `buildResolveCtxFromPending` with +real calls to host-service via `getHostServiceClientByUrl(hostUrl)`: + +```ts +fetchIssue: async (url) => { + const match = pending.linkedIssues.find(i => i.url === url); + if (!match?.number) throw notFound(url); + const data = await client.workspaceCreation.getIssueContent.query({ + projectId: pending.projectId, + issueNumber: match.number, + }); + return { + number: data.number, + url: data.url, + title: data.title, + body: data.body, + slug: match.slug, + }; +}, +``` + +Same pattern for PR (using `match.prNumber`) and task (using task API). + +### 5. Pass `hostUrl` to `buildForkAgentLaunch` + +Currently the function doesn't have the host-service client. Thread +`hostUrl` (or the client itself) through `BuildForkAgentLaunchInputs` +so the resolvers can make real calls. + +## Target prompt (after fixes) + +``` +<user prompt> + +# Task TASK-42 — Refactor auth middleware + +Split session-token storage from request handling so we can encrypt +at rest. Keep the public API shape stable. + +Acceptance criteria: +- Sessions encrypted at rest +- No public-API shape change +- Migration for existing sessions + +# Issue #123 — Auth middleware stores tokens in plaintext + +Legal flagged this. Sessions written to disk without encryption. We +need to move to an encrypted KV before the compliance deadline. + +The token-issuance path sets kid=k_primary but the active signing +key rotated to k_2026q1 last quarter. Decrypt falls back to +legacy plaintext which is the compliance violation... + +# PR #200 — Rewrite auth middleware + +Branch `fix/auth-encryption` is checked out in this workspace — +commits you make continue this PR. + +Replaces plaintext token storage with encrypted KV. Migrates +existing sessions on first request... + +# Attached files + +The user attached these files alongside the prompt. They've been +written into the worktree at `.superset/attachments/`. Read them +to understand the request. + +- .superset/attachments/trace.log +- .superset/attachments/notes.md +``` + +## Sequence + +1. `getIssueContent` host-service procedure + stub swap → issue bodies flow. +2. `getPullRequestContent` procedure + stub swap → PR bodies + branch. +3. Task body source (scope the API first). +4. Thread `hostUrl` into `buildForkAgentLaunch` inputs. + +## Deferred + +- Issue/PR comments (phase 2). +- Body truncation (revisit if agents hit context limits in practice). +- Attach-as-file mode (not needed; inline works). diff --git a/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md new file mode 100644 index 00000000000..b95f581ecfe --- /dev/null +++ b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md @@ -0,0 +1,145 @@ +# V2 Launch Dispatch — Test Plan + +Checklist for verifying the V2 workspace launch pipeline end-to-end. +Pair with `V2_LAUNCH_CONTEXT.md` for architectural background and +`v2-launch-test-artifacts/` for copy-pasteable sample data. + +## Setup + +1. `bun dev` at the repo root. +2. Ensure your active org has V2 cloud enabled (or you're testing a V2 + project). +3. Settings → Agents: confirm **Claude** is enabled. For chat-agent + tests, enable **Superset Chat**. +4. (Optional) Open devtools console and filter by `[v2-launch]` to trace + dispatch. `collections` is exposed globally for pending-row inspection: + ```js + collections.pendingWorkspaces.toArray() + ``` + +## A. Happy-path — terminal agent (Claude) + +- [ ] **A1. Prompt only** — "add a README explaining this repo." Claude pane + opens. The command includes the prompt. No errors. +- [ ] **A2. Prompt + text attachment** — drag `v2-launch-test-artifacts/trace.log` + into the modal. After launch: verify `.superset/attachments/trace.log` + exists in the worktree (terminal: `ls .superset/attachments/`). + Claude prompt contains `![trace.log](.superset/attachments/trace.log)`. +- [ ] **A3. Prompt + image** — drag `v2-launch-test-artifacts/sample.png`. + Same as A2 with the image. +- [ ] **A4. Duplicate filename** — drag `trace.log` twice. Both files exist; + second is named `trace_1.log`. Prompt references both. +- [ ] **A5. Prompt + linked GitHub issue** — paste a real issue URL from the + picker. Claude prompt contains `# <issue title>`. +- [ ] **A6. Prompt + linked PR** — paste a PR URL. Prompt contains + `# <PR title>` and `Branch: \`<branch>\``. +- [ ] **A7. Prompt + internal task** — link a task from the picker. + Prompt contains `# Task <id> — <title>`. +- [ ] **A8. Multi-source** — prompt + task + 2 issues + PR + 2 attachments. + All appear in the prompt, ordered: + user-prompt → tasks → issues → prs → attachment list. +- [ ] **A9. Rich-editor multimodal** — if the editor supports inline image + drops, drop an image between two text chunks. Image ref sits inline, + not appended at the end. + +## B. Happy-path — chat agent (Superset Chat) + +Disable Claude (or set Superset Chat as preferred via order in settings). + +- [ ] **B1. Prompt only** — chat pane opens; first user message = prompt; + agent response streams. +- [ ] **B2. Prompt + attachment** — first message carries the file + (visible in the message bubble). +- [ ] **B3. Prompt + linked issue** — first message contains the issue + title block. +- [ ] **B4. Retry on send failure** — block network before submit, wait + for V2 chat retry loop, unblock. Message eventually sends. + `pending.chatLaunch` only clears after success. + +## C. Pending-row lifecycle + +- [ ] **C1. Field clears after consume** — devtools console after launch: + ```js + collections.pendingWorkspaces.toArray() + .find(r => r.workspaceId === '<WS-ID>') + ``` + `terminalLaunch` / `chatLaunch` are `null`. +- [ ] **C2. No re-fire on revisit** — navigate out and back to the + workspace. No duplicate pane. +- [ ] **C3. Crash-safe** — submit, quit app before workspace opens. + Reopen app, navigate to `/v2-workspace/<ID>`. Pane still opens. + Pending row cleared after. +- [ ] **C4. Concurrent creates** — submit two workspaces in rapid + succession (different projects). Both pending rows dispatch + independently; no cross-contamination. + +## D. Failure paths + +- [ ] **D1. create fails** — kill host-service, submit. Pending page shows + "failed" with retry. No launch stashed. Retry after restart works. +- [ ] **D2. Attachment write fails** — manually `chmod` the worktree + read-only, submit with attachments. Dispatch logs warning; pane + still opens; files missing (expected degradation). +- [ ] **D3. Terminal WebSocket attach fails** — stop host-service after + create but before navigation. Terminal pane opens and reports the + connection failure. Restart host-service, refresh. Consume re-fires + only if `terminalLaunch` was not cleared before attach. +- [ ] **D4. Agent disabled mid-flow** — enable agent, start submit, disable + before create completes. Pending page finishes. No pane opens. + Pending row `terminalLaunch` stays null. +- [ ] **D5. No enabled agents** — disable all agents in settings. Submit. + Workspace creates. No pane opens. Expected. + +## E. Source-mapping edge cases + +- [ ] **E1. Empty prompt, attachments only** — submit with only a file, + no text. Terminal opens with the no-prompt command + (`claude --dangerously-skip-permissions`). +- [ ] **E2. Whitespace-only prompt** — `" \n "`. Treated as empty. +- [ ] **E3. Multiple linked issues** — 2+ github issues. Both render in + order. +- [ ] **E4. Task + issue together** — `taskSlug` = task's slug (task + wins). Both bodies render. +- [ ] **E5. Duplicate issue URL** — link same issue twice. Deduped. +- [ ] **E6. PR only** — no prompt, no issues, just a linked PR. Launch + succeeds; prompt = PR block. + +## F. Custom / non-default agents + +- [ ] **F1. Codex (terminal)** — disable Claude, enable Codex. Submit. + Codex pane runs prompt. +- [ ] **F2. Custom terminal agent** — create one in settings with command + `echo` (simple test). Submit. Pane runs `echo <prompt>`. +- [ ] **F3. Custom `contextPromptTemplateUser`** — settings → Claude → + override user template to `"PREFIX {{userPrompt}} SUFFIX"`. Submit. + Command contains `PREFIX <prompt> SUFFIX`. + +## G. Cross-pane behavior + +- [ ] **G1. Setup script + agent** — project has a setup script, submit. + Setup script pane **and** agent pane both appear as separate panes. + (This was the V1-bus bug that triggered the rewrite — if agent + appears but setup script merges into same pane, regression.) +- [ ] **G2. Presets + agent** — configure a default preset that + auto-applies. Submit. Preset terminals + agent terminal all + coexist. +- [ ] **G3. Chat + terminal presets** — chat agent + preset terminals. + Both appear. + +## H. V1 regression + +- [ ] **H1. V1 workspace creation still works** — create via the V1 + modal (old workspace view, not V2 dashboard). V1 dispatch via + `WorkspaceInitEffects` + `useTabsStore` unchanged. Agent runs + as before. + +## Priority + +If time-limited, run these first: + +- A1, A2 — minimum happy path terminal +- A8 — multi-source terminal +- B1 — minimum happy path chat +- C1 — field clears +- G1 — setup-script regression +- H1 — V1 regression diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 8b08ddd17ba..2c33562eb2c 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -18,6 +18,10 @@ const productName = pkg.productName; const macIconPath = join(pkg.resources, "build/icons/icon.icns"); const linuxIconPath = join(pkg.resources, "build/icons"); const winIconPath = join(pkg.resources, "build/icons/icon.ico"); +const dmgBackgroundPath = join( + pkg.resources, + "build/installer/background.tiff", +); const config: Configuration = { appId: "com.superset.desktop", @@ -86,6 +90,11 @@ const config: Configuration = { // Rebuild native modules for Electron's Node.js version npmRebuild: true, + // macOS DMG installer + dmg: { + ...(existsSync(dmgBackgroundPath) ? { background: dmgBackgroundPath } : {}), + }, + // macOS mac: { ...(existsSync(macIconPath) ? { icon: macIconPath } : {}), diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 82e745543f8..5f073935c45 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -65,6 +65,10 @@ export default defineConfig({ process.env.NEXT_PUBLIC_WEB_URL, "https://app.superset.sh", ), + "process.env.NEXT_PUBLIC_MARKETING_URL": defineEnv( + process.env.NEXT_PUBLIC_MARKETING_URL, + "https://superset.sh", + ), "process.env.NEXT_PUBLIC_DOCS_URL": defineEnv( process.env.NEXT_PUBLIC_DOCS_URL, "https://docs.superset.sh", @@ -72,6 +76,7 @@ export default defineConfig({ "process.env.SENTRY_DSN_DESKTOP": defineEnv( process.env.SENTRY_DSN_DESKTOP, ), + "process.env.RELAY_URL": defineEnv(process.env.RELAY_URL), // Must match renderer for analytics in main process "process.env.NEXT_PUBLIC_POSTHOG_KEY": defineEnv( process.env.NEXT_PUBLIC_POSTHOG_KEY, @@ -170,6 +175,10 @@ export default defineConfig({ process.env.NEXT_PUBLIC_WEB_URL, "https://app.superset.sh", ), + "process.env.NEXT_PUBLIC_MARKETING_URL": defineEnv( + process.env.NEXT_PUBLIC_MARKETING_URL, + "https://superset.sh", + ), "process.env.NEXT_PUBLIC_ELECTRIC_URL": defineEnv( process.env.NEXT_PUBLIC_ELECTRIC_URL, "https://electric-proxy.avi-6ac.workers.dev", @@ -188,6 +197,7 @@ export default defineConfig({ "import.meta.env.SENTRY_DSN_DESKTOP": defineEnv( process.env.SENTRY_DSN_DESKTOP, ), + "process.env.RELAY_URL": defineEnv(process.env.RELAY_URL), "process.env.STREAMS_URL": defineEnv( process.env.STREAMS_URL, "https://superset-stream.fly.dev", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index efe8add71fc..9af93ec9d16 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@superset/desktop", "productName": "Superset", "description": "The last developer tool you'll ever need", - "version": "1.4.0", + "version": "1.7.2", "main": "./dist/main/index.js", "resources": "src/resources", "repository": { @@ -18,7 +18,7 @@ "start": "electron-vite preview", "generate:icons": "bun run scripts/generate-file-icons.ts", "predev": "cross-env NODE_ENV=development bun run clean:dev && bun run generate:icons && cross-env NODE_ENV=development bun run scripts/clean-launch-services.ts && cross-env NODE_ENV=development bun run scripts/patch-dev-protocol.ts", - "dev": "cross-env NODE_ENV=development electron-vite dev --watch", + "dev": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=8192 electron-vite dev --watch", "compile:app": "cross-env NODE_OPTIONS=--max-old-space-size=8192 electron-vite build", "copy:native-modules": "bun run scripts/copy-native-modules.ts", "validate:native-runtime": "bun run scripts/validate-native-runtime.ts", @@ -39,7 +39,8 @@ "@ai-sdk/openai": "3.0.36", "@ai-sdk/react": "^3.0.0", "@ast-grep/napi": "^0.41.0", - "@better-auth/stripe": "1.4.18", + "@better-auth/api-key": "1.6.5", + "@better-auth/stripe": "1.6.5", "@codemirror/commands": "^6.10.2", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", @@ -64,20 +65,22 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@durable-streams/client": "^0.2.1", - "@electric-sql/client": "1.5.13", + "@durable-streams/client": "^0.2.3", + "@electric-sql/client": "1.5.15", "@headless-tree/core": "^1.6.3", "@headless-tree/react": "^1.6.3", "@hono/node-server": "^1.14.1", "@hookform/resolvers": "^5.2.2", "@lezer/highlight": "^1.2.3", - "@mastra/core": "^1.3.0", + "@mastra/core": "1.26.0-alpha.3", + "@paper-design/shaders-react": "^0.0.76", "@parcel/watcher": "^2.5.6", - "@pierre/diffs": "^1.0.10", + "@pierre/diffs": "1.1.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@replit/codemirror-css-color-picker": "^6.3.0", "@sentry/electron": "^7.7.0", - "@streamdown/mermaid": "^1.0.2", + "@streamdown/mermaid": "1.0.2", "@superset/auth": "workspace:*", "@superset/chat": "workspace:*", "@superset/db": "workspace:*", @@ -85,16 +88,19 @@ "@superset/host-service": "workspace:*", "@superset/local-db": "workspace:*", "@superset/macos-process-metrics": "workspace:*", - "@superset/pane-layout": "workspace:*", + "@superset/panes": "workspace:*", + "@superset/port-scanner": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@superset/workspace-client": "workspace:*", "@superset/workspace-fs": "workspace:*", "@t3-oss/env-core": "^0.13.8", - "@tanstack/db": "0.5.33", - "@tanstack/electric-db-collection": "0.2.41", - "@tanstack/react-db": "0.1.77", + "@tanstack/db": "0.6.5", + "@tanstack/electric-db-collection": "0.3.3", + "@tanstack/electron-db-sqlite-persistence": "0.1.9", + "@tanstack/node-db-sqlite-persistence": "0.1.9", + "@tanstack/react-db": "0.1.83", "@tanstack/react-query": "^5.90.19", "@tanstack/react-router": "^1.147.3", "@tanstack/react-table": "^8.21.3", @@ -108,6 +114,7 @@ "@tiptap/extension-code-block": "^3.17.1", "@tiptap/extension-code-block-lowlight": "^3.17.1", "@tiptap/extension-document": "^3.17.1", + "@tiptap/extension-emoji": "3.17.1", "@tiptap/extension-hard-break": "^3.17.1", "@tiptap/extension-heading": "^3.17.1", "@tiptap/extension-history": "^3.17.1", @@ -128,6 +135,7 @@ "@tiptap/extension-task-list": "^3.17.1", "@tiptap/extension-text": "^3.17.1", "@tiptap/extension-underline": "^3.17.1", + "@tiptap/pm": "^3.17.1", "@tiptap/react": "^3.17.1", "@tiptap/starter-kit": "^3.17.1", "@tiptap/suggestion": "^3.17.1", @@ -137,18 +145,19 @@ "@types/express": "^5.0.5", "@types/pidusage": "^2.0.5", "@vercel/blob": "^2.0.0", - "@xterm/addon-clipboard": "0.3.0-beta.195", - "@xterm/addon-fit": "0.12.0-beta.195", - "@xterm/addon-image": "0.10.0-beta.195", - "@xterm/addon-ligatures": "0.11.0-beta.195", - "@xterm/addon-search": "0.17.0-beta.195", - "@xterm/addon-serialize": "0.15.0-beta.195", - "@xterm/addon-unicode11": "0.10.0-beta.195", - "@xterm/addon-webgl": "0.20.0-beta.194", - "@xterm/headless": "6.1.0-beta.195", - "@xterm/xterm": "6.1.0-beta.195", + "@xterm/addon-clipboard": "0.3.0-beta.197", + "@xterm/addon-fit": "0.12.0-beta.197", + "@xterm/addon-image": "0.10.0-beta.197", + "@xterm/addon-ligatures": "0.11.0-beta.197", + "@xterm/addon-progress": "0.3.0-beta.197", + "@xterm/addon-search": "0.17.0-beta.197", + "@xterm/addon-serialize": "0.15.0-beta.197", + "@xterm/addon-unicode11": "0.10.0-beta.197", + "@xterm/addon-webgl": "0.20.0-beta.196", + "@xterm/headless": "6.1.0-beta.197", + "@xterm/xterm": "6.1.0-beta.197", "ai": "^6.0.0", - "better-auth": "1.4.18", + "better-auth": "1.6.5", "better-sqlite3": "12.6.2", "bindings": "^1.5.0", "bufferutil": "^4.1.0", @@ -156,10 +165,12 @@ "culori": "^4.0.2", "date-fns": "^4.1.0", "default-shell": "^2.2.0", + "detect-libc": "2.0.4", + "dexie": "^4.4.2", "dnd-core": "^16.0.1", "dotenv": "^17.3.1", - "drizzle-orm": "0.45.1", - "electron-updater": "^6.7.3", + "drizzle-orm": "0.45.2", + "electron-updater": "^6.8.3", "execa": "^9.6.0", "express": "^5.1.0", "fast-glob": "^3.3.3", @@ -169,7 +180,6 @@ "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", "http-proxy": "^1.18.1", - "idb": "^8.0.3", "idb-keyval": "^6.2.2", "jose": "^6.1.3", "libsql": "0.5.22", @@ -178,8 +188,9 @@ "lowdb": "^7.0.1", "lowlight": "^3.3.0", "lucide-react": "^0.563.0", - "mastracode": "^0.4.0", + "mastracode": "0.15.0-alpha.3", "nanoid": "^5.1.6", + "native-keymap": "^3.3.9", "node-addon-api": "^7.1.0", "node-pty": "1.1.0", "os-locale": "^6.0.2", @@ -193,6 +204,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "19.2.0", "react-hook-form": "^7.71.1", + "react-hotkeys-hook": "^5.2.4", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-mosaic-component": "^6.1.1", @@ -204,8 +216,9 @@ "semver": "^7.7.3", "shell-env": "^4.0.3", "shell-quote": "^1.8.3", + "shiki": "^3.21.0", "simple-git": "^3.30.0", - "streamdown": "^2.2.0", + "streamdown": "2.5.0", "strip-ansi": "^7.1.2", "superjson": "^2.2.5", "tailwind-merge": "^3.4.0", @@ -216,7 +229,7 @@ "tw-animate-css": "^1.4.0", "use-resize-observer": "^9.1.0", "utf-8-validate": "^6.0.6", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "zod": "^4.3.5", "zustand": "^5.0.8" }, @@ -240,7 +253,7 @@ "@vitejs/plugin-react": "^5.0.1", "code-inspector-plugin": "^1.2.2", "cross-env": "^10.0.0", - "electron": "40.2.1", + "electron": "40.8.5", "electron-builder": "^26.4.0", "electron-vite": "^4.0.0", "material-icon-theme": "^5.32.0", diff --git a/apps/desktop/plans/20260108-2251-static-ports-json.md b/apps/desktop/plans/20260108-2251-static-ports-json.md index 63d737066cc..c5a25f9af37 100644 --- a/apps/desktop/plans/20260108-2251-static-ports-json.md +++ b/apps/desktop/plans/20260108-2251-static-ports-json.md @@ -1,5 +1,12 @@ # Static Ports Configuration via ports.json +> Superseded semantics: this plan documents the original static-port replacement +> design. Current behavior treats `.superset/ports.json` as supplemental label +> metadata only: it names dynamically detected listening ports, but does not +> create port rows, hide unlabeled ports, or replace dynamic discovery. See +> `plans/20260422-v2-remote-ports.md` and `apps/docs/content/docs/ports.mdx` +> for the current contract. + This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. Reference: This plan follows conventions from AGENTS.md and the ExecPlan template at `.agents/commands/create-plan.md`. diff --git a/apps/desktop/plans/20260405-quit-tray-lifecycle.md b/apps/desktop/plans/20260405-quit-tray-lifecycle.md new file mode 100644 index 00000000000..ec31c8ba75d --- /dev/null +++ b/apps/desktop/plans/20260405-quit-tray-lifecycle.md @@ -0,0 +1,74 @@ +# macOS Quit & Tray Lifecycle + +## Decision (2026-04-05) + +All quit paths fully exit the app. No background-to-tray behavior for now. + +The tray exists while the app is running and provides host-service management and explicit quit actions. When the app quits, the tray goes away. + +### What shipped + +- **Removed macOS background-to-tray block** from `before-quit` (#3205). The old block prevented quit and kept tray alive when `hasActiveInstances()` was true, but left the dock icon visible (confusing UX). +- **Updater fix**: `installUpdate()` calls `quitAndInstall()` then `exitImmediately()`, bypassing the quit protocol entirely. The old `prepareQuit("release")` approach coupled the updater to the quit lifecycle unnecessarily. +- **Hardened `before-quit` cleanup**: host-service cleanup wrapped in try/catch so `app.exit(0)` always runs. Without this, an exception in cleanup would skip `app.exit(0)`, and the macOS window close handler (`event.preventDefault()` + `hide()`, added in #3157) would block the quit. + +### What was deferred + +Background-to-tray on macOS (Cmd+Q destroys windows but keeps tray alive) is the ideal target but was deferred because: + +1. **Dock icon stays visible** — macOS shows the dock icon as long as the Electron process is alive. Backgrounding to tray looks like the app is still running, which is confusing. +2. **Solving the dock icon requires a process split** — hiding the dock icon via `app.dock.hide()` has side effects (loses menu bar, loses Cmd+Tab). A clean solution requires a separate lightweight tray-host process, which is significant work. + +## Current behavior + +### Quit paths + +| Action | Behavior | +|--------|----------| +| Cmd+Q | Full exit (release services, dispose tray, exit) | +| Dock right-click Quit | Same | +| App menu Quit | Same | +| Window close (red-X / Cmd+W) | macOS: hide window (standard behavior). Non-macOS: close window, then app quits. | +| Tray "Quit (Keep Services Running)" | `requestQuit("release")` — release services, full exit | +| Tray "Quit & Stop Services" | `requestQuit("stop")` — stop services, full exit | +| Tray host-service "Stop" | Stops individual service, app stays running | +| Update install | `quitAndInstall()` + `exitImmediately()` — bypasses quit protocol | + +### Host-service lifecycle on quit + +- **Release** (default): services keep running as detached processes. On next app launch, they are re-adopted via manifest files. +- **Stop** (`requestQuit("stop")`): services are terminated via `SIGTERM`. + +### Key files + +- `src/main/index.ts` — `before-quit` handler, `requestQuit`, `exitImmediately` +- `src/main/windows/main.ts` — window close behavior +- `src/main/lib/tray/index.ts` — tray menu and actions +- `src/main/lib/auto-updater.ts` — update install flow +- `src/lib/electron-app/factories/app/setup.ts` — `activate` / `window-all-closed` handlers + +## Future: tray-resident background + +If we want the tray to persist after quit (like Docker Desktop), there are two viable architectures: + +### Option A: Electron tray host + separate UI Electron + +A small Electron process owns the tray and spawns the main UI Electron app on demand. + +- Pros: shared JS/TS stack, easiest evolution from current code +- Cons: two Electron runtimes, packaging/update complexity + +### Option B: Native Swift tray host + Electron UI + +A native macOS menu bar app owns the tray. The Electron app is launched/attached on demand. + +- Pros: smallest memory footprint, cleanest separation +- Cons: native code, signing, IPC complexity + +Either option requires: +1. A separate long-lived process that owns the tray icon +2. Socket/named-pipe IPC between tray host and UI +3. A launch-on-login mechanism (launchd) +4. Update coordination between two processes + +This is medium-term work and not needed for the current product requirements. diff --git a/apps/desktop/plans/20260409-tui-hotkey-forwarding.md b/apps/desktop/plans/20260409-tui-hotkey-forwarding.md new file mode 100644 index 00000000000..e2e8ecb4c74 --- /dev/null +++ b/apps/desktop/plans/20260409-tui-hotkey-forwarding.md @@ -0,0 +1,60 @@ +# TUI hotkey forwarding + +## Problem + +App-level hotkeys (Cmd+T, Cmd+D, Ctrl+1, etc.) don't fire while focus is in a +v2 terminal running a TUI. The TUI swallows them — they reach the PTY but +never bubble to `react-hotkeys-hook`. + +## Root cause + +v2's `terminal-runtime.ts` never installed a `customKeyEventHandler` on xterm. +Without one, xterm's `_keyDown` runs to completion for every modifier chord: +it encodes the key, delivers it to the PTY, then calls `event.preventDefault(); +event.stopPropagation();` (CoreBrowserTerminal.ts:925-928). The event dies at +target phase and `react-hotkeys-hook`'s document-bubble listener never sees it. + +Confirmed via instrumentation: Cmd+D in a Codex TUI produced capture-phase +logs but zero bubble-phase logs — `stopPropagation` was the culprit. + +## Fix (done) + +Mirrors VSCode's pattern (terminalInstance.ts:1116-1175): install a +`customKeyEventHandler` that returns `false` for chords bound to app hotkeys. +Returning `false` makes xterm bail at the top of `_keyDown` (line 847-849), +skipping the `stopPropagation` at the bottom. The event bubbles normally and +`react-hotkeys-hook` fires the app action. + +Implementation: + +- **`renderer/hotkeys/utils/resolveHotkeyFromEvent.ts`** — reverse-indexes + `HOTKEYS_REGISTRY` into a `Map<normalizedChord, HotkeyId>` at module load. + Uses the same `event.code` normalization as react-hotkeys-hook's internal + `K` function so the index can't drift from the matcher. Returns `HotkeyId | + null`. +- **`renderer/lib/terminal/terminal-runtime.ts`** — one-liner handler: + ```ts + terminal.attachCustomKeyEventHandler((event) => !isAppHotkey(event)); + ``` + Registered chords bubble to the app. Unregistered chords (Ctrl+R, Ctrl+L, + Alt+letter, etc.) still reach the TUI. + +## Remaining work + +### Migrate v1 to the same resolver + +v1's handler in `Terminal/helpers.ts:677-679` takes the opposite approach: it +returns `false` for **all** `ctrl/meta` chords, starving TUIs of unbound +chords like Ctrl+R. Replacing that catch-all with `resolveHotkeyFromEvent` +would give v1 the same precision as v2 — only registered app hotkeys bubble, +everything else reaches the PTY. + +### Escape hatches (optional, later) + +- **`sendKeybindingsToShell`** user preference (VSCode pattern): disables + every non-Meta skip entry so power users running tmux/emacs can forward + everything to the shell. +- **Alt-buffer gate**: use `isAlternateScreenRef` from `useTerminalModes.ts` + (or `xterm.buffer.onBufferChange`) to shrink the skip list while a TUI owns + the alt screen, so chords like Cmd+F can reach nvim instead of triggering + `FIND_IN_TERMINAL`. diff --git a/apps/desktop/plans/20260410-port-v2-hide-attach-to-v1.md b/apps/desktop/plans/20260410-port-v2-hide-attach-to-v1.md new file mode 100644 index 00000000000..0b5ce0f0877 --- /dev/null +++ b/apps/desktop/plans/20260410-port-v2-hide-attach-to-v1.md @@ -0,0 +1,158 @@ +# Port v2 "Hide Attach" Terminal Pattern into v1 Renderer + +## Context + +When switching tabs in v1, `TabsContent` renders only the active tab (`TabsContent/index.tsx:92`). The old `TabView` unmounts entirely, cascading to all `Terminal` components. The cleanup in `useTerminalLifecycle.ts` (lines 854-916) **disposes the entire xterm instance**, sends a backend detach, and nulls all refs. On remount, a brand new xterm is created from scratch, `createOrAttach` restores scrollback from the backend, and all addons/handlers re-initialize. This causes a visible flash and delay. + +V2 solves this with `terminal-runtime.ts` + `terminal-runtime-registry.ts`: xterm is opened into a persistent wrapper `<div>`, and tab switching just moves the wrapper in/out of the DOM. The xterm stays alive in memory, so reattach is instant. + +The existing **Terminal Persistence DX Hardening plan** (`apps/desktop/plans/20260107-1107-terminal-persistence-dx-hardening.md`) already identifies this problem and proposes an LRU warm set with `visibility: hidden`. Our approach is a cleaner alternative: DOM detach/reattach (the v2 pattern), which avoids WebGL texture atlas corruption issues that come from CSS-hidden terminals. + +## Approach + +Create a **v1 terminal instance cache** — a module-level `Map<paneId, CachedTerminal>` that stores xterm instances across React mount/unmount cycles. This borrows the wrapper-div pattern from `terminal-runtime.ts` but stays v1-specific (v1's tRPC communication, link providers, keyboard handling, and addon loading are different from v2's WebSocket-based registry). + +### Duplication is fine + +V1 and v2 will diverge wildly. This is a hotfix for v1 while v2 is still coming out. We freely duplicate whatever we need from v2's runtime code into v1's Terminal directory — no shared abstractions or imports from `renderer/lib/terminal/`. + +## File Changes + +### 1. NEW: `Terminal/v1-terminal-cache.ts` + +Module-level singleton cache managing xterm instance lifecycle independently from React. + +```typescript +interface CachedTerminal { + xterm: XTerm; + fitAddon: FitAddon; + searchAddon: SearchAddon; + rendererRef: TerminalRendererRef; + wrapper: HTMLDivElement; + cleanupCreation: () => void; // disposes renderer RAF, query suppression, etc. +} + +const cache = new Map<string, CachedTerminal>(); +``` + +Methods: +- **`has(paneId)`** — check for existing instance +- **`getOrCreate(paneId, options)`** — returns existing or creates new xterm in a wrapper div +- **`attachToContainer(paneId, container)`** — `container.appendChild(wrapper)`, fit, refresh, clear texture atlas +- **`detachFromContainer(paneId)`** — `wrapper.remove()` (keeps xterm alive in memory) +- **`dispose(paneId)`** — full cleanup: `cleanupCreation()`, `xterm.dispose()`, delete from cache + +### 2. MODIFY: `Terminal/helpers.ts` — `createTerminalInstance` + +Split into two phases: +- **Phase 1** (`createTerminalInWrapper`): Creates XTerm, opens into a new wrapper `<div>` (not the container), loads addons. Returns `{ xterm, fitAddon, rendererRef, wrapper, cleanup }`. +- **Phase 2**: Caller appends wrapper to container. + +The existing `createTerminalInstance(container, options)` becomes a convenience wrapper that calls Phase 1 + appends to container, so no other callers break. + +Key change: line 209 `xterm.open(container)` becomes `xterm.open(wrapper)` in the new function. + +### 3. MODIFY: `Terminal/hooks/useTerminalLifecycle.ts` — Main lifecycle hook + +This is the largest change, concentrated in the single `useEffect` body. + +**Mount (lines ~221-280) — replace xterm creation with cache:** + +```typescript +const isReattach = v1TerminalCache.has(paneId); +const cached = v1TerminalCache.getOrCreate(paneId, { ... }); +const { xterm, fitAddon, rendererRef: renderer } = cached; + +// Attach wrapper to live container +v1TerminalCache.attachToContainer(paneId, container); +``` + +On reattach: +- Set `didFirstRenderRef.current = true` immediately (xterm already rendered) +- Set `searchAddonRef.current` from cache instead of creating new +- Still call `createOrAttach` via `scheduleTerminalAttach` to re-establish backend session (tRPC subscription was killed on unmount) +- Skip writing scrollback to xterm in the `onSuccess` handler since buffer is already in memory + +Event handler setup (keyboard, paste, copy, focus, resize, click-to-move) still runs fresh each mount — these are ephemeral and depend on React refs. + +**Unmount cleanup (lines 854-916):** + +Replace pane-not-destroyed path: +```typescript +if (paneDestroyed) { + killTerminalForPane(paneId); + coldRestoreState.delete(paneId); + pendingDetaches.delete(paneId); + v1TerminalCache.dispose(paneId); // full disposal +} else { + v1TerminalCache.detachFromContainer(paneId); // keep xterm alive + // Still send backend detach after 50ms (existing pendingDetaches pattern) + const detachTimeout = setTimeout(() => { + detachRef.current({ paneId }); + pendingDetaches.delete(paneId); + coldRestoreState.delete(paneId); + }, 50); + pendingDetaches.set(paneId, detachTimeout); +} +``` + +Remove: `setTimeout(() => xterm.dispose(), 0)` — the cache owns disposal now. + +### 4. NO CHANGES to `Terminal/hooks/useTerminalRestore.ts` + +The reattach skip is handled entirely in `useTerminalLifecycle.ts`'s `createOrAttach` `onSuccess` handler. On reattach, instead of setting `pendingInitialStateRef.current = result` and calling `maybeApplyInitialState()`, we directly: +- Set `isStreamReadyRef.current = true` +- Call `flushPendingEvents()` +- Skip scrollback writing (buffer already in xterm memory) + +Cold restore still goes through the normal `maybeApplyInitialState` path since the cache is empty on fresh app start. + +### 5. NO CHANGES to `Terminal/Terminal.tsx` + +The tRPC subscription (`electronTrpc.terminal.stream.useSubscription`) still ties to React lifecycle — stops on unmount, restarts on remount. The backend buffers data during the gap, and `createOrAttach` returns it. Theme/font changes already apply on mount via existing `useEffect` hooks. + +### 6. NO CHANGES to `Terminal/state.ts` + +`pendingDetaches` and `coldRestoreState` continue working as before. The `pendingDetaches` cancel-on-remount pattern (lines 229-234) is still needed for React StrictMode. + +## Edge Cases + +| Case | Handling | +|------|----------| +| **Theme change while detached** | Existing `useEffect` in Terminal.tsx sets `xterm.options.theme` on mount | +| **Font change while detached** | Existing `useEffect` applies font settings + refits on mount | +| **WebGL context loss while detached** | `attachToContainer` calls `clearTextureAtlas()` + `refresh()` | +| **React StrictMode double-mount** | `pendingDetaches` cancel-on-remount handles this (existing pattern) | +| **Stream data gap** | Backend buffers; `createOrAttach` sends scrollback (discarded on reattach since buffer is in memory); stream subscription resumes | +| **Container resize while detached** | `fitAddon.fit()` runs during `attachToContainer` | +| **Many cached terminals (memory)** | Same tradeoff v2 makes. Browsers limit WebGL contexts (~8-16); existing context-loss handler falls back to DOM rendering | +| **Cold restore after reboot** | Not a reattach case — cache is empty on fresh start. Handled normally. | +| **Workspace-run pane restart** | `restartTerminalSession` in useTerminalLifecycle works the same — it uses the cached xterm | + +## Verification + +1. **Tab switch**: switch away and back — terminal should not flash/flicker +2. **Buffer preservation**: scroll up in terminal, switch tab, switch back — scroll position preserved +3. **Theme change**: switch tab, change theme, switch back — terminal has new theme +4. **Close pane**: close a terminal pane — fully disposed (no memory leak) +5. **StrictMode**: dev mode double-mount/unmount should not cause issues +6. **Cold restore**: restart the app — cold restore still works (cache empty on fresh start) +7. **Heavy splits**: 4-way split tab, switch away and back — all terminals reattach + +```bash +bun run typecheck +bun run lint:fix +bun test +``` + +## Critical Files + +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts` +- NEW: `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts` + +## Reference Implementation + +- `apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts` — wrapper div pattern, `attachToContainer`, `detachFromContainer`, `disposeRuntime` +- `apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts` — singleton registry pattern +- `apps/desktop/src/renderer/lib/terminal/terminal-addons.ts` — v2 addon loading (reference for WebGL handling) diff --git a/apps/desktop/plans/20260411-v1-terminal-resize-simplification.md b/apps/desktop/plans/20260411-v1-terminal-resize-simplification.md new file mode 100644 index 00000000000..00d9c1391d6 --- /dev/null +++ b/apps/desktop/plans/20260411-v1-terminal-resize-simplification.md @@ -0,0 +1,262 @@ +# Simplify v1 Terminal Resize to Match v2 Behavior + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Reference: This plan follows conventions from AGENTS.md and the ExecPlan template at `.agents/commands/create-plan.md`. It continues the work started in `apps/desktop/plans/20260410-port-v2-hide-attach-to-v1.md`, which ported the wrapper-div pattern and terminal cache from v2 into v1. + +## Purpose / Big Picture + +After the hide-attach pattern was ported from v2 to v1, v1 terminals still carry legacy resize infrastructure that v2 never needed. V1 debounces resize events at 150ms, listens to both `ResizeObserver` and `window.resize`, does scroll preservation in the resize handler, runs `fitAddon.fit()` twice on reattach, and manages the `ResizeObserver` lifecycle from the React component instead of the cache. V2 does none of this: it creates a plain `ResizeObserver` in `attachToContainer()`, tears it down in `detachFromContainer()`, and calls `measureAndResize()` with no debounce, no window listener, and no scroll logic. + +After this change, v1 resize behavior will match v2: a single undebounced `ResizeObserver` managed by the cache, one `fitAddon.fit()` call on reattach, and no scroll preservation in the resize path. The user-visible effect is a simpler, more responsive resize that eliminates the 150ms lag on every pane or window resize. + +## Assumptions + +1. The `ResizeObserver` fires on all container size changes, including those caused by window resize. Therefore the separate `window.addEventListener("resize")` listener in v1 is redundant. (This is well-established browser behavior and is what v2 relies on.) + +2. Scroll preservation on resize (the `wasAtBottom` check in v1's `setupResizeHandlers`) is not needed. V2 ships without it and no regressions have been reported. Xterm.js itself may handle this internally in recent versions. + +3. Removing the 150ms debounce will not cause excessive backend resize IPC calls. V2 runs without debounce and the PTY backend handles rapid resize fine. If needed, a much smaller debounce (e.g. via `requestAnimationFrame` coalescing like v2's `measureAndResize`) can be added later. + +## Open Questions + +None at this time. The v2 implementation serves as a proven reference. + +## Progress + +- [x] (2026-04-11) Milestone 1: Move ResizeObserver into the cache (attach/detach) +- [x] (2026-04-11) Milestone 2: Remove duplicate reattach resize from useTerminalLifecycle +- [x] (2026-04-11) Milestone 3: Remove setupResizeHandlers and window resize listener +- [x] (2026-04-11) Milestone 4: Validation (typecheck, lint pass) +- [x] (2026-04-11) Milestone 5: Wrap Terminal in React.memo to prevent re-renders during split resize + +## Surprises & Discoveries + +- Observation: V1 split resize goes through React state on every mouse-move pixel (store update → full component tree re-render → Mosaic repositioning), unlike v2 which is CSS-only via ResizablePanel. The terminal component doesn't remount (Mosaic uses stable `key={paneId}` in `MosaicRoot.js:72`), but it re-renders through the entire tree. Wrapping Terminal in `React.memo` prevents the xterm subtree from re-rendering since its props (`paneId`, `tabId`, `workspaceId`) are stable strings. + Evidence: react-mosaic-component `MosaicRoot.renderRecursively()` calls `renderTile()` on every render but uses `key: node` (the pane ID string) on tile divs, preserving component identity. The reverted commit 918e8f062 tried stabilizing `layoutPaneIds` but that only cut one link — `Mosaic value={}` still gets a new prop per pixel. + +## Decision Log + +- Decision: Duplication from v2 is acceptable. + Rationale: Per the hide-attach plan, v1 and v2 will diverge. We copy the pattern rather than share code across `renderer/lib/terminal/` and the v1 Terminal directory. + Date/Author: 2026-04-11 + +## Outcomes & Retrospective + +(To be filled on completion.) + +## Context and Orientation + +This work affects only the desktop app (`apps/desktop`), specifically the v1 terminal renderer. + +### Key files + +**V1 terminal (what we are changing):** + +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts` — The module-level cache that stores xterm instances across React mount/unmount cycles. Currently its `attachToContainer()` calls `fitAddon.fit()` and `xterm.refresh()` but does NOT create a `ResizeObserver`. After this plan, it will own the `ResizeObserver` lifecycle. + +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts` — Contains `setupResizeHandlers()` (lines 730-755), which creates a debounced `ResizeObserver` + `window.resize` listener. This function will be removed. + +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts` — The React lifecycle hook. Currently calls `setupResizeHandlers()` on mount (line 781) and does a duplicate `fitAddon.fit()` on reattach (lines 522-535). Both will be removed. + +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts` — Defines `RESIZE_DEBOUNCE_MS = 150`. This constant will be removed. + +**V2 terminal (reference implementation, no changes):** + +- `apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts` — V2's `attachToContainer()` (lines 174-195) is the model. It creates a `ResizeObserver` inline, calls `measureAndResize()`, and stores the observer on the runtime. `detachFromContainer()` disconnects it. No debounce. No window listener. No scroll preservation. + +### Terminology + +- **ResizeObserver**: A browser API that fires a callback whenever a DOM element's size changes. It detects container resizes regardless of cause (window resize, pane drag, sidebar toggle). +- **FitAddon**: An xterm.js addon that recalculates terminal column/row dimensions to fill its container. `fitAddon.fit()` reads the container's pixel dimensions and updates the xterm grid accordingly. +- **Reattach**: When a cached v1 terminal is moved back into the DOM after a tab switch. The xterm instance was never disposed — only its wrapper `<div>` was removed from the DOM. + +## Plan of Work + +### Milestone 1: Move ResizeObserver into the cache + +Add a `resizeObserver` field to `CachedTerminal` in `v1-terminal-cache.ts` and an `onResize` callback field. Create the observer in `attachToContainer()` and disconnect it in `detachFromContainer()`, matching v2's `terminal-runtime.ts` pattern. + +**File: `v1-terminal-cache.ts`** + +Add to `CachedTerminal` interface: + + resizeObserver: ResizeObserver | null; + +In `getOrCreate()`, initialize: + + resizeObserver: null, + +Rewrite `attachToContainer()` to accept an `onResize` callback and create a `ResizeObserver`: + + export function attachToContainer( + paneId: string, + container: HTMLDivElement, + onResize?: () => void, + ): void { + const entry = cache.get(paneId); + if (!entry) return; + + container.appendChild(entry.wrapper); + entry.fitAddon.fit(); + entry.xterm.refresh(0, Math.max(0, entry.xterm.rows - 1)); + entry.rendererRef.current.clearTextureAtlas?.(); + + // Manage ResizeObserver lifecycle in the cache, not in React. + entry.resizeObserver?.disconnect(); + const observer = new ResizeObserver(() => { + if (container.clientWidth === 0 && container.clientHeight === 0) return; + entry.fitAddon.fit(); + onResize?.(); + }); + observer.observe(container); + entry.resizeObserver = observer; + } + +Update `detachFromContainer()` to disconnect the observer: + + export function detachFromContainer(paneId: string): void { + const entry = cache.get(paneId); + if (!entry) return; + + entry.resizeObserver?.disconnect(); + entry.resizeObserver = null; + entry.wrapper.remove(); + } + +Update `dispose()` to also disconnect: + + export function dispose(paneId: string): void { + const entry = cache.get(paneId); + if (!entry) return; + + entry.resizeObserver?.disconnect(); + entry.subscription?.unsubscribe(); + entry.cleanupCreation(); + entry.xterm.dispose(); + cache.delete(paneId); + } + +### Milestone 2: Remove duplicate reattach resize from useTerminalLifecycle + +In `useTerminalLifecycle.ts`, the reattach branch (lines 518-535) currently does: + + if (isReattach) { + isStreamReadyRef.current = true; + requestAnimationFrame(() => { + if (isUnmounted) return; + const prevCols = xterm.cols; + const prevRows = xterm.rows; + fitAddon.fit(); + if (xterm.cols !== prevCols || xterm.rows !== prevRows) { + resizeRef.current({ paneId, cols: xterm.cols, rows: xterm.rows }); + } + }); + } + +This is redundant because `attachToContainer()` already calls `fitAddon.fit()` and the new `ResizeObserver` will fire immediately if the container size differs. Remove the `requestAnimationFrame` block. The reattach branch becomes: + + if (isReattach) { + isStreamReadyRef.current = true; + } + +Similarly, in the first-mount `onSuccess` handler (lines 626-637), there is another rAF block that calls `fitAddon.fit()` after `createOrAttach` succeeds. This is also redundant since `attachToContainer()` already fitted and the `ResizeObserver` handles subsequent changes. Remove it. + +### Milestone 3: Remove setupResizeHandlers and window resize listener + +**File: `helpers.ts`** + +Delete the `setupResizeHandlers()` function (lines 730-755) entirely. It is no longer called by any code. + +Remove the `debounce` import from lodash if it becomes unused. Remove the `RESIZE_DEBOUNCE_MS` import from `./config`. + +**File: `config.ts`** + +Remove the `RESIZE_DEBOUNCE_MS` constant (line 45). If it is not imported anywhere else, this is safe. + +**File: `useTerminalLifecycle.ts`** + +Remove the call to `setupResizeHandlers()` (lines 781-786): + + const cleanupResize = setupResizeHandlers( + container, + xterm, + fitAddon, + (cols, rows) => resizeRef.current({ paneId, cols, rows }), + ); + +Remove `cleanupResize()` from the unmount cleanup (line 825). + +Remove the `setupResizeHandlers` import from the helpers import block. + +Update the `attachToContainer` call (line 260) to pass the resize callback: + + v1TerminalCache.attachToContainer(paneId, container, () => { + resizeRef.current({ paneId, cols: xterm.cols, rows: xterm.rows }); + }); + +Remove the `scrollToBottom` import from `../utils` if it becomes unused in this file (it may still be used elsewhere in the file for `scheduleScrollToBottom`). + +### Milestone 4: Validation + +Run: + + cd apps/desktop + bun run typecheck # No type errors + bun run lint:fix # No lint errors + bun test # All tests pass + +Manual testing: + +1. **Window resize**: Drag the app window larger and smaller. Terminal content should reflow immediately with no 150ms lag. +2. **Pane resize via splitter**: Drag a split pane divider. Terminal should reflow smoothly. +3. **Tab switch + resize**: Switch to another tab, resize the window, switch back. Terminal should show correct dimensions. +4. **Close pane**: Close a terminal pane. No console errors, no leaked observers. +5. **Multiple terminals**: Open 3+ terminals in splits. Resize window. All terminals reflow correctly. + +## Concrete Steps + + cd apps/desktop + + # After all edits: + bun run typecheck + # Expected: No errors + + bun run lint:fix + # Expected: No lint errors (or only auto-fixed) + + bun test + # Expected: All tests pass + +## Validation and Acceptance + +Start the desktop app in dev mode and verify: + + bun dev + +1. Open a terminal, run a command that produces output (e.g. `ls -la`). Resize the window by dragging. Terminal content should reflow immediately — there should be no perceptible 150ms delay. +2. Split the terminal pane. Drag the splitter. Both terminals should resize smoothly. +3. Switch to a different tab, resize the window, switch back. The terminal should display at the correct size. +4. Close a terminal pane. Check the console for errors — there should be no "ResizeObserver" related warnings. +5. Open DevTools, check that no `window.resize` event listeners are registered by terminal code. + +## Idempotence and Recovery + +All changes are code deletions and refactors. They can be reverted with `git checkout` on the affected files. No migrations, no schema changes, no state format changes. + +## Interfaces and Dependencies + +No new dependencies. The `ResizeObserver` is a standard browser API available in all Electron versions Superset targets. + +The `lodash.debounce` import in `helpers.ts` may become unused after removing `setupResizeHandlers`. If so, it should be removed. The `lodash` package itself is used elsewhere and should not be uninstalled. + +## Critical Files + +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts` + +## Reference Implementation + +- `apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts` — V2's `attachToContainer()` (lines 174-195), `detachFromContainer()` (lines 197-204), and `measureAndResize()` (lines 127-132) are the model for the simplified v1 approach. diff --git a/apps/desktop/plans/20260411-v2-preset-parity.md b/apps/desktop/plans/20260411-v2-preset-parity.md new file mode 100644 index 00000000000..e367c9331f1 --- /dev/null +++ b/apps/desktop/plans/20260411-v2-preset-parity.md @@ -0,0 +1,103 @@ +# V2 Preset Execution Mode Parity + +## Problem + +`V2PresetsBar.openPresetInNewTab()` ignores `executionMode` -- always creates 1 tab with 1 pane and joins all commands with `" && "`. V1 supports three execution modes that produce different layouts: + +- **`split-pane`**: N commands -> N panes in the active tab +- **`new-tab`**: N commands -> N separate tabs +- **`new-tab-split-pane`**: N commands -> 1 new tab with N split panes + +V2's pane store already has multi-pane `addTab()` (with balanced tree) and `addPane()`. The infrastructure exists; it's just not wired up in the preset path. + +Preset hotkeys (OPEN_PRESET_1-9) also show labels in the V2 UI but have no handlers. + +## Design Decisions + +### 1. Extract execution logic into a `useV2PresetExecution` hook + +Both bar clicks and hotkeys need the same execution function. Currently the logic is inline in `V2PresetsBar` as a `useCallback`. Extracting it: + +- Lets `useWorkspaceHotkeys` call the same function without duplicating the query or logic +- Keeps `V2PresetsBar` focused on rendering +- Makes the execution logic testable + +### 2. Reuse V1's `getPresetLaunchPlan()` + +37 lines of pure logic with no V1 store dependency. Takes `{ mode, target, commandCount, hasActiveTab }` and returns one of 5 launch plans. No reason to duplicate it. + +### 3. Both clicks and hotkeys follow the preset's `executionMode` + +No separate `target` override. The preset's `executionMode` determines the behavior: + +- `split-pane` -> adds panes to the active tab (falls back to new tab with splits if no active tab) +- `new-tab` -> creates separate tabs, one per command +- `new-tab-split-pane` -> creates one new tab with N split panes + +This means we always pass `target: "active-tab"` to `getPresetLaunchPlan` for `split-pane` mode presets, and the function handles the fallback internally via `hasActiveTab`. + +### 4. Rename `onOpenInNewTab` -> `onExecutePreset` + +Descriptive: the callback executes the preset according to its configured mode. + +## Implementation + +### New: `useV2PresetExecution` hook + +`v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts` + +- Accepts `store`, `workspaceId`, `projectId` +- Queries presets via `useLiveQuery` + `filterMatchingPresetsForProject` +- Exposes `executePreset(preset)` and `matchedPresets` +- Derives the `target` from the preset's `executionMode`: + - `split-pane` -> `target: "active-tab"` + - `new-tab` / `new-tab-split-pane` -> `target: "new-tab"` +- Maps `getPresetLaunchPlan()` result to V2 store calls: + +| Plan | Store Call | +|---|---| +| `new-tab-single` | `addTab({ panes: [1 pane] })` | +| `new-tab-multi-pane` | `addTab({ panes: [N panes] })` (auto balanced tree) | +| `new-tab-per-command` | `addTab()` x N, 1 pane each | +| `active-tab-single` | `addPane({ tabId, pane })`, fallback to new-tab | +| `active-tab-multi-pane` | `addPane()` x N, fallback to new-tab | + +Each command gets its own pane with `initialCommand` (no `&&` joining). + +### Modify: `V2PresetsBar.tsx` + +- Remove inline `openPresetInNewTab`, preset querying, `matchedPresets` derivation +- Receive `executePreset` and `matchedPresets` via props from `WorkspaceContent` (which calls `useV2PresetExecution`) +- Pass `executePreset` to `V2PresetBarItem` as `onExecutePreset` + +### Modify: `V2PresetBarItem.tsx` + +- Rename `onOpenInNewTab` prop to `onExecutePreset` +- Context menu label: "Open in new tab" -> "Run preset" + +### Modify: `useWorkspaceHotkeys.ts` + +- Accept `matchedPresets` and `executePreset` as params +- Add `useHotkey("OPEN_PRESET_N", () => executePreset(matchedPresets[N-1]))` x 9 + +### Modify: `page.tsx` + +- Call `useV2PresetExecution({ store, workspaceId, projectId })` in `WorkspaceContent` +- Pass results to `V2PresetsBar` and `useWorkspaceHotkeys` + +## File Summary + +| File | Action | +|---|---| +| `.../hooks/useV2PresetExecution/useV2PresetExecution.ts` | Create | +| `.../hooks/useV2PresetExecution/index.ts` | Create | +| `.../V2PresetsBar/V2PresetsBar.tsx` | Simplify, use hook | +| `.../V2PresetBarItem/V2PresetBarItem.tsx` | Rename prop + label | +| `.../useWorkspaceHotkeys/useWorkspaceHotkeys.ts` | Add preset hotkeys | +| `.../v2-workspace/$workspaceId/page.tsx` | Wire hook | +| `renderer/stores/tabs/preset-launch.ts` | Reuse as-is | + +## Verification + +1. `bun run typecheck && bun run lint:fix` +2. Manual: multi-command presets with each mode, hotkeys Ctrl+1-9, single/empty edge cases diff --git a/apps/desktop/plans/20260412-file-editor-v2-feature-audit.md b/apps/desktop/plans/20260412-file-editor-v2-feature-audit.md new file mode 100644 index 00000000000..7a29e9c0b80 --- /dev/null +++ b/apps/desktop/plans/20260412-file-editor-v2-feature-audit.md @@ -0,0 +1,301 @@ +# File Editor v2 — Feature Audit & Rebuild Checklist + +## How to use this doc + +Living checklist for porting v1's file-editor feature set into v2 and rebuilding it to be better along the way. Each item is tagged: + +- `[x]` already working in v2 — verify by opening the cited v2 path +- `[ ]` not yet in v2 — open the cited v1 path as a reference when porting +- `[~]` partial in v2 (stubbed, TODO'd in code, or shared with v1) — needs finishing or cleanup +- 💡 intentional improvement over v1 (v1 does not have this) + +Mark items off as we ship them. Keep v1 code untouched per the V1→V2 duplicate rule — all work lands under `v2-workspace/$workspaceId/`. + +## Where things live + +**v1 editor:** `apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/ContentView/TabsContent/TabView/FileViewerPane/`. CodeMirror 6 surface (`.../WorkspaceView/components/CodeEditor/CodeEditor.tsx`) plus a coordinator layer (`WorkspaceView/state/editorCoordinator.ts`, `editorBufferRegistry.ts`, `useEditorDocumentsStore`, `useEditorSessionsStore`) handling buffers, dirty state, revisions, conflicts, and session-to-pane binding. + +**v2 editor:** `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/`. `FilePane.tsx` routes to `renderers/CodeRenderer`, `renderers/MarkdownRenderer`, or `renderers/ImageRenderer`. Read/write goes through `renderer/hooks/host-service/useFileDocument`. Pane state lives in the v2 pane registry + Zustand workspace store, not v1's editor coordinator. + +**Architectural flag:** `CodeRenderer.tsx:2` and `MarkdownRenderer.tsx:3` import `CodeEditor` directly from the v1 path. Decoupling this is item 13.1 below. + +--- + +## 0. Core architecture: file-type registry + +The rebuild centers on a small registry rather than the hardcoded `if (isImage) ... else if (isMarkdown) ...` branching that `FilePane.tsx` currently does. This section documents the shape we're building toward — everything below is evaluated against it. + +### Shape + +```ts +type FileHandler = { + id: string; // "markdown" | "image" | "csv" | "code" + match: (filePath: string, meta: FileMeta) => boolean; + documentKind: "text" | "bytes" | "custom"; + views: FileView[]; // ordered by priority; default is views[0] + defaultViewId?: string; +}; + +type FileView = { + id: string; // "code" | "preview" | "grid" + label: string; // "Markdown" | "Preview" | "Grid" + Renderer: ComponentType<ViewProps>; + search?: SearchAdapterFactory; // per-view search implementation +}; +``` + +### Rules + +1. **One pane, swap renderer.** All views on a handler render inside the *same* `FilePane` container. Toggling a view swaps the React component; it does **not** open a new tab or pane. This matches Cursor's behavior (one tab, header toggle, shared scroll position). +2. **Shared document across views.** Every view on a given URI subscribes to one reference-counted `useFileDocument` handle. Dirty state, revision, external-change detection, and save flow are shared — swapping views or opening the same file in a split keeps edits in sync. +3. **Hide the toggle when there's only one view.** If `handler.views.length === 1`, the pane header shows no mode-toggle UI at all. This is one of our three design decisions (see below). +4. **Search is per-view.** Each view registers its own `SearchAdapterFactory`. The find widget UI is shared (Cursor-style, see section 5); the matching implementation is delegated to whichever view is active. +5. **`documentKind: "custom"` is the escape hatch.** Views that need their own state model (future: Jupyter notebooks, SQLite explorer, hex editor) opt out of the shared-text-document machinery and manage their own document. 90% of file types go through `"text"`. + +### Three concrete design decisions + +1. **Markdown defaults to `code` view, not `preview`.** `MarkdownRenderer.tsx:23` currently hardcodes `"rendered"`; flip the default and make `"preview"` the secondary option. Label the code view "Markdown" (matching Cursor) rather than "Raw". +2. **Mode toggle only renders when `views.length > 1`.** Code-only file types get no header chrome clutter. Markdown gets a toggle. Future CSV/JSON-form get toggles. +3. **Cursor-style find widget + per-view search adapters.** One shared find component at the top of the pane (case-sensitive, whole-word, regex, match-count, up/down nav, close). The adapter interface (`open`, `setQuery`, `next`, `previous`, `close`, flags) is implemented per-view: CodeMirror uses its native search state; TipTap preview does DOM-range search; grid view (future) highlights matching cells. + +### Initial handlers + +| Handler | `match` | `documentKind` | Views | Toggle? | +|---|---|---|---|---| +| `image` | `isImageFile` | `"bytes"` | `image` | no | +| `markdown` | `isMarkdownFile` | `"text"` | `code` (default, "Markdown"), `preview` ("Preview") | yes | +| `binary` | content probe | `"bytes"` | `warning-then-code` | no | +| `code` | fallback | `"text"` | `code` | no | + +### Future handlers (design must scale to these) + +| Handler | Why it fits | Notes | +|---|---|---| +| `csv` / `tsv` | `documentKind: "text"`; `grid` (default) + `source` views share the same text buffer | Grid virtualizes rows; source is CodeMirror with CSV highlighting. Large-file fallback reuses the `too-large` view on the `code` handler. | +| `json-form` | `documentKind: "text"`; `form` + `source` views | Specific filename matchers for `package.json`, `tsconfig.json`, etc. can layer structured forms over raw JSON. | +| `env` | `documentKind: "text"`; `form` + `source` views | Key/value form UI for `.env` files. | +| `notebook` / `.ipynb` | `documentKind: "custom"` | Cell-based model, not a flat string. Owns its own state and save path. | +| `sqlite` | `documentKind: "custom"` | Query UI, schema explorer, result grid. | +| `pdf` | `documentKind: "bytes"` | Read-only viewer. | +| `hex` | `documentKind: "custom"` | Cross-file alternative view for binary content. | + +### Grounded in VS Code's editor architecture + +VS Code (verified against `/tmp/vscode-research/vscode/src/vs/workbench/services/editor/common/editorResolverService.ts`) uses `IEditorResolverService.registerEditor(glob, info, options, factory)` with priority levels `builtin | option | default | exclusive` (`RegisteredEditorPriority` at lines 64–69). Each registration produces a separate `EditorInput` that opens in its own pane — i.e., VS Code does **not** have an in-pane mode toggle. Cursor layered a header affordance on top that swaps renderers inside one pane while keeping the same underlying text model. + +We're taking two concrete things from VS Code: +- **Reference-counted shared text model.** VS Code's `textModelResolverService.createModelReference(resource)` (`customTextEditorModel.ts:30`) hands out refcounted references to a singleton `ITextModel` per URI. That's how source + preview stay in sync. Our equivalent: `useFileDocument` becomes keyed by absolute path with a refcount, so two views or a split share one buffer. +- **User override setting.** VS Code's `workbench.editorAssociations` setting (`editorResolverService.ts:37`) is a glob → default-view map. Our equivalent is a setting that lets a user say "always open `.md` in Preview" without changing the handler's default. + +We're **not** taking: +- VS Code's pane-per-EditorInput model. Views are renderer components inside one pane, not separate panes. +- `Reopen Editor With…` as a launch feature. Nice to have later for switching handlers entirely (e.g., `.json` → form editor); not needed for launch. + +--- + +## 1. Editor surface (CodeMirror) + +- [~] CodeMirror 6 with line numbers, history, bracket matching, multi-cursor, indent-on-input, line wrapping, drop cursor, selection-match highlight — *currently the v1 `CodeEditor.tsx` is imported into v2; duplicate it into v2* +- [x] Syntax highlighting (~25 languages via `loadLanguageSupport.ts`) +- [x] Cmd+S save keymap +- [x] Theme + font reactivity (`createCodeMirrorTheme`, `useResolvedTheme`) +- [ ] Word wrap toggle 💡 (v1 always-on) +- [ ] Tab width / indent size setting 💡 +- [ ] Read-only compartment for non-editable contexts (v1: `editableCompartment`) + +## 2. Save / dirty state / conflicts + +- [x] Save via `useFileDocument` → `filesystem.writeFile` with `ifMatch` revision precondition +- [x] Dirty dot in tab title — `usePaneRegistry.tsx:131` +- [x] External disk change detection via `fs:events` subscription (auto-reload when clean) +- [~] **Save conflict resolution dialog** — v1 ships `FileSaveConflictDialog` (Reload / Review Diff / Overwrite). v2 `useFileDocument` populates `conflict.diskContent` but `FilePane` never renders it. Port required. + - v1: `.../FileViewerPane/components/FileSaveConflictDialog/` +- [~] **Close-pane save guard** — `usePaneRegistry.tsx:154` "Save" button is a `// TODO: wire up save via editor ref` no-op. Needs a document handle. +- [ ] Discard / revert a dirty buffer (no hotkey, no menu in v2) +- [ ] Multi-file sequenced save when a tab with multiple dirty panes closes + - v1: `editorCoordinator.saveAndClosePendingTab` +- [ ] Document buffer registry equivalent so a file open in two panes shares state + - v1: `WorkspaceView/state/editorBufferRegistry.ts` +- [ ] External rename tracking — panes update their path and preserve dirty state + - v1: `FileViewerPane.pendingRenamePathRef` + +## 3. View modes (via the file-type registry — see section 0) + +Diff is **not** a view on the `code` or `markdown` handlers in v2 — it stays as its own pane kind (`DiffPane`, already in v2). This is a simplification over v1's three-way `raw | rendered | diff` toggle. + +- [ ] Build the `FileHandler` / `FileView` registry described in section 0 +- [ ] `FilePane.tsx` dispatches via the registry instead of hardcoded `isMarkdownFile`/`isImageFile` branches +- [ ] Pane header renders a segmented toggle only when `handler.views.length > 1` +- [~] Markdown handler registers `code` (default) + `preview` views + - Fix `MarkdownRenderer.tsx:23` — flip default from `"rendered"` to `"code"`, wire up `_setViewMode`, mount `MarkdownViewModeToggle` via the shared header toggle (not `renderHeaderExtras`) +- [ ] Code handler registers a single `code` view; no toggle shown +- [ ] Image handler registers a single `image` view; no toggle shown +- [ ] Binary handler registers a `warning-then-code` view that prompts before opening as text +- [ ] Mode-switch preserves the shared document — no remount, no dirty-state loss (v1: `requestViewModeChange` in `editorCoordinator`) +- [ ] User setting: per-glob default view override (VS Code's `workbench.editorAssociations` equivalent) 💡 + +## 4. Diff view (per-file) + +- [ ] Inline vs side-by-side toggle (v2 has this on the changes pane, not per-file) +- [ ] Hide unchanged regions toggle +- [ ] Auto-scroll to first changed line on diff open + - v1: `useScrollToFirstDiffChange` +- [ ] Diff scrollbar decorations + - v1: `DiffScrollbarDecorations` component +- [ ] Right-click "Edit at location" (see 3) + +## 5. Find / search + +Cursor-style: one shared find widget at the top of the pane with case-sensitive, whole-word, regex, match-count, up/down nav, close. Each view registers a `SearchAdapterFactory` (see section 0). Find UI is shared; matching logic is delegated. + +- [ ] Shared find widget component (`FilePaneFindBar`) rendered at the top of `FilePane` when search is open — case-sensitive, whole-word, regex toggles, match count, prev/next, close +- [ ] `SearchAdapter` interface: `open()`, `setQuery(q, flags)`, `next()`, `previous()`, `close()`, `matchCount`, `activeIndex` +- [ ] Thread `editorRef` through `CodeRenderer` so the `code` view's search adapter can call `openSearchPanel(view)` (currently broken — `editorRef` is not passed) +- [ ] `code` view adapter wraps CodeMirror's native search state +- [ ] `preview` view adapter does DOM-range text search over the TipTap container + - v1 reference: `MarkdownSearch` + `useMarkdownSearch` +- [ ] Diff pane adapter — lives on `DiffPane`, not the file-type registry + - v1 reference: `useDiffSearch` +- [x] CodeMirror's default Cmd+F keymap still works inside the code view (fallback) +- [ ] 💡 Project-wide find-in-files (v1 missing too) + +## 6. Tab / preview pane UX + +- [x] Preview pane (italic title when unpinned) — `usePaneRegistry.tsx:128` +- [x] Pin on header click — `onHeaderClick: ctx.actions.pin()` +- [ ] **Auto-pin on first edit** (v1: `pinPane` triggered by `dirty && !isPinned` in `FileViewerPane`) +- [ ] File-open-mode setting (preview vs always-new-tab) + - v1: `useFileOpenMode`, `settings.getFileOpenMode` +- [ ] Reopen-closed-tab hotkey (Cmd+Shift+R) + - v1: `REOPEN_TAB` in the hotkey registry +- [ ] Move pane to tab / move pane to new tab + +## 7. Split panes (within a tab) + +- [ ] Split horizontal / vertical / auto from file-pane toolbar +- [ ] Split with new chat / split with new browser +- [ ] Equalize splits +- [ ] Prev/Next pane keyboard nav (`Cmd+Shift+Left/Right` in v1) + +## 8. Context menu + +- [~] v2 only relabels "Close Pane" → "Close File" at `usePaneRegistry.tsx:172`. Needs: +- [ ] Cut / Copy / Paste +- [ ] Copy Path +- [ ] Copy Path:Line (with selection range — v1: `useEditorActions.handleCopyPathWithLine`) +- [ ] Find +- [ ] Reveal in Files sidebar +- [ ] Open in External Editor (tRPC: `external.openFileInEditor`) +- [ ] Pane actions (split, move-to-tab, close) + +## 9. File pane toolbar / header + +- [ ] Filename / breadcrumb in pane body (v2 only shows filename in tab title) +- [ ] Pin / unpin button (v1: `FileViewerToolbar`) +- [ ] Mode toggle (segmented control) — shown only when the current handler has >1 view (see section 0) +- [ ] Save indicator + manual save button +- [ ] Diff sub-controls (inline/side-by-side, hide unchanged) — these live on `DiffPane`, not on the file-type registry + +## 10. Image / binary / special files + +- [x] Image viewer (`ImageRenderer.tsx`) up to 10 MB, base64 +- [x] Too-large / not-found / binary placeholders (`FilePane.tsx:55-82`) +- [ ] 💡 Zoom / pan / fit / actual-size controls +- [ ] 💡 Copy image to clipboard + +## 11. Settings affecting the editor + +- [x] Editor font family + size (`settings.getFontSettings`) +- [x] Theme (light/dark/system) +- [ ] File open mode (preview vs new tab) +- [ ] Markdown style preference passthrough +- [ ] 💡 Word wrap, tab width, render whitespace + +## 12. Hotkeys + +- [x] Cmd+S save +- [ ] Cmd+F find (wired in CodeMirror, but no surfaced button/action) +- [ ] Cmd+Shift+C copy-path-with-line +- [ ] Cmd+Shift+W close-tab with dirty guard +- [ ] Cmd+Shift+R reopen-closed-tab +- [ ] Prev/next tab, prev/next pane +- [ ] User-overridable hotkey table in settings (v1: `hotkeyOverridesStore`) + +## 13. v2-specific architectural cleanup + +- [ ] **13.1** Duplicate `CodeEditor.tsx` (and its `createCodeMirrorTheme`, `loadLanguageSupport.ts`, adapter) from v1 into `v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/CodeEditor/`. Update imports in `CodeRenderer.tsx` and `MarkdownRenderer.tsx`. Per `feedback_v1_v2_port_duplicate.md`: duplicate, do not share or delete v1. +- [ ] **13.2** Same treatment for `TipTapMarkdownRenderer` and any other v1 editor utilities pulled in by v2. +- [ ] **13.3** Build the `FileHandler` / `FileView` registry described in section 0. Ship with `code`, `markdown`, `image`, `binary` handlers. `FilePane.tsx` dispatches through the registry instead of hardcoded type branches. +- [ ] **13.4** Make `useFileDocument` reference-counted and keyed by absolute path, so multiple views on the same file (or a split) share one buffer. This is our equivalent of VS Code's `textModelResolverService.createModelReference`. +- [ ] **13.5** Build a v2-native equivalent of `editorCoordinator` / `editorBufferRegistry` / session store, scoped to the v2 pane registry, so multi-pane shared buffers, conflict resolution, rename tracking, and close-tab save sequencing all have a consistent home. Decide whether it layers on top of `useFileDocument` or folds buffer ownership in. +- [ ] **13.6** Thread `editorRef` (the `CodeEditorAdapter`) through `CodeRenderer` so the pane can call `openFind()`, `revealPosition()`, etc. from outside the editor — blocks find widget, close-pane save guard, go-to-line, and copy-path-with-line. + +## 14. Rebuild improvements (do better than v1) + +- 💡 Go-to-line command (Cmd+G) +- 💡 **Link detection / Cmd+click navigation** — cheapest LSP-adjacent feature. Parse the visible buffer for path-like strings (imports, markdown links, `file.ts:123:4` log patterns), underline on hover, Cmd+click to open via `openFilePane`. One CodeMirror decoration extension + a path-resolver utility. Should be structured as a built-in `LinkProvider` (see section 15) so future LSP providers plug into the same registry. Ship as the next PR after v2 launches. +- 💡 Inline AI edits / ghost text driven by the workspace chat session — v1 has zero editor ↔ AI integration +- 💡 Sticky scroll (current function header pinned to the top of the viewport) — CodeMirror community extensions exist +- 💡 Breadcrumb path in pane body, click segments to navigate + +--- + +## 15. LSP roadmap (post-launch, not on the implementation spec) + +Language features (diagnostics, hover, go-to-definition, completion, rename) are **explicitly out of scope for v2 launch**. This section documents the tiered path for adding them later so we can reason about it without committing. + +VS Code's architecture (verified at `editor/contrib/links/browser/links.ts:42` and `editor/common/languages.ts:1551`) is a unified `LanguageFeatureRegistry<T>` pattern shared across `LinkProvider`, `DefinitionProvider`, `HoverProvider`, `CompletionProvider`, etc. Each file type / language ID can register N providers from N sources. If we build LSP, we mirror this registry pattern. + +### Tier 1 — Link detection (≈1 day) + +Parse the buffer for link-like patterns. Zero language-server involvement. Covers ~70% of "go to related file" use cases. + +- Path strings in import statements (`import X from "./foo"`, `from .foo import X`, etc.) +- Markdown links (`[text](./other.md)`) +- Log-line references (`foo.ts:123:4`) +- Bare path strings in any language (`"./config.json"`) + +Implementation: one CodeMirror decoration extension that underlines matches on hover; Cmd+click resolves against the file's directory and opens via `openFilePane`. Register as a built-in `LinkProvider` in whatever feature-registry shape we pick, so tier 3 can add more providers without refactoring tier 1. + +### Tier 2 — Inline diagnostics without a server (≈3–5 days) + +- Run `tsc --noEmit` / `eslint` / language-specific linters as subprocesses per save +- Parse output into diagnostics, feed into CodeMirror's `@codemirror/lint` extension +- Red squigglies + gutter markers + popover error details + +No protocol work. No hover docs, no completion, no go-to-definition — just diagnostics. Priority depends on user feedback after launch. + +### Tier 3 — Full LSP (≈2–4 weeks, own design doc required) + +- Per-workspace language-server process manager (spawns tsserver, pyright, rust-analyzer, etc. on file-type open) +- CodeMirror LSP bridge (community clients: `codemirror-languageserver`, `@open-rpc/codemirror-lsp-client`) +- Hover tooltips, completion popups, go-to-definition (peek overlay or pane navigation), find references, rename symbol +- Config for which server handles which extension +- `LanguageFeatureRegistry<T>` shared across all feature types so tier 1's link provider coexists with LSP-contributed providers + +Should be its own design doc with its own plan — not appended to this audit. Flagged here only so we don't accidentally design tier-1 in a way that blocks tier-3. + +### What we should NOT do + +- Don't try to ship tier 2 or 3 with v2 launch +- Don't build a custom LSP protocol layer (CodeMirror clients exist) +- Don't try to match VS Code's language feature completeness — pick the features that matter most for our users (likely: TypeScript + Python diagnostics, go-to-definition across files) + +--- + +## Key file paths + +**v1 reference:** +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/state/editorCoordinator.ts` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/state/editorBufferRegistry.ts` +- `.../FileViewerPane/components/FileSaveConflictDialog/` +- `.../FileViewerPane/hooks/{useFileSave,useFileContent,useDiffSearch,useMarkdownSearch}.ts` + +**v2 target:** +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx` +- `.../usePaneRegistry/components/FilePane/FilePane.tsx` +- `.../FilePane/renderers/{CodeRenderer,MarkdownRenderer,ImageRenderer}/` +- `.../FilePane/components/ExternalChangeBar/ExternalChangeBar.tsx` +- `apps/desktop/src/renderer/hooks/host-service/useFileDocument.ts` diff --git a/apps/desktop/plans/20260412-file-editor-v2-implementation.md b/apps/desktop/plans/20260412-file-editor-v2-implementation.md new file mode 100644 index 00000000000..a9267f93030 --- /dev/null +++ b/apps/desktop/plans/20260412-file-editor-v2-implementation.md @@ -0,0 +1,744 @@ +# File Editor v2 — Implementation Spec + +Tactical reference for the rebuild. Design rationale lives in `20260412-file-editor-v2-feature-audit.md` section 0. This doc is what you read when you're about to write code. + +--- + +## 1. File layout + +``` +apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/ +├── state/ +│ └── fileDocumentStore/ +│ ├── fileDocumentStore.ts — module Map<key, Entry>, refcount, lifecycle +│ ├── useSharedFileDocument.ts — React hook (acquire on mount, release on unmount) +│ ├── types.ts — Document, ContentState, DocumentEvents +│ └── index.ts +│ +└── hooks/usePaneRegistry/components/FilePane/ + ├── FilePane.tsx — acquires document, resolves views, renders all chrome + active view + ├── FilePane.types.ts — FilePaneData (filePath, viewId, forceViewId) + ├── components/ + │ ├── FileViewToggle/ — segmented control, RTL + │ ├── LoadingState/ + │ ├── ErrorState/ — not-found, too-large, is-directory + │ ├── ExternalChangeBar/ + │ ├── OrphanedBanner/ + │ ├── SaveErrorBanner/ + │ └── ConflictDialog/ + └── registry/ + ├── index.ts — resolveViews, ALL_VIEWS, orderForToggle + ├── types.ts — FileView, ViewProps, Priority, FileMeta + ├── resolveViews.ts + ├── allViews.ts — import list + └── views/ + ├── CodeView/ + │ ├── CodeView.tsx + │ ├── index.ts — exports FileView object + │ └── components/ + │ └── CodeEditor/ — duplicated from v1 in stage 1 + │ ├── CodeEditor.tsx + │ ├── createCodeMirrorTheme.ts + │ ├── loadLanguageSupport.ts + │ ├── CodeEditorAdapter.ts + │ └── index.ts + ├── MarkdownPreviewView/ + │ ├── MarkdownPreviewView.tsx + │ ├── index.ts + │ └── components/ + │ └── MarkdownSearch/ — view-owned find UI (ported from v1) + ├── ImageView/ + │ ├── ImageView.tsx + │ └── index.ts + └── BinaryWarningView/ + ├── BinaryWarningView.tsx + └── index.ts + +# If a second view later needs CodeEditor (e.g., a CsvSourceView, HexRawView), +# promote it to registry/views/components/CodeEditor/ at that point — not preemptively. +``` + +--- + +## 2. Core types + +### 2.1 Registry + +```ts +// registry/types.ts + +export type FileMeta = { + size?: number; + isBinary?: boolean; +}; + +export type DocumentKind = "text" | "bytes" | "custom"; + +export type Priority = "builtin" | "option" | "default" | "exclusive"; + +export const PRIORITY_RANK: Record<Priority, number> = { + exclusive: 5, + default: 4, + builtin: 3, + option: 1, +}; + +export type FileView = { + id: string; + label: string; + match: (filePath: string, meta: FileMeta) => boolean; + priority: Priority; + documentKind: DocumentKind; + Renderer: ComponentType<ViewProps>; +}; + +export type ViewProps = { + document: SharedFileDocument; + filePath: string; + workspaceId: string; + onDirtyChange: (dirty: boolean) => void; +}; +``` + +### 2.2 Document + +```ts +// state/fileDocumentStore/types.ts + +export type ContentState = + | { kind: "loading" } + | { kind: "text"; value: string; revision: string } + | { kind: "bytes"; value: Uint8Array; revision: string } + | { kind: "not-found" } + | { kind: "too-large" } + | { kind: "is-directory" }; + +export type DocumentPhase = "loading" | "resolved" | "disposed"; + +export type SharedFileDocument = { + // Identity + readonly workspaceId: string; + readonly absolutePath: string; + + // Lifecycle + readonly phase: DocumentPhase; + readonly content: ContentState; + + // State flags (any combination may be true simultaneously) + readonly dirty: boolean; + readonly pendingSave: boolean; + readonly saveError: Error | null; + readonly conflict: ConflictState | null; + readonly orphaned: boolean; + readonly hasExternalChange: boolean; + + // Metadata (for view resolution) + readonly byteSize: number | null; + readonly isBinary: boolean | null; + + // Content mutations + setContent(next: string): void; + save(opts?: { force?: boolean }): Promise<SaveResult>; + reload(): Promise<void>; + discard(): Promise<void>; + resolveConflict(choice: "reload" | "overwrite" | "keep"): Promise<void>; + + // Subscription (React consumes via useSyncExternalStore) + subscribe(listener: () => void): () => void; + snapshot(): SharedFileDocument; +}; + +export type ConflictState = { + diskContent: string; + diskRevision: string; +}; + +export type SaveResult = + | { status: "saved" } + | { status: "conflict"; diskContent: string; diskRevision: string } + | { status: "error"; error: Error }; +``` + +### 2.3 Pane data + +```ts +// FilePane.types.ts + +export type FilePaneData = { + kind: "file"; + filePath: string; // absolute path + mode: "editor"; + hasChanges: boolean; // mirrored from document.dirty for tab indicator + viewId?: string; // user's toggle selection + forceViewId?: string; // escape hatch from BinaryWarningView "Open Anyway" +}; +``` + +--- + +## 3. Document state machine + +State flags are independent booleans. Multiple can be true at once. Transitions are driven by actions and external events. + +### 3.1 Flag combinations (the interesting ones) + +| Flags | Meaning | FilePane renders | +|---|---|---| +| `phase=loading` | initial load | `LoadingState` | +| `phase=resolved` + text content | normal | view mounted | +| `dirty` | user edited | dot in tab title | +| `pendingSave` | save in flight | subtle indicator; block close | +| `dirty` + `hasExternalChange` | user edited, disk also changed | `ExternalChangeBar` | +| `saveError` | last save failed (non-conflict) | `SaveErrorBanner` + view mounted | +| `conflict` | save failed with ETag mismatch | `ConflictDialog` (modal over view) | +| `orphaned` + `dirty` | file deleted externally, unsaved buffer preserved | `OrphanedBanner` + view mounted with buffer | +| `orphaned` + `!dirty` | file deleted externally, no edits | `OrphanedBanner` + view mounted with last content | +| `phase=resolved` + `not-found` | never existed (not deleted — new file from stale link) | `ErrorState reason=not-found` | +| `phase=resolved` + `too-large` | file exceeds read limit | `ErrorState reason=too-large` | + +### 3.2 Transitions + +``` +[loading] + ↓ readFile success +[resolved, content=text|bytes] + ↓ setContent(next) +[resolved, content, dirty] + ↓ save() +[resolved, content, dirty, pendingSave] + ↓ writeFile success ↓ writeFile ETag mismatch ↓ writeFile other error +[resolved, content] [resolved, content, dirty, [resolved, content, dirty, + conflict] saveError] + ↓ resolveConflict("overwrite") + [resolved, content, dirty, pendingSave] + ↓ resolveConflict("reload") + [resolved, content] + +From [resolved, anything]: + fs:events delete → orphaned=true + fs:events rename → update absolutePath, preserve state + fs:events update/create + dirty → hasExternalChange=true + fs:events update/create + !dirty → auto reload → onDidResolve + fs:events overflow → treat like update (re-check + reload) +``` + +### 3.3 Event handling (store-level) + +**Constraint: v2 FilePane code uses `@superset/workspace-client` exclusively. No `electronTrpc`.** That's v1's IPC path. The whole point of the v2 architecture is that workspaces talk to the host service directly, not through Electron IPC. Any import of `electronTrpc*` in the new FilePane directory is a bug. + +All tRPC calls go through the imperative client returned by `useWorkspaceClient().trpcClient`; all event-bus subscriptions go through `getEventBus(hostUrl, tokenFn)` from `@superset/workspace-client`. The store itself is module-level but must be initialized from inside a React context once (to capture the trpcClient and host URL resolver); after that it runs imperatively. + +`packages/workspace-fs/src/watch.ts` already coalesces rapid-fire events, pairs delete+create sequences into `rename` events, and filters atomic-write false positives via `@parcel/watcher`. By the time we see a `delete` event it's a real delete, not a transient artifact. No debounced probe needed. + +**Initialization pattern** (inside the v2 workspace route): + +```tsx +// Some provider mounted inside v2-workspace/$workspaceId/ that initializes the store once +export function FileDocumentStoreProvider({ children }: { children: ReactNode }) { + const { trpcClient } = useWorkspaceClient(); + const hostUrl = useWorkspaceHostUrl(workspaceId); + + useEffect(() => { + if (!hostUrl) return; + initializeFileDocumentStore({ + trpcClient, + hostUrl, + tokenGetter: () => getHostServiceWsToken(hostUrl), + }); + return () => teardownFileDocumentStore(); + }, [trpcClient, hostUrl]); + + return <>{children}</>; +} +``` + +Once initialized, the store has what it needs to make imperative calls and subscribe to the event bus without any further React plumbing. + +**Store-level event handling** (runs after init, one subscription per workspace host, not per-entry): + +```ts +// Pseudocode inside fileDocumentStore.ts +function subscribeToFsEvents() { + const bus = getEventBus(hostUrl, tokenGetter); + bus.watchFs(workspaceId); + + const remove = bus.on("fs:events", workspaceId, (_wid, payload) => { + for (const event of payload.events) { + dispatchFsEvent(event); + } + }); + + const release = bus.retain(); + + return () => { + remove(); + bus.unwatchFs(workspaceId); + release(); + }; +} + +function dispatchFsEvent(event: FsWatchEvent) { + for (const entry of entries.values()) { + const affects = + entry.absolutePath === event.absolutePath || + (event.kind === "rename" && entry.absolutePath === event.oldAbsolutePath); + if (!affects) continue; + + switch (event.kind) { + case "delete": + entry.orphaned = true; + notify(entry); + break; + + case "rename": + entry.absolutePath = event.absolutePath; + if (entry.dirty) { + entry.hasExternalChange = true; + } + // path updated; if not dirty, in-memory content still matches — no reload needed + notify(entry); + break; + + case "create": + case "update": + case "overflow": + if (entry.dirty) { + entry.hasExternalChange = true; + notify(entry); + } else { + void reloadFromDisk(entry); + } + break; + } + } +} + +async function reloadFromDisk(entry: DocumentEntry) { + // Imperative tRPC call via the injected client — NOT electronTrpc + const result = await trpcClient.filesystem.readFile.query({ + workspaceId: entry.workspaceId, + absolutePath: entry.absolutePath, + }); + // ... update entry.content + revision + notify +} +``` + +Orphan re-appearance: if a `create` event lands on an `orphaned` entry with a `dirty` buffer, clear `orphaned` but keep `dirty` (user still has unsaved edits over newly-written disk content; they can resolve via the conflict dialog on next save). + +### 3.4 Dispose rules + +- `releaseDocument` decrements refCount +- If `refCount === 0` AND `!dirty` AND `!orphaned` → tear down entry +- If `refCount === 0` AND (`dirty` OR `orphaned`) → entry remains alive until explicit `discard()` or `save()` clears the flags + +This mirrors VS Code's `TextFileEditorModelManager.canDispose()` which blocks disposal on dirty models. Prevents losing unsaved buffers when the last tab closes. + +--- + +## 4. View inventory + +### 4.1 Launch views + +| View id | Label | Matcher | Priority | `documentKind` | Notes | +|---|---|---|---|---|---| +| `image` | `Image` | `isImageFile(fp)` | `exclusive` | `bytes` | Suppresses alternatives | +| `binary-warning` | `Binary` | `meta.isBinary === true` | `exclusive` | `bytes` | "Open Anyway" → `forceViewId: "code"` | +| `markdown-preview` | `Preview` | `isMarkdownFile(fp)` | `option` | `text` | Yields to code; appears in toggle | +| `code` | `Code` *(labelled `Markdown` on `.md` via override)* | `() => true` | `builtin` | `text` | Universal fallback | + +Label override note: the code view's static label is `"Code"`, but on markdown files the toggle should read `"Markdown"` (matching Cursor). Two options: +- **(a) Context-aware label**: `label: (filePath) => isMarkdownFile(filePath) ? "Markdown" : "Code"` — requires the label field to accept a function. +- **(b) Second registration**: register a `markdown-code` view with `match: isMarkdownFile`, `priority: "builtin"`, `label: "Markdown"` and pull `code`'s matcher to exclude markdown. Cleaner registry, more registrations. + +Decision: **(a)**. Label becomes `string | ((filePath: string) => string)`, resolved at render time. + +### 4.2 Priority choices (why each view uses what it does) + +- `code` is `builtin` — beats `option`, loses to `default`. Wins on `.ts/.py/.md/etc` but yields to CSV grid, JSON form, etc. +- `markdown-preview` is `option` — the only tier below `builtin`. This is the one file type where we want the universal fallback to beat the specialist. +- `image` is `exclusive` — no alternatives. Future: user hits `Reopen With…` and sets `forceViewId: "code"` to open as text. +- `binary-warning` is `exclusive` — forces the warning gate before any rendering. + +### 4.3 Future views + +| View id | Matcher | Priority | `documentKind` | +|---|---|---|---| +| `csv-grid` | `*.csv`, `*.tsv` | `default` | `text` | +| `json-form` | `package.json`, `tsconfig.json`, etc. | `default` | `text` | +| `env-form` | `.env*` | `default` | `text` | +| `notebook` | `*.ipynb` | `exclusive` | `custom` | +| `sqlite` | `*.sqlite`, `*.db` | `exclusive` | `custom` | +| `pdf` | `*.pdf` | `exclusive` | `bytes` | +| `hex` | — (via `Reopen With…`) | `option` | `custom` | + +Each future view = one new directory + one line in `allViews.ts`. No changes to `FilePane` or `resolveViews`. + +### 4.4 Resolution + +```ts +function resolveViews(filePath: string, meta: FileMeta): FileView[] { + const matches = ALL_VIEWS.filter((v) => v.match(filePath, meta)); + const exclusives = matches.filter((v) => v.priority === "exclusive"); + if (exclusives.length > 0) return exclusives; + return [...matches].sort((a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]); +} + +function orderForToggle(views: FileView[]): FileView[] { + return [...views].reverse(); // default ends up on the right (Cursor RTL) +} + +function pickDefaultView(views: FileView[]): FileView { + return views[0]; // first after priority sort +} +``` + +--- + +## 5. Responsibility split + +### 5.1 `FilePane.tsx` owns + +- Document acquisition via `useSharedFileDocument` +- View resolution via `resolveViews` + active view selection (`data.viewId ?? pickDefaultView(views).id`) +- `forceViewId` bypass for binary-warning "Open Anyway" +- Mirroring `document.dirty` back to `data.hasChanges` for the tab indicator +- **Content gating** — renders `LoadingState` / `ErrorState` and does NOT mount the active view until `document.content.kind ∈ {text, bytes}` +- **Chrome rendering** — `FileViewToggle` (when `views.length > 1`), `ExternalChangeBar`, `OrphanedBanner`, `SaveErrorBanner`, `ConflictDialog` +- **Close-pane save guard** — wires `usePaneRegistry.tsx:154` TODO via non-hook `fileDocumentStore.get()` + `document.save()` + +### 5.2 `FileView.Renderer` owns + +- Rendering `document.content.value` (text or bytes — view knows its kind) +- Reporting edits via `onDirtyChange` + `document.setContent` +- Handling `Cmd+S` via `document.save()` +- Find UI (mounted inside the view; zero FilePane involvement — CodeView uses CodeMirror's native search panel, MarkdownPreviewView ports v1's `MarkdownSearch`, ImageView has no find) +- Undo history, cursor, selection, scroll position +- Focus management +- View-specific context menu entries (if any) + +### 5.3 `SharedFileDocument` owns + +- File I/O (read, write, exists-probe) +- ETag / revision tracking +- Dirty detection (`currentContent !== savedContent`) +- External-change detection (fs:events subscription) +- Orphan detection (delete probe with 100ms debounce) +- Save state machine (`dirty` → `pendingSave` → `saved` | `saveError` | `conflict`) +- Refcount + lifetime rules (dispose blocked on dirty/orphaned) +- Event fan-out to subscribers + +### 5.4 Find architecture note + +VS Code's three-layer find (shared `FindReplaceState<T>`, shared `FindInput` DOM primitives, per-editor widget + search model) only pays off when you have multiple custom widgets with similar UX but different underlying engines. At launch we have two views with find: CodeView (CodeMirror's native search, fully baked) and MarkdownPreviewView (DOM-range search, ported from v1). Neither duplicates work — CodeMirror ships its own widget; the markdown search is small enough to own inline. No shared base needed at launch. Revisit when we ship a third view (CSV grid, notebook) whose find UI visibly resembles another view's. + +--- + +## 6. Flows + +### 6.1 Open + +1. `FilesTab` click → `openFilePane(filePath, { openInNewTab })` in `page.tsx` +2. `FilePaneData = { filePath, mode: "editor", hasChanges: false, viewId: undefined }` +3. `FilePane.tsx` mounts +4. `useSharedFileDocument(workspaceId, filePath)` → `acquireDocument` → refcount 0→1 +5. Store triggers async `filesystem.readFile`; initial state is `phase=loading` +6. `FilePane.tsx` first render: `document.content.kind === "loading"` → renders `LoadingState`, view does NOT mount +7. Read completes → `content.kind` transitions to `text`/`bytes`/`not-found`/`too-large`/`is-directory`; binary probe runs, sets `isBinary` +8. `resolveViews(filePath, { size, isBinary })` → matching view list +9. Active view renderer mounts with `document` + `filePath` props + +### 6.2 Save + +1. View calls `document.save()` (via CodeMirror keymap, TipTap keymap, etc.) +2. Document transitions: `dirty` → `dirty + pendingSave` +3. `filesystem.writeFile` with `precondition: { ifMatch: revision }` +4. **Success**: revision updates; `dirty = false`; `pendingSave = false`; `savedContent = currentContent`; subscribers notified; tab dirty-dot clears +5. **Conflict (ETag mismatch)**: `pendingSave = false`; `conflict = { diskContent, diskRevision }`; `dirty` stays true; `SaveErrorBanner` does NOT show; `ConflictDialog` shows +6. **Other error**: `pendingSave = false`; `saveError = error`; `dirty` stays true; `SaveErrorBanner` shows; view remains editable + +### 6.3 Conflict resolution + +- `resolveConflict("reload")` → `document.content = conflict.diskContent`; `currentContent = savedContent = diskContent`; `revision = diskRevision`; `dirty = false`; `conflict = null` +- `resolveConflict("overwrite")` → `document.save({ force: true })` which skips the `ifMatch` precondition +- `resolveConflict("keep")` → `conflict = null`, but `dirty` stays true (user keeps editing against stale revision; next save will conflict again unless merged) + +### 6.4 View swap + +1. User clicks inactive tab in `FileViewToggle` +2. `FileViewToggle` calls `onChangeView("preview")` +3. `FilePane.tsx`: `context.actions.updateData({ ...data, viewId: "preview" })` +4. Re-render: `activeView` recomputes; old Renderer unmounts; new Renderer mounts +5. `useSharedFileDocument` is on `FilePane`, NOT views → document stays alive; refcount unaffected +6. `document.currentContent`, `dirty`, `conflict`, `orphaned` all preserved across the swap +7. Per-view state (undo history, scroll, cursor, find query) does NOT carry over — each view has its own + +### 6.5 External change (disk edited while editor is open) + +- `fs:events` change for a held path: + - `!dirty` → store calls `reloadFromDisk(entry)` silently; content updates; subscribers notified + - `dirty` → `hasExternalChange = true`; `ExternalChangeBar` shows with Reload / Review Diff buttons +- User clicks Reload → `document.reload()` → discards in-memory buffer, loads disk content, clears `hasExternalChange` and `dirty` + +### 6.6 External delete + +- `fs:events` delete for a held path → `orphaned = true` immediately (watcher already filtered out atomic-write false positives) +- `OrphanedBanner` shows +- `dirty`: view stays mounted with unsaved buffer; user must `Save As` or `Discard` +- `!dirty`: view stays mounted with last-known content; user sees banner +- File re-appears on disk later (`create` event on orphaned entry): + - `!dirty`: clear `orphaned`, reload content silently + - `dirty`: clear `orphaned`, set `hasExternalChange = true`; user resolves via conflict dialog on next save + +### 6.7 Close with dirty + +1. User clicks close on a tab with `data.hasChanges === true` +2. `usePaneRegistry.onBeforeClose` reads `data.hasChanges`, returns an `alert(...)` Promise +3. Dialog: Save / Don't Save / Cancel +4. **Save** → calls `document.save()`; on success, resolves `true` (close proceeds); on conflict/error, resolves `false` (close blocked, banner shows) +5. **Don't Save** → calls `document.discard()` which forces refcount to allow teardown; resolves `true` +6. **Cancel** → resolves `false` + +Blocker: `FilePane` holds the document; `onBeforeClose` runs on pane data only. Either expose a `documentHandle` via pane context, or have the store expose a non-hook `getDocument(workspaceId, filePath)` for non-React callers. + +Decision: **non-hook store access** (`fileDocumentStore.get(workspaceId, filePath)`), used by `onBeforeClose`. Keeps the registry decoupled from React rendering. + +--- + +## 7. FilePane component (concrete) + +One component. Acquires the document, resolves views, gates on content state, renders chrome, mounts the active view. + +```tsx +// FilePane.tsx + +export function FilePane({ context, workspaceId }: FilePaneProps) { + const data = context.pane.data as FilePaneData; + const { filePath } = data; + + const document = useSharedFileDocument({ workspaceId, absolutePath: filePath }); + + // View resolution + const meta: FileMeta = { + size: document.byteSize ?? undefined, + isBinary: document.isBinary ?? undefined, + }; + const views = data.forceViewId + ? ALL_VIEWS.filter((v) => v.id === data.forceViewId) + : resolveViews(filePath, meta); + const activeView = views.find((v) => v.id === data.viewId) ?? pickDefaultView(views); + const ViewRenderer = activeView.Renderer; + + // Handlers + const handleChangeView = useCallback( + (viewId: string) => { + context.actions.updateData({ ...data, viewId } as PaneViewerData); + }, + [context.actions, data], + ); + const handleDirtyChange = useCallback( + (dirty: boolean) => { + if (dirty !== data.hasChanges) { + context.actions.updateData({ ...data, hasChanges: dirty } as PaneViewerData); + } + }, + [context.actions, data], + ); + + // Content gating — view not mounted until there's renderable content + if (document.content.kind === "loading") { + return <LoadingState />; + } + if (document.content.kind === "not-found" && !document.orphaned) { + return <ErrorState reason="not-found" />; + } + if (document.content.kind === "too-large") { + return <ErrorState reason="too-large" />; + } + if (document.content.kind === "is-directory") { + return <ErrorState reason="is-directory" />; + } + + // Chrome + active view + const showToggle = views.length > 1; + return ( + <div className="flex h-full w-full flex-col"> + {showToggle && ( + <div className="flex items-center justify-end border-b border-border px-2 py-1"> + <FileViewToggle + views={views} + activeViewId={activeView.id} + onChange={handleChangeView} + /> + </div> + )} + {document.hasExternalChange && <ExternalChangeBar document={document} />} + {document.orphaned && <OrphanedBanner document={document} />} + {document.saveError && <SaveErrorBanner document={document} />} + <div className="min-h-0 min-w-0 flex-1"> + <ViewRenderer + document={document} + filePath={filePath} + workspaceId={workspaceId} + onDirtyChange={handleDirtyChange} + /> + </div> + {document.conflict && <ConflictDialog document={document} />} + </div> + ); +} +``` + +--- + +## 8. Build stages + +Organized as thin-vertical-slice + grow-outward. Each PR lands something runnable and testable end-to-end — you can `bun dev`, open a file, see what changed. No refactor-only PRs. No half-wired intermediate states. + +Ships behind a feature flag (`fileEditorV2Enabled` or similar) so the old v2 `CodeRenderer`/`MarkdownRenderer`/`ImageRenderer` path keeps working throughout the build. Flag flips in the final PR. + +### PR 1 — Thin e2e slice (the hard one) + +Build just enough to render one view end-to-end. Missing features are acknowledged and deferred; the point is to prove the stack works. + +**Scope:** +- `fileDocumentStore` — minimum viable state machine: `phase`, `content`, `dirty`, refcount, subscribe, `acquireDocument`/`releaseDocument`/`get`, `save` via tRPC with ETag precondition. **Deferred**: `pendingSave`, `saveError`, `conflict`, `orphaned`, `hasExternalChange`, fs:events subscription. +- `useSharedFileDocument` hook +- Registry: `types.ts`, `resolveViews.ts`, `allViews.ts` containing only `codeView` +- Duplicated `CodeEditor.tsx` + deps at `registry/views/CodeView/components/CodeEditor/` +- `CodeView` component +- `FilePane.tsx` rewrite: acquire doc, resolve views, mount active view, minimal `LoadingState` gate +- Feature flag wiring: new path when flag is on, old path when off + +**Acceptance:** flag on → open a `.ts` file, edit, Cmd+S saves, tab dirty dot appears and clears. Split the tab, open the same file in the other pane, edit in one, see the content sync in real time (refcount sharing works). Flag off → old behavior unchanged. This proves: store, registry, FilePane dispatch, shared buffer, save flow. + +**Visible gaps (known, acceptable for this PR):** no markdown preview, no image view, no binary warning, no conflict dialog, no orphan handling, no external-change banner, no save-error banner, no toggle (only one view registered). + +### PR 2 — Second view unlocks the toggle + +Adds the registry's scaling proof: multiple views, the segmented toggle, shared document across view swaps. + +**Scope:** +- `MarkdownPreviewView` (TipTap) + ported `MarkdownSearch` colocated inside the view dir +- `FileViewToggle` component +- `ImageView` (small, no toggle, but fits naturally here since it doesn't add complexity) + +**Acceptance:** flag on → open a `.md` file, starts in code view labelled "Markdown", toggle appears on the right with "Preview · Markdown" RTL. Click Preview, content stays synced across the swap, edits in one view are visible in the other after toggling back. Open a `.png`, image renders, no toggle shown. This proves: multi-view registry, RTL toggle ordering, view swap preserves document state. + +### PR 3 — State machine completion + chrome + +Fills in the state machine fields that PR 1 deferred and builds all the banners/dialogs. + +**Scope:** +- Expand `fileDocumentStore` with `pendingSave`, `saveError`, `conflict`, `hasExternalChange` +- `ExternalChangeBar` component +- `SaveErrorBanner` component +- `ConflictDialog` component (ported from v1's `FileSaveConflictDialog`) +- `ErrorState` component (not-found, too-large, is-directory) +- Close-pane save guard fix via non-hook `fileDocumentStore.get()` in `usePaneRegistry.tsx:154` + +**Acceptance:** flag on → edit a file externally while v2 is showing it → `ExternalChangeBar` appears. Close a dirty tab → alert prompts save/discard/cancel, all three work. Simulate ETag mismatch (easiest: two tabs on the same file in two separate desktop processes, edit+save in both) → `ConflictDialog` shows. Open a nonexistent path → `ErrorState reason="not-found"`. + +### PR 4 — fs:events + orphan + binary + +Wires up the watcher and adds the last launch view. + +**Scope:** +- `orphaned` flag on the store +- fs:events subscription in `fileDocumentStore` (the `switch` over `create | update | delete | rename | overflow` from §3.3) +- `OrphanedBanner` component +- Dispose rules: block teardown when `dirty` or `orphaned` +- Rename path tracking +- `BinaryWarningView` + `meta.isBinary` threading + `forceViewId` bypass + +**Acceptance:** `rm` an open file from a terminal → `OrphanedBanner` appears immediately. Edit the file externally with `vim :w` → `rename` event fires, no phantom orphan banner, `ExternalChangeBar` shows instead. Close a dirty tab → dialog; "Don't Save" actually drops the buffer; "Save" persists and closes cleanly. Open a `.so` → `BinaryWarningView` shown; click "Open Anyway" → opens as code. + +### PR 5 — Cleanup and flip + +Mechanical. Deletes the old path. + +**Scope:** +- Flip the feature flag default to on +- Delete `CodeRenderer.tsx`, `MarkdownRenderer.tsx`, `ImageRenderer.tsx` +- Delete v2's current `useFileDocument` host-service hook (if no non-FilePane consumers remain — verify first) +- Remove the feature flag entirely +- Clean up any dead imports / unused exports + +**Acceptance:** no references to the old renderer components remain; typecheck passes; full regression suite (§9.2) runs clean. + +### Post-launch follow-up PRs (each independently small) + +- Context menu (audit §8): copy path, copy path:line, reveal in sidebar, open in external editor +- Hotkey wiring: Cmd+Shift+C (copy path:line), Cmd+Shift+R (reopen tab), prev/next tab, prev/next pane +- Link detection / Cmd+click on paths (audit §14, §15 tier 1) +- CSV grid view +- JSON form view for `package.json` / `tsconfig.json` +- `Reopen With…` menu for explicit handler override +- Per-glob user override setting (`fileViewOverrides`) +- Go-to-line command (Cmd+G) +- Sticky scroll extension +- Breadcrumb path in pane body + +Each of these touches one view or one component in isolation and doesn't require coordinating across the stack. + +### Why this shape + +- **PR 1 is the big one** (~600–1000 lines, half of that is the duplicated CodeEditor). Reviewable. Ships a working surface. +- **PRs 2–4 each add testable user-visible behavior.** No refactor-only PRs. Every PR has an acceptance check you can run manually in `bun dev`. +- **Flag isolates risk.** Old path coexists until PR 5. If PR 3 breaks something, PR 4 can still merge with the flag off. +- **Intermediate states are honest.** After PR 2 the flag path is already a usable editor for the three main file types (code, markdown, image). After PR 3 it matches v1 for conflict + external-change handling. After PR 4 it matches or exceeds v1 for everything except context menu + hotkeys. +- **Follow-up PRs are actually small.** The coupled refactor work is done in PRs 1–5; everything after is single-feature additions. + +--- + +## 9. Verification + +### 9.1 Per-PR + +- `bun typecheck` — must pass +- `bun run lint` — must pass +- `bun dev` → open v2 workspace → execute the PR's acceptance check +- Feature flag toggled off → regression check that old v2 still works unchanged + +### 9.2 Regression suite (run after every PR) + +Tests that map to which PR introduced them — not every check applies to every PR. + +| Check | Introduced by | +|---|---| +| Open `.ts` file → code view, no toggle, Cmd+S saves | PR 1 | +| Split pane, open same file in both → edits sync, dirty dot on both | PR 1 | +| Open `.md` file → Markdown view default, toggle shows "Preview · Markdown" | PR 2 | +| Click Preview → TipTap renders same content, swap preserves dirty state | PR 2 | +| Open `.png` → image view, no toggle | PR 2 | +| Edit file externally → `ExternalChangeBar` appears | PR 3 | +| Edit file, close tab → save/discard/cancel prompt, all three work | PR 3 | +| ETag mismatch on save → `ConflictDialog` shows, all three resolutions work | PR 3 | +| Open nonexistent path → `ErrorState reason="not-found"` | PR 3 | +| `rm` open file from terminal → `OrphanedBanner` appears | PR 4 | +| `vim :w` → `rename` event fires, no phantom orphan banner | PR 4 | +| Open `.so` → binary warning + "Open Anyway" → code view | PR 4 | +| Feature flag off → old v2 path unchanged | PRs 1–4 | + +### 9.3 Manual edge cases + +- Empty file +- Very large file at the 2MB boundary +- File with CRLF vs LF line endings +- Symbolic link +- File whose name has unicode characters +- File in a deeply nested path +- File on a case-insensitive filesystem where case of filename changes externally + +--- + +## 10. Open decisions + +| # | Question | Default | Needs decision by | +|---|---|---|---| +| 1 | Label override: function-per-view or second registration? | function | PR 2 | +| 2 | Binary detection: sync-in-readFile or async-after-first-render? | sync-in-readFile | PR 4 | +| 3 | `viewId` persistence migration for existing v2 pane data? | additive, default `undefined`, no migration | PR 1 | +| 4 | Per-glob user override setting (`fileViewOverrides`)? | deferred post-launch | — | +| 5 | `Reopen With…` menu? | deferred post-launch | — | +| 6 | Per-view undo history vs unified? | per-view (matches VS Code) | PR 2 | +| 7 | Orphan auto-clear on file reappearance? | Resolved — see Flow 6.6: clear `orphaned`, silently reload if clean, flag `hasExternalChange` if dirty | — | diff --git a/apps/desktop/plans/20260413-1600-v2-review-tab.md b/apps/desktop/plans/20260413-1600-v2-review-tab.md new file mode 100644 index 00000000000..a620fab261c --- /dev/null +++ b/apps/desktop/plans/20260413-1600-v2-review-tab.md @@ -0,0 +1,488 @@ +# V2 Workspace Sidebar: Review Tab (PR Info, Checks, Comments) + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Reference: This plan follows conventions from AGENTS.md and this template. + + +## Purpose / Big Picture + +Today the v2 workspace sidebar has three tabs: "All files", "Changes", and "Checks". The Checks tab is a placeholder that reads "Coming soon." There is no way for a user in the v2 workspace to see their pull request status, CI check results, or PR review comments. In the v1 workspace, all of this lives in a fully-featured "Review" tab inside the right sidebar's ChangesView component. That v1 tab shows the PR title and state, the review decision badge, requested reviewers, a collapsible list of CI checks with pass/fail/pending icons and links, and a full list of PR comments with avatar, author, age, preview text, copy-to-clipboard, resolve/unresolve, and a "mark all done" batch action. + +After this plan is implemented, a v2 workspace user will be able to click the "Review" tab in the right sidebar and see all of that information. They will be able to resolve and unresolve comment threads, copy individual comments or all comments to the clipboard, and click through to GitHub for checks and comments. The existing "Checks — Coming soon" stub will be replaced by this richer "Review" tab. + + +## Assumptions + +1. The v1 `electronTrpc` endpoints (`workspaces.getGitHubStatus`, `workspaces.getGitHubPRComments`, `workspaces.resolveReviewThread`) are the correct data sources. The v2 host-service has `git.getPullRequest` and `git.getPullRequestThreads`, but these return different shapes and the resolve-thread mutation only exists on the electron router. Using the v1 endpoints avoids needing to add a new host-service mutation and means the data shapes match the proven v1 UI exactly. + +2. The review tab replaces the "Checks" stub entirely (the tab ID changes from `"checks"` to `"review"`). Checks are shown inside the review tab as a collapsible section, matching v1 behavior. + +3. The `PRIcon` component at `renderer/screens/main/components/PRIcon/PRIcon.tsx` and the ReviewPanel utility functions at `renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/utils.ts` can be imported directly from their current locations. No need to move them into the v2 directory tree since they are general-purpose and already used by v1. + +4. The v2 sidebar tab architecture (the `useChangesTab` hook pattern that returns a `SidebarTabDefinition`) is the correct pattern to follow. The review tab will be built as a `useReviewTab` hook. + + +## Open Questions + +1. **Should we use the v1 electron endpoints or the v2 host-service endpoints for PR data?** Using v1 endpoints is simpler and proven. Using v2 endpoints would be more architecturally consistent but requires adding a resolve-thread mutation to the host-service and adapting the data shapes. Pre-linked to Decision Log entry D1. + +2. **Should the badge on the Review tab show open comment count, total comment count, or checks status?** V1 shows open comment count on the "Review" sub-tab. Pre-linked to Decision Log entry D2. + + +## Progress + +- [ ] Plan drafted and awaiting approval. +- [ ] Milestone 1: useReviewTab hook + ReviewTabContent shell. +- [ ] Milestone 2: PR header section (title, state, decision, reviewers). +- [ ] Milestone 3: Checks section (collapsible, icons, links). +- [ ] Milestone 4: Comments section (list, copy, resolve, batch resolve). +- [ ] Milestone 5: Wire into WorkspaceSidebar, remove Checks stub. +- [ ] Milestone 6: Validation and cleanup. + + +## Surprises & Discoveries + +(None yet.) + + +## Decision Log + +- **D1 — Use v1 electron endpoints for PR data.** + Rationale: The v1 endpoints (`electronTrpc.workspaces.getGitHubStatus`, `.getGitHubPRComments`, `.resolveReviewThread`) are battle-tested, return the exact shapes the v1 ReviewPanel already consumes, and include the resolve-thread mutation. The v2 host-service endpoints (`workspaceTrpc.git.getPullRequest`, `.getPullRequestThreads`) return different shapes (e.g., `CheckRun` with `conclusion` field vs. `CheckItem` with `status` field, and threads as nested objects vs. flat `PullRequestComment[]`). Using v1 endpoints lets us reuse the v1 utility functions directly and avoids adding a new mutation to the host-service. A future migration to host-service endpoints can happen independently. + Date: 2026-04-13 / Plan author. + +- **D2 — Badge shows open (unresolved) comment count, matching v1.** + Rationale: This is what v1 does and it is the most actionable number for a reviewer. + Date: 2026-04-13 / Plan author. + + +## Outcomes & Retrospective + +(To be filled at completion.) + + +## Context and Orientation + +This section explains the relevant parts of the codebase for someone who has never seen it. + +### The v2 workspace sidebar + +The v2 workspace lives at `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/`. Its right sidebar is rendered by the `WorkspaceSidebar` component at `components/WorkspaceSidebar/WorkspaceSidebar.tsx`. The sidebar shows tabs defined by the `SidebarTabDefinition` interface (from `components/WorkspaceSidebar/types.ts`): + + export interface SidebarTabDefinition { + id: string; + label: string; + badge?: number; + actions?: ReactNode; + content: ReactNode; + } + +Currently three tabs are assembled in `WorkspaceSidebar.tsx`: + + const tabs = [filesTab, changesTab, checksTab]; + +The `changesTab` is built by a hook, `useChangesTab`, which lives at `components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx`. It returns a `SidebarTabDefinition` with the "Changes" label, a badge showing the number of changed files, and a `ChangesTabContent` component as its `content`. This hook-returns-tab-definition pattern is the model we will follow. + +The `checksTab` is an inline stub: + + const checksTab: SidebarTabDefinition = useMemo( + () => ({ + id: "checks", + label: "Checks", + content: ( + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + Coming soon + </div> + ), + }), + [], + ); + +This stub will be replaced by the new review tab. + +### The v1 ReviewPanel + +The v1 review UI lives at `renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx` (590 lines). It is a single component that receives these props: + + interface ReviewPanelProps { + pr: GitHubStatus["pr"] | null; + comments?: PullRequestComment[]; + isLoading?: boolean; + isCommentsLoading?: boolean; + workspaceId?: string; + onCommentsChange?: () => void; + } + +The data is fetched by its parent (`ChangesView.tsx`) using: + +- `electronTrpc.workspaces.getGitHubStatus` — returns `{ pr, repoUrl, ... }` where `pr` has `number`, `title`, `url`, `state`, `reviewDecision`, `checksStatus`, `checks[]`, `requestedReviewers[]`. +- `electronTrpc.workspaces.getGitHubPRComments` — returns `PullRequestComment[]` with `id`, `authorLogin`, `avatarUrl`, `body`, `createdAt`, `url`, `kind`, `path`, `line`, `isResolved`, `threadId`. +- `electronTrpc.workspaces.resolveReviewThread` — mutation taking `{ workspaceId, threadId, resolve }`. + +Refetch policies are controlled by `getGitHubStatusQueryPolicy` and `getGitHubPRCommentsQueryPolicy` from `renderer/lib/githubQueryPolicy`. GitHub status polls every 10 seconds when active; comments poll every 30 seconds. + +The v1 ReviewPanel utility functions live at `components/ReviewPanel/utils.ts` and export: `reviewDecisionConfig`, `checkIconConfig`, `checkSummaryIconConfig`, `prStateLabel`, `formatShortAge`, `getCommentPreviewText`, `getCommentAvatarFallback`, `buildCommentClipboardText`, `buildAllCommentsClipboardText`, `splitPullRequestComments`, `countOpenPullRequestComments`, `getCommentCopyActionKey`, `resolveCheckDestinationUrl`, `getCommentKindText`. + +The `PRIcon` component at `renderer/screens/main/components/PRIcon/PRIcon.tsx` renders different colored git icons based on PR state (open/merged/closed/draft). + +### tRPC clients in v2 + +The v2 workspace code uses two tRPC clients: + +- `workspaceTrpc` (from `@superset/workspace-client`) — a React Query tRPC client that talks to the host-service server. Used for `git.*`, `workspace.*` queries. This is the primary v2 data layer. +- `electronTrpcClient` (from `renderer/lib/trpc-client`) — an imperative (non-hook) proxy client that talks to the Electron main process via IPC. Used for things like `external.openFileInEditor`. +- `electronTrpc` (from `renderer/lib/electron-trpc`) — a React Query tRPC client that also talks to the Electron main process via IPC (same router as `electronTrpcClient`, but with `useQuery`/`useMutation` hooks). This is what v1 uses extensively. It is available in v2 renderer code and already imported in several places. + +For the review tab, we will use `electronTrpc` (the React hooks client) since the GitHub endpoints live on the electron router. + +### Clipboard in v2 + +The v2 code already has a `useCopyToClipboard` hook at `renderer/hooks/useCopyToClipboard.ts` that wraps `electronTrpc.external.copyPath.useMutation()`. It returns `{ copyToClipboard, copied }`. We will use this instead of the v1 pattern of calling `electronTrpc.external.copyText.useMutation()` directly. + +### Types + +`GitHubStatus` and `PullRequestComment` are defined in `packages/local-db/src/schema/zod.ts` and exported from `@superset/local-db`. They are Zod-inferred types. Key shapes: + +- `GitHubStatus.pr.checks[n]` has `{ name, status, url?, durationText? }` where `status` is `"success" | "failure" | "pending" | "skipped" | "cancelled"`. +- `PullRequestComment` has `{ id, authorLogin, avatarUrl?, body, createdAt?, url?, kind?, path?, line?, isResolved?, threadId? }`. + + +## Plan of Work + +The work is split into a data hook and four UI sub-components, following the v2 co-location pattern from AGENTS.md: each component gets its own folder with `ComponentName.tsx` + `index.ts`. + +### File structure to create + +All new files live under the v2 sidebar hooks directory, mirroring how `useChangesTab` is structured: + + components/WorkspaceSidebar/ + hooks/ + useChangesTab/ # existing + useReviewTab/ # NEW — hook + components + useReviewTab.tsx + index.ts + components/ + ReviewTabContent/ + ReviewTabContent.tsx + index.ts + PRHeader/ + PRHeader.tsx + index.ts + ChecksSection/ + ChecksSection.tsx + index.ts + CommentsSection/ + CommentsSection.tsx + index.ts + +### Milestone 1: useReviewTab hook + ReviewTabContent shell + +Create the `useReviewTab` hook that fetches GitHub status and comments, and returns a `SidebarTabDefinition`. + +**File: `hooks/useReviewTab/useReviewTab.tsx`** + +This hook accepts `{ workspaceId }` and does the following: + +1. Calls `electronTrpc.workspaces.getGitHubStatus.useQuery({ workspaceId })` with `getGitHubStatusQueryPolicy("changes-sidebar", { hasWorkspaceId: true, isActive: true })`. +2. Extracts `activePullRequest` from the result. +3. Calls `electronTrpc.workspaces.getGitHubPRComments.useQuery(...)` with `getGitHubPRCommentsQueryPolicy(...)`, only enabled when a PR exists. +4. Calls `countOpenPullRequestComments(comments)` to compute the badge count. +5. Returns a `SidebarTabDefinition` with `id: "review"`, `label: "Review"`, `badge` set to the open comment count (or undefined if zero), and `content` set to `<ReviewTabContent ... />`. + +**File: `hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx`** + +A `memo`'d wrapper component that handles the loading/empty/error states and renders three sections: `PRHeader`, `ChecksSection`, `CommentsSection`. Props: + + interface ReviewTabContentProps { + pr: GitHubStatus["pr"] | null; + comments: PullRequestComment[]; + isLoading: boolean; + isCommentsLoading: boolean; + workspaceId: string; + onRefresh: () => void; + } + +When `isLoading && !pr`, show "Loading review..." centered text. When `!pr`, show "Open a pull request to view review status, checks, and comments." centered text. Otherwise render the three sections. + +**File: `hooks/useReviewTab/index.ts`** + +Barrel export of `useReviewTab`. + +At the end of this milestone, the review tab renders with loading/empty states but no real content sections yet. Verify by running `bun dev`, opening a v2 workspace, and seeing the "Review" tab appear in the sidebar. If no PR exists, it should show the placeholder message. + +### Milestone 2: PR header section + +**File: `hooks/useReviewTab/components/PRHeader/PRHeader.tsx`** + +Renders the PR title row and review decision badge, matching v1's layout. Structure: + +1. A clickable row with `PRIcon` (from `renderer/screens/main/components/PRIcon`), the PR title (truncated), and an external-link icon on hover. +2. Below that, a row with the review decision badge (using `reviewDecisionConfig` from the v1 utils) and requested reviewers list. + +Props: + + interface PRHeaderProps { + pr: NonNullable<GitHubStatus["pr"]>; + } + +The PR title links to `pr.url` via `<a href={pr.url} target="_blank">`. The review decision badge uses the same Tailwind classes as v1 (`reviewDecisionConfig[pr.reviewDecision]`). Requested reviewers are shown as "Awaiting reviewer1, reviewer2" in muted text, truncated. + +### Milestone 3: Checks section + +**File: `hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx`** + +A collapsible section showing CI checks. Uses `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` from `@superset/ui/collapsible`. Props: + + interface ChecksSectionProps { + checks: NonNullable<GitHubStatus["pr"]>["checks"]; + checksStatus: NonNullable<GitHubStatus["pr"]>["checksStatus"]; + prUrl: string; + } + +Behavior: + +1. Filter out checks with status `"skipped"` or `"cancelled"` to get `relevantChecks`. +2. Compute `passingChecks` count and `checksSummary` string (e.g., "3/5 checks passing"). +3. Render a collapsible trigger with "Checks" label, count badge, and summary icon (from `checkSummaryIconConfig`). Pending checks get `animate-spin` on the icon. +4. In the collapsible content, render each check as a row with its status icon (from `checkIconConfig`), name, optional duration text, and an external link icon. If `resolveCheckDestinationUrl` returns a URL, wrap the row in an `<a>` tag. Otherwise render a plain `<div>`. + +Starts open by default (`useState(true)`). + +### Milestone 4: Comments section + +**File: `hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx`** + +The most complex section. Shows active and resolved comments with actions. Props: + + interface CommentsSectionProps { + comments: PullRequestComment[]; + isLoading: boolean; + workspaceId: string; + onCommentsChange: () => void; + } + +Internal state: + +- `commentsOpen: boolean` (default true) — controls the active comments collapsible. +- `resolvedOpen: boolean` (default false) — controls the resolved comments collapsible. +- `copiedActionKey: string | null` — tracks which comment was just copied (for showing the check icon briefly). +- `resolvingThreadIds: Set<string>` — tracks which threads have an in-flight resolve mutation. +- `isResolvingAll: boolean` — tracks the batch resolve-all operation. + +Data flow: + +1. Split comments into `active` and `resolved` using `splitPullRequestComments`. +2. Get the clipboard hook via `useCopyToClipboard`. +3. Set up the resolve mutation via `electronTrpc.workspaces.resolveReviewThread.useMutation()`. + +Actions: + +- **Copy single comment**: Calls `copyToClipboard(buildCommentClipboardText(comment))` and sets `copiedActionKey`. +- **Copy all comments**: Calls `copyToClipboard(buildAllCommentsClipboardText(activeComments))`. +- **Resolve/unresolve thread**: Calls the resolve mutation with `{ workspaceId, threadId, resolve: !comment.isResolved }`. On success, calls `onCommentsChange()` to refetch. +- **Mark all done**: Iterates unique resolvable thread IDs, calls the resolve mutation for each via `Promise.allSettled`, then calls `onCommentsChange()`. + +Each comment row renders: avatar (using `Avatar` from `@superset/ui/avatar`), author login, kind badge ("Review" or "Comment" via `getCommentKindText`), age (via `formatShortAge`), and a one-line body preview (via `getCommentPreviewText`). On hover, action buttons appear: resolve/unresolve, copy, and open-on-GitHub link. + +The resolved comments section appears below the active section only when `resolvedComments.length > 0`, collapsed by default. + +### Milestone 5: Wire into WorkspaceSidebar + +**Edit: `components/WorkspaceSidebar/WorkspaceSidebar.tsx`** + +1. Add import: `import { useReviewTab } from "./hooks/useReviewTab";` +2. Call the hook: `const reviewTab = useReviewTab({ workspaceId });` +3. Replace the `checksTab` useMemo block entirely. +4. Change the tabs array: `const tabs = [filesTab, changesTab, reviewTab];` +5. Remove the `checksTab` variable and its import (it's inline, so just delete lines 89-100). + +### Milestone 6: Validation and cleanup + +Run these commands from the repo root and verify: + + bun run typecheck + # Expected: No errors + + bun run lint:fix + # Expected: No unfixable lint errors + + bun dev + # Expected: Desktop app opens. Create or open a v2 workspace that has + # a GitHub repo with a pull request. Click the "Review" tab in the + # right sidebar. Verify: + # - PR title, state icon, and link to GitHub are shown + # - Review decision badge (approved/changes requested/pending) appears + # - Requested reviewers are listed + # - CI checks section is collapsible, shows pass/fail/pending icons + # - Comments section shows active comments with avatars and previews + # - Hovering a comment reveals copy/resolve/link action buttons + # - Clicking resolve marks the thread as done (spinner, then updates) + # - "Mark all done" resolves all active threads + # - "Copy all" copies all comments to clipboard + # - Resolved comments appear in a separate collapsed section + # - If no PR exists, the tab shows the placeholder message + # - The tab badge shows the count of unresolved comments + +Also verify the existing v1 ChangesView ReviewPanel still works (it imports from its own utils.ts and should be unaffected). + + +## Concrete Steps + +All paths are relative to the repository root. + +### Step 1: Create the useReviewTab hook + +Create the file `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx` with the hook implementation described in Milestone 1. The hook uses `electronTrpc` for data fetching and returns a `SidebarTabDefinition`. + +Create the barrel `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/index.ts` exporting `useReviewTab`. + +### Step 2: Create ReviewTabContent + +Create `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx` and its `index.ts`. + +### Step 3: Create PRHeader + +Create `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/PRHeader.tsx` and its `index.ts`. Import `PRIcon` from `renderer/screens/main/components/PRIcon` and `reviewDecisionConfig` from the v1 ReviewPanel utils. + +### Step 4: Create ChecksSection + +Create `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx` and its `index.ts`. Import `checkIconConfig`, `checkSummaryIconConfig`, `resolveCheckDestinationUrl` from the v1 ReviewPanel utils. + +### Step 5: Create CommentsSection + +Create `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx` and its `index.ts`. Import comment utilities from the v1 ReviewPanel utils and `useCopyToClipboard` from `renderer/hooks/useCopyToClipboard`. + +### Step 6: Wire into WorkspaceSidebar + +Edit `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx`: + +- Import `useReviewTab`. +- Call `useReviewTab({ workspaceId })`. +- Replace `checksTab` with the result. +- Update the `tabs` array. + +### Step 7: Validate + + cd /Users/avipeltz/.superset/worktrees/superset/review-v2-screen + bun run typecheck + bun run lint:fix + bun dev + + +## Validation and Acceptance + +After implementation, run: + + bun run typecheck + # Expected: 0 errors + + bun run lint:fix + # Expected: Clean or only pre-existing warnings + + bun dev + # Open the desktop app. Navigate to a v2 workspace with a GitHub PR. + +Manual verification checklist: + +1. The sidebar shows three tabs: "All files", "Changes", "Review". +2. The "Review" tab badge shows the count of unresolved comments (or no badge if zero). +3. Clicking "Review" when no PR exists shows: "Open a pull request to view review status, checks, and comments." +4. With a PR, the header shows: PR icon (colored by state), PR title (clickable to GitHub), review decision badge, requested reviewers. +5. The Checks section is collapsible, defaults to open, shows each check with status icon and name. Clicking a check with a URL opens it in the browser. +6. The Comments section shows active comments with: avatar, author, kind badge, age, one-line preview. Hover reveals action buttons. +7. Clicking the resolve button on a comment shows a spinner and then the comment moves to the "Resolved" section. +8. "Mark all done" resolves all threads with a loading state. +9. "Copy all" copies all active comments to clipboard. +10. The "Resolved" section appears collapsed when resolved comments exist. Expanding it shows the resolved comments. + + +## Idempotence and Recovery + +All steps create new files or make additive edits to one existing file (`WorkspaceSidebar.tsx`). Running the steps multiple times is safe — creating a file that already exists will overwrite it with the same content. The edit to `WorkspaceSidebar.tsx` replaces the checksTab stub, which is idempotent (replacing the same lines again produces the same result). + +If something goes wrong mid-implementation, `git checkout -- apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/\$workspaceId/components/WorkspaceSidebar/` will revert all changes. The new `hooks/useReviewTab/` directory can be deleted to start fresh. + + +## Interfaces and Dependencies + +### Imports from v1 code (already exist, no changes needed) + +- `electronTrpc` from `renderer/lib/electron-trpc` — React Query tRPC client for Electron IPC. +- `getGitHubStatusQueryPolicy`, `getGitHubPRCommentsQueryPolicy` from `renderer/lib/githubQueryPolicy` — refetch policy helpers. +- `reviewDecisionConfig`, `checkIconConfig`, `checkSummaryIconConfig`, `formatShortAge`, `getCommentPreviewText`, `getCommentAvatarFallback`, `buildCommentClipboardText`, `buildAllCommentsClipboardText`, `splitPullRequestComments`, `countOpenPullRequestComments`, `getCommentCopyActionKey`, `resolveCheckDestinationUrl`, `getCommentKindText` from `renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/utils`. +- `PRIcon` from `renderer/screens/main/components/PRIcon`. +- `useCopyToClipboard` from `renderer/hooks/useCopyToClipboard`. + +### Imports from shared packages (already exist) + +- `GitHubStatus`, `PullRequestComment` from `@superset/local-db`. +- `Avatar`, `AvatarFallback`, `AvatarImage` from `@superset/ui/avatar`. +- `Collapsible`, `CollapsibleContent`, `CollapsibleTrigger` from `@superset/ui/collapsible`. +- `Skeleton` from `@superset/ui/skeleton`. +- `cn` from `@superset/ui/utils`. + +### Key function signatures + + // The hook + function useReviewTab({ workspaceId }: { workspaceId: string }): SidebarTabDefinition + + // Sub-components + function ReviewTabContent(props: { + pr: GitHubStatus["pr"] | null; + comments: PullRequestComment[]; + isLoading: boolean; + isCommentsLoading: boolean; + workspaceId: string; + onRefresh: () => void; + }): ReactNode + + function PRHeader(props: { + pr: NonNullable<GitHubStatus["pr"]>; + }): ReactNode + + function ChecksSection(props: { + checks: NonNullable<GitHubStatus["pr"]>["checks"]; + checksStatus: NonNullable<GitHubStatus["pr"]>["checksStatus"]; + prUrl: string; + }): ReactNode + + function CommentsSection(props: { + comments: PullRequestComment[]; + isLoading: boolean; + workspaceId: string; + onCommentsChange: () => void; + }): ReactNode + + +## Artifacts and Notes + +### V1 ReviewPanel data flow (for reference) + +In v1, `ChangesView.tsx` (the parent) fetches all data and passes it as props to `ReviewPanel`: + + const { data: githubStatus } = electronTrpc.workspaces.getGitHubStatus.useQuery( + { workspaceId }, + githubStatusQueryPolicy, + ); + const activePullRequest = githubStatus?.pr ?? null; + + const { data: githubComments = [] } = electronTrpc.workspaces.getGitHubPRComments.useQuery( + { workspaceId, prNumber: activePullRequest?.number, repoUrl: githubStatus?.repoUrl, ... }, + githubPRCommentsQueryPolicy, + ); + + <ReviewPanel + pr={activePullRequest} + comments={githubComments} + isLoading={isGitHubStatusLoading} + isCommentsLoading={isGitHubCommentsLoading} + workspaceId={workspaceId} + onCommentsChange={refetchGitHubComments} + /> + +In v2, the `useReviewTab` hook will own this data fetching internally (rather than having the sidebar parent do it), keeping the pattern consistent with how `useChangesTab` works. + +### Why not reuse the v1 ReviewPanel component directly + +The v1 `ReviewPanel.tsx` is 590 lines and mixes data concerns (resolve mutation state, clipboard mutation) with rendering. Splitting it into focused sub-components (PRHeader, ChecksSection, CommentsSection) improves readability and aligns with the v2 convention of smaller, single-responsibility components. The utility functions from `utils.ts` are reused directly without modification. diff --git a/apps/desktop/plans/20260416-v2-pr-checkout-endpoint.md b/apps/desktop/plans/20260416-v2-pr-checkout-endpoint.md new file mode 100644 index 00000000000..4f0251d13ef --- /dev/null +++ b/apps/desktop/plans/20260416-v2-pr-checkout-endpoint.md @@ -0,0 +1,396 @@ +# V2 PR Checkout + +Extend v2's `workspaceCreation.checkout` procedure to materialize a PR's branch +(via `gh pr checkout`) when the modal carries a `linkedPR`. Not a new endpoint +— `checkout` already means "materialize an externally-defined branch into a +worktree"; a PR branch is just another form of that. The client's +`pr-checkout` intent differentiates progress labels + payload construction, +but routes to the same tRPC mutation. + +Reuses the `getGitHubPullRequestContent` fetch that already happens at launch +time — moved earlier in the pending-page sequence and shared between the +mutation payload and the agent-launch resolver. **Zero net new fetches.** + +Cross-refs: +- `apps/desktop/V2_WORKSPACE_CREATION.md` — umbrella design this extends. +- `packages/host-service/GIT_REFS.md` — ref handling discipline. +- V1 source: `apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts:752` (`createFromPr`) + `.../utils/git.ts:1630-1791`. + +## Problem + +V2's `NewWorkspaceModal` accepts a `linkedPR` in its draft, and the UI already +signals the intent switch — when a PR is attached, the branch picker is +replaced with "based off PR #N" (`PromptGroup.tsx:365-376`). But submit +currently routes through the `fork` intent, which creates a new branch off +`baseBranch`. The PR is passed only as prompt context to the agent +(`buildForkAgentLaunch.ts:354`). Result: the workspace has no PR commits, +`git diff` shows nothing meaningful, and the user has to manually `gh pr +checkout` after the fact. + +V2's existing `checkout` procedure almost covers this case but not quite: +- Resolves branches via `origin/<branch>` — fork PRs live at + `refs/pull/N/head` and fail `resolveRef`. +- No fork-owner-prefix branch naming (`<owner>/<headRefName>` to avoid + collisions with local branches of the same name). +- No PR metadata awareness (base branch, state, cross-repo flag). + +The fix is a narrow expansion of `checkout`, not a new endpoint. + +## V1 pain points we're fixing + +1. **Server re-parses the PR URL** (`parsePrUrl` → `gh pr view`) even though + the picker already has structured data. +2. **`gh pr view` runs twice** — once at attach time, once at checkout time. +3. **`gh pr checkout --force` silently overwrites** any local branch with the + same name. V1's "existing worktree" check fires after the git op, not + before. +4. **Fire-and-forget** — no pending-row, no retry, no progress steps. +5. **Host-local only** — v1 writes to `worktrees` + `workspaces` tables, no + cloud `v2Workspace.create`, no `ensureV2Host`. +6. **Silent on closed/merged PRs** — worktree still created, user has to + notice. +7. **Untyped branch-name derivation** — inline string munging in `git.ts:1630`, + no unit tests. + +## Scope + +In: `checkout` widening, `getGitHubPullRequestContent` response widening, +pending-page fetch-before-mutate wiring, `buildForkAgentLaunch` resolved-PR +pass-through, tests. + +Out: picker-initiated PR checkout (no entry path today), PR comments, re-adopt +after cloud delete (existing `hasWorkspace` safety net covers this). + +--- + +## 1. Server + +### 1a. Widen `getGitHubPullRequestContent` + +File: `packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts` (line 1377) + +Currently returns `{number, title, body, url, state, branch, baseBranch, author, isDraft, createdAt, updatedAt}`. Missing for our use: `headRepositoryOwner`, `isCrossRepository`. + +`gh pr view --json` already returns both natively (v1 pulls them at +`git.ts:1704`). Three small edits: + +- Append `,headRepositoryOwner,isCrossRepository` to the `--json` flag + list (line 1394). +- Extend `PrSchema` zod (line 1430) with those fields. +- Expose them in the return mapping (line 1396-1409). + +Not a new fetch — same `gh pr view` call, two more fields surfaced. + +### 1b. Widen `checkout` + +File: same file, line 811. Two input changes: + +1. Add optional `pr` for the PR-path. +2. Add `composer.baseBranch` — matches `create`'s composer shape. Used by + the shared postlude to write `branch.<name>.base` for the Changes tab. + Populated client-side per mode (picker selection for branch path, + `pr.baseRefName` for PR path). + +Exactly one of `branch` or `pr` must be set — enforced at the zod layer: + +```ts +checkout: protectedProcedure + .input(z.object({ + pendingId: z.string(), + projectId: z.string(), + workspaceName: z.string(), + + branch: z.string().optional(), + pr: z.object({ + number: z.number().int().positive(), + url: z.string().url(), + title: z.string(), + headRefName: z.string(), + baseRefName: z.string(), + headRepositoryOwner: z.string(), + isCrossRepository: z.boolean(), + state: z.enum(["open", "closed", "merged", "draft"]), + }).optional(), + + composer: z.object({ + prompt: z.string().optional(), + baseBranch: z.string().optional(), // ← new; shared across branch + PR paths + runSetupScript: z.boolean().optional(), + }), + linkedContext: /* unchanged */, + }).refine( + (v) => (!!v.branch) !== (!!v.pr), + "exactly one of `branch` or `pr` must be set", + )) +``` + +### PR-path middle section + +```ts +if (input.pr) { + const branch = derivePrLocalBranchName(input.pr); + + // Idempotency: existing workspace for this branch → "open existing". + // Not an error — renderer navigates to it as if a create succeeded. + const existing = ctx.db.query.workspaces.findFirst({ + where: and(eq(workspaces.projectId, input.projectId), eq(workspaces.branch, branch)), + }).sync(); + if (existing) { + clearProgress(input.pendingId); + return { workspace: existing, terminals: [], warnings: [], alreadyExists: true }; + } + + const worktreePath = safeResolveWorktreePath(localProject.repoPath, branch); + const git = await ctx.git(localProject.repoPath); + + // Detached worktree → `gh pr checkout` inside creates the branch with + // correct fork-remote + upstream config. Matches v1's `createWorktreeFromPr`. + await git.raw(["worktree", "add", "--detach", worktreePath]); + try { + await execGh( + ["pr", "checkout", String(input.pr.number), "--branch", branch, "--force"], + { cwd: worktreePath, timeout: 120_000 }, + ); + } catch (err) { + await git.raw(["worktree", "remove", "--force", worktreePath]).catch(() => {}); + clearProgress(input.pendingId); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `gh pr checkout failed: ${errMsg(err)}`, + }); + } + + await git.raw(["-C", worktreePath, "config", "--local", "push.autoSetupRemote", "true"]).catch(warn); + // NOTE: `branch.<name>.base` write lives in `finishCheckout` and reads + // from `composer.baseBranch` — client passes `pr.baseRefName` for PR + // mode. No intent-specific config write here. See §3. + + return await finishCheckout(ctx, { + pendingId: input.pendingId, + projectId: input.projectId, + workspaceName: input.workspaceName, + branch, + worktreePath, + runSetup: input.composer.runSetupScript ?? false, + rollbackGit: git, + extraWarnings: input.pr.state !== "open" + ? [`PR is ${input.pr.state} — commits are included, but the PR may not merge.`] + : [], + }); +} + +// ...existing branch-path body, refactored to also call finishCheckout() +``` + +`finishCheckout` is a local helper in the same file wrapping: + +- `branch.<name>.base` config write (if `composer.baseBranch` set) +- `ensureV2Host` + `v2Workspace.create` (with rollback) +- local `workspaces` insert +- setup terminal +- `clearProgress` + +Called from both branches. Skips a full pipeline-extraction — two callers in +one file is a local helper, not a module. The existing branch-path in +`checkout` (non-PR) also routes through `finishCheckout`, which means +regular picker-driven checkouts start writing `branch.<name>.base` too — +fixes a current gap where only `create` records the base. + +### 1c. `derivePrLocalBranchName` + +New file: `packages/host-service/src/trpc/router/workspace-creation/utils/pr-branch-name.ts` + +```ts +export function derivePrLocalBranchName(pr: { + headRefName: string; + headRepositoryOwner: string; + isCrossRepository: boolean; +}): string { + if (pr.isCrossRepository) { + const owner = pr.headRepositoryOwner.toLowerCase(); + return `${owner}/${pr.headRefName}`; + } + return pr.headRefName; +} +``` + +Unit tests: same-repo passthrough, cross-repo prefix, owner case-folding, +cross-repo with slash-containing head refs, empty-field rejection. Pure +function — importable from the renderer too. + +## 2. Renderer + +### 2a. Pending-row schema + +File: `apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts` + +Only change: add `"pr-checkout"` to the `intent` enum. **`linkedPR` stays +narrow** (`{prNumber, title, url, state}`) — the enriched fields don't need to +persist in the pending row; they're fetched on-page via `useQuery`. + +### 2b. Submit dispatch + +File: `.../PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts` + +```ts +collections.pendingWorkspaces.insert({ + id: pendingId, + projectId, + intent: draft.linkedPR ? "pr-checkout" : "fork", + // ...rest unchanged +}); +``` + +### 2c. Pending-page dispatch + +File: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx` + +New case in `useFireIntent`: + +```ts +case "pr-checkout": { + // Fetch PR content before firing. Stable query key — react-query dedupes. + const { data: prContent, error } = useQuery({ + queryKey: ["workspaceCreation.getGitHubPullRequestContent", pending.projectId, pending.linkedPR.prNumber], + queryFn: () => hostServiceClient.workspaceCreation.getGitHubPullRequestContent.query({ + projectId: pending.projectId, + prNumber: pending.linkedPR!.prNumber, + }), + }); + if (error) { /* pending row error state, user can retry */ return; } + if (!prContent) { /* still loading — progress label: "Resolving PR..." */ return; } + + const result = await checkoutWorkspace(buildPrCheckoutPayload(pending, prContent)); + // ...agent-launch builder receives prContent via resolvedPr — no re-fetch +} +``` + +Progress labels at the UI layer differ per intent (`"Resolving PR..."`, +`"Checking out PR #123..."`). Server progress step names stay generic. + +### 2d. `buildForkAgentLaunch` — accept resolved PR + +File: `apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts` + +Add optional `resolvedPr` to `BuildForkAgentLaunchInputs`. When provided, +`fetchPullRequest` resolver (line 436) returns it directly instead of calling +`client.workspaceCreation.getGitHubPullRequestContent.query`. For `fork` +intent (no prefetch), the existing fetch-on-demand path is unchanged. + +The shape `prContent` returns already has `branch` (= `headRefName`) and +`body` — exactly what the resolver needs. + +### 2e. `buildPrCheckoutPayload` + +New file or addition alongside existing `buildForkPayload` etc. Pure function, +unit-tested. Constructs: + +```ts +{ + pendingId, projectId, workspaceName, + pr: { number, url, title, headRefName, baseRefName, headRepositoryOwner, isCrossRepository, state }, + composer: { + prompt: pending.prompt, + baseBranch: prContent.baseRefName, // ← sourced from fetched PR + runSetupScript: pending.runSetupScript, + }, + linkedContext: ..., +} +``` + +The branch-path equivalent (`buildCheckoutPayload`) gains a matching +`composer.baseBranch` field sourced from the picker's base selection. + +### What does NOT change + +- `DashboardNewWorkspaceDraftContext.tsx` — `LinkedPR` type stays narrow. +- `PRLinkCommand.tsx` — no attach-time fetch, no spinner on the pill, no + loading state on the modal. +- `searchPullRequests` endpoint — unchanged. +- `create` and `adopt` procedures — untouched. +- `checkout`'s existing branch-path body — the git ops stay as-is; only the + postlude is factored into `finishCheckout`, and `composer.baseBranch` + (new field) feeds into it. + +## 3. Base branch — the Changes-tab decision + +**Always write `branch.<name>.base` in the `checkout` postlude**, sourced from +`composer.baseBranch`. Client populates the field per mode: + +- Branch path: picker-selected base branch (same semantics as `create` + uses today). +- PR path: `pr.baseRefName` — the PR's merge target on GitHub. +- Absent: skip the write (matches `create`'s current `head`-start-point + behavior). + +Server doesn't branch on intent for this config write — it reads +`composer.baseBranch` uniformly and writes. Simpler server, simpler +contract. + +### Why PR path always uses `pr.baseRefName` + +- Changes tab compares workspace HEAD against `branch.<name>.base`. For a PR, + the semantically correct comparison is "my PR head vs PR's merge target on + GitHub" — that's `baseRefName`. +- Users don't have a mental model of "pick a base for a PR checkout." +- Rare retarget case: existing `setBranchBaseConfig` helper covers it + post-create. + +### Side benefit: fixes a current gap + +Today's `checkout` procedure doesn't write `branch.<name>.base` at all — only +`create` (fork) does. That means picker-driven "Check out" workspaces have no +recorded base, and the Changes tab has to infer. With this change, all three +intents (fork, checkout, pr-checkout) record a base via the same config key. +Consistent Changes-tab behavior across creation paths. + +## 4. Decisions locked + +- **`gh pr checkout` as mechanism.** Hard dep on `gh auth login`; handles + fork-remote + upstream in one shot. +- **Closed/merged PRs: allow with warning.** V1's silent-allow + `warnings[]` + entry. +- **Base branch: shared postlude write, sourced from `composer.baseBranch`.** + PR path fills it with `pr.baseRefName`; branch path fills it with picker + selection. See §3. +- **One endpoint, two modes.** Widen `checkout`; client keeps a distinct + `pr-checkout` pending intent. +- **Zero net new fetches.** Pending page fetches once via `useQuery`, feeds + both mutation payload and agent-launch resolver. Moves the existing + `buildForkAgentLaunch` fetch earlier, not adds a new one. + +## 5. Fetch accounting + +| Scenario | Before (today) | After | +|---|---|---| +| Fork, no PR | 0 PR fetches | 0 PR fetches | +| Fork with linkedPR (today's behavior, no longer reachable once `pr-checkout` intent branches) | 1 fetch at agent-launch | — | +| PR-checkout | — | 1 fetch at pending-page, shared with agent-launch | + +Same total call count per submit. Timing moves from "after mutation" to +"before mutation," which is required for the mutation payload. + +## 6. Test plan + +### Host-service + +1. `pr-branch-name.test.ts` — pure function, ~8 cases. +2. `workspace-creation.checkout.integration.test.ts`: + - Existing branch-path tests unchanged. + - PR-path: same-repo, fork, idempotency (existing workspace → `alreadyExists: true`), closed-PR warning, `gh pr checkout` failure rolls back worktree, cloud-create failure rolls back worktree. + - Schema guards: both `branch` + `pr` → zod error; neither → zod error. + +### Renderer + +3. `buildPrCheckoutPayload.test.ts` — pure builder, construction cases. +4. Manual smoke: + - Same-repo PR: attach, submit, verify PR commits in workspace. + - Cross-repo PR: fork remote added, branch named `<owner>/<head>`. + - Re-attach same PR: `alreadyExists` navigation. + - Closed PR: warning toast, workspace still created. + - `gh` missing: clear error at pending page. + +## 7. Rollout + +One PR: server widenings (1a, 1b, 1c) + renderer wiring (2a-2e) + tests. No +feature flag — gated by "user links a PR in the modal," same as v1. diff --git a/apps/desktop/plans/20260417-1830-lsof-excessive-spawning-3372.md b/apps/desktop/plans/20260417-1830-lsof-excessive-spawning-3372.md new file mode 100644 index 00000000000..63b59d57c90 --- /dev/null +++ b/apps/desktop/plans/20260417-1830-lsof-excessive-spawning-3372.md @@ -0,0 +1,74 @@ +# Stop Excessive `lsof` Process Spawning (Issue #3372) + +## Problem + +[#3372](https://github.com/superset-sh/superset/issues/3372): Superset spawns a growing pile of `lsof` processes. Symptoms: CPU pinned at 100%, count grows with open workspaces, closing workspaces doesn't help, quitting Superset leaves `lsof` behind. + +Related: [#3235](https://github.com/superset-sh/superset/issues/3235) — EDR agents amplify every spawn, so fixing this also reduces their CPU. + +## Root Causes (three, compounding) + +All in `apps/desktop/src/main/lib/terminal/`. + +1. **Interval never stops.** `PortManager` constructor called `startPeriodicScan()` at module load. The 2.5s `setInterval` kept firing forever — zero sessions, closed workspaces, whatever. +2. **Hint scans run concurrently with the bulk scan.** `scanPane` had no `isScanning` guard. Hint regexes `/port\s+(\d+)/i` and `/:(\d{4,5})\s*$/` were so loose they matched routine `git`/`ssh` output, firing spurious scans on top of the periodic ones. +3. **`lsof` children outlive us.** Code ran `exec("sh -c 'lsof … || true'")`. On timeout, Node SIGTERMs the shell; the shell doesn't forward to `lsof`; the child gets reparented to `launchd`/`init` and survives even app quit. + +## Fix (minimum-churn, one PR) + +1. **Lifecycle:** interval starts on first `registerSession`/`upsertDaemonSession`, stops on the last unregister. +2. **Coalesce:** one debounced hint timer + a `scanRequested` flag. If a hint or tick fires mid-scan, queue exactly one follow-up. `maxInFlight == 1` guaranteed. +3. **No orphans:** `execFile` instead of `exec` (no shell wrapper). `AbortController` on `PortManager`; `stopPeriodicScan` aborts the in-flight child. +4. **Regex noise:** delete the two over-broad patterns; keep the three that imply a real listener (`listening on`, `server started`, `ready on`). + +## Alternatives Considered (and why rejected) + +- **A3, event-driven only (no interval):** misses silent port openers. Deferred. +- **B1, shared `isScanning` flag only:** still drops detection for up to 2.5s with no follow-up guarantee. B2 is strictly better for the same worst-case latency. +- **B3, per-pane `isScanning` + semaphore:** more state, same behavior as B2. +- **C3, `killSignal: "SIGKILL"`:** kills the shell, child still orphans. Doesn't address the root issue. +- **D3, delete all hints:** loses fast-detection for dev servers (UX regression). +- **Option 1: delete the whole dynamic-port subsystem and rely on `.superset/ports.json`:** attractive (-1500 lines) but regresses feature for users without a static config. +- **Option 2: delete periodic scan, hint-only:** halves the code but misses silent port openers. +- **Option 4: delete `lsof` entirely, parse ports from terminal output:** most elegant (-700 lines) but loses PID info (Kill Port), still has edge cases. + +Chose minimum-churn because the real cost isn't `lsof`'s per-call expense (~100 ms on the fast path) — it's the three lifecycle bugs multiplying it. Fix those and the feature works fine. + +## Prior Attempt — Superseded + +Auto-generated PR [#3373](https://github.com/superset-sh/superset/pull/3373) by `github-actions[bot]` addresses lifecycle + a weaker `isScanning` guard on `scanPane`. Leaves the orphan-on-timeout and noisy-regex causes untouched. This PR addresses all three. + +## Progress + +- [x] (2026-04-17) Lifecycle: lazy start/stop via `ensurePeriodicScanRunning` / `stopPeriodicScanIfIdle` +- [x] (2026-04-17) Concurrency: `scanRequested` follow-up flag; deleted `scanPane`, `scanPidTreeAndUpdate`, `pendingHintScans` +- [x] (2026-04-17) `execFile` + `AbortSignal`; `runTolerant` helper for lsof exit-1 +- [x] (2026-04-17) `AbortController` aborted on `stopPeriodicScan` +- [x] (2026-04-17) Deleted the two over-broad hint regexes +- [x] (2026-04-17) Deleted dead `getProcessName` export and unused `paneId` parameter +- [x] (2026-04-17) 13 regression tests in `port-manager.test.ts`; A/B verified (8 fail on `main`) +- [x] (2026-04-17) `bun run typecheck` + `bun run lint:fix` clean; 127/127 terminal tests pass +- [x] (2026-04-17) PR [#3547](https://github.com/superset-sh/superset/pull/3547) opened +- [ ] Manual validation on macOS with 10 workspaces +- [ ] #3547 merged, #3372 and #3373 closed + +## Surprises + +- `execFile` via `promisify` rejects on non-zero exit codes. `lsof` exits 1 when its `-p` filter matches no PIDs — legitimate empty result. Added `runTolerant` helper that reads `err.stdout` off the rejection. +- The production `getListeningPortsLsof` swallows all errors and returns `[]`. The initial test mock rejected on abort, which broke `forceScan`'s contract; fixed by mirroring production (resolve on abort). +- `getProcessName` was exported but had zero in-repo call sites. Likely dead since a prior hint-scan refactor. + +## Decisions + +- **Supersede #3373.** It fixes ~60% of the bug. Three causes → one PR is easier to review and revert. +- **Coalesce (B2) over shared-flag (B1).** B1 silently drops hint scans; B2 guarantees a follow-up at the same worst-case latency. +- **`execFile` + `AbortController`, not shell `exec`.** Removes the `sh -c` wrapper that strands children. Signal delivery becomes deterministic. +- **Delete the two over-broad regexes.** Routine non-port text shouldn't trigger scans. + +## Outcome + +- `port-manager.ts`: +36 / −110 +- `port-scanner.ts`: +52 / −40 +- `port-manager.test.ts`: +255 (new, 13 tests) + +A/B (mocked `lsof`): 100 hint-matching chunks during a 30 ms scan → ≤2 `lsof` calls, `maxInFlight == 1`. On `main`: unbounded concurrency. diff --git a/apps/desktop/plans/20260420-1045-agent-driven-pr-flow.md b/apps/desktop/plans/20260420-1045-agent-driven-pr-flow.md new file mode 100644 index 00000000000..7d64fea11cd --- /dev/null +++ b/apps/desktop/plans/20260420-1045-agent-driven-pr-flow.md @@ -0,0 +1,521 @@ +# Agent-driven PR flow for the V2 diff editor sidebar + +Status: proposed +Owner: @AviPeltz +Date: 2026-04-20 + +## Summary + +Replace direct-mutation PR actions in the V2 workspace sidebar with an +agent-driven dispatch model. The top of the right sidebar shows a single +context-aware action button; clicking it computes the current PR flow +state, picks a skill, builds a synthesized markdown context attachment, +and opens (or reuses) a chat pane with the skill pre-invoked and the +attachment loaded. The agent performs the actual git/GitHub work via its +existing tools. + +## Motivation + +- V2 currently has a read-only PR header (`PRHeader.tsx`) and no way to + create, update, merge, or resolve a PR without leaving the app. +- V1's `PRButton` (in `screens/main/.../ChangesView/.../PRButton.tsx`) does + this via direct tRPC mutations and a cascade of `if` branches. The logic + is split across `getPRActionState`, `getPrimaryAction`, and inline + conditionals, and has no single place to reason about all the states. +- Moving the "what to do next" logic into markdown skills makes the flow + forkable per-repo, reviewable in PRs, and lets the agent handle + conversational edge cases (rebase conflicts, failing checks, review + comments) that a direct mutation can't. + +## Current state (verified) + +**V1, full PR UI** — `apps/desktop/src/renderer/screens/main/.../ChangesView/` +- `components/ChangesHeader/components/PRButton/PRButton.tsx` — renders + create/link/merge states +- `utils/pr-action-state.ts` — pure reducer: + `{hasRepo, hasExistingPR, hasUpstream, pushCount, pullCount, isDefaultBranch}` + → `{canCreatePR, blockedReason}` +- `components/CommitInput/utils/getPrimaryAction.ts` — commit/sync/push/pull + cascade +- `utils/auto-create-pr-after-publish.ts` — auto-triggers PR create after + publishing a new branch +- `renderer/screens/main/hooks/useCreateOrOpenPR/useCreateOrOpenPR.ts` — + wraps `electronTrpc.changes.createPR` with a "behind upstream" confirm+retry + +**V2, read-only** — `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/` +- `hooks/useReviewTab/useReviewTab.tsx` — pulls `git.getPullRequest` + + `git.getPullRequestThreads`, normalizes to `NormalizedPR` with + `state`, `reviewDecision`, `checksStatus`, `checks[]` +- `hooks/useReviewTab/components/PRHeader/PRHeader.tsx` — title + review + decision pill, no actions +- `components/SidebarHeader/SidebarHeader.tsx:64` — tabs have an `actions` + slot on the right that is not currently used by the review tab +- No `mergeable` field, no branch-sync data, no PR-creation wiring + +**Backend** — `apps/desktop/src/lib/trpc/routers/changes/` +- `git-operations.ts` — `push`, `createPR`, `mergePR` already exist and + are what the agent will call as tools +- `utils/pull-request-discovery.ts` — `findExistingOpenPRUrl`, + `buildNewPullRequestUrl` + +**Chat plumbing in V2** (verified available for dispatch) +- `packages/panes/src/core/store/store.ts` — `addTab(...)`, `openPane(...)` +- `apps/desktop/src/shared/tabs-types.ts` — `ChatLaunchConfig` with + `initialPrompt: string` + `initialFiles: Array<{data, mediaType, filename}>` +- Attachments accept `data:text/markdown;base64,...` — no disk write needed +- Skills live in `.agents/commands/` (with `.claude/commands` and + `.cursor/commands` as symlinks — AGENTS.md rule 3). `packages/chat` + discovers them from `.claude/commands`. +- There is no programmatic `executeSlashCommand()`; we invoke a skill by + setting `initialPrompt: "/<skill-name>"` on the chat pane's launch config. + +## Architecture + +The header has three regions, left to right: + +- **PR link button** (left). Always rendered whenever a PR exists, in any + state (draft, open, merged, closed). Shows `#NNN` with a state icon and + an external-link chevron; clicking opens `pr.url` in the browser. Hidden + only when there is no PR for the current branch. +- **Status badge** (middle). Derived from `PRFlowState`. +- **Action button** (right). Context-aware; described in the Button + states section below. + +``` +┌─────────────────────────────────────────┐ +│ PRActionHeader (new) │ top of right sidebar +│ [#NNN ↗pr-link] [status] [▶action] │ +└──────────────────┬──────────────────────┘ + │ click + ▼ +┌───────────────────────────────┐ +│ getPRFlowState (pure) │ reducer over PR + branch + checks +└──────────────┬────────────────┘ + ▼ +┌───────────────────────────────┐ +│ usePRFlowDispatch │ +│ 1. build pr-context.md │ buildPRContext (pure) +│ 2. data-URL encode │ +│ 3. ensureChatPane │ new tab OR reuse +│ 4. addTab / openPane with │ +│ ChatLaunchConfig │ +└──────────────┬────────────────┘ + ▼ + chat pane opens with + initialPrompt = "/<skill>" + initialFiles = [pr-context.md] + ▼ + agent runs skill, calling existing + tRPC mutations + gh CLI as tools +``` + +The direct-mutation endpoints (`changes.createPR`, `changes.mergePR`, +`changes.push`) stay — they become the agent's tools, not the UI's. + +## Data layer changes + +`NormalizedPR` is missing the fields that distinguish the more nuanced +states (mergeable=conflicting, mergeable=behind, etc.), and V2 has no +branch-sync query. Without these the state machine can't disambiguate +states 13, 21, 22, 23 in the table below. + +### `getPullRequest` router (extend) + +File: `apps/desktop/src/lib/trpc/routers/changes/git-operations.ts` + +Surface GitHub's `mergeStateStatus` on the PR output. Normalize to: + +```ts +mergeable: "clean" | "conflicting" | "behind" | "blocked" | "unknown" +``` + +Mapping from GitHub's enum: +- `CLEAN` → `"clean"` +- `DIRTY` | `CONFLICTING` → `"conflicting"` +- `BEHIND` → `"behind"` +- `BLOCKED` (branch protection) → `"blocked"` +- `UNKNOWN` | `DRAFT` | anything else → `"unknown"` + +### New router: `getBranchSyncStatus` + +File: same. Input: `{ workspaceId }`. Output: + +```ts +{ + hasRepo: boolean, + hasUpstream: boolean, + pushCount: number, + pullCount: number, + isDefaultBranch: boolean, + hasUncommitted: boolean, + isDetached: boolean, + ghAuthenticated: boolean, + online: boolean, +} +``` + +V1 has the pieces of this scattered across `useGitChangesStatus` and +ad-hoc checks; we consolidate them into one query. Poll at ~10s (same +cadence as `getPullRequest`). + +### `NormalizedPR` (extend) + +File: `useReviewTab/types.ts` — add `mergeable`, `isDraft`. + +## State machine + +Design principle: **main states are coarse; the agent handles +preconditions.** Everything that blocks PR creation (uncommitted, +unpublished, unpushed, out-of-sync) collapses into one `no-pr` state +with a single **Create PR ▾** split button. The skill decides whether +to commit, publish, or push before calling `gh pr create`. The UI does +not need a separate state for each precondition. + +Post-PR states only fork when the next user action genuinely differs +(resolve conflicts ≠ fix checks ≠ address review ≠ merge). + +```ts +// getPRFlowState.ts +export type PRFlowState = + // system / gating + | { kind: "loading" } + | { kind: "unavailable"; reason: UnavailableReason } + // pre-PR (collapsed; one button covers all of these) + | { kind: "no-pr"; sync: BranchSyncStatus; + hasUncommitted: boolean } + // PR exists + | { kind: "pr-draft"; pr: NormalizedPR } + | { kind: "pr-checks-pending"; pr: NormalizedPR } + | { kind: "pr-checks-failing"; pr: NormalizedPR } + | { kind: "pr-review-pending"; pr: NormalizedPR } + | { kind: "pr-changes-requested"; pr: NormalizedPR } + | { kind: "pr-ready-to-merge"; pr: NormalizedPR } + | { kind: "pr-behind"; pr: NormalizedPR } + | { kind: "pr-conflicts"; pr: NormalizedPR } + | { kind: "pr-blocked"; pr: NormalizedPR } + | { kind: "pr-merged"; pr: NormalizedPR; + localBranchExists: boolean } + | { kind: "pr-closed"; pr: NormalizedPR } + // transient + | { kind: "busy"; pr: NormalizedPR | null } + | { kind: "error"; pr: NormalizedPR | null; + message: string }; + +type UnavailableReason = + | "no-repo" + | "offline" + | "gh-unauthenticated" + | "default-branch" + | "detached-head" + | "no-changes" + | "mergeability-unknown"; + +export function getPRFlowState(input: { + pr: NormalizedPR | null; + sync: BranchSyncStatus | null; + hasUncommitted: boolean; + isAgentRunning: boolean; + loadError: Error | null; +}): PRFlowState; +``` + +15 main states, down from 33. Precedence (short-circuit in this order): + +1. `error` if `loadError` and no last-known data +2. `loading` if queries are still fetching first time +3. `busy` if there's an in-flight chat turn dispatched from this header +4. `unavailable` for all hard gates (no-repo, offline, gh-unauth, + default-branch, detached-head, no-changes, mergeability-unknown) +5. `pr` present → route into one of the PR states +6. No PR → always `no-pr` (the single pre-PR state) + +### Button states (action button only) + +Fewer action-button variants than main states: the split buttons +(`create-pr-dropdown`, `merge-dropdown`) each serve one main state but +offer two or three options. The action button is independent of the PR +link button on the left, which is always shown when a PR exists. + +| Variant id | Rendering | Enabled | Options / on click | +|-----------------------|--------------------------------|---------|-------------------------------------------------------------------------------| +| `hidden` | not rendered | — | — | +| `disabled-tooltip` | greyed out, tooltip `reason` | no | — | +| `sign-in` | **Sign in** | yes | dispatch `pr/gh-auth` | +| `create-pr-dropdown` | **Create PR ▾** (split button) | yes | primary: `pr/create-pr`; dropdown: "Create draft PR" → `pr/create-pr --draft` | +| `mark-ready` | **Mark ready** | yes | dispatch `pr/mark-ready` | +| `view-checks` | **View checks** | yes | dispatch `pr/watch-checks` | +| `fix-checks` | **Fix checks** | yes | dispatch `pr/fix-checks` | +| `request-review` | **Request review** | yes | dispatch `pr/request-review` | +| `address-review` | **Address review** | yes | dispatch `pr/address-review` | +| `update-from-base` | **Update from base** | yes | dispatch `pr/update-branch` | +| `resolve` | **Resolve** | yes | dispatch `pr/resolve-conflicts` | +| `view-rules` | **View rules** | yes | dispatch `pr/branch-protection` | +| `merge-dropdown` | **Merge ▾** (split button) | yes | primary: repo default; dropdown: squash / merge / rebase → `pr/merge` | +| `clean-up` | **Clean up** | yes | dispatch `pr/cleanup-merged` | +| `reopen` | **Reopen** | yes | dispatch `pr/reopen` | +| `retry` | **Retry** | yes | refetch queries (no agent dispatch) | +| `cancel-busy` | spinner + **Cancel** | yes | cancel the current chat turn | + +16 variants, down from 25. `disabled-tooltip` carries a `reason` prop +for the tooltip text. Split buttons (`create-pr-dropdown`, +`merge-dropdown`) render a primary label plus a chevron opening a +dropdown of alternates. + +**`pr/create-pr` is one skill** that receives the full branch state in +`pr-context.md` and decides internally whether to commit, publish (set +upstream + push), or just push before calling `gh pr create`. The UI +passes `--draft` as a CLI-style arg in the `initialPrompt` when the +dropdown option is chosen; the skill parses it and adds `--draft` to +the `gh` call. + +### PR link button states (always visible when a PR exists) + +| Variant id | Rendering | On click | +|-------------------|-----------------------------------|-------------------------| +| `none` | not rendered (no PR for branch) | — | +| `pr-link-open` | `#NNN` + open-PR icon + ↗ | open `pr.url` | +| `pr-link-draft` | `#NNN` + draft-PR icon + ↗ | open `pr.url` | +| `pr-link-merged` | `#NNN` + merged-PR icon + ↗ | open `pr.url` | +| `pr-link-closed` | `#NNN` + closed-PR icon + ↗ | open `pr.url` | + +Icons reuse the existing `PRIcon` component at +`renderer/screens/main/components/PRIcon`. The link button is shown in +every PR-present flow state, including all `busy-agent-running` and +`error-stale` variants where a PR exists — so the user can always jump +to GitHub regardless of what the agent is doing. + +### Full state × action table + +15 main states. Every row names: PR link button (left), status badge +(middle), action button (right), and — for dispatchable actions — the +skill invoked and the `pr-context.md` payload. + +| # | State | PR link | Status badge | Action button | Skill | Attachment contents | +|----|-------------------------|-------------------|-----------------------------|-----------------------|-----------------------------|------------------------------------------------------------------| +| 1 | `loading` | `none` | spinner | `hidden` | — | — | +| 2 | `unavailable` | if PR: open link | reason-specific label † | `disabled-tooltip` or `sign-in` ‡ | `pr/gh-auth` (only for gh-unauth) | env diagnostics (only for gh-unauth) | +| 3 | `no-pr` | `none` | branch-sync summary § | `create-pr-dropdown` | `pr/create-pr` (`--draft` opt) | branch, commits since base, uncommitted status, push/pull counts, suggested title/body | +| 4 | `pr-draft` | `pr-link-draft` | "Draft" | `mark-ready` | `pr/mark-ready` | PR number, checks summary | +| 5 | `pr-checks-pending` | `pr-link-open` | "Checks running" | `view-checks` | `pr/watch-checks` | running check names + URLs | +| 6 | `pr-checks-failing` | `pr-link-open` | "Checks failing" | `fix-checks` | `pr/fix-checks` | failing check names + log URLs + last diff | +| 7 | `pr-review-pending` | `pr-link-open` | "Review pending" | `request-review` | `pr/request-review` | PR URL, suggested reviewers | +| 8 | `pr-changes-requested` | `pr-link-open` | "Changes requested" | `address-review` | `pr/address-review` | unresolved comments (path, line, body) | +| 9 | `pr-ready-to-merge` | `pr-link-open` | "Ready to merge" | `merge-dropdown` | `pr/merge` | PR number, strategy, post-merge cleanup flag | +| 10 | `pr-behind` | `pr-link-open` | "Update branch" | `update-from-base` | `pr/update-branch` | base branch, merge-base sha | +| 11 | `pr-conflicts` | `pr-link-open` | "Merge conflicts" | `resolve` | `pr/resolve-conflicts` | conflict file list, base sha, branch sha, merge command | +| 12 | `pr-blocked` | `pr-link-open` | "Branch protected" | `view-rules` | `pr/branch-protection` | protection reason | +| 13 | `pr-merged` | `pr-link-merged` | "Merged" | `clean-up` or `hidden` ¶ | `pr/cleanup-merged` | branch to delete, switch-to target | +| 14 | `pr-closed` | `pr-link-closed` | "Closed" | `reopen` | `pr/reopen` | PR number, close reason | +| 15 | `busy` | if PR: open link | "Agent working…" | `cancel-busy` | (cancel current chat turn) | — | +| 16 | `error` | if PR: open link | "Failed to refresh — retry" | `retry` | — | — | + +† Unavailable labels: "No GitHub repo" / "Offline" / "Sign in to GitHub" / +"On default branch" / "Detached HEAD" / "No changes" / +"Checking mergeability". + +‡ `unavailable` renders `disabled-tooltip` for every reason except +`gh-unauthenticated`, which renders `sign-in`. + +§ `no-pr` status badge text collapses branch-sync variants into one +short label: "Not published" / "N to push" / "N to pull" / "Diverged" / +"Uncommitted changes" / "Ready". All paths fire the same +`create-pr-dropdown` button. + +¶ `pr-merged` shows `clean-up` when the local branch still exists, +`hidden` when it's already been deleted. + +## New / changed files + +### New (code) + +- `.../useReviewTab/utils/getPRFlowState.ts` + `.test.ts` +- `.../useReviewTab/utils/buildPRContext.ts` + `.test.ts` +- `.../WorkspaceSidebar/hooks/usePRFlowDispatch/usePRFlowDispatch.ts` + `index.ts` +- `.../WorkspaceSidebar/components/PRActionHeader/PRActionHeader.tsx` + `index.ts` +- `.../WorkspaceSidebar/components/PRActionHeader/components/MergeStrategyDropdown/MergeStrategyDropdown.tsx` + `index.ts` +- `renderer/shared/utils/ensureChatPane/ensureChatPane.ts` + `.test.ts` + `index.ts` + +### Modified + +- `apps/desktop/src/lib/trpc/routers/changes/git-operations.ts` — surface + `mergeable` on `getPullRequest` +- `apps/desktop/src/lib/trpc/routers/changes/index.ts` — add + `getBranchSyncStatus` procedure +- `useReviewTab/types.ts` — `mergeable`, `isDraft` on `NormalizedPR` +- `useReviewTab/useReviewTab.tsx` — normalize `mergeable`, surface + `actions` (the `<PRActionHeader/>` element) for `SidebarHeader`'s + `actions` slot, or mount inside `ReviewTabContent` above `PRHeader` +- `ReviewTabContent.tsx` — mount `PRActionHeader` + +### V1 untouched + +V1 `PRButton` stays as-is. This plan is V2-only. We do not back-port the +agent flow. + +## Skills to author + +Location: `.agents/commands/pr/` (nested; existing skills at +`.agents/commands/` are flat — we namespace to keep the PR set together). +Symlinks already make them visible under `.claude/commands/pr/` and +`.cursor/commands/pr/`. + +Each skill is a markdown file with YAML frontmatter (`description`) and +a short body describing the goal and the allowed tool calls. The agent +consumes `pr-context.md` as an attachment. + +Skills to write (12): + +- `pr/create-pr.md` — one skill for the whole pre-PR path (commit, + publish, push, `gh pr create`). Accepts `--draft`. +- `pr/gh-auth.md` +- `pr/mark-ready.md` +- `pr/watch-checks.md` +- `pr/fix-checks.md` +- `pr/request-review.md` +- `pr/address-review.md` +- `pr/update-branch.md` +- `pr/resolve-conflicts.md` +- `pr/merge.md` +- `pr/branch-protection.md` +- `pr/cleanup-merged.md` +- `pr/reopen.md` + +Example (`pr/create-pr.md`): + +```markdown +--- +description: Create a pull request for the current branch +--- + +You are creating a PR. `pr-context.md` has branch name, base branch, +commits since base, whether the branch is published, uncommitted file +status, suggested title/body, and push/pull counts. + +Arguments: +- `--draft` create the PR as a draft + +Steps, in order, only doing what's needed: +1. If there are uncommitted changes, stage + commit them with a + message derived from the diff (confirm with user first). +2. If the branch has no upstream, `git push -u origin <branch>`. + Otherwise if `pushCount > 0`, `git push`. +3. If `pullCount > 0`, stop and tell the user to sync first. +4. `gh pr create --title "..." --body "..." --base <default>`, + adding `--draft` if the flag was passed. +5. Print the PR URL. + +Never force-push. If push fails non-fast-forward, stop and report. +``` + +## Dispatcher behavior + +```ts +function dispatchPRAction(state: PRFlowState) { + const action = primaryActionFor(state); + if (!action) return; + + const markdown = buildPRContext(state); + const attachment = { + data: `data:text/markdown;base64,${btoa(markdown)}`, + mediaType: "text/markdown", + filename: "pr-context.md", + }; + + const launchConfig: ChatLaunchConfig = { + initialPrompt: `/${action.skill}`, + initialFiles: [attachment], + }; + + const existing = findExistingChatPane(paneStore.getState()); + if (existing) { + focusPane(existing.id); + // see open question 1 + enqueueFollowUpTurn(existing.id, launchConfig); + } else { + paneStore.getState().addTab({ + panes: [{ kind: "chat", data: { sessionId: null, launchConfig } }], + }); + } +} +``` + +### Reuse vs new tab + +Two options: + +- **(a) Always open a new chat tab.** Simplest. Matches how + `useConsumePendingLaunch` already works. Multiple clicks = multiple + tabs. Ship this first. +- **(b) Reuse the existing chat pane.** Requires a new + `enqueueFollowUpTurn(sessionId, launchConfig)` on the chat session store + so subsequent clicks feed into the same conversation. + +Recommendation: ship (a), add (b) after skills stabilize. + +## Testing + +- **Unit (pure):** `getPRFlowState` table-driven test covering every + discriminant. Input shape is small (PR + sync + flags) so snapshot + coverage is cheap. Mirrors the style of + `pr-action-state.test.ts`. +- **Unit (pure):** `buildPRContext` per-state snapshot tests — the + markdown is the contract between UI and skill, so it needs regression + coverage. +- **Integration (renderer):** mock `getPullRequest` returning each + `mergeStateStatus` and assert `PRActionHeader` renders the right button + label. +- **Integration (dispatch):** stub the pane store; click each button; + assert `addTab` called with the right `initialPrompt` and a + base64-decodable `pr-context.md`. +- **Manual:** unpublished branch → click **Publish & Create PR** → + verify chat pane opens with `/pr/publish-and-create` + attachment and + the agent completes the flow. + +## Phasing + +MVP ships the `no-pr` path only (states 1–3 and 15–16). Everything else +lands incrementally. + +1. **MVP backend.** Add `getBranchSyncStatus`. No UI change yet. +2. **MVP pure layer.** `getPRFlowState` covering only `loading`, + `unavailable`, `no-pr`, `busy`, `error`. `buildPRContext` for `no-pr`. +3. **MVP skill.** `pr/create-pr.md` with `--draft` arg support. +4. **MVP dispatcher.** `ensureChatPane` + `usePRFlowDispatch` using + option (a) (new tab per click). +5. **MVP UI.** `PRActionHeader` in `ReviewTabContent` with the + `create-pr-dropdown` and PR link button. This is the first shippable + cut: user sees **Create PR ▾** on any pre-PR branch, dropdown offers + "Create draft PR". +6. **Post-PR states.** Add `mergeable` to `getPullRequest`. Expand + `getPRFlowState` to cover states 4–14 one at a time, each with its + own skill. +7. **Reuse existing chat pane** — option (b) — with a follow-up turn + API. Optional polish. + +## Open questions + +1. **New chat tab per click, or reuse existing pane?** Recommended: new + tab first, optimize later. +2. **Auto-execute the skill, or land user at a pre-typed prompt they + confirm?** Auto-execute is one-click but riskier for destructive + actions (merge, force-push paths). Option: auto-execute for + non-destructive states, confirm for destructive. +3. **`busy-agent-running` cancel button** — is there already an API to + cancel the current chat turn, or do we need one? +4. **Skill namespace**: `.agents/commands/pr/*.md` (nested) vs flat like + existing `.agents/commands/create-pr.md`. Recommend nested for grouping. +5. **`resolve-conflicts` UX** — does the agent run `git merge origin/<base>` + locally and leave conflict markers in the worktree for in-app + resolution, or does it send the user to GitHub's web conflict editor? + Recommend local-first. +6. **Draft PRs** — should the draft path also auto-create as a draft + (extra `--draft` flag on `gh pr create`) from a dedicated button, or + is "mark ready" always a post-creation step? + +## Non-goals + +- Back-porting to V1. +- Replacing `changes.createPR` / `changes.mergePR` tRPC endpoints — they + stay as the agent's tools. +- A generic skill-invocation API outside the PR flow. If that gets built + later, this dispatcher becomes its first caller. diff --git a/apps/desktop/plans/20260421-v2-main-workspace-creation.md b/apps/desktop/plans/20260421-v2-main-workspace-creation.md new file mode 100644 index 00000000000..ef21df5a599 --- /dev/null +++ b/apps/desktop/plans/20260421-v2-main-workspace-creation.md @@ -0,0 +1,123 @@ +# V2 Main Workspace Creation + +## Problem + +V1 auto-creates a singleton `type='branch'` workspace per project via +`ensureMainWorkspace` (`apps/desktop/src/lib/trpc/routers/projects/projects.ts:174`), +called inline from five mutations. V2 has no equivalent — `v2_workspaces` rows only +come from `workspaceCreation`, which always produces worktrees. Users finish +`project.setup` and see an empty sidebar. + +## Goals + +- Each `(projectId, hostId)` gets one "main" workspace whose path == the host's + `repoPath`. Multiple mains per project (one per host) are allowed. +- Created automatically on project create/setup success — no type picker, no UI + step. +- Main workspaces are real v2 workspace rows but are not deletable through normal + workspace delete flows. Users can remove them from the sidebar; host/project + removal owns deleting the cloud row. + +## Design + +### Schema + +Add to `v2_workspaces` (`packages/db/src/schema/schema.ts:524`): + +```ts +type: v2WorkspaceType().notNull().default("worktree"), +``` + +Backed by a `pgEnum("v2_workspace_type", ["main", "worktree"])` for DB-level +enforcement, matching the `v2ClientType` / `v2UsersHostRole` precedent. +Partial unique index: `(projectId, hostId) WHERE type = 'main'`. + +Column name `type` over `isMain: boolean` so the workspace-creation modal's +contract is explicit — it only ever writes `'worktree'`. + +No `pinnedAt`/pinning column. Sidebar membership remains renderer-local in +`v2WorkspaceLocalState`, and main workspace auto-visibility is derived from +`type='main'`, current host, and project sidebar membership. + +### `ensureMainWorkspace` helper (host-service) + +New helper in `packages/host-service/src/trpc/router/project/`. Given +`(projectId, repoPath)`: + +1. `ensureV2Host` (reuse call from `workspace-creation.ts:372`). +2. Resolve current branch: `git symbolic-ref --short HEAD` at `repoPath`. +3. `ctx.api.v2Workspace.create.mutate({ ..., type: "main", branch, name: branch })`. + Skip if the unique index rejects — idempotent. +4. Insert local `workspaces` row (`packages/host-service/src/db/schema.ts:95`) + with `worktreePath = repoPath`. The column is named `worktreePath` but holds + any absolute checkout path; for main that's the repo root. + +Log-and-continue on failure: any cloud/local error is caught, logged, and +swallowed (the helper returns `null`). `project.setup` doesn't regress when a +transient cloud blip hits — the startup sweep backfills on the next boot. +Idempotency via the partial unique index handles duplicates on retry. + +If a main row already exists and the repo root branch changed, cloud branch +metadata is refreshed. The display name only follows the branch while it still +equals the previous branch name; user-renamed main workspaces keep their label. + +### Call sites + +1. **`project.create` success** — after cloning/importing a new cloud project and + persisting the local project row. +2. **`project.setup` success** (`packages/host-service/src/trpc/router/project/project.ts:134`) — + after `persistLocalProject` in both `clone` and `import` branches. +3. **Workspace creation/adoption/checkout preflight** — before creating or + adopting a worktree, call the helper for the local project. This keeps normal + workspace flows self-healing if setup returned before main creation completed. +4. **Host-service startup sweep** — on boot, iterate local `projects` rows and + call the helper for each. Idempotent via the unique index, so it's safe on + every boot; in practice only does work once per pre-existing project. This is + the recovery path for projects already set up before this change ships. + +### Sidebar and modal + +Workspace-creation modal continues to write worktree workspaces only. Project +setup/create responses include `mainWorkspaceId` when the helper succeeds, so the +renderer can add the main workspace to the sidebar immediately. + +Main workspaces are auto-visible when: + +- the workspace is `type='main'` +- the host is the current machine +- the project is in the sidebar +- no renderer-local hidden tombstone exists for that workspace + +Removing a main workspace from the sidebar sets +`v2WorkspaceLocalState.sidebarState.isHidden = true` instead of deleting the local +state row. This prevents auto-visibility from immediately re-adding the row and +keeps hidden-row filtering centralized through the dashboard sidebar local-state +visibility helper. + +### Delete behavior + +`v2Workspace.delete` rejects `type='main'` rows. Regular workspace cleanup/delete +flows are worktree-only. Host project removal uses the explicit +`v2Workspace.deleteMainForHost` endpoint for the repo-root main row, then removes +the local project row. Cloud project deletion still cascades through the database +like any other project-level delete. + +## Migration + +`bunx drizzle-kit generate --name="v2_workspaces_main_type"`. No SQL backfill — +the cloud doesn't know `repoPath` or current branch. Existing setups are filled +in by the startup sweep the first time the updated host-service boots. + +## Rollout + +Cloud/API before desktop (per deploy ordering). Verify: + +- Fresh `project.setup` creates exactly one main row + local row. +- Re-running `project.setup` on the same host is idempotent. +- Two hosts on one project each get their own main row. +- Startup sweep fills in a main row for a project set up pre-update, without + duplicating on subsequent boots. +- `project.remove` cleans up the main row alongside worktrees. +- Normal workspace delete and cleanup flows reject main workspaces. +- Removing a main workspace from the sidebar hides it and does not re-add it on + the next sidebar recompute. diff --git a/apps/desktop/plans/20260422-2100-v1-to-v2-port.md b/apps/desktop/plans/20260422-2100-v1-to-v2-port.md new file mode 100644 index 00000000000..55fd466329c --- /dev/null +++ b/apps/desktop/plans/20260422-2100-v1-to-v2-port.md @@ -0,0 +1,251 @@ +# v1 → v2 port: migrate users' local-db projects and workspaces into v2 + +Status: planned (2026-04-22) +Branch: `port` +Followup tickets: [SUPER-469](https://linear.app/superset-sh/issue/SUPER-469), [SUPER-470](https://linear.app/superset-sh/issue/SUPER-470) + +## Goal + +Give v1 users a one-click path to bring their existing projects and workspaces into v2. The script creates or links `v2_projects` in their active organization, creates `v2_workspaces` under them, and registers host-service records so the new v2 rows work immediately. Idempotent: safe to re-run. + +## Scope + +**Ported:** + +| v1 source | v2 destination | +|---|---| +| `projects` row | `v2_projects` (cloud) + host-service `projects` (local SQLite) + `dashboardSidebarProjectSchema` collection (for `defaultOpenInApp`) | +| `workspaces` row (both types) | `v2_workspaces` (cloud) + host-service `workspaces` (local SQLite) | +| `worktrees` row | folded into host-service `workspaces.worktree_path`; no dedicated table in v2 | + +**Dropped (no v2 destination or no user value):** + +`color`, `tab_order`, `section_id`, `default_branch`, `workspace_base_branch`, `branch_prefix_mode`, `branch_prefix_custom`, `worktree_base_dir`, `neon_project_id`, `github_owner`, `icon_url`, `hide_image`, `config_toast_dismissed`, `is_unread`, `is_unnamed`, `deleting_at`, `port_base`, all timestamps, all `workspace_sections` rows. Rationale per field is in the sanity-check exploration (2026-04-22) — most are features v2 doesn't have yet; two (`workspace_base_branch`, `branch_prefix_*`) are tracked in SUPER-469/470. + +## Field mapping + +### v1 `projects` → v2 + +| v1 field | v2 target | Derivation | +|---|---|---| +| `id` | new `v2_projects.id` (uuid) | server-generated; mapping recorded in `migration_state` | +| `name` | `v2_projects.name` | direct | +| `main_repo_path` | host-service `projects.repo_path` | direct | +| parsed from `main_repo_path` | `v2_projects.repo_clone_url` | call v2's `parseGitHubRemote` on the repo's `origin` remote; NULL if no remote or un-parseable | +| parsed from `main_repo_path` | host-service `projects.{repo_provider, repo_owner, repo_name, repo_url, remote_name}` | same parser | +| slugify(`name`) | `v2_projects.slug` | slug with conflict retry (`foo`, `foo-2`, `foo-3`, …) | +| `default_app` | v2 `dashboardSidebarProjectSchema.defaultOpenInApp` | write to PGlite collection keyed by new v2 project id | + +`v2_projects.organization_id` = user's active org (resolved in preflight). +`v2_projects.github_repository_id` = set by `v2Project.create`'s existing logic that links against `github_repositories`. + +### v1 `workspaces` + `worktrees` → v2 + +| v1 field | v2 target | Derivation | +|---|---|---| +| `workspaces.id` | new `v2_workspaces.id` | server-generated | +| `workspaces.project_id` | `v2_workspaces.project_id` | via mapping table | +| `workspaces.name` | `v2_workspaces.name` | direct | +| `workspaces.branch` | `v2_workspaces.branch` + host-service `workspaces.branch` | direct | +| `workspaces.worktree_id` → `worktrees.path` (type=worktree) or `projects.main_repo_path` (type=branch) | host-service `workspaces.worktree_path` | folded from the worktree row | +| — | `v2_workspaces.host_id` | current machine's `v2_hosts` row | +| — | `v2_workspaces.organization_id` | same active org | +| — | `v2_workspaces.created_by_user_id` | ctx.userId | + +`worktrees.base_branch` is already preserved by v2 via `git config branch.<name>.base` — nothing to migrate. + +## UI + +**Trigger:** Settings → "Migrate v1 data to v2" button. No auto-prompt, no silent run. + +**Single modal, three states:** + +1. **Preview** + - "Found N projects, M workspaces in your local v1 data." + - Active org picker (shown only if user is in multiple orgs; defaults to current active org). + - Table of v1 projects, each row flagged as "Create new" or "Link to existing '{v2 project name}' in this org". + - For projects without a git remote: flagged as "Create new (local-only)" with a tooltip noting each machine creates its own. + - "Start migration" / "Cancel" buttons. + +2. **In progress** + - Progress bar + "Porting {projectName}…" label. + - Disable close while running. + +3. **Summary** + - "Created X projects, linked Y to existing, M workspaces migrated. K errors." + - Expandable error list: one row per failed v1 record with its error message. + - "Close" button. Re-running shows only un-migrated + errored rows. + +No wizard steps, no drag-and-drop, no per-row edit UI. If users want to rename a project, they do it in v2 after migration. + +## Duplicate project prevention + +v2's schema only requires unique slugs per org: +- `(organization_id, slug)` + +`repo_clone_url` is intentionally not unique. Multiple v2 projects may point at +the same GitHub repository. + +The migration relies on explicit dedup logic: + +### Happy-path flow per project + +``` +parsed_url = parseGitHubRemote(origin_url_from_main_repo_path) + +if parsed_url: + existing = v2Project.findByGitHubRemote({ organizationId, cloneUrl: parsed_url }) + if existing: + link: record (v1_id -> existing.id) in migration_state + if existing.repoCloneUrl is null: + v2Project.linkRepoCloneUrl({ id: existing.id, cloneUrl: parsed_url }) + continue + else: + try v2Project.create({ orgId, name, slug, repoCloneUrl: parsed_url }) + on success: record mapping, continue + on UNIQUE_VIOLATION(slug): + retry with slug-2, slug-3, ... (bounded: 10 attempts) +else: + # local-only repo, no dedup signal + v2Project.create({ orgId, name, slug, repoCloneUrl: null }) + on UNIQUE_VIOLATION(slug): retry with slug-N +``` + +### Trickinesses this handles + +1. **URL canonicalization** — reuse v2's `parseGitHubRemote` (lives in the v2 project router package). Do not re-implement in the migration package. Both paths must produce `https://github.com/acme/foo` from every input variant (`git@github.com:Acme/foo.git`, `ssh://git@github.com/acme/foo/`, trailing slashes, uppercase). + +2. **Create-after-lookup race** — if two users in the same org run migration simultaneously, `findByGitHubRemote` can say null for both, then one `create` wins and the other hits the unique index. The loser catches, re-looks-up, links. + +3. **Forks** — user A with `origin=github.com/acme/foo` and user B with `origin=github.com/userb/foo` (their fork) create *two* v2 projects. This is correct: different remotes = different projects. Not a bug to solve. + +4. **Local-only dupes across machines** — no remote means no dedup; accepted. Documented in the preview modal ("local-only, will create fresh"). + +5. **Slug uglification** — slug conflicts across repos in the same org produce `api-2`, `api-3`. Acceptable; user can rename post-port. + +6. **Unparseable remotes** — GitLab, Bitbucket, internal Git hosts: `parseGitHubRemote` returns null → treated as local-only (no dedup, no `github_repository_id`). Future work if v2 supports non-GitHub remotes. + +## Script architecture + +**The orchestrator runs in the renderer, not main.** This is the established pattern for v1→v2 migrations in this codebase (see `useMigrateV1PresetsToV2`) and is forced by the `dashboardSidebarProjectSchema` collection being renderer-only localStorage — main has no window/localStorage and no IPC bridge for this collection. The renderer has direct access to everything it needs: `electronTrpcClient` to read v1 via main, cloud tRPC to write `v2_projects`/`v2_workspaces`, `@tanstack/db` collections for local sidebar meta, and `device.ensureV2Host` to resolve `host_id`. + +``` +apps/desktop/src/renderer/routes/_authenticated/settings/.../MigrateV1Data/ + MigrateV1Data.tsx # button + modal; hosts the orchestrator hook + useV1ToV2Migration.ts # the orchestrator hook (preflight/preview/run/status) + readV1.ts # wraps electronTrpcClient calls to load v1 rows from main + preflight.ts # ensureV2Host, load migration_state, compute preview + migrateProject.ts # parseOriginRemote → dedup → create-or-link → collection write + migrateWorkspace.ts # v2Workspace.create → host-service workspace registration via electronTrpc + migrationState.ts # CRUD via electronTrpcClient against main's local-db + parseRemote.ts # imports v2's parseGitHubRemote (do not re-implement) + types.ts + components/ + MigrationPreview/ + MigrationProgress/ + MigrationSummary/ + index.ts + +apps/desktop/src/main/ipc/v1-migration.ts # thin IPC surface main exposes: + # - readV1.{projects,workspaces,worktrees,settings} + # - gitRemote(mainRepoPath) -> origin URL or null + # - hostService.registerProject(projectId, ...) + # - hostService.registerWorkspace(workspaceId, ...) + # - migrationState.{read,upsert,list} + +packages/local-db/src/schema/schema.ts # add migration_state table +packages/local-db/drizzle/0041_v1_to_v2_migration_state.sql # via drizzle-kit generate +``` + +No changes to `packages/db` (v2 cloud schema). Main process only exposes read/IPC endpoints the renderer orchestrator needs. + +## `migration_state` table (new v1 migration) + +```ts +migrationState = sqliteTable("migration_state", { + v1Id: text().notNull(), + v2Id: text(), // nullable: for errored/skipped rows with no v2 counterpart + organizationId: text().notNull(), // scopes this migration to an org; prevents multi-org confusion + kind: text({ enum: ["project", "workspace"] }).notNull(), + status: text({ enum: ["success", "linked", "error", "skipped"] }).notNull(), + reason: text(), // free-form: error message, or skip reason ("orphan_worktree", "deleting_at_set") + migratedAt: integer().notNull(), +}, (table) => [ + primaryKey({ columns: [table.v1Id, table.kind] }), + index("migration_state_v2_id_idx").on(table.v2Id), + index("migration_state_organization_id_idx").on(table.organizationId), +]); +``` + +Status semantics: +- `success` — we created a new v2 row; `v2Id` is set +- `linked` — dedup hit; we mapped to an existing v2 row; `v2Id` is set +- `error` — attempt failed; `v2Id` may be set (e.g. created but then something after failed) or null; retry on rerun +- `skipped` — intentionally not migrated (orphan, `deleting_at`); `v2Id` null; don't retry + +Rerun logic: re-attempt `error` rows; skip `success`, `linked`, `skipped`. + +## Execution order per run + +1. **Preflight** (renderer orchestrator) + - Assert user is signed in. If they're in multiple orgs, show the org picker; default to current active org. + - Call `device.ensureV2Host({ organizationId, machineId: getHashedDeviceId(), name: getDeviceName() })` to upsert and capture `host_id`. This is idempotent — safe to run every time. On most of our own machines the row already exists. + - Read v1 `projects`, `workspaces` (filtering `deleting_at IS NULL`), `worktrees`, and the `default_app` per project via `electronTrpcClient`. + - Shell out via `electronTrpcClient.gitRemote(mainRepoPath)` to get each project's origin URL (v1 doesn't store remotes; we must discover at migration time). `parseGitHubRemote` the result. Handles: missing-repo-on-disk, non-GitHub remotes, no remote at all → all fall back to "local-only, create new". + - Load `migration_state`; subtract already-successful rows. If prior run was against a different `organizationId`, refuse and surface the error ("this machine has already migrated to Org X; use that org or reset the migration"). + - Handle orphan v1 workspaces (`worktree_id` set but worktree row missing) by flagging them `skipped` with reason `orphan_worktree`. Surface in preview count as "N workspaces will be skipped due to missing worktree data". + - Build preview: per-project rows labelled `create-new`, `link-to-existing`, or `local-only`. Return to UI. + +2. **Run** (renderer, after user confirms) + - Loop v1 projects sequentially (not parallel — cheap throttle, avoids rate-limit surprises): + - `migrateProject`: dedup via `v2Project.findByGitHubRemote` → `v2Project.create` / `linkRepoCloneUrl` → `electronTrpc.hostService.registerProject` → write `defaultOpenInApp` to the `v2SidebarProjects` collection. + - Record mapping on success; record `status=error` with message on failure; continue. + - Loop v1 workspaces grouped by project, skipping workspaces whose parent project errored and orphan workspaces: + - `migrateWorkspace`: `v2Workspace.create` → `electronTrpc.hostService.registerWorkspace` (passes v2 workspace id + worktree path + branch). + - Record on success/error; continue. + - No global transaction — remote network writes. Per-row atomicity via `migration_state` suffices. + +3. **Summary** + - Aggregate from `migration_state`. Return counts + expandable error list. + +## Idempotency + +- Every insert path checks `migration_state` first; already-successful rows are skipped. +- Errored rows are retried on rerun (re-attempted, may succeed this time). +- Linked rows are stable; rerunning just no-ops them. + +## Not doing (in this phase) + +- Not migrating `workspace_sections` (user decision, 2026-04-22). +- Not migrating `tab_order` for projects or workspaces (user decision). +- Not migrating `color`, `icon_url`, `hide_image` (no v2 destination, minor aesthetic). +- Not migrating `workspace_base_branch`, `branch_prefix_*` (per-project), `default_branch`, `worktree_base_dir` (per-project), `neon_project_id` (project-level): no v2 destination. Covered by SUPER-469, SUPER-470 where a v2 feature is planned; otherwise v2 intentionally diverges. +- Not migrating anything from the v1 singleton `settings` table in this phase (see "Out of scope — settings migration" below). +- Not migrating v1 `workspaces` rows where `deleting_at != null` — these are already being deleted; filtered out in `readV1.ts`. +- Not deleting v1 rows post-migration — v1 local.db stays intact so users can fall back via `v2-local-override.forceV1` and consult old settings until v2 has feature parity. +- Not building a CLI — Electron-internal only. + +## Out of scope — settings migration (phase 2) + +The v1 singleton `settings` table (`packages/local-db/src/schema/schema.ts:178-231`) holds global user state that's independent of projects/workspaces. Today only `terminal_presets` is migrated (via existing `useMigrateV1PresetsToV2` hook). A follow-up migration step should cover: + +**Likely worth porting** (real user customization that users would re-enter): +- `agent_preset_overrides` — per-agent permission customizations +- `agent_custom_definitions` — user-defined custom agents +- `branch_prefix_mode` / `branch_prefix_custom` (global) — in addition to per-project tracked in SUPER-470 +- `worktree_base_dir` (global) +- `default_editor` (global external-app default) + +**Probably ignore** (ephemeral, v2 handles differently, or low-stakes UI prefs): +- `last_active_workspace_id`, `active_organization_id` (session state) +- Font sizes, notification volume/ringtone, terminal link/persist behavior, `show_resource_monitor`, `confirm_on_quit`, etc. +- Migration markers (`agent_preset_permissions_migrated_at`, `terminal_presets_initialized`) + +This work belongs in its own ticket after the project/workspace port ships. It has different destinations (mostly PGlite collections or v2 session/user state, not `v2_projects`/`v2_workspaces`), so bundling it would expand scope without benefit. + +## Followups after ship + +- SUPER-469: when v2 adds project-level `workspace_base_branch`, extend migration to port it. +- SUPER-470: when v2 adds branch prefix settings, extend migration to port them. +- When v2 adds project `color` / `icon_url` — extend if those land. +- When v2 deprecates fallback-to-v1 (removes `v2-local-override`), add a "delete v1 data" button separate from the port action. diff --git a/apps/desktop/plans/20260423-1226-v2-pane-persistence-across-workspace-switch.md b/apps/desktop/plans/20260423-1226-v2-pane-persistence-across-workspace-switch.md new file mode 100644 index 00000000000..60c73dea91e --- /dev/null +++ b/apps/desktop/plans/20260423-1226-v2-pane-persistence-across-workspace-switch.md @@ -0,0 +1,130 @@ +# v2 pane persistence across workspace switch + +## Context + +Switching v2 workspaces unmounts the entire `<WorkspaceTrpcProvider>` subtree +(`layout.tsx:79` uses `key={`${workspace.id}:${hostUrl}`}`). Every pane React +component for the outgoing workspace is torn down and recreated for the +incoming one. Load-bearing long-lived state (xterm instance + WebSocket, +webview guest process, CodeMirror `EditorView`) must live OUTSIDE the +remounting subtree to survive. This note captures the root cause for each +pane kind and the fix pattern so we don't have to re-derive it. + +## Shared root cause + +The `key` on `WorkspaceTrpcProvider` is load-bearing — it exists +(commit `57557f806`) to prevent crashes from hook calls bleeding across +trpc clients during transitions. We cannot remove it. Any pane that wants +to survive workspace switches must: + +1. Hold its long-lived state in a module-level registry singleton. +2. Own a DOM node (or native handle) parented *outside* the React + workspace subtree (body-level `<div>` is the simplest). +3. Let the React component be a thin placeholder that only drives + position/visibility of the registry-owned node. + +Think "VSCode `TerminalInstance` + `setVisible`" or the existing +`browserRuntimeRegistry` root-container pattern. + +## Terminal — fixed in PR #3687 + +Was broken: `registry.attach()` fused DOM attach with WebSocket open and was +gated on `ensureSession`. The wrapper was `wrapper.remove()`'d on every +React unmount, so workspace switch was visible detach + reattach. The +`ensureSession` gate also added tRPC latency on warm returns, and opened +the WS against a nonexistent session on cold mount → "Session not found". + +Fixed by: +- Park wrapper in a hidden body-level `#v2-terminal-parking` div on + detach instead of `.remove()`. +- Split `attach` into `mount` (sync DOM) and `connect` (called only after + `ensureSession` resolves). +- Narrow `TerminalPane` effect deps to `[terminalId]`; read `workspaceId` + and `websocketUrl` through refs. `websocketUrl` changes go through a + separate `registry.reconnect` that no-ops on a cold transport. + +Refs: `terminal-runtime.ts`, `terminal-runtime-registry.ts`, +`TerminalPane.tsx`. + +## Browser — fixed + +### Symptom + +Switching workspaces destroyed the browser webview (and the guest page +along with it) instead of preserving state across the switch. + +### Root cause + +Confirmed via instrumentation: `browserRuntimeRegistry.destroy` was +being called on workspace switch with a stack rooted in React commit. +The only caller was `usePaneRegistry.tsx`'s `onRemoved` wiring: + +```ts +onRemoved: (pane) => browserRuntimeRegistry.destroy(pane.id), +``` + +`onRemoved` comes from `packages/panes/.../Workspace.tsx`, which diffs +`previousPanesRef` against `current` in a `useEffect` and calls +`registry[kind].onRemoved` for any id that disappeared. The diff lives +inside a single Workspace component instance. Under ideal conditions — +the v2 layout's `key={`${workspace.id}:${hostUrl}`}` remounts on every +switch — this diff should never observe cross-workspace "removal" +because each workspace has its own Workspace component. + +But the remount isn't always prompt: layout.tsx's `useLiveQuery` can +return stale WS-A data for a tick while `page.tsx`'s query has already +flipped to WS-B. During that tick the `key` hasn't changed yet, so the +existing `WorkspaceContent` stays mounted, `useV2WorkspacePaneLayout` +calls `store.replaceState(WS-B panes)` on the *same* store instance, +and the Panes library's diff correctly observes "the browser pane from +WS-A is gone now" → fires `onRemoved` → destroys the webview. By the +time the user returns to WS-A, the entry is gone; `attach()` runs the +`createEntry()` cold path and the webview is recreated with its +`initialUrl`, losing state. + +The terminal never hit this because terminal destruction goes through +`useGlobalTerminalLifecycle`, which sweeps against *all* workspaces' +persisted `paneLayout` rows and only destroys ids that are provably +absent everywhere. Cross-workspace "removal" isn't a real removal from +that sweep's perspective. + +### Fix + +Mirrored the terminal pattern: added `useGlobalBrowserLifecycle` under +`_authenticated/components/GlobalBrowserLifecycle/`, mounted it next to +`<GlobalTerminalLifecycle />` in `_authenticated/layout.tsx`, and +removed the `onRemoved` wiring from `usePaneRegistry.tsx`. The new hook +extracts browser `pane.id`s from every workspace's `paneLayout`, diffs +against the previous set, and schedules `browserRuntimeRegistry.destroy` +on a 500 ms grace delay (same timing as the terminal sweep) so +cross-workspace pane moves don't trigger premature teardown. + +Hypothesis #1 (placeholder-rect race) and #3 (webview recycling on +`visibility: hidden`) from the original list did not reproduce once #2 +was fixed — the instrumentation showed `updateLayout` applying correct +non-zero rects and the webview surviving detach as long as no `destroy` +call fired. Left in place as known-good; will revisit if a future +regression points at either. + +## File / Code editor — lower priority + +File-viewer panes use CodeMirror `EditorView` created in a `useEffect([])` +inside `CodeEditor.tsx:153-171`, disposed on unmount. Workspace switch +therefore loses: undo history, cursor position, scroll position, any +unsaved viewport scroll. Not reported yet but predictable; users may +complain after terminal/browser are solid. + +Fix pattern is identical: a module-level `codeEditorRegistry` keyed by +`${workspaceId}:${filePath}` (or pane id, if file viewer panes are +per-workspace) that owns the `EditorView` and its host div, with a body- +level root container. `CodeEditor` becomes a placeholder that registers +a rect. + +Defer until it's a reported problem — the migration is mechanical but +the value is speculative and CodeMirror re-init is already fast. + +## Not in scope + +- v1 terminal. Sunset per CLAUDE.md / memory. +- v2 chat pane. Currently a "temporarily disabled" stub. +- Diff / comment / devtools. No long-lived state. diff --git a/apps/desktop/plans/done/20260412-keyboard-recorder-ctrl-binding-fix.md b/apps/desktop/plans/done/20260412-keyboard-recorder-ctrl-binding-fix.md new file mode 100644 index 00000000000..8b5620b33d5 --- /dev/null +++ b/apps/desktop/plans/done/20260412-keyboard-recorder-ctrl-binding-fix.md @@ -0,0 +1,189 @@ +# Keyboard Recorder — Ctrl Binding & event.code Unification + +**Date:** 2026-04-12 · **Scope:** `apps/desktop/src/renderer/hotkeys/*` (+ 1 terminal file) · **PR:** #3391 + +## TL;DR + +User couldn't bind Ctrl-based shortcuts in Settings → Keyboard. Root cause: the +recorder filtered modifier keys using the wrong string (`"ctrl"` vs the actual +`event.key === "Control"`). Investigation surfaced a cluster of related bugs +all rooted in the recorder using `event.key` while the rest of the system +(registry, library dispatch, resolver) uses `event.code`. Consolidated on +`event.code` via shared helpers. + +## What was broken + +| # | Bug | Fix | +|---|------------------------------------------------------------------------------|--------------------------------------------------------------| +| 1 | Lone Ctrl auto-committed `ctrl+control` before the user pressed key 2 | Filter against `"control"` (the lowercased `event.key` name) + lock keys + altgraph | +| 2 | Recorder used `event.key`, resolver/registry use `event.code` → Shift+digit, Alt+letter on Mac, punctuation, non-US layouts silently unmatchable | Unified recorder on `event.code` via shared `normalizeToken` | +| 3 | `===` string compares missed equivalent chords (`meta+alt+up` ≠ `alt+meta+arrowup`) | Added `canonicalizeChord`; apply at conflict/reset/reserved lookups | +| 4 | Terminal forwarding used a frozen default-only reverse index → rebinds swallowed, freed defaults eaten | Reverse index subscribes to override store and rebuilds on change; `null` overrides drop from index | +| 5 | Migration blindly copied old corrupt overrides into localStorage | Sanitizer canonicalizes and drops entries that don't parse to one word-char key | +| 6 | Terminal helpers (`isTerminalReservedEvent`, `matchesKey`) used event.key and a duplicated `TERMINAL_RESERVED` | Exported `eventToChord` + `matchesChord` + `TERMINAL_RESERVED_CHORDS` as single source of truth; deleted duplicate | + +## Key code changes + +### Shared helpers (`utils/resolveHotkeyFromEvent.ts`) + +Exposes the canonical normalizer and matcher used everywhere: + +```ts +export function normalizeToken(token: string): string; // code/key → canonical +export function isIgnorableKey(normalized: string): boolean; // modifier + lock keys +export function canonicalizeChord(chord: string): string; // stable compare form +export function eventToChord(event: KeyboardEvent): string | null; +export function matchesChord(event: KeyboardEvent, chord: string): boolean; +export const TERMINAL_RESERVED_CHORDS: Set<string>; // canonical form +export const MODIFIERS: Set<string>; +``` + +Reverse index is now live (Bug 4): + +```ts +let registeredAppChords = buildRegisteredAppChords( + useHotkeyOverridesStore.getState().overrides, +); +useHotkeyOverridesStore.subscribe((state) => { + registeredAppChords = buildRegisteredAppChords(state.overrides); +}); +``` + +### Recorder (`hooks/useRecordHotkeys/useRecordHotkeys.ts`) + +Bug 1 + 2 in one: + +```ts +if (event.code === undefined) return null; // synthetic / autofill guard +const key = normalizeToken(event.code); // event.code, not event.key +if (isIgnorableKey(key)) return null; // catches Control/Shift/Alt/Meta/lock +const isFKey = /^f([1-9]|1[0-2])$/.test(key); +if (!isFKey && !event.ctrlKey && !event.metaKey) return null; +// …emit in registry MODIFIER_ORDER to stay string-comparable with defaults. +``` + +Bug 3: `canonicalizeChord` on both sides of every comparison (reset-to-default, +conflict detection, reserved-list lookup). `TERMINAL_RESERVED_CHORDS` imported +from the shared module — no more duplicate. + +### Migration (`migrate.ts`) + +Bug 5: sanitize each migrated value. Drops garbage (`ctrl+control`, +`ctrl+shift+@`, `meta+[`) and logs the count. Preserves `null` (explicit +unassignment). + +```ts +const canonical = canonicalizeChord(value); +const keys = canonical.split("+").filter((p) => !MODIFIERS.has(p)); +if (keys.length !== 1) return undefined; +if (!/^[a-z0-9]+$/.test(keys[0])) return undefined; +return canonical; +``` + +### Terminal helpers + +Bug 6: `utils/utils.ts` and `Terminal/helpers.ts` now use `matchesChord` + +shared `TERMINAL_RESERVED_CHORDS`: + +```ts +// utils/utils.ts +export function isTerminalReservedEvent(event: KeyboardEvent): boolean { + const chord = eventToChord(event); + return chord != null && TERMINAL_RESERVED_CHORDS.has(chord); +} + +// Terminal/helpers.ts — was: matchesKey(event, keys) +if (clearKeys && matchesChord(event, clearKeys)) { … } +``` + +### Display (`display.ts`) + +Runs each chord part through `normalizeToken` and extends `KEY_DISPLAY` to +cover both short (`up`) and canonical (`arrowup`) arrow names plus common +punctuation (`backslash`, `semicolon`, `quote`, `period`, `minus`, `equal`). + +## Library audit — nothing else missed + +Checked every `react-hotkeys-hook` usage against upstream docs: + +| Option | Our use | +|---------------------------------|---------------------------------------------| +| `useKey` (default false → code) | default (matches our `event.code` path) | +| `splitKey` / `sequenceSplitKey` | not used (no `,` multi-binds, no `>` chords)| +| `mod` alias | skipped — per-platform registry covers it | +| `scopes` / `HotkeysProvider` | not used (global `*` scope) | +| `keyup` / `keydown` | default (keydown only) | +| `preventDefault` | default false; callbacks handle when needed | +| `ignoreModifiers` | not used | +| `enableOnFormTags: true` | set in our `useHotkey` helper | + +Registry defaults already use event.code-canonical tokens (`bracketleft`, +`comma`, `slash`, `arrowup`). No hardcoded chord strings found outside +`hotkeys/` that need canonicalization. + +## Decisions taken + +- **Meta (Win/Super) on non-Mac — kept allowed.** Originally blocked; flipped + after review. Power users on tiling WMs / custom Windows configs can bind + Super-based chords. Extended `OS_RESERVED` on Windows with common shell + intercepts (`meta+d/e/l/r/tab`) so users get a "Reserved by OS" *warning* + instead of a silent block. +- **`mod` alias — skipped.** Registry's per-platform `{mac,windows,linux}` + covers the same ground without adding a parsing rule. +- **Migration: dropping invalid entries is better than carrying them.** + Silent corruption is worse than a visible drop count in console. + +## Testability + +Everything fixed is in pure functions over primitives. **62 tests across 4 +files**, no React/DOM harness needed (plain KeyboardEvent stubs): + +| File | Covers | +|-------------------------------------------------|-----------------------------------------------------| +| `utils/resolveHotkeyFromEvent.test.ts` | `normalizeToken`, `isIgnorableKey`, `canonicalizeChord`, `eventToChord`, `matchesChord`, live override index, `isTerminalReservedEvent` parity | +| `utils/overrideSanitizer.test.ts` | migration validation (Bug 5) | +| `hooks/useRecordHotkeys/useRecordHotkeys.test.ts` | recorder capture — all 3 bug classes | +| `display.test.ts` | display formatting parity (short + canonical forms) | + +Only untested branch: non-Mac `PLATFORM` path in the recorder's OS-reserved +warning. Would need module-mocking `PLATFORM`; not worth the harness. + +## Files changed + +``` +apps/desktop/plans/20260412-keyboard-recorder-ctrl-binding-fix.md (this doc) +apps/desktop/src/renderer/hotkeys/ + display.ts + display.test.ts (new) + migrate.ts + hooks/useRecordHotkeys/useRecordHotkeys.ts + hooks/useRecordHotkeys/useRecordHotkeys.test.ts (new) + utils/resolveHotkeyFromEvent.ts + utils/resolveHotkeyFromEvent.test.ts (new) + utils/utils.ts + utils/overrideSanitizer.test.ts (new) + utils/index.ts (barrel) + index.ts (barrel) +apps/desktop/src/renderer/screens/.../Terminal/helpers.ts +``` + +## Test plan (manual QA) + +- [ ] macOS: Settings → Keyboard → Record, press Cmd alone → no auto-commit, still recording +- [ ] Press Ctrl alone → no auto-commit (was the reported bug) +- [ ] Press Ctrl+Shift+2 → captures `ctrl+shift+2`, not `ctrl+shift+@` +- [ ] Press Meta+[ → captures `meta+bracketleft` +- [ ] Rebind a hotkey, press the new chord inside a terminal pane → fires +- [ ] Press the OLD default of a rebound hotkey in terminal → not swallowed +- [ ] Unassign (Backspace while recording) → old chord no longer swallowed in terminal +- [ ] Rebind CLEAR_TERMINAL to `ctrl+shift+bracketleft`, press it → clears (Bug 6) +- [ ] Windows: try binding Win+R → allowed with "Reserved by OS" warning + +## Sources + +- [react-hotkeys-hook GitHub](https://github.com/JohannesKlauss/react-hotkeys-hook) +- [`parseHotkeys.ts`](https://raw.githubusercontent.com/JohannesKlauss/react-hotkeys-hook/main/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts) — upstream modifier table + `mapCode` +- [`useRecordHotkeys.ts`](https://raw.githubusercontent.com/JohannesKlauss/react-hotkeys-hook/main/packages/react-hotkeys-hook/src/lib/useRecordHotkeys.ts) — upstream uses `event.code` by default and guards `event.code === undefined` +- [`useHotkeys` docs](https://react-hotkeys-hook.vercel.app/docs/api/use-hotkeys) — all options reviewed +- [MDN — KeyboardEvent.key values](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values) (`"Control"` not `"Ctrl"`) +- [MDN — KeyboardEvent.code values](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values) diff --git a/apps/desktop/plans/done/20260415-v2-host-service-ai-branch-naming-test-plan.md b/apps/desktop/plans/done/20260415-v2-host-service-ai-branch-naming-test-plan.md new file mode 100644 index 00000000000..e6f2cffb604 --- /dev/null +++ b/apps/desktop/plans/done/20260415-v2-host-service-ai-branch-naming-test-plan.md @@ -0,0 +1,86 @@ +# Manual Testing Plan — PR #3517 + +## Prerequisites +- Desktop dev running (`bun dev` from apps/desktop, or full `bun dev` from root) +- At least one project configured with a git repo + +## 1. v1 AI Branch Naming (API key path) + +**Setup**: `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` set in env (or stored via Settings > Models). + +| Step | Expected | +|---|---| +| Open v1 new-workspace modal (Cmd+N) | Modal opens | +| Type a prompt: "fix dropdown alignment bug" | Text entered | +| Submit (Enter or click Create) | Modal closes, pending workspace shows "Generating branch…" briefly | +| Wait for workspace to initialize | Branch name is AI-generated kebab-case (e.g. `fix-dropdown-alignment`), not random words | +| Check worktree | Branch exists locally | + +## 2. v1 AI Branch Naming (no credentials) + +**Setup**: unset `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` from env. No stored API keys in Settings > Models. + +| Step | Expected | +|---|---| +| Create workspace with prompt | Branch name falls back to random friendly name (e.g. `pickle-streetcar`) or prompt-derived slug | +| No error toast | Degradation is silent | + +## 3. v1 Workspace Auto-Rename + +**Setup**: API key available. + +| Step | Expected | +|---|---| +| Create workspace with prompt "refactor auth middleware" | Workspace title updates to AI-generated name (e.g. "Refactor Auth Middleware") after a few seconds | +| If no API key available | Title falls back to prompt text or friendly name | + +## 4. Anthropic OAuth Auto-Refresh (from #3510) + +**Setup**: Anthropic OAuth configured (Claude Max). Requires waiting for token expiry or manual simulation. + +| Step | Expected | +|---|---| +| Sign in to Anthropic via OAuth in Settings > Models | "Active" badge appears | +| Force-expire: edit `~/Library/Application Support/mastracode/auth.json`, set `anthropic.expires` to a past timestamp | — | +| Send a chat message | Chat succeeds silently (token auto-refreshed via `authStorage.getApiKey`). No "Reconnect" prompt. | +| If refresh token is also invalid | Falls to expired state, "Expired" badge + "Reconnect" button appears | +| Check terminal for `[chat-service] Anthropic OAuth refresh failed` | Logged if refresh fails | + +## 5. Settings > Models Page + +| Step | Expected | +|---|---| +| Navigate to Settings > Models | Page loads with Anthropic + OpenAI sections, each with provider icon in header | +| Each provider shows a single card with OAuth row + API Key row | OAuth row: label + badge + action. API Key row: input + contextual buttons | +| **Disconnected state** | "Not connected" badge, primary "Connect" button, no Save/Clear buttons | +| **API key flow**: type key → Save appears → click Save | "API key updated" toast, "Active" badge, "Logout" button appears | +| **API key flow**: click Clear | Key removed, badge reverts to "Not connected" | +| **OAuth flow**: click Connect → complete in browser | "Active" badge, "Logout" button | +| **OAuth flow**: click Logout | Badge reverts, Connect button returns | +| **API key + OAuth**: set API key, then connect OAuth, then disconnect OAuth | API key should survive the OAuth cycle (backup/restore workaround) | +| **OpenAI dialog** auto-opens browser on Connect | No manual "Open browser" step needed | +| **Copy URL** button shows "Copied!" feedback for 2s | — | + +## 6. Production Build + +| Step | Expected | +|---|---| +| `bun run compile:app` (from apps/desktop) | Succeeds. `get-small-model` chunk ~1.2 MB, no 20 MB chunk. | +| `bun run copy:native-modules` | Succeeds | +| `bun run validate:native-runtime` | All checks pass | +| `npx electron dist/main/index.js` | Main process boots (renderer 404 expected in non-packaged mode). No onnxruntime error. | + +## 7. Host-Service Procedure (dormant — future v2) + +Not yet wired to UI. Verify via tRPC playground or direct call if available: + +| Step | Expected | +|---|---| +| Call `workspaceCreation.generateBranchName({ projectId, prompt: "fix auth bug" })` | Returns `{ branchName: "fix-auth-bug" }` or similar (requires API key in host-service env) | +| Call with empty prompt | Returns `{ branchName: null }` | +| Call with no API key in env | Returns `{ branchName: null }` (graceful fallback) | + +## Known Regressions (documented, accepted) + +- **OAuth-only users** (Claude Max / OpenAI Codex without stored API key) get random branch names and prompt-derived workspace titles for small-model tasks. Main chat retains full OAuth. +- **Upstream dependency**: API key storage slot collision with OAuth is worked around via backup/restore. Proper fix tracked at mastra-ai/mastra#15483. diff --git a/apps/desktop/plans/done/20260415-v2-host-service-ai-branch-naming.md b/apps/desktop/plans/done/20260415-v2-host-service-ai-branch-naming.md new file mode 100644 index 00000000000..5a88bb50813 --- /dev/null +++ b/apps/desktop/plans/done/20260415-v2-host-service-ai-branch-naming.md @@ -0,0 +1,168 @@ +# V2 Workspace Modal — Host-Service AI Branch Naming + +Port v1's AI branch-name generation into v2's workspace modal, routed through host-service. Approach: **use upstream `mastracode`'s `resolveModel`** via a lightweight `createMastraCode({ disableMcp: true, disableHooks: true })` singleton. Delete our small-model abstraction; keep OAuth parity (Claude Max + Codex) because mastracode handles it internally. + +## Completed + +- ✅ Bumped `mastracode` 0.9.2 → **0.14.0** (+ transitive `@mastra/core` 1.16 → 1.25). Typecheck + tests green. Removed `minimumReleaseAge` from `bunfig.toml`. + +## Target architecture + +``` +v2 useSubmitWorkspace + └─> client.workspaceCreation.generateBranchName.mutate({ projectId, prompt }) + └─> generateBranchNameFromPrompt(...) [host-service] + └─> getSmallModel() [shared helper] + └─> resolveModel(modelId) from mastracode + (full auth: API-key + keychain + OAuth middleware) +``` + +Desktop v1's existing `ai-branch-name.ts` migrates to the same `getSmallModel` helper — single implementation, two consumers. + +## Shared helper + +`packages/chat/src/server/shared/small-model/get-small-model.ts`: + +```ts +import { createAuthStorage, createMastraCode } from "mastracode"; +import type { MastraLanguageModel } from "@mastra/core/llm"; + +const ANTHROPIC_SMALL = "anthropic/claude-haiku-4-5-20251001"; +const OPENAI_SMALL = "openai/gpt-4o-mini"; + +type Resolver = Awaited<ReturnType<typeof createMastraCode>>["resolveModel"]; +let initPromise: Promise<Resolver> | null = null; + +function getResolver(): Promise<Resolver> { + if (!initPromise) { + initPromise = createMastraCode({ disableMcp: true, disableHooks: true }) + .then((r) => r.resolveModel); + } + return initPromise; +} + +function pickSmallModelId(): string | null { + const auth = createAuthStorage(); + auth.reload(); + if (auth.has("anthropic")) return ANTHROPIC_SMALL; + if (auth.has("openai")) return OPENAI_SMALL; + return null; +} + +export async function getSmallModel(): Promise<MastraLanguageModel | null> { + const modelId = pickSmallModelId(); + if (!modelId) return null; + const resolveModel = await getResolver(); + return resolveModel(modelId) as MastraLanguageModel; +} +``` + +Module-level promise caches the mastracode init (one-time cost per process). Credential check is per-call (cheap, in-memory). + +## Code-removal budget + +| File | LOC | Fate | +|---|---|---| +| `apps/desktop/src/lib/ai/call-small-model.ts` | 184 | delete | +| `apps/desktop/src/lib/ai/call-small-model.test.ts` | 399 | delete | +| `apps/desktop/src/lib/ai/provider-diagnostics.ts` | 89 | delete if no other consumer | +| `packages/chat/src/server/desktop/small-model/small-model.ts` | 146 | delete | +| `packages/chat/src/server/desktop/small-model/small-model.test.ts` | 391 | delete | +| `packages/chat/src/server/desktop/title-generation/title-generation.ts` | 99 | trim (~50, drop streaming variant) | +| `packages/chat/src/server/desktop/auth/anthropic/anthropic.ts` | 232 | trim (~50, keep OAuth login helpers chat-service uses) | +| `packages/chat/src/server/desktop/auth/openai/openai.ts` | 99 | trim (~30) | +| `apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts` | 117 | rewrite → ~60 | +| New `shared/small-model/get-small-model.ts` | — | +50 | + +Net: **~1200 lines removed**. + +--- + +## Step 1 — Shared helper + migrate v1 branch naming + +### Actionable tasks +1. Create `packages/chat/src/server/shared/small-model/{get-small-model.ts, index.ts}` with the helper above. +2. Update `packages/chat/src/server/desktop/index.ts` barrel if needed; new helper lives in `shared/` and is imported directly from `@superset/chat/server/shared/small-model` — no re-export from desktop. +3. Rewrite `apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts`: + - Replace `callSmallModel` + provider branching with `getSmallModel()` + `generateText({ model, system, prompt })`. + - Keep `BRANCH_NAME_INSTRUCTIONS`, `resolveConflict`, `sanitizeBranchNameWithMaxLength`. +4. Grep for `callSmallModel`, `SmallModelProvider`, `getDefaultSmallModelProviders`, `generateTitleFromMessageWithStreamingModel`: + - Rewrite each consumer to `getSmallModel` + `generateText` (or Mastra Agent if the caller wants tracing). +5. Delete: + - `apps/desktop/src/lib/ai/call-small-model.ts` + test. + - `packages/chat/src/server/desktop/small-model/small-model.ts` + test + `index.ts`. + - `generateTitleFromMessageWithStreamingModel` from `title-generation.ts`. +6. `apps/desktop/src/lib/ai/provider-diagnostics.ts` — grep for consumers; delete if only `call-small-model.ts` uses it. Otherwise leave. +7. Audit `auth/anthropic` and `auth/openai`: keep exports chat-service uses for OAuth login UI; delete any credential-resolution helpers used only for small-model. +8. Run `bun run typecheck` + focused tests (chat-service, ai-branch-name). Fix breaks. +9. Smoke: launch desktop, create v1 workspace with a prompt, verify AI branch naming still works (both API key and OAuth paths). + +### Risks (step 1) +- **mastracode init side effects**: `createMastraCode` with disabled MCP/hooks still initializes storage, auto-detects project, etc. Confirm startup stays under ~200ms and doesn't create unwanted files. If it tries to touch a DB/libsql, pass an explicit `storage` config. +- **Second init conflict**: chat service already calls `createMastraCode` for its runtime. Running a second one for small-model might duplicate auth-storage singletons or compete for files. Mitigation: verify `createMastraCode` is side-effect-safe when called twice; if not, share the existing chat runtime's resolver. +- **Credential regression**: `authStorage.has("anthropic")` must cover all the "credential present" cases our current `getAnthropicCredentialsFromAnySource` covers (env vars, stored API keys, OAuth). Audit before replacing. + +--- + +## Step 2 — Host-service procedure + +### Actionable tasks +1. Port `sanitizeBranchNameWithMaxLength` (`apps/desktop/src/shared/utils/branch.ts`) and `resolveBranchPrefix` (`apps/desktop/src/lib/trpc/routers/workspaces/utils/branch-prefix.ts`) into `packages/host-service/src/trpc/router/workspace-creation/utils/`. +2. Create `packages/host-service/src/trpc/router/workspace-creation/utils/ai-branch-name.ts` — same helper as desktop's rewritten v1, imports `getSmallModel` from `@superset/chat/server/shared/small-model`. +3. Add to `workspace-creation.ts`: + ```ts + generateBranchName: publicProcedure + .input(z.object({ projectId: z.string(), prompt: z.string() })) + .mutation(async ({ input }) => { + const trimmed = input.prompt.trim(); + if (!trimmed) return { branchName: null }; + const project = /* existing project lookup */; + const existingBranches = /* existing branch listing */; + const prefix = await resolveBranchPrefix(project, existingBranches); + const branchName = await generateBranchNameFromPrompt(trimmed, existingBranches, prefix); + return { branchName }; + }), + ``` +4. Delete `packages/host-service/src/providers/model-providers/LocalModelProvider/utils/resolveAnthropicCredential.ts` + `resolveOpenAICredential.ts` if unused after step (LocalModelProvider no longer needs them since auth flows through mastracode). +5. Run typecheck + host-service tests. + +--- + +## Step 3 — Wire v2 + +### Actionable tasks +1. Update `apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts`: + - Compute `willGenerateAIName = !draft.branchNameEdited && !!trimmedPrompt && !draft.linkedPR`. + - Fallback via `resolveNames(draft)` (unchanged). + - Insert pending row with status `"generating-branch"` if `willGenerateAIName`. + - Close + navigate (unchanged). + - If `willGenerateAIName`, race `client.workspaceCreation.generateBranchName.mutate(...)` vs 30s timeout: + - success → update pending row `branchName` + status `"creating"`. + - auth error → toast + abort + remove pending row. + - other/timeout → toast `"Using random branch name..."`, keep fallback name. + - Call `client.workspaceCreation.create(...)` with resolved `branchName`. +2. Add `"generating-branch"` to `pendingWorkspaces` status union (`packages/local-db/src/schema/schema.ts`). Drizzle migration. +3. Update pending page UI (`apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx`) to render "Naming your branch…" for that status. + +--- + +## Effort + +| Step | Effort | +|---|---| +| 0. mastracode upgrade | ✅ done | +| 1. Shared helper + v1 migration + deletions | 2–3 hrs | +| 2. Host-service procedure | 1–1.5 hrs | +| 3. v2 wiring + pending UI | 1–2 hrs | +| **Remaining** | **~4–6.5 hrs** | + +## Risks + +- **mastracode init side effects** at singleton init (see step 1). +- **Remote host-service API-key availability**: remote hosts need `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` set; otherwise v2 on remote hosts falls back to random-name. Document. +- **OAuth parity in host-service**: host-service can't do an interactive OAuth flow. `createAuthStorage().loadStoredApiKeysIntoEnv(...)` loads stored API keys but NOT OAuth tokens into env. For host-service, OAuth-only users get random names. +- **Diagnostics UI**: removing `provider-diagnostics.ts` removes mid-call `reportProviderIssue` signals. Audit settings UI for providers; they may source signals from chat-service regardless. + +## Out of scope +- Live/debounced ghost suggestion in v2 branch-name input. +- Retiring v1's desktop-tRPC `generateBranchName` procedure (it becomes a proxy over the shared helper; deleting it is a follow-up). diff --git a/apps/desktop/plans/done/20260417-fix-api-key-storage-slot.md b/apps/desktop/plans/done/20260417-fix-api-key-storage-slot.md new file mode 100644 index 00000000000..d31b76a60bb --- /dev/null +++ b/apps/desktop/plans/done/20260417-fix-api-key-storage-slot.md @@ -0,0 +1,57 @@ +# Fix: API keys overwritten by OAuth connect/disconnect cycle + +## Problem + +Settings > Models "API key" field writes to the same auth.json slot as OAuth. When a user: +1. Saves an API key → `authStorage.set("anthropic", { type: "api_key", key: "sk-..." })` +2. Connects OAuth → `authStorage.login("anthropic", ...)` overwrites with `{ type: "oauth", ... }` +3. Disconnects OAuth → `authStorage.remove("anthropic")` deletes everything + +The API key is lost. The model picker shows "disabled" even though the user saved a key. + +Chat still works because `createMastraCode`'s model resolution reads from env vars / external config independently of this status check. + +## Root cause + +`setApiKeyForProvider` uses `authStorage.set(providerId, credential)` which writes to the main provider slot. OAuth also writes to the same slot. They collide. + +mastracode's `AuthStorage` has **two separate storage mechanisms**: +- `set(providerId, credential)` / `get(providerId)` → main slot (`"anthropic"` in auth.json) +- `setStoredApiKey(providerId, key)` / `getStoredApiKey(providerId)` → dedicated API key slot (`"apikey:anthropic"` in auth.json) + +We're using the wrong one for API keys. + +## Fix + +### `auth-storage-utils.ts` + +**`setApiKeyForProvider`**: switch from `authStorage.set()` to `authStorage.setStoredApiKey()`. + +**`clearApiKeyForProvider`**: clear the `apikey:` slot. Use `authStorage.set("apikey:<providerId>", ...)` with a removal, or check `hasStoredApiKey` and handle accordingly. Since mastracode doesn't expose `removeStoredApiKey`, use `authStorage.remove("apikey:<providerId>")`. + +**`resolveAuthMethodForProvider`**: after checking the main slot, also check `authStorage.hasStoredApiKey(providerId)` as a fallback → return `"api_key"`. + +### `chat-service.ts` + +No changes needed — `getAnthropicAuthStatus` and `getOpenAIAuthStatus` already delegate to `resolveAuthMethodForProvider` which will now find stored API keys. + +The `setStoredAnthropicApiKeyFromEnvVariables` helper in `disconnectAnthropicOAuth` should also use `setStoredApiKey` for consistency, but it's less critical since it reads from the env config file. + +## Behavior after fix + +| Action | `"anthropic"` (main) | `"apikey:anthropic"` (dedicated) | +|---|---|---| +| Save API key (Settings) | unchanged | written | +| Connect OAuth | overwritten with OAuth | survives | +| Disconnect OAuth | removed | survives | +| Auth status check | reads both | ← | + +## Side effect: small-model tasks + +`getSmallModel` reads `apikey:anthropic` from auth.json directly. Currently, API keys saved via Settings go to the main `"anthropic"` slot, so `getSmallModel` doesn't find them. After this fix, saved API keys land in `apikey:anthropic` where `getSmallModel` already looks → branch naming works for Settings-saved keys without any additional change. + +## Scope + +- `packages/chat/src/server/desktop/chat-service/auth-storage-utils.ts` (~15 LOC changed) +- `packages/chat/src/server/desktop/chat-service/chat-service.ts` — `setStoredAnthropicApiKeyFromEnvVariables` updated for consistency (~2 LOC) +- Tests in `chat-service.test.ts` if any mock `setApiKeyForProvider` behavior diff --git a/apps/desktop/plans/done/20260425-1430-sidebar-remove-and-rerender.md b/apps/desktop/plans/done/20260425-1430-sidebar-remove-and-rerender.md new file mode 100644 index 00000000000..306742b7fe1 --- /dev/null +++ b/apps/desktop/plans/done/20260425-1430-sidebar-remove-and-rerender.md @@ -0,0 +1,342 @@ +# Sidebar "Remove from Sidebar" fix + sidebar re-render review + +Status: Part 1 targeted fixes and Part 2 low-risk sidebar re-render hardening +implemented. The remote "Remove from Sidebar" bug is real, and the +implementation now addresses both re-add paths identified below. The proposed +broad `v2WorkspaceLocalState` split is not validated by the current evidence +and should not be implemented as the fix for sidebar re-renders without new +profiling data. + +## Review verdict + +1. **Bug validity:** real. Removing the currently viewed remote workspace can + be undone by still-mounted workspace code that calls `ensureWorkspaceInSidebar`. +2. **Previous provider-only fix correctness:** not correct as written. The + provider value was still unstable because `getCollections(activeOrganizationId)` + returns a new wrapper object on every call. +3. **Regression risk:** the implemented targeted fixes are low risk. The collection + split is higher risk and currently unsupported by the behavior I verified. + +## Part 1 - bug: "Remove from Sidebar" does nothing for remote workspaces + +### Repro + +1. Have at least two v2 workspaces in the sidebar where one is a remote + workspace whose `host.machineId` is not the current device. +2. Be on `/v2-workspace/<remote_id>`. +3. Right-click the row and choose **Remove from Sidebar**. + +Expected: the row disappears and the app navigates to the next sidebar +workspace. + +Observed: the row stays visible, or briefly disappears and then reappears. + +The bug often looks remote-only because remote workspace route transitions keep +the source workspace subtree alive long enough for a re-add path to win. + +### Remove call chain + +1. `DashboardSidebarWorkspaceContextMenu.tsx:144` calls + `onSelect={onRemoveFromSidebar}`. +2. `DashboardSidebarWorkspaceItem.tsx:130/196` wires that prop to + `handleRemoveFromSidebar`. +3. `useDashboardSidebarWorkspaceItemActions.ts:73`: + ```ts + const handleRemoveFromSidebar = () => { + navigateAway(workspaceId); + removeWorkspaceFromSidebar(workspaceId); + }; + ``` +4. `useNavigateAwayFromWorkspace.ts:17` only navigates if the removed workspace + is the current route. +5. `useDashboardSidebarState.ts:430` deletes the local sidebar row: + ```ts + const workspace = collections.v2WorkspaceLocalState.get(workspaceId); + if (!workspace) return; + cleanupWorkspacePaneRuntimes([workspace]); + collections.v2WorkspaceLocalState.delete(workspaceId); + ``` + +### Re-add paths + +There are two relevant paths that can reinsert a just-deleted sidebar row while +the old workspace page is still mounted. + +#### 1. Unstable provider value re-runs the mount ensure effect + +`useV2WorkspacePaneLayout.ts:55` has this effect: + +```ts +useEffect(() => { + ensureWorkspaceInSidebar(workspaceId, projectId); +}, [ensureWorkspaceInSidebar, projectId, workspaceId]); +``` + +`ensureWorkspaceInSidebar` comes from `useDashboardSidebarState`, whose callbacks +depend on `[collections]`. If `useCollections()` returns a new object reference +on each provider render, this callback changes identity and the effect runs +again. The effect calls `ensureSidebarWorkspaceRecord`, which inserts the row +if missing. + +This makes the old sequence: + +1. `removeWorkspaceFromSidebar` deletes the local-state row. +2. A provider render changes the `collections` object reference. +3. `ensureWorkspaceInSidebar` identity changes. +4. The still-mounted workspace pane-layout effect re-runs and re-inserts the row. + +#### 2. Pane-layout persistence re-adds before checking row existence + +`useV2WorkspacePaneLayout.ts:69` subscribes to the pane store. Inside the +subscription, it currently calls `ensureWorkspaceInSidebar(workspaceId, +projectId)` before checking whether a local row exists: + +```ts +ensureWorkspaceInSidebar(workspaceId, projectId); +if (!collections.v2WorkspaceLocalState.get(workspaceId)) { + return; +} +``` + +If the removed workspace remains mounted and the pane store emits, this call can +recreate the row even if the provider value has been stabilized. This is a +separate re-add path and should be fixed directly. + +### Why the previous provider-only fix was incomplete + +The previous diff memoized `contextValue` like this: + +```ts +const collections = activeOrganizationId + ? getCollections(activeOrganizationId) + : null; + +const contextValue = useMemo<CollectionsContextType | null>( + () => (collections ? { ...collections, switchOrganization } : null), + [collections, switchOrganization], +); +``` + +That does not stabilize the provider. `getCollections()` returns: + +```ts +return { + ...orgCollections, + organizations: organizationsCollection, +}; +``` + +So `collections` is a fresh wrapper object on every render, and the +`useMemo([collections, switchOrganization])` recomputes every render. + +### Implemented targeted fixes + +#### Fix A - stabilize the provider value for real + +The implementation memoizes the `getCollections` call in `CollectionsProvider`: + +```tsx +const collections = useMemo( + () => (activeOrganizationId ? getCollections(activeOrganizationId) : null), + [activeOrganizationId], +); + +const contextValue = useMemo<CollectionsContextType | null>( + () => (collections ? { ...collections, switchOrganization } : null), + [collections, switchOrganization], +); +``` + +This keeps collection caching behavior scoped to the provider while making the +context value stable across unrelated parent renders. + +#### Fix B - do not auto-ensure from pane-layout persistence + +In `useV2WorkspacePaneLayout.ts`, the store subscription should not create a +sidebar row. It should persist pane layout only if the row still exists: + +```ts +const existing = collections.v2WorkspaceLocalState.get(workspaceId); +if (!existing) { + return; +} + +collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.paneLayout = { + version: nextStore.version, + tabs: nextStore.tabs, + activeTabId: nextStore.activeTabId, + }; +}); +``` + +Initial insertion remains covered by the mount ensure effect in +`useV2WorkspacePaneLayout.ts:55` and by `v2-workspace/layout.tsx:61`, so this +does not prevent workspaces opened from outside the sidebar from being added. + +### Regression considerations for Part 1 + +- Memoizing `collections` by `activeOrganizationId` should be safe because + `getCollections(orgId)` is already org-scoped and cached internally. +- `switchOrganization` can still change when the active org changes or the + session refetch callback changes. +- Removing the store-subscription ensure preserves the current behavior that + opening a workspace ensures it in the sidebar, while preventing a removed + still-mounted workspace from resurrecting itself. +- After implementing the fixes, manually verify local and remote current-route + removal and non-current-route removal. + +### Verification run after code fixes + +- `bun run --cwd apps/desktop typecheck` +- `bunx @biomejs/biome@2.4.2 check <changed files>` + +Manual verification completed: + +- Manual: remove the currently viewed remote workspace; it should navigate away + and stay removed. +- Manual: remove the currently viewed local workspace; it should navigate away + and stay removed. +- Manual: open a v2 workspace from the all-workspaces list; it should still be + inserted into the sidebar. + +## Part 2 - sidebar re-render hardening + +The previous proposal said the sidebar live query re-emits when any field on +`v2WorkspaceLocalState` changes. I do not think that is true for the current +TanStack DB behavior. + +I verified this with a direct `@tanstack/db` live-query script matching the +sidebar query shape. A query that selected only: + +- `workspaceId` +- `sidebarState.projectId` +- `sidebarState.tabOrder` +- `sidebarState.sectionId` + +did **not** emit when these fields changed: + +- `paneLayout` +- `viewedFiles` +- `sidebarState.changesFilter` +- `sidebarState.changesSubtab` + +It did emit when `sidebarState.tabOrder` changed, and the full sidebar query +emits when joined host fields like `v2Hosts.isOnline` change. That is expected +because the sidebar query selects host online state. + +### Valid re-render sources + +These still look valid: + +1. **Host online-status updates.** `useDashboardSidebarData.ts:103` left-joins + `v2Hosts` and selects `hostIsOnline`. Any host ping that changes `isOnline` + should update the sidebar. +2. **PR refetch every 10s.** `useDashboardSidebarData.ts:148` refetches local + workspace PR data. `localPullRequestsByWorkspaceId` is rebuilt as a new + `Map` at line 177 whenever `pullRequestData?.workspaces` gets a new array. +3. **No memo barriers downstream.** `DashboardSidebarProjectSection` and + `DashboardSidebarWorkspaceItem` are not memoized, so a real `groups` change + can still fan out through the tree. +4. **Shortcut-label map identity.** `useDashboardSidebarShortcuts.ts:21` + returns a new `Map` when `flattenedWorkspaces` changes. If `groups` changes + for PR or host reasons, this can defeat memo boundaries. + +### Implemented low-risk hardening + +The implementation keeps the existing collection model and only stabilizes +derived data identities: + +1. `useDashboardSidebarData.ts` now keeps the local workspace id array stable + when the ids are unchanged, so PR refetch dependencies do not churn on equal + sidebar data. +2. `useDashboardSidebarData.ts` now reuses the local pull-request `Map` when + the refetched PR payload is structurally unchanged. +3. `useDashboardSidebarData.ts` now preserves unchanged project object + references after a real sidebar update, so updates in one project do not + force every project subtree to receive new props. +4. `useDashboardSidebarShortcuts.ts` now reuses the shortcut-label `Map` when + the first nine workspace ids are unchanged. +5. `DashboardSidebar.tsx` now memoizes `SortableProjectWrapper`, allowing + unchanged project rows to skip render work while still responding to dnd-kit + context changes. + +### Verification run after re-render hardening + +- `bun run --cwd apps/desktop typecheck` +- `bunx @biomejs/biome@2.4.2 check <changed files>` + +Still recommended manually: + +- Smoke test sidebar drag/reorder for projects, sections, and workspaces. +- Smoke test workspace shortcut labels and `Cmd+1` through `Cmd+9`. +- Use React DevTools Profiler while PR polling is active; unchanged project + rows should no longer receive new project props when the PR payload is equal. + +### Collection split recommendation + +Do **not** do the `v2WorkspaceLocalState` split as the fix for the stated +sidebar re-render issue based on the current evidence. + +The split may still be worth considering later for domain clarity, but it would +carry migration and callsite risk. It should require fresh profiling that proves +unrelated local-state writes currently invalidate the sidebar in production, not +just an assumption about row-level invalidation. + +### If a split is revisited, missed callsites + +The previous callsite list was incomplete. At minimum, also account for: + +- `useAccessibleV2Workspaces.ts:98` - left-joins local sidebar state to compute + `isInSidebar`. +- `ResourceConsumption.tsx:81` - reads sidebar workspace order. +- `getFlattenedV2WorkspaceIds.ts:7` - computes next workspace for navigation + after removal. +- `useDevSeedV2Sidebar.ts:26` - checks whether any sidebar workspace state + exists before dev seeding. +- `writeSidebarState.ts:130` and related tests - V1 migration writes combined + local state rows. +- `useDashboardSidebarState.ts` - most ordering, grouping, removal, and + cleanup logic reads or mutates the current combined collection. +- `GlobalTerminalLifecycle` and `GlobalBrowserLifecycle` - read all local rows + to detect pane removals. +- `V2NotificationController.tsx:52` - reads `paneLayout` for notification + targeting. +- `WorkspaceSidebar.tsx:74`, `useChangesTab.tsx:29`, and + `useSidebarDiffRef.ts:10` - read `changesSubtab` / `changesFilter`. +- `useViewedFiles.ts` and `useRecentlyViewedFiles.ts` - read and write local + workspace-page state. + +### Lower-risk hardening work + +Do these before any collection split: + +1. Stabilize `localPullRequestsByWorkspaceId` by content equality or use a + TanStack Query `select`/structural sharing strategy so equivalent refetches + preserve identity. +2. Stabilize `workspaceShortcutLabels` by returning the previous `Map` when the + workspace id/order labels are unchanged. +3. Add `React.memo` only after props are stable enough for it to be useful. +4. Profile with React DevTools before and after each change. Treat host online + status updates as legitimate sidebar updates unless the UI no longer needs + live online indicators. + +## Feedback summary + +The remote remove bug should be fixed with a small targeted patch, not the +collection split: + +1. Memoize `getCollections(activeOrganizationId)` by `activeOrganizationId`, or + return a fully cached object from `getCollections()`. +2. Remove `ensureWorkspaceInSidebar` from the pane-layout store subscription and + persist only when the local row still exists. +3. Keep the broad split out of this fix unless profiling demonstrates that + selected-field live queries actually emit on unrelated local-state writes in + the real app. + +## Files currently involved + +- `apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx` +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts` +- `apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts` +- `apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts` diff --git a/apps/desktop/plans/done/20260427-keyboard-layout-plan.md b/apps/desktop/plans/done/20260427-keyboard-layout-plan.md new file mode 100644 index 00000000000..6ea6d24f0dd --- /dev/null +++ b/apps/desktop/plans/done/20260427-keyboard-layout-plan.md @@ -0,0 +1,478 @@ +# Keyboard Layout & Shortcut Plan + +**Date:** 2026-04-27 +**Branch:** `keyboard-shortcut-analysi` +**Scope:** `apps/desktop` keyboard shortcut matching, recording, display, migration, terminal forwarding, and Electron menu accelerators. +**Supersedes:** drafts of `20260427-keyboard-layout-options.md` and `20260427-keyboard-shortcut-system-audit.md`. +**Builds on:** `apps/desktop/plans/20260412-keyboard-recorder-ctrl-binding-fix.md` (April refactor — established `event.code` baseline) and `apps/desktop/plans/20260409-tui-hotkey-forwarding.md` (xterm forwarding). + +## Objective + +Make Superset's desktop keyboard handling correct on every keyboard layout — Dvorak, AZERTY, QWERTZ, Spanish, CJK IME — without regressing existing US-QWERTY users' muscle memory. + +## TL;DR + +Three phases: + +1. **Phase 0 — Correctness fixes (~1 day):** AltGr guard, IME composition guard, fail-closed dead-key sanitizer, `event.key` discipline in `line-edit-translations.ts`. No new deps. **Status: shipped.** +2. **Phase 1 — Layout service via `native-keymap` (~half day):** Microsoft's `native-keymap` in the Electron main process; expose live `{ layoutId, keymap }` to the renderer via tRPC observable; layout-aware display + reliable on-the-fly switching. Matching stays on `event.code`. +3. **Phase 2 — Versioned dual-mode bindings (~1–2 weeks):** Each binding declares `matchMode: "physical" | "logical"`. Existing bindings migrate as `physical` (preserves muscle memory); new user-recorded printable bindings default to `logical`. `react-hotkeys-hook`'s `useKey: true` option does the matching — no custom matcher needed. + +Phase 3 is demand-driven (menu accelerator sync, multi-stroke chords, when-clauses). + +## Decision history: browser API → native-keymap + +We initially planned Phase 1 with `native-keymap`, briefly switched to `navigator.keyboard.getLayoutMap()` after verifying it returned data in our packaged Electron 40 `file://` build, then switched back to `native-keymap` after empirical testing showed the browser API can't observe macOS input-source switches in real time. + +What we learned, in order: + +1. **Browser API returns data in Electron `file://`** (verified: 48-entry map with correct US glyphs). The codebase comment claiming otherwise was stale. +2. **Browser API only exposes the unshifted glyph per code** (`Map<code, char>`); native-keymap exposes 4 layers plus dead-key flags. For *display* we don't need the extra layers. +3. **`layoutchange` doesn't fire for macOS input-source switches.** Empirically tested: switching ABC → German via the menu-bar input picker did not trigger the listener. The browser API treats input-source changes as IME events, not layout changes. +4. **VSCode solves this with `native-keymap`** — its mac implementation hooks `kTISNotifySelectedKeyboardInputSourceChanged` (Apple distributed notification) which fires for *every* input-source change. See `~/workplace/native-keymap/src/keyboard_mac.mm:182-189` and `~/workplace/vscode/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts:48-60`. +5. **The desktop already ships native modules** (`better-sqlite3`, `node-pty`, `@parcel/watcher`) — `electron-rebuild` infrastructure is in place; native-keymap is one more dep in an existing pipeline. Native-binding risk is essentially zero. +6. **Hand-rolled alternatives create bug surface:** focus / visibilitychange / keydown-mismatch heuristics, US-fingerprint detection that false-positives on UK, two parallel detection paths (boot probe + runtime store). Adopting native-keymap lets us **delete** these instead of accumulating workarounds. + +Net of the swap: ~175 LOC + several heuristic edge cases removed; ~160 LOC + tests added; single source of truth; reliable runtime updates. + +## Design principle: physical vs logical key identity + +The central design question is *how a binding identifies a key*. Two valid identities: + +- **Physical** (`event.code`): the hardware position. The QWERTY-`P` slot is `KeyP` on every layout. +- **Logical** (`event.key` or layout-resolved character): the character the active layout produces. On Dvorak, the physical `KeyR` slot produces `p`. + +Picking only one globally creates bad behavior for some users. Today we are physical-only: + +- ✅ Stable: `Cmd+Shift+P` works regardless of the user's layout. +- ❌ Unintuitive: a Dvorak user pressing the key labeled `P` on their keyboard does *not* trigger Quick Open — they must press the key labeled `L` (the QWERTY-`P` position). +- ❌ UI lies on non-US layouts: the Settings page shows "⌘P" but on QWERTZ that's the wrong glyph for the physical slot. + +The fix is **per-binding match mode**, defaulting to physical for shipped defaults (preserves today's behavior for everyone) and logical for new user-recorded printable bindings (matches what users expect from their printed keys). + +## Current state (brief) + +Hotkeys live in `apps/desktop/src/renderer/hotkeys/`. The April 2026 refactor (PR #3391) unified the system on `event.code`: + +- Registry of 50+ per-platform defaults — `registry.ts:29-571` +- Canonical normalization — `utils/resolveHotkeyFromEvent.ts:42-89` +- Customization UI with conflict detection — `routes/_authenticated/settings/keyboard/page.tsx` +- Zustand + localStorage overrides — `stores/hotkeyOverridesStore.ts` +- v1→v2 migration with sanitizer — `migrate.ts`, `utils/sanitizeOverride.ts` +- Terminal forwarding via xterm `customKeyEventHandler` — `lib/terminal/terminal-runtime.ts` +- 62 unit tests across 4 files + +What's missing — addressed by this plan: + +| Gap | Severity | Phase | +|---|---|---| +| `MAC_US_DEAD_KEYS` rewrites apply when layout is unknown (fails open) | medium-high | 0 | +| AltGr (`event.getModifierState("AltGraph")`) treated as Ctrl+Alt | medium | 0 | +| No IME `event.isComposing` guard | low–medium | 0 | +| `line-edit-translations.ts` uses `event.key` without a discipline note | low (latent) | 0 | +| `navigator.keyboard.getLayoutMap()` is unreliable in Electron `file://`; layout never re-detected | medium-high | 1 | +| Display uses hardcoded US glyphs regardless of layout | medium | 1 | +| Bindings can only match physical position | medium | 2 | +| Hardcoded menu accelerators in `main/lib/menu.ts:13-100` shadow user bindings | medium | 3 | +| v1 terminal handler returns `false` for all `ctrl/meta` (starves TUIs) | medium | 3 (already in `20260409-tui-hotkey-forwarding.md`) | + +## Library decision: `native-keymap` + +- **Microsoft, MIT, v3.3.9 (Jan 2026), actively maintained**, ~125k weekly downloads. Same library VSCode ships. +- API: + ```ts + import { + getKeyMap, + getCurrentKeyboardLayout, + onDidChangeKeyboardLayout, + } from "native-keymap"; + ``` +- `getKeyMap()` returns `{ ScanCodeName: { value, withShift, withAltGr, withShiftAltGr, valueIsDeadKey, ... } }` for the current OS layout. +- Constraints: + - Native node-gyp addon → must run after `electron-rebuild`. Ships prebuilt binaries for major Electron ABIs. + - **Main-process only.** Wire to renderer via tRPC observable per `apps/desktop/AGENTS.md` (`trpc-electron` requires observables, not async generators). + - Linux dev/CI needs `libx11-dev` + `libxkbfile-dev`. + +Conceptual role: a supplementary lookup table answering *"on this user's current OS layout, what character is printed at physical position `KeyZ`?"* It does **not** replace `event.code` matching. Phase 1 uses it for display + reliable layout detection; Phase 2 uses its `value` field to resolve logical bindings. + +## Considered alternatives (rejected) + +- **`keyboard-layout` (Atom):** archived 2022, dead `nan` dep. +- **`mousetrap` / `hotkeys-js`:** match on deprecated `keyCode`; layout-unaware. +- **`tinykeys`:** no layout features beyond what `react-hotkeys-hook` already gives us. +- **Vendor full VSCode keybinding engine (`KeyCodeUtils`, `KeybindingResolver`):** long import tail; overkill. +- **Browser-only via `navigator.keyboard.getLayoutMap()`:** unreliable in Electron `file://`; current fallback behavior already proves the risk. +- **Vendor VSCode `keyboardLayouts/*.ts` static tables:** 70+ files, MIT, but custom layouts uncovered. Keep as future fallback if `native-keymap` ever proves insufficient. + +## Cross-cutting requirements (checklist for every phase) + +- **AltGr:** check `event.getModifierState("AltGraph")`; do not treat AltGr as Ctrl+Alt unless a binding explicitly opts in. +- **IME composition:** ignore matching during composition: `event.isComposing || event.keyCode === 229`. +- **Dead keys:** never rewrite a composed/dead-key glyph without layout certainty. Fail closed. +- **Numpad:** decide explicitly whether `Numpad1` and `Digit1` collapse (today: yes, via `normalizeToken` stripping `Digit`/`Numpad`). Phase 2 may differentiate for power users. +- **Special keys:** Enter, Escape, Backspace, Delete, F-keys, arrows, Home/End/PageUp/PageDown match by stable named-key (`event.code` is stable for these regardless of layout). +- **Conflict detection:** must run in the same mode used at runtime (physical-vs-physical, logical-vs-logical, mixed flagged). +- **Electron menus:** generate accelerators from effective bindings where representable; omit native accelerator otherwise rather than showing a wrong one. + +--- + +## Phase 0 — Correctness fixes + +**Goal:** close existing layout-related bugs without changing matching semantics or adding deps. +**Effort:** ~1 day. +**Owner:** anyone. + +### Implementation + +#### 0.1 AltGr guard in `eventToChord` + +`apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts:71-82` + +Add an `altGr` modifier flag derived from `event.getModifierState("AltGraph")`. When true on Linux/Windows, do not also set `ctrl` and `alt` from `event.ctrlKey`/`event.altKey` — Chromium reports both for AltGr. Bindings must opt in to AltGr explicitly via an `altgr+` token (none today; reserved for Phase 2). + +```ts +export function eventToChord(event: KeyboardEvent): string | null { + if (event.code === undefined) return null; + if (event.isComposing || event.keyCode === 229) return null; // see 0.2 + const key = normalizeToken(event.code); + if (isIgnorableKey(key)) return null; + + const altGr = event.getModifierState?.("AltGraph") === true; + const mods: string[] = []; + if (event.metaKey) mods.push("meta"); + if (event.ctrlKey && !altGr) mods.push("ctrl"); + if (event.altKey && !altGr) mods.push("alt"); + if (event.shiftKey) mods.push("shift"); + if (altGr) mods.push("altgr"); // dropped on match unless binding opts in + mods.sort(); + return [...mods, key].join("+"); +} +``` + +`canonicalizeChord` strips unknown `altgr` tokens for backward compatibility until Phase 2 adds first-class support. Net effect today: `Ctrl+Alt+E` typed via AltGr+E on a German layout no longer matches a US `Ctrl+Alt+E` binding. (Today it matches and surprises users.) + +#### 0.2 IME composition guard + +Same file, top of `eventToChord` (shown above). Returns `null` during composition so `react-hotkeys-hook` doesn't fire. + +#### 0.3 Fail-closed dead-key sanitizer + +`apps/desktop/src/renderer/hotkeys/utils/sanitizeOverride.ts:47-80, 98-121` +`apps/desktop/src/renderer/hotkeys/utils/detectUSLayout.ts:21-43` +`apps/desktop/src/renderer/hotkeys/migrate.ts:36` + +Today `isUSCompatibleLayout()` returns `true` when `navigator.keyboard.getLayoutMap()` is unavailable (common in Electron `file://`) — so the US-Mac dead-key rewrite table runs on non-US Macs. + +Flip the fallback: + +```ts +// detectUSLayout.ts +export async function isUSCompatibleLayout(): Promise<boolean | "unknown"> { + const keyboard = (navigator as Navigator & { keyboard?: Keyboard }).keyboard; + if (!keyboard?.getLayoutMap) return "unknown"; + try { /* probe ... */ } catch { return "unknown"; } +} +``` + +In `migrate.ts`, treat `"unknown"` as "do not apply US dead-key rewrites" — pass `assumeUSMacLayout: false`. Sanitizer drops the entry instead (existing behavior for invalid entries; user gets a console message and sees the binding empty, which is recoverable). Phase 1 deletes this file entirely. + +#### 0.4 `event.key` discipline in `line-edit-translations.ts` + +`apps/desktop/src/renderer/lib/terminal/line-edit-translations.ts:21-42` + +Add a top-of-file comment: + +```ts +// CONTRACT: only check event.key for stable named keys +// (Backspace, ArrowLeft/Right, Home, End, ...). Never event.key for printable +// characters — those vary by layout and break non-US users. Use event.code +// via resolveHotkeyFromEvent for printable keys. +``` + +No code change today; the comment exists to prevent regression. + +### Tests + +| Test | File | What it covers | +|---|---|---| +| AltGr does not double-match Ctrl+Alt bindings | `utils/resolveHotkeyFromEvent.test.ts` (new case) | Synthetic `KeyboardEvent` with `getModifierState("AltGraph") === true`, `ctrlKey: true`, `altKey: true` → chord excludes `ctrl` and `alt` | +| `event.isComposing` short-circuits matching | `utils/resolveHotkeyFromEvent.test.ts` (new case) | `eventToChord` returns `null` when `isComposing` is true | +| `event.keyCode === 229` short-circuits matching | same | covers Safari IME path | +| Sanitizer fails closed on unknown layout | `utils/overrideSanitizer.test.ts` (new case) | `assumeUSMacLayout: false` drops entries that match `MAC_US_DEAD_KEYS` keys | +| `isUSCompatibleLayout()` returns `"unknown"` when API absent | new file `utils/detectUSLayout.test.ts` | mock `navigator.keyboard = undefined` | + +### Manual QA + +- [ ] Linux QWERTZ: type `Ctrl+Alt+E` via AltGr+E in a textarea — does **not** trigger any app hotkey bound to `ctrl+alt+e`. +- [ ] macOS, US-EN keyboard active, Japanese input source available: switch to Japanese, type `かな` in a chat input — no hotkeys fire mid-composition. +- [ ] Fresh install on German Mac: v1→v2 migration drops legacy `meta+option+@` rather than rewriting it to a wrong physical slot. Console logs the drop count. + +### Acceptance + +- All Phase 0 tests pass; existing 62 tests still pass. +- Manual QA above all green. +- No new runtime deps added. + +--- + +## Phase 1 — Layout service via `native-keymap` + +**Goal:** non-US users see the printed glyph of the physical key bound; on-the-fly input-source switches reflect immediately. +**Effort:** ~half day. +**Depends on:** Phase 0. +**Outcome:** Settings page and tooltips show "⌘Y" instead of "⌘Z" on QWERTZ for the default `meta+z` (physical KeyZ) binding. Switching between ABC and German via the macOS menu-bar input picker updates the display without app reload. Matching semantics unchanged. + +### Architecture (mirrors VSCode) + +``` +┌──────────────────────────────────────────────┐ +│ Electron Main process │ +│ │ +│ native-keymap (npm) │ +│ ├─ getKeyMap() → IKeyboardMapping │ +│ ├─ getCurrentKeyboardLayout() → IKeyboardLayoutInfo │ +│ └─ onDidChangeKeyboardLayout(cb) │ +│ └─ macOS: kTISNotifySelectedKeyboardInputSourceChanged │ +│ │ +│ apps/desktop/src/main/lib/keyboardLayout.ts │ +│ └─ EventEmitter wrapping native-keymap │ +└──────────────────┬───────────────────────────┘ + │ tRPC observable (per AGENTS.md) + ▼ +┌──────────────────────────────────────────────┐ +│ Electron Renderer process │ +│ │ +│ apps/desktop/src/renderer/hotkeys/stores/ │ +│ keyboardLayoutStore.ts │ +│ └─ Zustand store, subscribed to tRPC │ +│ └─ flattens IKeyMapping → Map<code, char> │ +│ │ +│ display.ts → formatHotkeyDisplay(chord, platform, layoutMap) │ +└──────────────────────────────────────────────┘ +``` + +### Implementation + +#### 1.1 Add the dependency + +```bash +cd apps/desktop && bun add native-keymap +``` + +`electron-rebuild` is already in the pipeline (we ship `better-sqlite3`, `node-pty`, `@parcel/watcher`). Linux dev/CI: ensure `libx11-dev` + `libxkbfile-dev` are installed. + +#### 1.2 Main-process wrapper + +``` +apps/desktop/src/main/lib/keyboardLayout.ts +``` + +Wraps `native-keymap`. Lazy-init on first call. Exposes `getSnapshot()` and `onChange(cb): unsubscribe` via a single EventEmitter. Mirrors VSCode's `keyboardLayoutMainService.ts`. + +#### 1.3 tRPC router + +``` +apps/desktop/src/lib/trpc/routers/keyboardLayout.ts +``` + +`get` query + `changes` subscription. Subscription **must** be an `observable` (`apps/desktop/AGENTS.md` — `trpc-electron` rejects async generators). Mount under root router in `routers/index.ts`. + +#### 1.4 Renderer store + +``` +apps/desktop/src/renderer/hotkeys/stores/keyboardLayoutStore.ts +``` + +Replace the `navigator.keyboard.getLayoutMap` probe + `layoutchange` listener with a tRPC subscription. Flatten `IKeyMapping` (4 fields per code) to `Map<code, value>` so consumers (display.ts) don't change. No focus / visibilitychange listeners. No debug shims. + +#### 1.5 Deletions (sweep) + +- `apps/desktop/src/renderer/hotkeys/utils/detectUSLayout.ts` — superseded by `keyboardLayoutStore.layoutId`. The 6-key fingerprint heuristic also false-positives on UK; the layout id is authoritative. +- `apps/desktop/src/renderer/hotkeys/utils/detectUSLayout.test.ts` — goes with it. +- `migrate.ts` — read layout id from store instead of probing. +- The window debug shims (`__hotkeyLayoutMap`, `__refreshHotkeyLayout`). + +#### 1.6 No change + +- `display.ts` — already accepts `layoutMap: ReadonlyMap<string, string> | null` (Phase 1 first attempt). The IPC just feeds the same shape. +- `useHotkey` / `useHotkeyDisplay` — unchanged. +- `formatHotkeyDisplay` consumers — unchanged. + +### Tests + +| Test | File | Covers | +|---|---|---| +| `glyphForCode("z", null)` → `null` | `display.test.ts` (extended) | falls back to KEY_DISPLAY when API unavailable | +| `glyphForCode("z", usMap)` → `"Z"` | same | US baseline | +| `glyphForCode("z", qwertzMap)` → `"Y"` | same | QWERTZ — physical Z slot prints Y | +| `glyphForCode("slash", azertyMap)` → glyph from map | same | non-letter punctuation | +| `glyphForCode("arrowup", anyMap)` → `null` | same | special keys bypass keymap | +| `formatHotkeyDisplay("meta+z", "mac", qwertzMap)` → `"⌘Y"` | same | end-to-end display swap | +| `formatHotkeyDisplay("meta+z", "mac", null)` matches today's output | same | regression guard | + +### Manual QA + +| Layout | Action | Expected | +|---|---|---| +| US QWERTY | Open Settings → Keyboard | Glyphs identical to today | +| German QWERTZ (or any non-US, e.g. UK) | Open Settings → Keyboard | Default `meta+z`-style binding shows the printed key glyph for the user's physical KeyZ slot | +| Switch layout mid-session | Watch Settings page | Display refreshes within ~1 second (native-keymap `onDidChangeKeyboardLayout`, hooked to the OS distributed notification) | + +### Acceptance + +- All tests pass. +- Manual QA on US (regression) + at least one non-US layout (verifies the swap). +- One new native dep (`native-keymap`); `electron-rebuild` already runs for `better-sqlite3` / `node-pty` so no new build-pipeline work. + +--- + +## Phase 2 — Versioned dual-mode bindings + +**Goal:** allow each binding to declare physical vs logical match. Existing bindings stay physical; new printable user bindings default to logical. +**Effort:** ~1–2 weeks. +**Depends on:** Phase 1 (layout service). +**Outcome:** Dvorak / AZERTY / QWERTZ users get shortcuts that follow their printed keys. + +### Binding shape + +```ts +// apps/desktop/src/renderer/hotkeys/types.ts +export type ShortcutBinding = + | string // legacy v1; treated as { version: 1, matchMode: "physical", chord: string } + | { + version: 2; + matchMode: "physical" | "logical" | "named"; + modifiers: { + meta?: boolean; + ctrl?: boolean; + alt?: boolean; + altGr?: boolean; + shift?: boolean; + }; + // exactly one of: + code?: string; // matchMode: "physical" — e.g. "KeyP" + key?: string; // matchMode: "logical" — e.g. "p" + named?: NamedKey; // matchMode: "named" — e.g. "Enter", "ArrowUp", "F5" + // for display only; recorder fills these in: + recordedAt?: { layoutId: string; glyph: string }; + }; +``` + +`named` covers Enter, Escape, Backspace, Delete, arrows, F-keys, Home/End/PageUp/PageDown — keys whose `event.code` *is* the right identity regardless of layout. + +### Match algorithm + +```ts +function matches(event: KeyboardEvent, b: ShortcutBinding, keymap: Keymap): boolean { + if (event.isComposing || event.keyCode === 229) return false; + if (typeof b === "string") return matchesPhysical(event, parseLegacy(b)); + if (!modifiersMatch(event, b.modifiers)) return false; + switch (b.matchMode) { + case "physical": return event.code === b.code; + case "named": return event.code === b.named; + case "logical": { + // Prefer event.key when it's a single printable char; fall back to keymap lookup. + const produced = isSinglePrintable(event.key) + ? event.key.toLowerCase() + : keymap[event.code]?.value?.toLowerCase(); + return produced === b.key?.toLowerCase(); + } + } +} +``` + +### Migration + +- v1 string bindings → `{ version: 2, matchMode: "physical", code: <upgrade(token)>, modifiers: ... }`. Codified in `migrate.ts`. Default registry follows the same shape. +- All shipped defaults stay `matchMode: "physical"` — preserves muscle memory for everyone on day 1. +- Recorder writes `matchMode: "named"` for Enter/Escape/etc., `matchMode: "logical"` for printable-with-modifier (the common case for new bindings), `matchMode: "physical"` if the user toggles the advanced "by physical position" option. + +### Recorder & UI + +- `useRecordHotkeys` captures `{ code, key, modifiers, layoutId }` simultaneously. +- Settings → Keyboard adds an "advanced" disclosure on the recording row showing the captured `code`, `key`, and a toggle. Default is logical for printable, physical only when toggled. +- Conflict detection runs per match-mode pair: + - physical vs physical → string compare + - logical vs logical → string compare + - physical vs logical → resolve via current keymap; if they collide on this layout, warn but allow +- Display: + - logical / named → show the bound character / named key directly + - physical → show `glyphForCode(code, ..., keymap)` with a small "physical" badge + +### Tests + +#### Unit + +| Test | File | Covers | +|---|---|---| +| `matches` returns true for physical binding hitting same code on any layout | `utils/match.test.ts` (new) | regression of today's behavior | +| `matches` for logical binding on Dvorak: typing the key labeled `p` (physical KeyR) triggers `{ key: "p" }` | same | the new capability | +| `matches` for named binding: ArrowUp triggers regardless of any layout-shifted glyph | same | named is layout-immune | +| Conflict detector: physical `meta+p` and logical `meta+p` collide on US, not on Dvorak | `utils/conflicts.test.ts` (new) | per-mode honesty | +| Migration: legacy string `"meta+p"` becomes `{ matchMode: "physical", code: "KeyP" }` | `migrate.test.ts` (extended) | preserves muscle memory | +| Recorder default for `Cmd+Shift+P` → `matchMode: "logical"`, `key: "p"` | `useRecordHotkeys.test.ts` (extended) | new bindings follow printed keys | +| Recorder for `Cmd+ArrowUp` → `matchMode: "named"`, `named: "ArrowUp"` | same | named keys recognized | +| AltGr binding: `altGr+e` on a layout where AltGr+E is `€` matches that key | `utils/match.test.ts` | AltGr first-class | +| Logical binding with shift fallback via keymap when `event.key` is a dead-key replacement | same | fallback path | + +#### Integration + +| Test | Covers | +|---|---| +| Headless Electron: load app on simulated German layout, register a logical `meta+z` binding, dispatch synthetic event with `code: "KeyZ", key: "y"` → does not fire | logical mode honors layout | +| Same setup, dispatch `code: "KeyY", key: "z"` → fires | logical match | +| US layout: same logical `meta+z` fires for `code: "KeyZ", key: "z"` | US baseline | +| Storage roundtrip: save v2 binding → reload app → matches & displays correctly | persistence | + +#### Manual QA matrix (Phase 2) + +For each of {US QWERTY, Dvorak, German QWERTZ, French AZERTY, Spanish, Japanese with US fallback}: + +| Action | Expected | +|---|---| +| Default `Cmd+Shift+P` (Quick Open) — physical | Same physical key as today | +| Record new logical binding `Cmd+J` for some action | Pressing the key labeled `J` on the user's keyboard fires; UI shows "⌘J" | +| Record physical binding via advanced toggle on same chord | Pressing the QWERTY-`J` slot fires; UI shows the layout's glyph for that slot with "physical" badge | +| Conflict warning when both above coexist on the same layout | Visible warning; both still saved | +| Default `Cmd+ArrowUp` — named | Always fires regardless of layout | +| Type a sentence with the bound character in a text field | Hotkey does **not** fire (input focus + IME guard work) | +| Switch system layout mid-session | Display refreshes; physical bindings unchanged; logical bindings now match the new printed keys | + +### Acceptance + +- All v1 bindings continue to work for existing users (regression suite of the 62 April tests passes unchanged). +- Phase 2 tests pass. +- Manual QA matrix complete on at least US, German, AZERTY, and Dvorak. +- No localStorage corruption — old format reads cleanly, new format roundtrips. + +--- + +## Phase 3 — Menu sync & advanced (demand-driven) + +Each item is independently scoped; pick up only when warranted. + +| Item | Why | +|---|---| +| Generate `main/lib/menu.ts` accelerators from effective bindings | Today `Reload`, `Show Hotkeys`, `Open Settings`, `Quit` are hardcoded and silently shadow user rebinds | +| Migrate v1 terminal `Terminal/helpers.ts:677-679` to `resolveHotkeyFromEvent` | Already in `20260409-tui-hotkey-forwarding.md`; lets v1 TUIs receive unbound `Ctrl+R` etc. | +| Multi-stroke chords (`Ctrl+K Ctrl+S`) | Ship if a feature actually needs the keyspace | +| When-clauses / context system | Ship if global conflicts get hard to reason about | +| Vendor selected VSCode `keyboardLayouts/*.ts` files | Only if `native-keymap` proves insufficient on a real user's machine | + +## Out of scope + +- VSCode-style `KeybindingResolver` / context engine. +- Global system shortcuts via `globalShortcut` (we have no use case). +- Configurable shortcut scopes per pane / per mode (works today via per-component `useHotkey`). +- Per-extension keybinding contributions (no extension surface). + +## References + +- April 2026 baseline: `apps/desktop/plans/20260412-keyboard-recorder-ctrl-binding-fix.md` +- TUI forwarding: `apps/desktop/plans/20260409-tui-hotkey-forwarding.md` +- `native-keymap` on npm: https://www.npmjs.com/package/native-keymap +- `native-keymap` source: https://github.com/microsoft/node-native-keymap +- VSCode `KeyboardLayoutMainService` (architecture reference, not vendored): https://github.com/microsoft/vscode/blob/main/src/vs/platform/keyboardLayout/electron-main/keyboardLayoutMainService.ts +- VSCode `keyboardLayouts/` data files (vendor candidate, MIT): https://github.com/microsoft/vscode/tree/main/src/vs/workbench/services/keybinding/browser/keyboardLayouts +- MDN `KeyboardEvent.code`: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values +- MDN `KeyboardEvent.getModifierState`: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState diff --git a/apps/desktop/plans/done/20260428-keyboard-qa-plan.md b/apps/desktop/plans/done/20260428-keyboard-qa-plan.md new file mode 100644 index 00000000000..32032c4b26f --- /dev/null +++ b/apps/desktop/plans/done/20260428-keyboard-qa-plan.md @@ -0,0 +1,213 @@ +# Keyboard Shortcut System — Manual QA Plan + +**Date:** 2026-04-28 +**Branch:** `keyboard-shortcut-analysi` +**Covers:** Phases 0–2 of `apps/desktop/plans/20260427-keyboard-layout-plan.md` (commits `eaf066364` → `5749f28b3`). + +This plan validates user-facing behavior. **Dev server is fine** — the renderer code paths exercised here behave identically to a packaged build. (We empirically verified `navigator.keyboard.getLayoutMap()` works in `file://` Electron 40, and `native-keymap` is loaded the same way in both modes once `bun run install:deps` has run.) A packaged build is only needed for final pre-release sign-off / bundle-size checks. + +--- + +## Setup + +### Required +- macOS with US (ABC) input source active +- A second input source: **German** (preferred, exercises Y/Z swap + ü/ö/ä) or **French (AZERTY)** (exercises punctuation more) +- App running (`bun dev` or packaged build — either works) + +### Optional but valuable +- Linux or Windows machine with a non-US layout for AltGr testing +- Japanese / Korean input source for IME testing +- Dvorak layout for the most dramatic logical-binding test + +### Quick switching +On macOS, add input sources via *System Settings → Keyboard → Input Sources*. Toggle via the menu-bar input picker (or `⌃Space` / `⌃⌥Space` if assigned). + +--- + +## 1. Smoke regression (US baseline) + +**Must pass before anything else.** If any of these fail, stop and revert. + +- [ ] App launches; no console errors related to `native-keymap`, `keyboardLayout`, or hotkeys. +- [ ] `Cmd+Shift+P` opens Quick Open. +- [ ] `Cmd+T` opens a new terminal tab. +- [ ] `Cmd+,` opens Settings. +- [ ] Settings → Keyboard renders all categories. Glyphs match what they did before this work (e.g. `⌘[`, `⌘]`, `⌘,`, `⌘P`). +- [ ] Click any hotkey row → recording → press a new combo → save. Reload app — binding persists. +- [ ] `Reset all` clears overrides and restores defaults. + +--- + +## 2. Layout-aware display (Phase 1) + +Verifies `native-keymap` is feeding live data and the display refreshes on the fly. + +### 2.1 Display swap on input-source change + +- [ ] US active. Settings → Keyboard. Note the glyph for **Navigate Back** (should be `⌘[`) and **Navigate Forward** (should be `⌘]`). +- [ ] Switch to **German** via menu-bar picker. **Without reloading or reopening Settings:** + - [ ] **Navigate Back** display updates to `⌘Ü` + - [ ] **Navigate Forward** display updates to `⌘+` + - [ ] Update happens within ~1 second +- [ ] Switch back to US. Glyphs revert to `⌘[` and `⌘]`. + +### 2.2 Layout-stable keys don't change + +- [ ] Arrow-key bindings (e.g. any `⌘↑`-style binding) display the same on every layout — they're named keys, immune to layout. +- [ ] **Open Settings** (`⌘,`) still dispatches correctly on every layout (the comma key is at the same physical position on US/UK/German). On AZERTY the comma is at a different physical position and the displayed glyph may change accordingly — verify it dispatches when pressing the actual comma key, regardless of label. + +### 2.3 Live-data sanity (DevTools, optional) + +In renderer DevTools console: +```js +JSON.stringify([ + ["KeyA", await navigator.keyboard.getLayoutMap().then(m => m.get("KeyA"))], + ["KeyZ", await navigator.keyboard.getLayoutMap().then(m => m.get("KeyZ"))], +]) +``` +On German: `KeyZ` should be `"y"`. On US: `"z"`. (We use `native-keymap` internally, not this API, but it's a quick sanity check that the OS *is* reporting different layouts.) + +--- + +## 3. Logical bindings (Phase 2 — the feature) + +Verifies that bindings can be recorded by **printed character** and follow that character across layouts. + +### 3.1 Record a logical binding + +- [ ] US active. Settings → Keyboard. Pick any printable shortcut (e.g. one of the workspace bindings). +- [ ] Click its current binding to start recording. +- [ ] Press `Cmd+Shift+J` (or any unused chord). Save. +- [ ] Settings shows the new binding as `⌘⇧J`. +- [ ] Reload the app — binding persists, fires correctly when pressed. +- [ ] Inspect `localStorage.getItem("hotkey-overrides")` — the override is a v2 object: `{ version: 2, mode: "logical", chord: "meta+shift+j" }`. + +### 3.2 The cross-layout payoff + +This is what Phase 2 was built for. Demonstrates that a binding follows the *printed character*, not the *physical position*. + +- [ ] Switch to **German**. +- [ ] In Settings → Keyboard, find the binding you recorded above (`⌘⇧J`). It still displays as `⌘⇧J`. +- [ ] Press the **key labeled `J`** on your German layout. The binding fires. + - On a German keyboard, the J key is at the same physical position as US, so this is unremarkable. The interesting test: +- [ ] Re-record any letter binding by pressing **the key labeled Z** on German (which is physical KeyY). E.g. record as `⌘Z`. +- [ ] Settings shows `⌘Z`. +- [ ] Switch back to **US**. The binding still displays as `⌘Z`. +- [ ] Press US's `Z` (physical KeyZ). The binding fires. +- [ ] **Before Phase 2, this was impossible** — the binding would have been bound to physical KeyY on German, so on US it would fire when pressing Y, not Z. + +### 3.3 Default registry bindings stay physical + +The shipped defaults preserve today's behavior — only *new user recordings* default to logical. + +- [ ] On US, Settings → Keyboard. The default `Cmd+P` (Quick Open) display is `⌘P`. +- [ ] Switch to **German**. Display becomes `⌘P` still — same physical key (P is at the same position on German anyway). +- [ ] But for a default that uses a key whose position differs (none today, since registry doesn't bind Y or Z): the display would update via the layout map. This is layout-aware *display*, not mode-switching. + +### 3.4 Named keys are layout-immune + +- [ ] Re-record a binding to use `Cmd+ArrowUp`. Save. +- [ ] Switch to German. Display unchanged: `⌘↑`. +- [ ] Press `Cmd+ArrowUp`. Fires. +- [ ] Same test for an F-key binding. + +### 3.5 Conflict detection across modes + +- [ ] On US, record any binding as `Cmd+Shift+K`. +- [ ] Try to record a *different* hotkey to the same `Cmd+Shift+K`. Conflict dialog should appear. +- [ ] "Reassign" should move the binding to the new hotkey, clear the old. + +--- + +## 4. Edge cases & defensive guards (Phase 0) + +### 4.1 IME composition guard + +Mac with Japanese (Hiragana) input source. + +- [ ] Switch to Japanese. Type into any text field (chat input, settings search). Composition underline appears. +- [ ] Press Enter — composition commits as Japanese characters. ✓ correct macOS behavior. +- [ ] Press `Cmd+P` mid-composition — Quick Open opens. ✓ correct macOS behavior (Cmd bypasses IME). +- [ ] No console errors during any of this. + +The guard prevents bare-key (no-modifier) hotkeys from firing during composition. We don't have any bare-key bindings, so this is mostly preventive — main thing is no regressions. + +### 4.2 AltGr guard (Linux/Windows w/ German, optional) + +On a Linux or Windows machine with German layout: + +- [ ] Press `AltGr+E` in a text field. `€` is typed. No app hotkey fires. +- [ ] Confirms `ctrl+alt+e` bindings (if any) don't fire on AltGr-typed printables. + +Skip if you don't have a Linux/Windows test machine. + +### 4.3 Migration is fail-closed (Mac with v1 overrides, optional) + +If you have a Mac with the old v1 hotkey storage (pre-April migration): + +- [ ] On a non-US Mac (e.g. German active), launch the app. +- [ ] Watch console for `[hotkeys] Migrated N override(s), dropped K invalid`. +- [ ] Specifically, v1 entries that look like Mac-Option dead-key glyphs (e.g. `meta+alt+å`) should be **dropped**, not silently rewritten to wrong physical keys. + +Skip if you're already on v2 (most likely). + +### 4.4 Terminal forwarding still works + +- [ ] Open a terminal pane. Run `nvim` or `htop` or any TUI. +- [ ] `Ctrl+C`, `Ctrl+D`, `Ctrl+Z`, `Ctrl+S`, `Ctrl+Q`, `Ctrl+\` all reach the TUI/PTY (terminal-reserved). +- [ ] `Cmd+T`, `Cmd+Shift+P` etc. still trigger app hotkeys even with terminal focused. +- [ ] Rebind a hotkey to `Ctrl+R`, then in a terminal press `Ctrl+R` — should trigger the app hotkey, not reach the shell. (Phase 2 dispatch into the terminal-forwarding reverse index.) + +### 4.5 Shifted glyphs + +- [ ] Record a binding to `Cmd+Shift+/` (US: `?`). Display: `⌘⇧/` (we use unshifted-glyph + ⇧ symbol convention, not `⌘⇧?`). + +--- + +## 5. Storage shape (sanity check) + +Open DevTools, paste: +```js +JSON.parse(localStorage.getItem("hotkey-overrides") || "{}") +``` + +- [ ] Existing physical / shipped-default overrides remain bare strings: `"meta+shift+j"`. +- [ ] New logical overrides are v2 objects: `{ version: 2, mode: "logical", chord: "meta+shift+j" }`. +- [ ] Explicitly unassigned hotkeys are `null`. +- [ ] No `undefined` values, no malformed entries. + +--- + +## 6. Performance / no-regression + +- [ ] App launch time feels normal (no perceptible delay added by `native-keymap` lazy-init or layout subscription). +- [ ] Settings → Keyboard scrolls smoothly with all bindings rendered. +- [ ] Switching input sources doesn't cause UI lag or stutter. +- [ ] Heavy typing in the chat / editor doesn't show input lag (the keydown listener at document level for hotkey dispatch is cheap, but worth checking). + +--- + +## What to do if something fails + +| Symptom | Likely cause | Where to look | +|---|---|---| +| Display doesn't swap on layout change | `native-keymap` IPC not flowing | Main process logs; `apps/desktop/src/main/lib/keyboardLayout.ts` | +| Logical binding fires for wrong key | `translateLogicalChord` misbehaving | `apps/desktop/src/renderer/hotkeys/utils/binding.ts` | +| Default hotkey stops firing | Registry / migration regression | `apps/desktop/src/renderer/hotkeys/registry.ts`, `migrate.ts` | +| Storage corruption | `setOverride` / `serializeBinding` writing wrong shape | `stores/hotkeyOverridesStore.ts`, `utils/binding.ts` | +| Terminal swallows app hotkey | Reverse index not subscribing to layout changes | `utils/resolveHotkeyFromEvent.ts` | +| Console error mentioning `keymapping.node` | `electron-rebuild` didn't run for native-keymap | `bun run install:deps` in `apps/desktop` | + +--- + +## Sign-off criteria + +- [ ] Section 1 (smoke) all green. +- [ ] Section 2 (display) all green on at least US + one non-US layout. +- [ ] Section 3 (logical bindings) — at least 3.1 + 3.2 green; 3.3–3.5 if time. +- [ ] Section 4 (edge cases) — 4.1 + 4.4 green; 4.2/4.3 if you have the environment. +- [ ] Section 5 (storage) green. +- [ ] No console errors throughout. + +If all of the above pass, the keyboard system is ready to ship. diff --git a/apps/desktop/docs/CODE_EDITOR_MIGRATION_PLAN.md b/apps/desktop/plans/done/CODE_EDITOR_MIGRATION_PLAN.md similarity index 100% rename from apps/desktop/docs/CODE_EDITOR_MIGRATION_PLAN.md rename to apps/desktop/plans/done/CODE_EDITOR_MIGRATION_PLAN.md diff --git a/apps/desktop/docs/ROUTING_REFACTOR_PLAN.md b/apps/desktop/plans/done/ROUTING_REFACTOR_PLAN.md similarity index 100% rename from apps/desktop/docs/ROUTING_REFACTOR_PLAN.md rename to apps/desktop/plans/done/ROUTING_REFACTOR_PLAN.md diff --git a/apps/desktop/docs/SEMANTIC_SEARCH_PLAN.md b/apps/desktop/plans/done/SEMANTIC_SEARCH_PLAN.md similarity index 100% rename from apps/desktop/docs/SEMANTIC_SEARCH_PLAN.md rename to apps/desktop/plans/done/SEMANTIC_SEARCH_PLAN.md diff --git a/apps/desktop/plans/v1-create-scenario-analysis.md b/apps/desktop/plans/v1-create-scenario-analysis.md new file mode 100644 index 00000000000..8fd4af66375 --- /dev/null +++ b/apps/desktop/plans/v1-create-scenario-analysis.md @@ -0,0 +1,198 @@ +# V1 Workspace Creation — Scenario Analysis + +Walks through every user scenario in the V1 create flow. Identifies what works, what's wrong, and what V2 should do differently. + +--- + +## Scenario 1: Prompt only (most common) + +**User action:** Types "fix the login bug", hits Cmd+Enter. No workspace name, no branch name. + +**Renderer** (`PromptGroup.tsx:740-806`): +1. `displayName = "fix the login bug"` (from `trimmedPrompt`) +2. `willGenerateAIName = true` (no branchNameEdited, has prompt, no PR) +3. Shows pending workspace with "generating-branch" status +4. Calls `generateBranchNameMutation.mutateAsync({ prompt, projectId })` with 30s timeout +5. **AI succeeds** → `aiBranchName = "fix-login-bug"` +6. Sends to server: `{ name: undefined, prompt: "fix the login bug", branchName: "fix-login-bug" }` + +**Server** (`create.ts:369-374`): +1. `input.branchName` is set → `branch = sanitizeBranchNameWithMaxLength(withPrefix("fix-login-bug"))` → e.g. `"kiet/fix-login-bug"` +2. Collision check runs (line 382): `input.branchName?.trim()` is truthy +3. No existing workspace on that branch → creates new worktree + workspace +4. `workspace.name = input.name ?? branch = "kiet/fix-login-bug"` (since `name` is undefined) +5. `isUnnamed: true` + +**Post-create** (`useCreateWorkspace.ts:79-100`): +1. `wasExisting = false` → sets up pending terminal, runs setup script +2. Navigates to workspace + +**UX issues:** +- ✅ Works well when AI succeeds +- ❌ **Workspace name becomes the branch name** (`"kiet/fix-login-bug"`) because `input.name` was undefined. User sees a slash-separated branch string as their workspace title instead of their prompt. +- The `isUnnamed: true` flag triggers a post-create auto-rename via `attemptWorkspaceAutoRenameFromPrompt` in `initializeWorkspaceWorktree` (`create.ts:523`), which is ANOTHER AI call. So there are TWO serial AI calls: one for branch name, one for workspace display name. + +--- + +## Scenario 2: Prompt only, AI branch gen fails + +**User action:** Same as Scenario 1 but AI times out or auth fails. + +**Renderer** (`PromptGroup.tsx:780-806`): +1. Catches error, shows `"Using random branch name"` toast +2. `aiBranchName = null` +3. Sends to server: `{ name: undefined, prompt: "fix the login bug", branchName: undefined }` + +**Server** (`create.ts:376-380`): +1. `input.branchName` is undefined → hits the `else` branch +2. `branch = generateBranchName({ existingBranches, authorPrefix })` → e.g. `"kiet/cheerful-umbrella"` +3. Collision check at line 382: `input.branchName?.trim()` is **falsy** → **collision check SKIPPED entirely** +4. Creates new worktree + workspace +5. `workspace.name = "kiet/cheerful-umbrella"` + +**UX issues:** +- ✅ Always creates a new workspace (random name can't collide) +- ❌ Workspace name is `"kiet/cheerful-umbrella"` — meaningless to the user +- ❌ Post-create auto-rename (another AI call) may also fail, leaving the random name permanently + +--- + +## Scenario 3: Explicit workspace name, no branch name + +**User action:** Types workspace name "Login Fix", types prompt, no branch name. + +**Renderer** (`PromptGroup.tsx:984-989`): +1. `workspaceNameEdited = true`, `workspaceName = "Login Fix"` +2. `branchNameEdited = false` +3. AI branch gen runs (same as Scenario 1) +4. Sends: `{ name: "Login Fix", prompt: "...", branchName: "fix-login-bug" }` + +**Server**: +1. Branch from AI name → `"kiet/fix-login-bug"` +2. Collision check runs (branchName was set) +3. Creates workspace with `name: "Login Fix"` (input.name is set) +4. `isUnnamed: false` + +**UX:** ✅ Works correctly. User sees "Login Fix" as workspace name. + +--- + +## Scenario 4: Explicit branch name, no workspace name + +**User action:** Types branch name "feature/auth-fix" in the branch input, types prompt. + +**Renderer** (`PromptGroup.tsx:990-999`): +1. `branchNameEdited = true`, `branchName = "feature/auth-fix"` +2. `willGenerateAIName = false` (branchNameEdited is true) +3. AI branch gen does NOT run +4. Sends: `{ name: undefined, prompt: "...", branchName: "feature/auth-fix" }` + +**Server** (`create.ts:369-374`): +1. `branch = sanitizeBranchNameWithMaxLength(withPrefix("feature/auth-fix"))` → `"kiet/feature/auth-fix"` +2. Collision check runs (branchName was set) +3. If branch already has a workspace → returns `{ wasExisting: true }`, navigates to existing +4. If no collision → creates new, `workspace.name = "kiet/feature/auth-fix"`, `isUnnamed: true` + +**UX issues:** +- ❌ Collision check fires because `input.branchName?.trim()` is truthy — **even though the user might not intend to open an existing workspace**. They typed a branch name for a NEW workspace and it silently opens something else. +- ❌ Workspace name is the prefixed branch string, not the prompt +- ❌ No user confirmation: "This branch already has a workspace, open it?" — just silently navigates + +--- + +## Scenario 5: No prompt, no name, no branch (empty create) + +**User action:** Hits Cmd+Enter with nothing filled in. + +**Renderer** (`PromptGroup.tsx:740-746`): +1. `displayName = "New workspace"` +2. `willGenerateAIName = false` (no trimmedPrompt) +3. No AI branch gen +4. Sends: `{ name: undefined, prompt: undefined, branchName: undefined }` + +**Server** (`create.ts:376-380`): +1. All undefined → `branch = generateBranchName(...)` → random `"kiet/cheerful-umbrella"` +2. Collision check skipped (branchName wasn't set) +3. Creates workspace with `name: "kiet/cheerful-umbrella"`, `isUnnamed: true` + +**UX issues:** +- ✅ Always works (random name) +- ❌ Meaningless workspace name +- ❌ Post-create rename has no prompt to derive from, so the auto-rename AI call has nothing to work with → stays as random name + +--- + +## Scenario 6: PR link (create from PR) + +**User action:** Links a PR, types a prompt, hits create. + +**Renderer** (`PromptGroup.tsx:960-978`): +1. `linkedPR` is set → takes a completely different code path +2. Calls `createFromPr.mutateAsyncWithSetup({ projectId, prUrl }, launchRequest)` +3. Does NOT call `createWorkspace` at all + +**V1 `createFromPr`** (`useCreateFromPr.ts`): +1. Calls `electronTrpc.workspaces.createFromPr.mutateAsync({ projectId, prUrl })` +2. Server clones the PR's head branch, creates worktree +3. Workspace name = PR title +4. Branch name = PR head branch + +**UX:** ✅ Works well. PR provides all naming context. + +--- + +## Scenario 7: Branch selected from base-branch picker, then create + +**User action:** Opens base-branch picker, selects `feature/existing`, then hits create with a prompt. + +**Renderer**: +1. `compareBaseBranch = "feature/existing"` is set +2. This is the BASE branch (what the new branch forks from), NOT the workspace branch +3. AI branch gen runs normally, creates a new branch from `feature/existing` + +**UX:** ✅ Works correctly. The base-branch picker only sets the fork point. + +--- + +## Scenario 8: Branch selected from base-branch picker, "Open" action on existing workspace + +**User action:** Opens base-branch picker, sees a branch with an active workspace, clicks "Open". + +**Renderer** (`PromptGroup.tsx:1111-1117`): +1. Calls `handleOpenActiveWorkspace(workspaceId)` +2. Closes modal, navigates to existing workspace +3. Does NOT call create at all + +**UX:** ✅ Works correctly. Clear intent from user action. + +--- + +## Summary of V1 issues + +### Naming +1. **Workspace display name is the branch name when user didn't type a name.** The user typed a prompt but the workspace gets named `"kiet/fix-login-bug"` or `"kiet/cheerful-umbrella"` instead of their prompt or a human-friendly derivative. +2. **Two serial AI calls** — one for branch name (renderer), one for auto-rename (server). Both can fail independently, and the auto-rename runs after create, so the user sees the branch name flash then change. +3. **Random names are meaningless** when AI fails — `"cheerful-umbrella"` tells you nothing about the workspace. + +### Collision behavior +4. **Silent open on branch collision** — when user types a branch name that already has a workspace, V1 silently navigates to the existing one with `wasExisting: true` and toast still says "Workspace created." No confirmation dialog, no visual indication that the user's prompt/attachments/agent selection were all ignored. +5. **Collision check gate is fragile** — it's based on `input.branchName?.trim()`, which means collision check only runs for user-typed branch names. But the USER might have typed a branch name intending to create a new workspace on that branch. The condition conflates "user provided a name" with "check for collisions." + +### Architecture +6. **Branch name generation split across renderer + server** — the renderer does AI generation, the server does random fallback. The server also does prefix application. Two different processes own parts of the branch name logic, making it hard to reason about what name you'll get. +7. **`useExistingBranch` boolean is a separate code path** — adds complexity to the input schema and collision logic for what could be a single `behavior.onExistingBranch: "use" | "error"` flag. +8. **`sourceWorkspaceId` adds another code path** for forking from an existing workspace's branch — but none of this is exercised by the modal UI. It's dead surface area in the create endpoint. + +--- + +## What V2 should do differently + +| V1 problem | V2 approach | +|------------|-------------| +| Workspace name = branch name | `workspaceName = input.prompt \|\| branchName` — prompt is always preferred for display name | +| Two serial AI calls | Single AI call (renderer) for branch name; workspace display name derived from prompt synchronously (no second AI call) | +| Silent open on collision | When branch collision detected and user provided explicit branch: return `opened_existing_workspace` outcome + renderer shows distinct toast "Opened existing workspace" (not "Workspace created") | +| Random names when AI fails | Derive from prompt slug (`sanitizeBranchNameWithMaxLength(prompt)`) before falling back to random. Random is last resort, not first fallback. | +| Collision check gate tied to `input.branchName` | Gate on a semantic flag: was the branch name auto-generated or user-provided? Only run collision check on user-provided names. | +| Branch name logic split renderer/server | Server owns all branch name resolution. Renderer sends `prompt` + optional `branchName`. Server derives branch from prompt, applies deduplication, skips collision check on auto-generated names. | +| `useExistingBranch` / `sourceWorkspaceId` dead paths | Not in V2 schema. Single `behavior.onExistingWorkspace` / `behavior.onExistingWorktree` flags. | diff --git a/apps/desktop/plans/v2-create-decisions-final.md b/apps/desktop/plans/v2-create-decisions-final.md new file mode 100644 index 00000000000..367e272ce0a --- /dev/null +++ b/apps/desktop/plans/v2-create-decisions-final.md @@ -0,0 +1,124 @@ +# V2 Create Workspace — Final Decisions + +## 1. Branch name generation + +**Owner:** Renderer. + +**Phase 1 flow:** +1. User typed a branch name → use it +2. No branch name → derive from prompt slug (`sanitizeBranchNameWithMaxLength(prompt)` → `fix-the-login-bug`) +3. No prompt either → `workspace-${crypto.randomUUID().slice(0, 8)}` + +Host-service receives a branch name every time. Never `undefined`. Host-service deduplicates it (decision #8). + +**Phase 2:** AI branch gen runs async in parallel — fire at submit, don't block create. If AI returns before the host-service call, swap in the better name. Requires `workspaceCreation.generateBranchName` on host-service (decision #3). + +## 2. Workspace display name + +**Owner:** Renderer. + +Renderer sends `workspaceName` explicitly: +- User typed a name → use it +- No name → use prompt text (truncated) +- No prompt → use branch name + +No post-create AI rename. No flash. What you see in the modal is what you get. + +## 3. AI branch name generation + +**Behavior:** Not in Phase 1. Use prompt slug (`sanitizeBranchNameWithMaxLength(prompt)`) — purely client-side, no backend call, no project ID needed. + +**No `electronTrpc` calls from V2 modal for workspace operations.** The existing `electronTrpc.workspaces.generateBranchName` is a V1 endpoint that requires a V1 local project ID. The V2 modal must not call it — that's a boundary violation. + +**Phase 2 migration:** Add `workspaceCreation.generateBranchName({ projectId, prompt })` to host-service. It has the repo (for branch dedup) and needs a model provider for the AI call. The gap is the host-service doesn't have access to the user's AI credentials today (`callSmallModel` reads from Electron settings). Either extend the host-service model provider for utility calls, or proxy through it to the user's config. + +**How V1's AI branch gen works** (for reference): +1. `callSmallModel` picks user's configured provider (OpenAI/Anthropic) +2. Sends prompt with instruction: "Generate a concise git branch name (2-4 words, kebab-case)" +3. Sanitizes + deduplicates against existing branches +4. Returns name without prefix (server applies prefix) +5. Needs: repo path (for branch list), git author config (for prefix), AI credentials + +**File:** `apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts` + +## 4. Collision detection + +**None.** No collision detection. No `opened_existing_workspace` outcome from the create modal flow. + +The host-service receives a branch name and deduplicates it against existing branches. If `fix-the-login-bug` exists, it becomes `fix-the-login-bug-2`. Always creates a new workspace. Never silently opens an existing one. + +If the user wants to open an existing workspace, they use the sidebar. + +## 5. Collision UX + +**N/A.** There is no collision — the branch name is always unique after dedup. The create modal always creates. + +Remove the `opened_existing_workspace`, `opened_worktree`, and `adopted_external_worktree` outcome paths from the create flow. The only outcome is `created_workspace`. + +## 6. Modal close timing + +**Behavior:** Close immediately on submit. Show pending workspace skeleton in sidebar. Navigate when create succeeds. + +**Draft preservation:** Stash a snapshot of the draft into a zustand atom before closing. Close + reset the modal normally. On create failure, restore the stash and reopen the modal so the user can retry. On create success, clear the stash. + +This doesn't depend on the context provider staying mounted — the zustand atom survives route changes and component lifecycle. + +## 7. Pending workspace phases + +**Single phase:** `creating`. That's it. + +No `generating-branch` (no blocking AI), no `preparing` (no renderer-side prep between close and API call). Skeleton appears, host-service call runs, skeleton resolves to workspace or error. + +## 8. Worktree creation always creates a new branch + +**Behavior:** Always `git worktree add -b branchName worktreePath baseBranch`. If the branch name already exists (despite dedup), fall back to a new deduplicated name — never check out the existing branch. + +Checking out an existing branch into a worktree is a separate intent (e.g. "import existing branch" or `createFromPr`). The create flow should never silently check out someone else's branch. If the `-b` fails because the branch exists, append a suffix and retry — don't switch to a checkout. + +V1's try/catch pattern (`worktree add` then fallback to `worktree add -b`) conflates "create" and "checkout" intents. V2 keeps them separate. + +## 9. Branch dedup + +**Owner:** Host-service (at create time). + +Host-service has the authoritative branch list from git at create time. It deduplicates the incoming branch name against existing branches. `fix-the-login-bug` → `fix-the-login-bug-2` if it exists. Renderer sends its best-effort name; host-service guarantees uniqueness. + +## 9. `sanitizeBranchNameWithMaxLength` location + +**Decision:** Copy into host-service for dedup. Renderer keeps its copy for the branch name preview in the UI. Both need it — renderer for preview, host-service for dedup + sanitization of the final name. + +## 10. Host-service input schema + +`names.branchName` is always provided by renderer. `names.workspaceName` is always provided by renderer. Host-service sanitizes + deduplicates `branchName` before creating the worktree. Uses `workspaceName` as-is for the cloud row display name. + +## 11. Return shape + +Simplified. No `outcome` field — create always creates. + +```ts +{ workspace: { id, branch, ... }, warnings: string[] } +``` + +Remove `opened_existing_workspace`, `opened_worktree`, `adopted_external_worktree` outcomes and all their code paths from the host-service. The create endpoint does one thing: create a workspace on a deduplicated branch name. + +## 12. PR create path + +**Separate endpoint:** `workspaceCreation.createFromPr({ projectId, prUrl })`. + +The PR flow is fundamentally different from normal create — it parses a PR URL, fetches metadata (title, head branch, fork info), checks out the PR's branch with remote tracking, and handles cross-repo fork PRs. None of this overlaps with the normal branch-name-from-prompt flow. + +V1 does this as a separate `createFromPr` mutation that takes just `{ projectId, prUrl }` and the server does all resolution. V2 should do the same, using Octokit instead of `gh` CLI. + +**Not in Phase 1.** For now, when user links a PR, the renderer uses the PR's head branch as `branchName` and PR title as `workspaceName`, then calls the normal `create` endpoint. This creates a worktree on a new local branch — it doesn't check out the actual PR with remote tracking. Good enough for Phase 1, but loses fork PR support and proper remote setup. + +Phase 2 adds `workspaceCreation.createFromPr` with full PR checkout semantics. + +## 13. Init/setup flow + +**Streamlined.** After worktree + cloud row creation, if `runSetupScript` is true, the host-service runs the setup script (`.superset/setup.sh` or equivalent) inside the worktree before returning. No background job manager, no progress events, no separate init system. + +Create blocks until setup is done. The workspace is fully ready when the renderer navigates to it. + +V1's complexity (workspaceInitManager, initializeWorkspaceWorktree, async AI rename, progress tracking) is unnecessary. The only post-create work is running a shell script in the worktree directory. + +If setup scripts turn out to be too slow (>10s), we can split later: return immediately, run setup async, show "running setup..." in the workspace view. But start simple. diff --git a/apps/desktop/plans/v2-workspace-init-status.md b/apps/desktop/plans/v2-workspace-init-status.md new file mode 100644 index 00000000000..e8921938d4a --- /dev/null +++ b/apps/desktop/plans/v2-workspace-init-status.md @@ -0,0 +1,298 @@ +# V2 Workspace Creation Status — Design + +## Concept + +When the user creates a workspace, navigate immediately to a pending workspace page. The host-service writes progress to an in-memory map that the pending page polls for step-by-step detail. The create promise runs independently of component lifecycle (fire-and-forget from PromptGroup) and updates the `pendingWorkspaces` collection on resolve/reject. + +Multiple workspaces can be creating simultaneously. Each has its own sidebar skeleton, clickable to view progress. + +## Ownership + +Three actors, each with a clear responsibility: + +1. **PromptGroup** (fires create, owns the promise): inserts pending row into collection, fires `void createWorkspace(...)`, updates collection to `succeeded`/`failed` when the promise resolves/rejects. Runs independently of component lifecycle — the async closure keeps running after the modal unmounts. Only touches the collection (not React state), so no stale-component issues. + +2. **Sidebar** (always visible): reads `pendingWorkspaces` via `useLiveQuery`. Shows skeleton + status. Clickable. Does not poll — just reacts to collection changes. + +3. **Pending page** (optional progress viewer): polls `workspaceCreation.getProgress({ pendingId })` every 500ms for step-by-step detail. Only runs while the page is mounted. If user navigates away, polling stops but create continues. On `succeeded`, auto-navigates to real workspace. + +## Data model: `pendingWorkspaces` local collection + +Backed by `localStorageCollectionOptions` from `@tanstack/react-db`, same as `v2SidebarProjects`, `v2WorkspaceLocalState`, etc. Persists to localStorage, survives app restart. + +```ts +export const pendingWorkspaceSchema = z.object({ + // Identity + id: z.string().uuid(), // renderer-generated, NOT the eventual workspace ID + projectId: z.string().uuid(), + + // Draft data (preserved for retry on failure) + name: z.string(), // resolved workspace display name + branchName: z.string(), // resolved branch name + prompt: z.string(), + compareBaseBranch: z.string().nullable(), + runSetupScript: z.boolean(), + linkedIssues: z.array(z.unknown()), + linkedPR: z.unknown().nullable(), + hostTarget: z.unknown(), // WorkspaceHostTarget + + // Status (updated by PromptGroup's create promise) + status: z.enum(["creating", "failed", "succeeded"]), + error: z.string().nullable(), // set when status === "failed" + workspaceId: z.string().nullable(),// set when status === "succeeded" + + createdAt: persistedDateSchema, +}); +``` + +**Lifecycle:** +1. On submit: insert row with `status: "creating"` +2. Promise resolves: set `status: "succeeded"`, `workspaceId: realId` +3. Promise rejects: set `status: "failed"`, `error: message` +4. On navigate to real workspace: delete the pending row +5. On retry (from failed page): reset `status: "creating"`, re-fire create +6. On dismiss: delete the pending row + +## Progress polling + +### Host-service: in-memory progress map + +```ts +// Module-level (not persisted, not in DB) +const createProgress = new Map<string, { step: string }>(); +``` + +The create mutation writes its current step as it progresses: + +```ts +createProgress.set(input.pendingId, { step: "ensuring_repo" }); +// ... clone/resolve ... +createProgress.set(input.pendingId, { step: "creating_worktree" }); +// ... git worktree add ... +createProgress.set(input.pendingId, { step: "registering" }); +// ... cloud API ... +createProgress.delete(input.pendingId); // done — tRPC response carries the result +``` + +### Host-service: query endpoint + +```ts +workspaceCreation.getProgress({ pendingId }) +// → { step: "creating_worktree" } | null (not found / already done) +``` + +### Pending page: polls with react-query + +```ts +const { data: progress } = useQuery({ + queryKey: ["workspaceCreation", "getProgress", pendingId], + queryFn: () => client.workspaceCreation.getProgress.query({ pendingId }), + refetchInterval: 500, + enabled: pendingWorkspace?.status === "creating", +}); +``` + +~10 small queries over 5 seconds. Polling stops when status changes to `succeeded` or `failed` (detected via `useLiveQuery` on the collection). + +## Flow + +``` +User clicks Create + ↓ +PromptGroup: + 1. Compute names (branch, workspace) + 2. Insert into pendingWorkspaces collection (status: "creating") + 3. Store attachments in IndexedDB + 4. Close modal + 5. Navigate to /v2-workspace/pending/$pendingId + 6. Fire void createWorkspace(...) — runs independently + ↓ +Pending page mounts, starts polling: +┌──────────────────────────────────────────┐ +│ fix the login bug │ +│ ⑂ fix-the-login-bug │ +│ │ +│ Creating workspace... │ +│ ├─ Ensuring local repository ✓ │ +│ ├─ Creating worktree ✓ │ +│ ├─ Registering workspace ● │ +│ │ +└──────────────────────────────────────────┘ + ↓ +PromptGroup's create promise resolves: + → Updates collection: status: "succeeded", workspaceId: realId + ↓ +Pending page detects succeeded (via useLiveQuery): + → Stops polling + → Navigates to /v2-workspace/$workspaceId + → Dispatches initialCommands to terminal pane + ↓ +Normal workspace UI (setup running in terminal) +``` + +**On failure:** +``` +PromptGroup's create promise rejects: + → Updates collection: status: "failed", error: message + ↓ +Pending page detects failed (via useLiveQuery): + → Stops polling + → Shows error + Retry + Dismiss +``` + +**If user navigates away from pending page:** +- Polling stops (component unmounts) +- Create promise still runs (it's a closure, not tied to React lifecycle) +- Collection still gets updated on resolve/reject +- Sidebar skeleton still reflects current status +- User can click skeleton to return to pending page + +## Sidebar behavior + +The sidebar renders pending workspaces from the `pendingWorkspaces` collection alongside real workspaces from `v2Workspaces`: + +- **Creating:** workspace name + spinner + "Creating..." label +- **Failed:** workspace name + error badge +- **Succeeded:** brief flash, then replaced by the real workspace from collections + +All states are clickable — navigate to `/v2-workspace/pending/$id`. + +## Input schema update + +Add `pendingId` to create input: + +```ts +workspaceCreation.create({ + pendingId: z.string(), // renderer-generated UUID for progress polling correlation + projectId: z.string(), + names: { ... }, + composer: { ... }, + linkedContext: { ... }, +}) +``` + +## Return shape update + +Add `initialCommands`: + +```ts +{ + workspace: { id, branch, ... }, + initialCommands: string[] | null, + warnings: string[], +} +``` + +Host-service reads setup config, returns commands, does not execute them. Renderer dispatches to terminal pane. + +## Pending workspace route + +**Route:** `/v2-workspace/pending/$pendingId` + +- Reads pending workspace from `pendingWorkspaces` collection via `useLiveQuery` +- Polls `workspaceCreation.getProgress` for step detail while `status === "creating"` +- Shows workspace name + branch name + step progress +- On `succeeded` (detected via collection): auto-navigate to `/v2-workspace/$workspaceId` +- On `failed` (detected via collection): error message + Retry + Dismiss + +## Retry flow + +From the failed pending page: +1. User clicks Retry +2. Update the pending row: `status: "creating"`, clear `error` +3. Re-fire `createWorkspace` with the same data from the pending row + attachments from IndexedDB +4. Same polling + collection-update flow as initial create + +## Replaces + +| Old | New | +|-----|-----| +| `pendingWorkspace` in zustand store (single item) | `pendingWorkspaces` local collection (multiple) | +| `stashedDraft` zustand atom | Draft data lives in the pending row itself | +| `setPendingWorkspace` / `clearPendingWorkspace` / `setPendingWorkspaceStatus` | Collection insert / update / delete | +| `restoreStashedDraft` (reopen modal) | Retry from pending page (no modal reopen) | + +## Files to change + +### Host-service +| File | Change | +|------|--------| +| `.../workspace-creation/workspace-creation.ts` | Accept `pendingId`, write to in-memory progress map, add `getProgress` query, remove `execSync`, return `initialCommands` | + +### Renderer — data +| File | Change | +|------|--------| +| `.../CollectionsProvider/dashboardSidebarLocal/schema.ts` | Add `pendingWorkspaceSchema` | +| `.../CollectionsProvider/collections.ts` | Add `pendingWorkspaces` collection | +| `renderer/stores/new-workspace-modal.ts` | Remove `pendingWorkspace`, `stashedDraft` and related actions (moved to collection) | +| **New:** `renderer/lib/pending-attachment-store.ts` | IndexedDB wrapper for attachment blobs | + +### Renderer — UI +| File | Change | +|------|--------| +| **New:** `.../v2-workspace/pending/$pendingId/page.tsx` | Pending workspace progress page (polls getProgress, navigates on success) | +| `.../PromptGroup/PromptGroup.tsx` | Insert into collection, store attachments in IndexedDB, fire-and-forget create, update collection on resolve/reject | +| `.../DashboardSidebar/...` | Query `pendingWorkspaces` collection, render skeletons | + +## Attachments: IndexedDB blob storage + +Attachments (images, PDFs, markdown files) can't go in the localStorage-backed collection — they're too large. Store raw blobs in IndexedDB alongside the pending workspace metadata. + +### Storage pattern + +```ts +// Key scheme: "pending-attachments/${pendingId}/${index}-${filename}" + +// On import (user adds file in modal): +const blob = await fetch(blobUrl).then(r => r.blob()); +await idb.put("pending-attachments", { + blob, + mediaType: file.mediaType, + filename: file.filename, +}, `${pendingId}/${index}-${file.filename}`); + +// On submit: +// Read blobs from IndexedDB → convert to data URLs → send in API payload + +// On retry: +// Read same blobs → convert again + +// On success or dismiss: +// Delete all entries matching pendingId prefix +``` + +### No compression + +Images and PDFs are already compressed — gzipping saves 0-2%. IndexedDB has no practical size limit. These blobs are ephemeral (seconds to minutes). Not worth the CPU cost. + +### Pending workspace row stores metadata only + +The `pendingWorkspaces` collection row holds attachment metadata (not data): + +```ts +attachments: z.array(z.object({ + filename: z.string(), + mediaType: z.string(), + size: z.number(), +})).default([]), +``` + +The actual blobs are in IndexedDB, keyed by `pendingId`. + +### Files + +| File | Change | +|------|--------| +| **New:** `renderer/lib/pending-attachment-store.ts` | IndexedDB wrapper: `storeAttachments(pendingId, files)`, `loadAttachments(pendingId)`, `clearAttachments(pendingId)` | +| `.../PromptGroup/PromptGroup.tsx` | Store attachments to IndexedDB on submit, load on retry | + +## Not in scope + +- Attachment compression (not needed — IndexedDB has no size limit, most files already compressed) +- Agent launch (Phase 2) +- AI workspace rename (dropped) +- Streaming setup output (setup runs in terminal pane — user sees it live) + +## TODO: cleanup + +- **Clean up module-level `createProgress` Map.** Entries are deleted on create completion, but if the process crashes mid-create or the promise is abandoned, stale entries leak. Add a TTL sweep (e.g. delete entries older than 5 minutes on each `getProgress` call) or use a `WeakRef`-based approach. Not urgent — the map holds tiny objects and the host-service restarts clear it. diff --git a/apps/desktop/runtime-dependencies.ts b/apps/desktop/runtime-dependencies.ts index f039d9f6fe7..fa36b93e61b 100644 --- a/apps/desktop/runtime-dependencies.ts +++ b/apps/desktop/runtime-dependencies.ts @@ -43,6 +43,12 @@ const externalizedRuntimeModules: ExternalizedRuntimeModule[] = [ packagedCopies: [copyWholeModule("node-pty")], asarUnpackGlobs: ["**/node_modules/node-pty/**/*"], }, + { + specifier: "native-keymap", + materialize: ["native-keymap"], + packagedCopies: [copyWholeModule("native-keymap")], + asarUnpackGlobs: ["**/node_modules/native-keymap/**/*"], + }, { specifier: "@superset/macos-process-metrics", materialize: ["@superset/macos-process-metrics"], @@ -88,6 +94,11 @@ const packagedSupportModules = [ export const mainExternalizedDependencies = [ ...externalizedRuntimeModules.map((module) => module.specifier), "pg-native", + // mastracode transitively loads @mastra/fastembed → onnxruntime-node, whose + // native binding is loaded via a dynamic `require` that @rollup/plugin-commonjs + // can't resolve at bundle time. Externalizing lets Node handle the require at + // runtime from node_modules. Also keeps the bundle size sane (~20 MB chunk). + "mastracode", ]; export const packagedNodeModuleCopies = [ diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index 8e8bde1fe2f..3b34f3ecc75 100644 --- a/apps/desktop/scripts/copy-native-modules.ts +++ b/apps/desktop/scripts/copy-native-modules.ts @@ -25,6 +25,7 @@ import { rmSync, } from "node:fs"; import { dirname, join } from "node:path"; +import { satisfies } from "semver"; import { requiredMaterializedNodeModules } from "../runtime-dependencies"; // Target architecture for cross-compilation. When set, platform-specific @@ -110,6 +111,119 @@ function copyModuleIfSymlink( return true; } +function readInstalledModuleVersion(modulePath: string): string | null { + const packageJsonPath = join(modulePath, "package.json"); + if (!existsSync(packageJsonPath)) return null; + type PackageJson = { version?: string }; + const packageJson = JSON.parse( + readFileSync(packageJsonPath, "utf8"), + ) as PackageJson; + return packageJson.version ?? null; +} + +function copyExactModuleVersion( + nodeModulesDir: string, + moduleName: string, + version: string, + destPath: string, + required: boolean, +): boolean { + const bunStoreDir = getBunStoreDir(nodeModulesDir); + const bunStoreFolderName = findBunStoreFolderName( + bunStoreDir, + moduleName, + version, + ); + if (bunStoreFolderName) { + const sourcePath = join( + bunStoreDir, + bunStoreFolderName, + "node_modules", + moduleName, + ); + if (existsSync(sourcePath)) { + mkdirSync(dirname(destPath), { recursive: true }); + cpSync(sourcePath, destPath, { recursive: true }); + console.log(` Copied ${moduleName}@${version} to: ${destPath}`); + return true; + } + } + + if (fetchNpmPackage(moduleName, version, destPath)) { + return true; + } + + if (required) { + console.error( + ` [ERROR] Failed to materialize ${moduleName}@${version} at ${destPath}`, + ); + process.exit(1); + } + + return false; +} + +function copyDependencyForPackage( + nodeModulesDir: string, + parentModuleName: string, + dependencyName: string, + dependencyRange: string, + required: boolean, +): void { + const topLevelDependencyPath = join(nodeModulesDir, dependencyName); + const topLevelVersion = readInstalledModuleVersion(topLevelDependencyPath); + + if (topLevelVersion && satisfies(topLevelVersion, dependencyRange)) { + copyModuleIfSymlink(nodeModulesDir, dependencyName, required); + return; + } + + if (!topLevelVersion) { + console.log( + ` ${dependencyName}: top-level version missing; materializing ${dependencyRange} at the workspace root`, + ); + copyExactModuleVersion( + nodeModulesDir, + dependencyName, + dependencyRange, + topLevelDependencyPath, + required, + ); + return; + } + + const nestedDependencyPath = join( + nodeModulesDir, + parentModuleName, + "node_modules", + dependencyName, + ); + const nestedVersion = readInstalledModuleVersion(nestedDependencyPath); + if (nestedVersion && satisfies(nestedVersion, dependencyRange)) { + const nestedStats = lstatSync(nestedDependencyPath); + if (nestedStats.isSymbolicLink()) { + const realPath = realpathSync(nestedDependencyPath); + rmSync(nestedDependencyPath); + cpSync(realPath, nestedDependencyPath, { + recursive: true, + }); + } + return; + } + + console.log( + ` ${dependencyName}: top-level version ${topLevelVersion ?? "missing"} does not satisfy ${dependencyRange}; materializing nested copy for ${parentModuleName}`, + ); + + copyExactModuleVersion( + nodeModulesDir, + dependencyName, + dependencyRange, + nestedDependencyPath, + required, + ); +} + /** * Fetch an npm package tarball and extract it to destPath. * Used when cross-compiling and the target platform package isn't in the Bun store. @@ -241,12 +355,12 @@ function copyLibsqlDependencies(nodeModulesDir: string): void { const libsqlPkg = JSON.parse( readFileSync(libsqlPkgJsonPath, "utf8"), ) as LibsqlPackageJson; - const deps = Object.keys(libsqlPkg.dependencies ?? {}); + const deps = libsqlPkg.dependencies ?? {}; const optionalDeps = libsqlPkg.optionalDependencies ?? {}; console.log("\nPreparing libsql runtime dependencies..."); - for (const dep of deps) { - copyModuleIfSymlink(nodeModulesDir, dep, true); + for (const [dep, version] of Object.entries(deps)) { + copyDependencyForPackage(nodeModulesDir, "libsql", dep, version, true); } // Copy whichever optional native platform packages Bun installed for this platform. diff --git a/apps/desktop/scripts/demo-launch-spec.ts b/apps/desktop/scripts/demo-launch-spec.ts new file mode 100644 index 00000000000..c3f89146300 --- /dev/null +++ b/apps/desktop/scripts/demo-launch-spec.ts @@ -0,0 +1,248 @@ +/** + * Demo: show what buildLaunchContext + buildLaunchSpec produce for various + * canonical inputs, across all built-in agents. + * + * Not a test — manual eyeball tool for template iteration before the V2 + * modal wire-up lands (step 9). + * + * Run: bun run scripts/demo-launch-spec.ts + * or: bun run scripts/demo-launch-spec.ts claude + * or: bun run scripts/demo-launch-spec.ts codex cursor-agent + */ + +import { + indexResolvedAgentConfigs, + resolveAgentConfigs, +} from "@superset/shared/agent-settings"; +import { buildLaunchSpec } from "../src/shared/context/buildLaunchSpec"; +import { buildLaunchContext } from "../src/shared/context/composer"; +import { defaultContributorRegistry } from "../src/shared/context/contributors"; +import type { LaunchSource, ResolveCtx } from "../src/shared/context/types"; + +// --------------------------------------------------------------------------- +// Stub resolvers (mirror what host-service/issues + task services would return) +// --------------------------------------------------------------------------- + +const resolveCtx: ResolveCtx = { + projectId: "demo-project", + signal: new AbortController().signal, + fetchIssue: async (url) => ({ + number: 123, + url, + title: "Auth middleware stores tokens in plaintext", + body: "Legal flagged this last week. Sessions written to disk without encryption. We need to move to an encrypted KV before the compliance deadline.", + slug: "auth-middleware-stores-tokens-in-plaintext", + }), + fetchPullRequest: async (url) => ({ + number: 200, + url, + title: "Rewrite auth middleware", + body: "Replaces plaintext token storage with encrypted KV. Migrates existing sessions on first request.", + branch: "fix/auth-encryption", + }), + fetchInternalTask: async (id) => ({ + id, + slug: "refactor-auth", + title: "Refactor auth middleware", + description: + "Split session-token storage from request handling so we can encrypt at rest. Keep the public API shape stable.", + }), +}; + +// --------------------------------------------------------------------------- +// Scenarios +// --------------------------------------------------------------------------- + +interface Scenario { + name: string; + sources: LaunchSource[]; +} + +const SCENARIOS: Scenario[] = [ + { + name: "plain prompt", + sources: [ + { + kind: "user-prompt", + content: [ + { type: "text", text: "add e2e tests for the checkout flow" }, + ], + }, + ], + }, + { + name: "prompt + linked issue", + sources: [ + { + kind: "user-prompt", + content: [{ type: "text", text: "fix this" }], + }, + { + kind: "github-issue", + url: "https://github.com/acme/repo/issues/123", + }, + ], + }, + { + name: "inline text + image + text (rich editor)", + sources: [ + { + kind: "user-prompt", + content: [ + { type: "text", text: "look at this:" }, + { + type: "image", + data: new Uint8Array([137, 80, 78, 71]), + mediaType: "image/png", + }, + { type: "text", text: "<- heres more text" }, + ], + }, + ], + }, + { + name: "inline + issue (editor image between text with linked issue)", + sources: [ + { + kind: "user-prompt", + content: [ + { type: "text", text: "look at this:" }, + { + type: "image", + data: new Uint8Array([137, 80, 78, 71]), + mediaType: "image/png", + }, + { type: "text", text: "<- heres more text" }, + ], + }, + { kind: "github-issue", url: "https://github.com/acme/repo/issues/123" }, + ], + }, + { + name: "prompt + task + issue + PR + attachment", + sources: [ + { + kind: "user-prompt", + content: [ + { type: "text", text: "refactor the auth middleware end-to-end" }, + ], + }, + { kind: "internal-task", id: "TASK-42" }, + { + kind: "github-issue", + url: "https://github.com/acme/repo/issues/123", + }, + { + kind: "github-pr", + url: "https://github.com/acme/repo/pull/200", + }, + { + kind: "attachment", + file: { + data: new TextEncoder().encode( + "2026-04-14 ERROR auth.ts:42 token decrypt failed\n", + ), + mediaType: "text/plain", + filename: "logs.txt", + }, + }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +const requestedAgentsArg = process.argv.slice(2); +const configs = indexResolvedAgentConfigs(resolveAgentConfigs({})); +const requestedAgents = + requestedAgentsArg.length > 0 + ? requestedAgentsArg + : ["claude", "codex", "cursor-agent"]; + +function divider(char = "=", n = 72): string { + return char.repeat(n); +} + +function indent(text: string, prefix = " "): string { + return text + .split("\n") + .map((line) => prefix + line) + .join("\n"); +} + +for (const scenario of SCENARIOS) { + console.log(`\n${divider("=")}`); + console.log(`SCENARIO: ${scenario.name}`); + console.log(divider("=")); + + const ctx = await buildLaunchContext( + { + projectId: "demo-project", + sources: scenario.sources, + agent: { id: "claude" }, + }, + { contributors: defaultContributorRegistry, resolveCtx }, + ); + + if (ctx.failures.length > 0) { + console.log("FAILURES:"); + for (const f of ctx.failures) console.log(` - ${f.error}`); + } + + for (const agentId of requestedAgents) { + const config = configs.get(agentId as never); + if (!config) { + console.log(`\n[skip] ${agentId} — not a known agent`); + continue; + } + + const spec = buildLaunchSpec({ ...ctx, agent: { id: config.id } }, config); + console.log(`\n${divider("-")}`); + console.log(`AGENT: ${config.label} (${config.id})`); + console.log(divider("-")); + + if (!spec) { + console.log("(null — no agent)"); + continue; + } + + console.log(`taskSlug: ${spec.taskSlug ?? "(none)"}`); + console.log(`system parts: ${spec.system.length}`); + console.log(`user parts: ${spec.user.length}`); + console.log( + `attachments: ${spec.attachments.length} (${ + spec.attachments.map((p) => p.type).join(", ") || "none" + })`, + ); + + if (spec.system.length > 0) { + console.log("\n[SYSTEM]"); + for (const part of spec.system) { + if (part.type === "text") console.log(indent(part.text)); + } + } + + if (spec.user.length > 0) { + console.log("\n[USER]"); + for (const part of spec.user) { + if (part.type === "text") { + console.log(indent(part.text)); + } else if (part.type === "image") { + console.log( + indent(`<image: ${part.mediaType}, ${part.data.length} bytes>`), + ); + } else if (part.type === "file") { + console.log( + indent( + `<file: ${part.filename ?? "(unnamed)"}, ${part.mediaType}, ${part.data.length} bytes>`, + ), + ); + } + } + } + } +} + +console.log("\n"); diff --git a/apps/desktop/scripts/generate-file-icons.ts b/apps/desktop/scripts/generate-file-icons.ts index da13b64df52..0975d41afc4 100644 --- a/apps/desktop/scripts/generate-file-icons.ts +++ b/apps/desktop/scripts/generate-file-icons.ts @@ -33,16 +33,25 @@ function run() { addIcon(manifest.file); addIcon(manifest.folder); addIcon(manifest.folderExpanded); + addIcon(manifest.rootFolder); + addIcon(manifest.rootFolderExpanded); // File mappings for (const icon of Object.values(manifest.fileNames ?? {})) addIcon(icon); for (const icon of Object.values(manifest.fileExtensions ?? {})) addIcon(icon); + // Language ID mappings (VS Code languageId → icon, not covered by extensions) + for (const icon of Object.values(manifest.languageIds ?? {})) addIcon(icon); + // Folder mappings for (const icon of Object.values(manifest.folderNames ?? {})) addIcon(icon); for (const icon of Object.values(manifest.folderNamesExpanded ?? {})) addIcon(icon); + for (const icon of Object.values(manifest.rootFolderNames ?? {})) + addIcon(icon); + for (const icon of Object.values(manifest.rootFolderNamesExpanded ?? {})) + addIcon(icon); // Build condensed manifest const condensed: CondensedManifest = { @@ -56,13 +65,19 @@ function run() { }; // material-icon-theme relies on VS Code's languageIds for base extensions. - // Since Electron has no languageId system, add them explicitly. - const baseExtensionDefaults: Record<string, string> = { + // Since Electron has no languageId system, fold languageIds where the + // language name matches a common file extension so they resolve at runtime. + const languageIdExtensionMap: Record<string, string> = { ts: "typescript", js: "javascript", + php: "php", + tex: "tex", + m: "matlab", + diff: "diff", + patch: "diff", }; - for (const [ext, icon] of Object.entries(baseExtensionDefaults)) { + for (const [ext, icon] of Object.entries(languageIdExtensionMap)) { if (!condensed.fileExtensions[ext]) { condensed.fileExtensions[ext] = icon; referencedIcons.add(icon); diff --git a/apps/desktop/scripts/patch-dev-protocol.ts b/apps/desktop/scripts/patch-dev-protocol.ts index b1956eb011d..dc89ff36fe0 100644 --- a/apps/desktop/scripts/patch-dev-protocol.ts +++ b/apps/desktop/scripts/patch-dev-protocol.ts @@ -151,7 +151,10 @@ export function resolveWorkspaceIdentity( : undefined; const displayWorkspaceName = resolvedDisplayName || workspaceName; const bundleDisplayWorkspaceName = - displayWorkspaceName.replaceAll("/", "-").trim() || workspaceName; + displayWorkspaceName + .replaceAll("/", "-") + .replaceAll(/[^a-zA-Z0-9 -]/g, "") + .trim() || workspaceName; return { workspaceName, diff --git a/apps/desktop/scripts/validate-native-runtime.ts b/apps/desktop/scripts/validate-native-runtime.ts index 93b1ddbf6d8..5115b848f7f 100644 --- a/apps/desktop/scripts/validate-native-runtime.ts +++ b/apps/desktop/scripts/validate-native-runtime.ts @@ -7,11 +7,14 @@ * 3) required native runtime packages are missing from apps/desktop/node_modules */ -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs"; import { builtinModules } from "node:module"; import { join } from "node:path"; import ts from "typescript"; -import { mainExternalizedDependencies } from "../runtime-dependencies"; +import { + mainExternalizedDependencies, + requiredMaterializedNodeModules, +} from "../runtime-dependencies"; const projectRoot = join(import.meta.dirname, ".."); const allowedBareRequirePackages = new Set([ @@ -360,6 +363,24 @@ function validateNativeModulesPrepared(): void { ); } + for (const moduleName of requiredMaterializedNodeModules) { + const modulePath = join(nodeModulesDir, moduleName); + assertExists( + modulePath, + "Required materialized runtime dependency is missing.", + ); + if (lstatSync(modulePath).isSymbolicLink()) { + fail( + [ + "Required materialized runtime dependency is still a symlink.", + `Dependency: ${moduleName}`, + `Path: ${modulePath}`, + "Run `bun run copy:native-modules` and ensure Bun store symlinks are replaced with real files.", + ].join("\n"), + ); + } + } + const platformCandidates = getPlatformLibsqlCandidates(); if (platformCandidates.length === 0) { console.warn( diff --git a/apps/desktop/src/lib/ai/call-small-model.test.ts b/apps/desktop/src/lib/ai/call-small-model.test.ts deleted file mode 100644 index ff133f56a18..00000000000 --- a/apps/desktop/src/lib/ai/call-small-model.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import type { SmallModelProvider } from "@superset/chat/server/desktop"; - -const getDefaultSmallModelProvidersMock = mock((): SmallModelProvider[] => []); - -mock.module("@superset/chat/server/desktop", () => ({ - getDefaultSmallModelProviders: getDefaultSmallModelProvidersMock, - generateTitleFromMessage: mock(async () => null), - generateTitleFromMessageWithStreamingModel: mock(async () => null), -})); - -const { callSmallModel } = await import("./call-small-model"); - -describe("callSmallModel", () => { - beforeEach(() => { - getDefaultSmallModelProvidersMock.mockReset(); - getDefaultSmallModelProvidersMock.mockReturnValue([]); - }); - - it("skips unsupported credentials and falls through to the next working provider", async () => { - const { result, attempts } = await callSmallModel({ - providers: [ - { - id: "openai", - name: "OpenAI", - resolveCredentials: () => ({ - apiKey: "oauth-token", - kind: "oauth", - source: "auth-storage", - }), - isSupported: () => ({ - supported: false, - reason: "unsupported oauth", - }), - createModel: () => "openai-model", - }, - { - id: "anthropic", - name: "Anthropic", - resolveCredentials: () => ({ - apiKey: "anthropic-token", - kind: "oauth", - source: "auth-storage", - }), - isSupported: () => ({ supported: true }), - createModel: () => "anthropic-model", - }, - ], - invoke: async ({ providerId, model }) => - providerId === "anthropic" && model === "anthropic-model" - ? "generated title" - : null, - }); - - expect(result).toBe("generated title"); - expect(attempts).toEqual([ - { - providerId: "openai", - providerName: "OpenAI", - credentialKind: "oauth", - credentialSource: "auth-storage", - issue: { - code: "unsupported_credentials", - capability: "small_model_tasks", - remediation: "add_api_key", - message: "unsupported oauth", - }, - outcome: "unsupported-credentials", - reason: "unsupported oauth", - }, - { - providerId: "anthropic", - providerName: "Anthropic", - credentialKind: "oauth", - credentialSource: "auth-storage", - outcome: "succeeded", - }, - ]); - }); - - it("allows OpenAI OAuth credentials on the small-model path", async () => { - const { result, attempts } = await callSmallModel({ - providers: [ - { - id: "openai", - name: "OpenAI", - resolveCredentials: () => ({ - apiKey: "oauth-token", - kind: "oauth", - source: "auth-storage", - }), - isSupported: () => ({ supported: true }), - createModel: () => "openai-model", - }, - ], - invoke: async ({ providerId, model }) => - providerId === "openai" && model === "openai-model" - ? "generated title" - : null, - }); - - expect(result).toBe("generated title"); - expect(attempts).toEqual([ - { - providerId: "openai", - providerName: "OpenAI", - credentialKind: "oauth", - credentialSource: "auth-storage", - outcome: "succeeded", - }, - ]); - }); - - it("treats empty-string results as successful model output", async () => { - const { result, attempts } = await callSmallModel({ - providers: [ - { - id: "openai", - name: "OpenAI", - resolveCredentials: () => ({ - apiKey: "oauth-token", - kind: "oauth", - source: "auth-storage", - }), - isSupported: () => ({ supported: true }), - createModel: () => "openai-model", - }, - ], - invoke: async () => "", - }); - - expect(result).toBe(""); - expect(attempts).toEqual([ - { - providerId: "openai", - providerName: "OpenAI", - credentialKind: "oauth", - credentialSource: "auth-storage", - outcome: "succeeded", - }, - ]); - }); - - it("classifies missing OpenAI scopes as a canonical provider issue", async () => { - const { result, attempts } = await callSmallModel({ - providers: [ - { - id: "openai", - name: "OpenAI", - resolveCredentials: () => ({ - apiKey: "oauth-token", - kind: "oauth", - source: "auth-storage", - }), - isSupported: () => ({ supported: true }), - createModel: () => "openai-model", - }, - ], - invoke: async () => { - throw new Error( - "You have insufficient permissions for this operation. Missing scopes: api.responses.write.", - ); - }, - }); - - expect(result).toBeNull(); - expect(attempts).toEqual([ - { - providerId: "openai", - providerName: "OpenAI", - credentialKind: "oauth", - credentialSource: "auth-storage", - issue: { - code: "missing_scope", - capability: "small_model_tasks", - remediation: "check_permissions", - scope: "api.responses.write", - message: "OpenAI needs permission api.responses.write", - }, - outcome: "failed", - reason: - "You have insufficient permissions for this operation. Missing scopes: api.responses.write.", - }, - ]); - }); - - it("returns null after exhausting providers", async () => { - const { result, attempts } = await callSmallModel({ - providers: [ - { - id: "anthropic", - name: "Anthropic", - resolveCredentials: () => null, - isSupported: () => ({ supported: true }), - createModel: () => "unused", - }, - { - id: "openai", - name: "OpenAI", - resolveCredentials: () => ({ - apiKey: "api-key", - kind: "apiKey", - source: "auth-storage", - }), - isSupported: () => ({ supported: true }), - createModel: () => "openai-model", - }, - ], - invoke: async () => null, - }); - - expect(result).toBeNull(); - expect(attempts).toEqual([ - { - providerId: "anthropic", - providerName: "Anthropic", - outcome: "missing-credentials", - }, - { - providerId: "openai", - providerName: "OpenAI", - credentialKind: "apiKey", - credentialSource: "auth-storage", - outcome: "empty-result", - }, - ]); - }); - - it("skips expired oauth credentials before attempting the request", async () => { - const { result, attempts } = await callSmallModel({ - providers: [ - { - id: "anthropic", - name: "Anthropic", - resolveCredentials: () => ({ - apiKey: "expired-oauth", - kind: "oauth", - source: "config", - expiresAt: Date.now() - 1_000, - }), - isSupported: () => ({ supported: true }), - createModel: () => "anthropic-model", - }, - ], - invoke: async () => "should-not-run", - }); - - expect(result).toBeNull(); - expect(attempts).toEqual([ - { - providerId: "anthropic", - providerName: "Anthropic", - credentialKind: "oauth", - credentialSource: "config", - issue: { - code: "expired", - capability: "small_model_tasks", - remediation: "reconnect", - message: "Anthropic session expired", - }, - outcome: "expired-credentials", - reason: "Anthropic session expired", - }, - ]); - }); - - it("continues after a provider throws and returns the next successful result", async () => { - const { result, attempts } = await callSmallModel({ - providers: [ - { - id: "openai", - name: "OpenAI", - resolveCredentials: () => ({ - apiKey: "api-key", - kind: "apiKey", - source: "auth-storage", - }), - isSupported: () => ({ supported: true }), - createModel: () => { - throw new Error("provider unavailable"); - }, - }, - { - id: "anthropic", - name: "Anthropic", - resolveCredentials: () => ({ - apiKey: "anthropic-key", - kind: "apiKey", - source: "config", - }), - isSupported: () => ({ supported: true }), - createModel: () => "anthropic-model", - }, - ], - invoke: async ({ providerId, model }) => - providerId === "anthropic" && model === "anthropic-model" - ? "fallback title" - : null, - }); - - expect(result).toBe("fallback title"); - expect(attempts).toEqual([ - { - providerId: "openai", - providerName: "OpenAI", - credentialKind: "apiKey", - credentialSource: "auth-storage", - issue: { - code: "unknown_error", - capability: "small_model_tasks", - remediation: "try_again", - message: "OpenAI could not complete this request", - }, - outcome: "failed", - reason: "provider unavailable", - }, - { - providerId: "anthropic", - providerName: "Anthropic", - credentialKind: "apiKey", - credentialSource: "config", - outcome: "succeeded", - }, - ]); - }); - - it("respects providerOrder when a caller prefers one provider first", async () => { - const visited: string[] = []; - - await callSmallModel({ - providers: [ - { - id: "anthropic", - name: "Anthropic", - resolveCredentials: () => ({ - apiKey: "anthropic-key", - kind: "apiKey", - source: "config", - }), - isSupported: () => ({ supported: true }), - createModel: () => "anthropic-model", - }, - { - id: "openai", - name: "OpenAI", - resolveCredentials: () => ({ - apiKey: "openai-key", - kind: "apiKey", - source: "auth-storage", - }), - isSupported: () => ({ supported: true }), - createModel: () => "openai-model", - }, - ], - providerOrder: ["openai", "anthropic"], - invoke: async ({ providerId }) => { - visited.push(providerId); - return "title"; - }, - }); - - expect(visited).toEqual(["openai"]); - }); - - it("uses shared default providers when none are supplied", async () => { - getDefaultSmallModelProvidersMock.mockReturnValue([ - { - id: "openai", - name: "OpenAI", - resolveCredentials: () => ({ - apiKey: "api-key", - kind: "apiKey", - source: "auth-storage", - }), - isSupported: () => ({ supported: true }), - createModel: () => "shared-openai-model", - }, - ]); - - const { result, attempts } = await callSmallModel({ - invoke: async ({ providerId, model }) => - providerId === "openai" && model === "shared-openai-model" - ? "title" - : null, - }); - - expect(result).toBe("title"); - expect(getDefaultSmallModelProvidersMock).toHaveBeenCalledTimes(1); - expect(attempts).toEqual([ - { - providerId: "openai", - providerName: "OpenAI", - credentialKind: "apiKey", - credentialSource: "auth-storage", - outcome: "succeeded", - }, - ]); - }); -}); diff --git a/apps/desktop/src/lib/ai/call-small-model.ts b/apps/desktop/src/lib/ai/call-small-model.ts deleted file mode 100644 index 429a7a0147e..00000000000 --- a/apps/desktop/src/lib/ai/call-small-model.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { - getDefaultSmallModelProviders, - type SmallModelCredential, - type SmallModelProvider, -} from "@superset/chat/server/desktop"; -import { - classifyProviderIssue, - type ProviderId, - type ProviderIssue, -} from "shared/ai/provider-status"; -import { - clearProviderIssue, - reportProviderIssue, -} from "./provider-diagnostics"; - -type SmallModelProviderId = ProviderId; - -export interface SmallModelAttempt { - providerId: SmallModelProviderId; - providerName: string; - credentialKind?: SmallModelCredential["kind"]; - credentialSource?: string; - issue?: ProviderIssue; - outcome: - | "missing-credentials" - | "expired-credentials" - | "unsupported-credentials" - | "empty-result" - | "failed" - | "succeeded"; - reason?: string; -} - -export interface SmallModelInvocationContext { - providerId: SmallModelProviderId; - providerName: string; - model: unknown; - credentials: SmallModelCredential; -} - -function orderProviders( - providers: SmallModelProvider[], - providerOrder?: SmallModelProviderId[], -): SmallModelProvider[] { - if (!providerOrder || providerOrder.length === 0) { - return providers; - } - - const rank = new Map( - providerOrder.map((providerId, index) => [providerId, index]), - ); - return [...providers].sort((left, right) => { - const leftRank = rank.get(left.id) ?? Number.MAX_SAFE_INTEGER; - const rightRank = rank.get(right.id) ?? Number.MAX_SAFE_INTEGER; - return leftRank - rightRank; - }); -} - -export async function callSmallModel<TResult>({ - invoke, - providers = getDefaultSmallModelProviders(), - providerOrder, -}: { - invoke: ( - context: SmallModelInvocationContext, - ) => Promise<TResult | null | undefined>; - providers?: SmallModelProvider[]; - providerOrder?: SmallModelProviderId[]; -}): Promise<{ - result: TResult | null; - attempts: SmallModelAttempt[]; -}> { - const attempts: SmallModelAttempt[] = []; - - for (const provider of orderProviders(providers, providerOrder)) { - const credentials = provider.resolveCredentials(); - if (!credentials) { - attempts.push({ - providerId: provider.id, - providerName: provider.name, - outcome: "missing-credentials", - }); - clearProviderIssue(provider.id, "small_model_tasks"); - continue; - } - if ( - credentials.kind === "oauth" && - typeof credentials.expiresAt === "number" && - credentials.expiresAt <= Date.now() - ) { - const issue: ProviderIssue = { - code: "expired", - capability: "small_model_tasks", - remediation: "reconnect", - message: `${provider.name} session expired`, - }; - attempts.push({ - providerId: provider.id, - providerName: provider.name, - credentialKind: credentials.kind, - credentialSource: credentials.source, - issue, - outcome: "expired-credentials", - reason: issue.message, - }); - reportProviderIssue(provider.id, issue); - continue; - } - - const support = provider.isSupported(credentials); - if (!support.supported) { - const issue: ProviderIssue = { - code: "unsupported_credentials", - capability: "small_model_tasks", - remediation: "add_api_key", - message: - support.reason ?? - `${provider.name} credentials are not supported for this request`, - }; - attempts.push({ - providerId: provider.id, - providerName: provider.name, - credentialKind: credentials.kind, - credentialSource: credentials.source, - issue, - outcome: "unsupported-credentials", - reason: support.reason, - }); - reportProviderIssue(provider.id, issue); - continue; - } - - try { - const model = await provider.createModel(credentials); - const result = await invoke({ - providerId: provider.id, - providerName: provider.name, - model, - credentials, - }); - if (result != null) { - attempts.push({ - providerId: provider.id, - providerName: provider.name, - credentialKind: credentials.kind, - credentialSource: credentials.source, - outcome: "succeeded", - }); - clearProviderIssue(provider.id, "small_model_tasks"); - return { result, attempts }; - } - - attempts.push({ - providerId: provider.id, - providerName: provider.name, - credentialKind: credentials.kind, - credentialSource: credentials.source, - outcome: "empty-result", - }); - clearProviderIssue(provider.id, "small_model_tasks"); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - const issue = classifyProviderIssue({ - providerId: provider.id, - errorMessage: reason, - }); - attempts.push({ - providerId: provider.id, - providerName: provider.name, - credentialKind: credentials.kind, - credentialSource: credentials.source, - issue, - outcome: "failed", - reason, - }); - reportProviderIssue(provider.id, issue); - } - } - - return { - result: null, - attempts, - }; -} diff --git a/apps/desktop/src/lib/ai/provider-diagnostics.ts b/apps/desktop/src/lib/ai/provider-diagnostics.ts deleted file mode 100644 index be8206edc0e..00000000000 --- a/apps/desktop/src/lib/ai/provider-diagnostics.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { - ProviderCapability, - ProviderDiagnostic, - ProviderId, - ProviderIssue, -} from "shared/ai/provider-status"; - -const DIAGNOSTIC_CAPABILITIES: ProviderCapability[] = [ - "chat", - "small_model_tasks", - "workspace_titles", -]; - -const diagnostics = new Map<string, ProviderDiagnostic>(); - -function getDiagnosticKey( - providerId: ProviderId, - capability: ProviderCapability, -): string { - return `${providerId}:${capability}`; -} - -function getEmptyDiagnostic(providerId: ProviderId): ProviderDiagnostic { - return { - providerId, - issue: null, - updatedAt: null, - }; -} - -export function getProviderDiagnostic( - providerId: ProviderId, - capability?: ProviderCapability, -): ProviderDiagnostic { - if (capability) { - return ( - diagnostics.get(getDiagnosticKey(providerId, capability)) ?? - getEmptyDiagnostic(providerId) - ); - } - - let latestDiagnostic: ProviderDiagnostic | null = null; - for (const supportedCapability of DIAGNOSTIC_CAPABILITIES) { - const diagnostic = diagnostics.get( - getDiagnosticKey(providerId, supportedCapability), - ); - if (!diagnostic) { - continue; - } - if ( - latestDiagnostic === null || - (diagnostic.updatedAt ?? 0) > (latestDiagnostic.updatedAt ?? 0) - ) { - latestDiagnostic = diagnostic; - } - } - - return latestDiagnostic ?? getEmptyDiagnostic(providerId); -} - -export function getProviderDiagnostics(): ProviderDiagnostic[] { - return [getProviderDiagnostic("anthropic"), getProviderDiagnostic("openai")]; -} - -export function reportProviderIssue( - providerId: ProviderId, - issue: ProviderIssue, -): void { - const capability = issue.capability ?? "chat"; - diagnostics.set(getDiagnosticKey(providerId, capability), { - providerId, - issue, - updatedAt: Date.now(), - }); -} - -export function clearProviderIssue( - providerId: ProviderId, - capability?: ProviderCapability, -): void { - if (capability) { - diagnostics.delete(getDiagnosticKey(providerId, capability)); - return; - } - - for (const supportedCapability of DIAGNOSTIC_CAPABILITIES) { - diagnostics.delete(getDiagnosticKey(providerId, supportedCapability)); - } -} diff --git a/apps/desktop/src/lib/electron-app/factories/app/setup.ts b/apps/desktop/src/lib/electron-app/factories/app/setup.ts index d2590b7a697..e48db56505a 100644 --- a/apps/desktop/src/lib/electron-app/factories/app/setup.ts +++ b/apps/desktop/src/lib/electron-app/factories/app/setup.ts @@ -33,8 +33,10 @@ export async function makeAppSetup( if (!windows.length) { window = await createWindow(); } else { + // Show hidden windows (macOS hide-to-tray) or restore minimized ones for (window of windows.reverse()) { - window.restore(); + window.show(); + window.focus(); } } }); @@ -50,8 +52,10 @@ export async function makeAppSetup( }); }); + // macOS: keep the app alive (standard behavior) — tray/dock provide re-entry. + // Windows/Linux: quit the app UI. Host-services survive via releaseAll() + // and will be re-adopted on next launch. app.on("window-all-closed", () => !PLATFORM.IS_MAC && app.quit()); - app.on("before-quit", () => {}); return window; } @@ -70,7 +74,20 @@ PLATFORM.IS_WINDOWS && app.commandLine.appendSwitch("force-color-profile", "srgb"); -// Enable CDP for browser DevTools and desktop automation MCP -const cdpPort = String(process.env.DESKTOP_AUTOMATION_PORT || 41729); -app.commandLine.appendSwitch("remote-debugging-port", cdpPort); -app.commandLine.appendSwitch("remote-allow-origins", "*"); +// Each xterm pane holds one WebGL context. v2 parking keeps panes alive +// across workspace switches, so cumulative contexts can reach the low +// hundreds — past Chromium's default cap of 16, Blink force-evicts the +// oldest context and the terminal blanks out. 256 covers the parking load +// while staying bounded enough that a runaway leak still surfaces (Tabby +// raises this to 9000, which masks leaks). +app.commandLine.appendSwitch("max-active-webgl-contexts", "256"); + +// Only expose CDP in development when a port is explicitly configured. +const cdpPort = + env.NODE_ENV === "development" + ? process.env.DESKTOP_AUTOMATION_PORT + : undefined; +if (cdpPort) { + app.commandLine.appendSwitch("remote-debugging-port", cdpPort); + app.commandLine.appendSwitch("remote-allow-origins", "*"); +} diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts index 6d33f04240c..d622d9425b3 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -1,11 +1,11 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import { AUTH_PROVIDERS } from "@superset/shared/constants"; +import { getHostId, getHostName } from "@superset/shared/host-info"; import { observable } from "@trpc/server/observable"; import { shell } from "electron"; import { env } from "main/env.main"; -import { getDeviceName, getHashedDeviceId } from "main/lib/device-info"; -import { getHostServiceManager } from "main/lib/host-service-manager"; +import { getHostServiceCoordinator } from "main/lib/host-service-coordinator"; import { PLATFORM, PROTOCOL_SCHEME } from "shared/constants"; import { env as sharedEnv } from "shared/env.shared"; import { z } from "zod"; @@ -23,8 +23,8 @@ export const createAuthRouter = () => { getStoredToken: publicProcedure.query(() => loadToken()), getDeviceInfo: publicProcedure.query(() => ({ - deviceId: getHashedDeviceId(), - deviceName: getDeviceName(), + deviceId: getHostId(), + deviceName: getHostName(), })), persistToken: publicProcedure @@ -109,7 +109,7 @@ export const createAuthRouter = () => { }), signOut: publicProcedure.mutation(async () => { - getHostServiceManager().stopAll(); + getHostServiceCoordinator().stopAll(); await fs.unlink(TOKEN_FILE).catch(() => {}); authEvents.emit("token-cleared"); return { success: true }; diff --git a/apps/desktop/src/lib/trpc/routers/auth/utils/crypto-storage.ts b/apps/desktop/src/lib/trpc/routers/auth/utils/crypto-storage.ts index 7276a7f30fc..9b2ec3ae2ad 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/utils/crypto-storage.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/utils/crypto-storage.ts @@ -4,7 +4,7 @@ import { randomBytes, scryptSync, } from "node:crypto"; -import { getMachineId } from "main/lib/device-info"; +import { getMachineId } from "@superset/shared/host-info"; const ALGORITHM = "aes-256-gcm"; const KEY_LENGTH = 32; diff --git a/apps/desktop/src/lib/trpc/routers/browser/browser.ts b/apps/desktop/src/lib/trpc/routers/browser/browser.ts index 3d5823f9216..50681573e85 100644 --- a/apps/desktop/src/lib/trpc/routers/browser/browser.ts +++ b/apps/desktop/src/lib/trpc/routers/browser/browser.ts @@ -136,13 +136,6 @@ export const createBrowserRouter = () => { return { success: true }; }), - getDevToolsUrl: publicProcedure - .input(z.object({ browserPaneId: z.string() })) - .query(async ({ input }) => { - const url = await browserManager.getDevToolsUrl(input.browserPaneId); - return { url }; - }), - getPageInfo: publicProcedure .input(z.object({ paneId: z.string() })) .query(({ input }) => { diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.test.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.test.ts index 4d82f5f8c89..b5533b98103 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.test.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.test.ts @@ -6,6 +6,7 @@ import { import { getExistingPRHeadRepoUrl, resolveRemoteNameForExistingPRHead, + shouldRetargetPushToExistingPRHead, } from "./utils/existing-pr-push-target"; describe("git-operations error handling", () => { @@ -183,4 +184,49 @@ describe("existing PR push target resolution", () => { }), ).toBe("https://github.com/kitenite/superset"); }); + + test("retargets push when the tracked branch differs from the linked PR head", () => { + expect( + shouldRetargetPushToExistingPRHead({ + trackingRef: { + remoteName: "origin", + branchName: "feature/local-branch", + }, + target: { + remote: "origin", + targetBranch: "feature/pr-branch", + }, + }), + ).toBe(true); + }); + + test("retargets push when the tracked remote differs from the linked PR head repo", () => { + expect( + shouldRetargetPushToExistingPRHead({ + trackingRef: { + remoteName: "origin", + branchName: "feature/pr-branch", + }, + target: { + remote: "kitenite", + targetBranch: "feature/pr-branch", + }, + }), + ).toBe(true); + }); + + test("keeps plain push when tracking already matches the linked PR head", () => { + expect( + shouldRetargetPushToExistingPRHead({ + trackingRef: { + remoteName: "kitenite", + branchName: "feature/pr-branch", + }, + target: { + remote: "kitenite", + targetBranch: "feature/pr-branch", + }, + }), + ).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index b514633654a..73826001d8b 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,464 +1,50 @@ import { TRPCError } from "@trpc/server"; -import type { RemoteWithRefs, SimpleGit } from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; -import { - execGitWithShellPath, - getSimpleGitWithShellPath, -} from "../workspaces/utils/git-client"; -import { - fetchGitHubPRStatus, - getPullRequestRepoArgs, - getRepoContext, -} from "../workspaces/utils/github"; -import { execWithShellEnv } from "../workspaces/utils/shell-env"; -import { resolveTrackingRemoteName } from "../workspaces/utils/upstream-ref"; +import { getCurrentBranch } from "../workspaces/utils/git"; +import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client"; import { isNoPullRequestFoundMessage, isUpstreamMissingError, } from "./git-utils"; import { assertRegisteredWorktree } from "./security/path-validation"; import { - type GitRemoteInfo, - isOpenPullRequestState, - resolveRemoteNameForExistingPRHead, -} from "./utils/existing-pr-push-target"; + fetchCurrentBranch, + getTrackingBranchStatus, + hasUpstreamBranch, + isNonFastForwardPushError, + pushCurrentBranch, + pushWithResolvedUpstream, +} from "./utils/git-push"; import { mergePullRequest } from "./utils/merge-pull-request"; import { - buildPullRequestCompareUrl, - normalizeGitHubRepoUrl, - parseUpstreamRef, -} from "./utils/pull-request-url"; + buildNewPullRequestUrl, + findExistingOpenPRUrl, +} from "./utils/pull-request-discovery"; import { clearStatusCacheForWorktree } from "./utils/status-cache"; import { clearWorktreeStatusCaches } from "./utils/worktree-status-caches"; export { isUpstreamMissingError }; -async function getTrackingRef( - git: SimpleGit, -): Promise<{ remoteName: string; branchName: string } | null> { - try { - const upstream = ( - await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]) - ).trim(); - return parseUpstreamRef(upstream); - } catch { - return null; - } -} - -async function hasUpstreamBranch(git: SimpleGit): Promise<boolean> { - try { - await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]); - return true; - } catch { - return false; - } -} - -async function getTrackingRemote(git: SimpleGit): Promise<string> { - const trackingRef = await getTrackingRef(git); - return trackingRef?.remoteName ?? "origin"; -} - -async function fetchCurrentBranch(git: SimpleGit): Promise<void> { - const localBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); - const trackingRef = await getTrackingRef(git); - const branch = trackingRef?.branchName ?? localBranch; - const remote = trackingRef?.remoteName ?? resolveTrackingRemoteName(null); - try { - await git.fetch([remote, branch]); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (isUpstreamMissingError(message)) { - try { - await git.fetch([remote]); - } catch (fallbackError) { - const fallbackMessage = - fallbackError instanceof Error - ? fallbackError.message - : String(fallbackError); - if (!isUpstreamMissingError(fallbackMessage)) { - console.error( - `[git/fetch] failed fallback fetch for branch ${branch}:`, - fallbackError, - ); - throw fallbackError; - } - } - return; - } - throw error; - } -} - -async function pushWithSetUpstream({ - git, - targetBranch, - remote, -}: { - git: SimpleGit; - targetBranch: string; - remote?: string; -}): Promise<void> { - const trimmedBranch = targetBranch.trim(); - if (!trimmedBranch || trimmedBranch === "HEAD") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: - "Cannot push from detached HEAD. Please checkout a branch and try again.", - }); - } - - const targetRemote = remote ?? (await getTrackingRemote(git)); - - // Use HEAD refspec to avoid resolving the branch name as a local ref. - // This is more reliable for worktrees where upstream tracking isn't set yet. - await git.push([ - "--set-upstream", - targetRemote, - `HEAD:refs/heads/${trimmedBranch}`, - ]); -} - -interface ExistingPullRequestPushTarget { - remote: string; - targetBranch: string; -} - -function toGitRemoteInfo(remote: RemoteWithRefs): GitRemoteInfo { - return { - name: remote.name, - fetchUrl: remote.refs.fetch, - pushUrl: remote.refs.push, - }; +async function getGitWithShellPath(worktreePath: string) { + return getSimpleGitWithShellPath(worktreePath); } -async function resolveExistingPullRequestPushTarget({ - git, +async function getLocalBranchOrThrow({ worktreePath, - fallbackRemote, + action, }: { - git: SimpleGit; worktreePath: string; - fallbackRemote: string; -}): Promise<ExistingPullRequestPushTarget | null> { - clearWorktreeStatusCaches(worktreePath); - const githubStatus = await fetchGitHubPRStatus(worktreePath); - const pr = githubStatus?.pr; - if (!pr || !isOpenPullRequestState(pr.state) || !pr.headRefName?.trim()) { - return null; - } - - const targetBranch = pr.headRefName.trim(); - const remotes = (await git.getRemotes(true)).map(toGitRemoteInfo); - const remote = resolveRemoteNameForExistingPRHead({ - remotes, - pr, - fallbackRemote, - }); - - if (remote) { - return { remote, targetBranch }; - } - - if (pr.isCrossRepository) { - const repoLabel = - pr.headRepositoryOwner && pr.headRepositoryName - ? `${pr.headRepositoryOwner}/${pr.headRepositoryName}` - : "the PR head repository"; - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: `Found open pull request ${pr.url}, but couldn't find a git remote for ${repoLabel}. Reattach the PR branch or add that remote before pushing.`, - }); - } - - return null; -} - -async function pushWithResolvedUpstream({ - git, - worktreePath, - localBranch, -}: { - git: SimpleGit; - worktreePath: string; - localBranch: string; -}): Promise<void> { - const fallbackRemote = await getTrackingRemote(git); - const existingPullRequestTarget = await resolveExistingPullRequestPushTarget({ - git, - worktreePath, - fallbackRemote, - }); - - if (existingPullRequestTarget) { - await pushWithSetUpstream({ - git, - remote: existingPullRequestTarget.remote, - targetBranch: existingPullRequestTarget.targetBranch, - }); - return; - } - - await pushWithSetUpstream({ - git, - remote: fallbackRemote, - targetBranch: localBranch, - }); -} - -function shouldRetryPushWithUpstream(message: string): boolean { - const lowerMessage = message.toLowerCase(); - return ( - lowerMessage.includes("no upstream branch") || - lowerMessage.includes("no tracking information") || - lowerMessage.includes( - "upstream branch of your current branch does not match", - ) || - lowerMessage.includes("cannot be resolved to branch") || - lowerMessage.includes("couldn't find remote ref") - ); -} - -function isNonFastForwardPushError(message: string): boolean { - const lowerMessage = message.toLowerCase(); - return ( - lowerMessage.includes("non-fast-forward") || - (lowerMessage.includes("failed to push some refs") && - (lowerMessage.includes("rejected") || - lowerMessage.includes("fetch first") || - lowerMessage.includes("tip of your current branch is behind") || - lowerMessage.includes("remote contains work"))) - ); -} - -interface TrackingStatus { - pushCount: number; - pullCount: number; - hasUpstream: boolean; -} - -async function getTrackingBranchStatus( - git: SimpleGit, -): Promise<TrackingStatus> { - try { - const upstream = await git.raw([ - "rev-parse", - "--abbrev-ref", - "@{upstream}", - ]); - if (!upstream.trim()) { - return { pushCount: 0, pullCount: 0, hasUpstream: false }; - } - - const tracking = await git.raw([ - "rev-list", - "--left-right", - "--count", - "@{upstream}...HEAD", - ]); - const [pullStr, pushStr] = tracking.trim().split(/\s+/); - return { - pushCount: Number.parseInt(pushStr || "0", 10), - pullCount: Number.parseInt(pullStr || "0", 10), - hasUpstream: true, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (isUpstreamMissingError(message)) { - return { pushCount: 0, pullCount: 0, hasUpstream: false }; - } - console.warn( - "[git/tracking] Failed to resolve upstream tracking status:", - message, - ); - return { pushCount: 0, pullCount: 0, hasUpstream: false }; - } -} - -async function findExistingOpenPRUrl( - worktreePath: string, -): Promise<string | null> { - // Prefer tracking-based lookup first for fork/branch-name mismatch scenarios. - try { - const { stdout } = await execWithShellEnv( - "gh", - [ - "pr", - "view", - "--json", - "url,state", - "--jq", - 'if .state == "OPEN" then .url else "" end', - ], - { cwd: worktreePath }, - ); - const url = stdout.trim(); - if (url) { - return url; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const isNoPROpenError = message - .toLowerCase() - .includes("no pull requests found"); - if (!isNoPROpenError) { - console.warn( - "[git/findExistingOpenPRUrl] Failed tracking-branch PR lookup:", - message, - ); - } - // Fallback to commit-SHA search below. - } - - const byHeadCommit = await findOpenPRByHeadCommit(worktreePath); - if (byHeadCommit) { - return byHeadCommit; - } - - return null; -} - -async function findOpenPRByHeadCommit( - worktreePath: string, -): Promise<string | null> { - try { - const { stdout: headOutput } = await execGitWithShellPath( - ["rev-parse", "HEAD"], - { cwd: worktreePath }, - ); - const headSha = headOutput.trim(); - if (!headSha) { - return null; - } - - const repoArgs = getPullRequestRepoArgs(await getRepoContext(worktreePath)); - - const { stdout } = await execWithShellEnv( - "gh", - [ - "pr", - "list", - ...repoArgs, - "--state", - "open", - "--search", - `${headSha} is:pr`, - "--limit", - "20", - "--json", - "url,headRefOid", - ], - { cwd: worktreePath }, - ); - - const parsed = JSON.parse(stdout) as Array<{ - url?: string; - headRefOid?: string; - }>; - const match = parsed.find((candidate) => candidate.headRefOid === headSha); - return match?.url?.trim() || null; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn( - "[git/findExistingOpenPRUrl] Failed commit-based PR lookup:", - message, - ); - return null; - } -} - -const ghRepoMetadataSchema = z.object({ - url: z.string().url(), - isFork: z.boolean(), - parent: z - .object({ - url: z.string().url(), - }) - .nullable(), - defaultBranchRef: z.object({ - name: z.string().min(1), - }), -}); - -async function getMergeBaseBranch( - git: SimpleGit, - branch: string, -): Promise<string | null> { - try { - const configuredBaseBranch = await git.raw([ - "config", - "--get", - `branch.${branch}.gh-merge-base`, - ]); - return configuredBaseBranch.trim() || null; - } catch { - return null; - } -} - -async function buildNewPullRequestUrl( - worktreePath: string, - git: SimpleGit, - branch: string, -): Promise<string> { - const { stdout } = await execWithShellEnv( - "gh", - ["repo", "view", "--json", "url,isFork,parent,defaultBranchRef"], - { cwd: worktreePath }, - ); - const repoMetadata = ghRepoMetadataSchema.parse(JSON.parse(stdout)); - const currentRepoUrl = normalizeGitHubRepoUrl(repoMetadata.url); - const baseRepoUrl = normalizeGitHubRepoUrl( - repoMetadata.isFork && repoMetadata.parent?.url - ? repoMetadata.parent.url - : repoMetadata.url, - ); - - if (!currentRepoUrl || !baseRepoUrl) { + action: string; +}): Promise<string> { + const branch = await getCurrentBranch(worktreePath); + if (!branch) { throw new TRPCError({ code: "BAD_REQUEST", - message: "GitHub is not available for this workspace.", + message: `Cannot ${action} from detached HEAD. Please checkout a branch and try again.`, }); } - - const configuredBaseBranch = await getMergeBaseBranch(git, branch); - const baseBranch = configuredBaseBranch ?? repoMetadata.defaultBranchRef.name; - let headRepoOwner = currentRepoUrl.split("/").at(-2) ?? ""; - let headBranch = branch; - - try { - const upstreamRef = ( - await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]) - ).trim(); - const parsedUpstreamRef = parseUpstreamRef(upstreamRef); - - if (parsedUpstreamRef) { - headBranch = parsedUpstreamRef.branchName; - const upstreamRemoteUrl = await git.raw([ - "remote", - "get-url", - parsedUpstreamRef.remoteName, - ]); - headRepoOwner = - normalizeGitHubRepoUrl(upstreamRemoteUrl)?.split("/").at(-2) ?? - headRepoOwner; - } - } catch { - // Fall back to the current repository owner and local branch name. - } - - return buildPullRequestCompareUrl({ - baseRepoUrl, - baseBranch, - headRepoOwner, - headBranch, - }); -} - -async function getGitWithShellPath(worktreePath: string) { - return getSimpleGitWithShellPath(worktreePath); + return branch; } export const createGitOperationsRouter = () => { @@ -496,33 +82,26 @@ export const createGitOperationsRouter = () => { const git = await getGitWithShellPath(input.worktreePath); const hasUpstream = await hasUpstreamBranch(git); + const localBranch = await getLocalBranchOrThrow({ + worktreePath: input.worktreePath, + action: "push", + }); if (input.setUpstream && !hasUpstream) { - const localBranch = await git.revparse(["--abbrev-ref", "HEAD"]); await pushWithResolvedUpstream({ git, worktreePath: input.worktreePath, localBranch, }); } else { - try { - await git.push(); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - if (shouldRetryPushWithUpstream(message)) { - const localBranch = await git.revparse(["--abbrev-ref", "HEAD"]); - await pushWithResolvedUpstream({ - git, - worktreePath: input.worktreePath, - localBranch, - }); - } else { - throw error; - } - } + await pushCurrentBranch({ + git, + worktreePath: input.worktreePath, + localBranch, + }); } - await fetchCurrentBranch(git); + + await fetchCurrentBranch(git, input.worktreePath); clearStatusCacheForWorktree(input.worktreePath); return { success: true }; }), @@ -569,20 +148,32 @@ export const createGitOperationsRouter = () => { const message = error instanceof Error ? error.message : String(error); if (isUpstreamMissingError(message)) { - const localBranch = await git.revparse(["--abbrev-ref", "HEAD"]); + const localBranch = await getLocalBranchOrThrow({ + worktreePath: input.worktreePath, + action: "push", + }); await pushWithResolvedUpstream({ git, worktreePath: input.worktreePath, localBranch, }); - await fetchCurrentBranch(git); + await fetchCurrentBranch(git, input.worktreePath); clearStatusCacheForWorktree(input.worktreePath); return { success: true }; } throw error; } - await git.push(); - await fetchCurrentBranch(git); + + const localBranch = await getLocalBranchOrThrow({ + worktreePath: input.worktreePath, + action: "push", + }); + await pushCurrentBranch({ + git, + worktreePath: input.worktreePath, + localBranch, + }); + await fetchCurrentBranch(git, input.worktreePath); clearStatusCacheForWorktree(input.worktreePath); return { success: true }; }), @@ -592,7 +183,7 @@ export const createGitOperationsRouter = () => { .mutation(async ({ input }): Promise<{ success: boolean }> => { assertRegisteredWorktree(input.worktreePath); const git = await getGitWithShellPath(input.worktreePath); - await fetchCurrentBranch(git); + await fetchCurrentBranch(git, input.worktreePath); clearStatusCacheForWorktree(input.worktreePath); return { success: true }; }), @@ -609,7 +200,11 @@ export const createGitOperationsRouter = () => { assertRegisteredWorktree(input.worktreePath); const git = await getGitWithShellPath(input.worktreePath); - const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); + const branch = await getLocalBranchOrThrow({ + worktreePath: input.worktreePath, + action: "create a pull request", + }); + const trackingStatus = await getTrackingBranchStatus(git); const hasUpstream = trackingStatus.hasUpstream; const isBehindUpstream = @@ -635,17 +230,15 @@ export const createGitOperationsRouter = () => { }); } else { try { - await git.push(); + await pushCurrentBranch({ + git, + worktreePath: input.worktreePath, + localBranch: branch, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (shouldRetryPushWithUpstream(message)) { - await pushWithResolvedUpstream({ - git, - worktreePath: input.worktreePath, - localBranch: branch, - }); - } else if ( + if ( input.allowOutOfDate && isBehindUpstream && hasUnpushedCommits && @@ -656,15 +249,14 @@ export const createGitOperationsRouter = () => { message: "Branch has local commits but is behind upstream. Pull/rebase first so local commits can be pushed before creating a PR.", }); - } else { - throw error; } + throw error; } } const existingPRUrl = await findExistingOpenPRUrl(input.worktreePath); if (existingPRUrl) { - await fetchCurrentBranch(git); + await fetchCurrentBranch(git, input.worktreePath); clearWorktreeStatusCaches(input.worktreePath); return { success: true, url: existingPRUrl }; } @@ -675,7 +267,7 @@ export const createGitOperationsRouter = () => { git, branch, ); - await fetchCurrentBranch(git); + await fetchCurrentBranch(git, input.worktreePath); clearWorktreeStatusCaches(input.worktreePath); return { success: true, url }; @@ -686,7 +278,7 @@ export const createGitOperationsRouter = () => { input.worktreePath, ); if (recoveredPRUrl) { - await fetchCurrentBranch(git); + await fetchCurrentBranch(git, input.worktreePath); clearWorktreeStatusCaches(input.worktreePath); return { success: true, url: recoveredPRUrl }; } diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts index 6fac9cfc5b9..230ea918154 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -1,4 +1,5 @@ import { runWithPostCheckoutHookTolerance } from "../../utils/git-hook-tolerance"; +import { getCurrentBranch } from "../../workspaces/utils/git"; import { getSimpleGitWithShellPath } from "../../workspaces/utils/git-client"; import { assertRegisteredWorktree, @@ -29,8 +30,7 @@ async function isCurrentBranch({ expectedBranch: string; }): Promise<boolean> { try { - const git = await getGitWithShellPath(worktreePath); - const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); + const currentBranch = await getCurrentBranch(worktreePath); return currentBranch === expectedBranch; } catch { return false; diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts index 2e111cdc3b9..4f79251f98a 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/existing-pr-push-target.ts @@ -9,6 +9,16 @@ export interface GitRemoteInfo { pushUrl?: string; } +export interface GitTrackingRefInfo { + remoteName: string; + branchName: string; +} + +export interface ExistingPullRequestPushTargetInfo { + remote: string; + targetBranch: string; +} + export function isOpenPullRequestState( state: ExistingPullRequest["state"], ): boolean { @@ -75,3 +85,20 @@ export function resolveRemoteNameForExistingPRHead({ return null; } + +export function shouldRetargetPushToExistingPRHead({ + trackingRef, + target, +}: { + trackingRef: GitTrackingRefInfo | null; + target: ExistingPullRequestPushTargetInfo; +}): boolean { + if (!trackingRef) { + return true; + } + + return ( + trackingRef.remoteName !== target.remote || + trackingRef.branchName !== target.targetBranch + ); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/git-push.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/git-push.ts new file mode 100644 index 00000000000..5c6b54402e3 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/git-push.ts @@ -0,0 +1,337 @@ +import { TRPCError } from "@trpc/server"; +import type { RemoteWithRefs, SimpleGit } from "simple-git"; +import { getCurrentBranch } from "../../workspaces/utils/git"; +import { fetchGitHubPRStatus } from "../../workspaces/utils/github"; +import { resolveTrackingRemoteName } from "../../workspaces/utils/upstream-ref"; +import { isUpstreamMissingError } from "../git-utils"; +import { + type ExistingPullRequestPushTargetInfo, + type GitRemoteInfo, + isOpenPullRequestState, + resolveRemoteNameForExistingPRHead, + shouldRetargetPushToExistingPRHead, +} from "./existing-pr-push-target"; +import { parseUpstreamRef } from "./pull-request-url"; +import { clearWorktreeStatusCaches } from "./worktree-status-caches"; + +export interface TrackingStatus { + pushCount: number; + pullCount: number; + hasUpstream: boolean; +} + +async function getTrackingRef( + git: SimpleGit, +): Promise<{ remoteName: string; branchName: string } | null> { + try { + const upstream = ( + await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]) + ).trim(); + return parseUpstreamRef(upstream); + } catch { + return null; + } +} + +export async function hasUpstreamBranch(git: SimpleGit): Promise<boolean> { + try { + await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]); + return true; + } catch (error) { + // Expected for branches without a tracking upstream; log unexpected errors. + const msg = error instanceof Error ? error.message : String(error); + if ( + !msg.includes("no upstream configured") && + !msg.includes("@{upstream}") + ) { + console.warn("[git] Unexpected error checking upstream branch:", msg); + } + return false; + } +} + +async function getTrackingRemote(git: SimpleGit): Promise<string> { + const trackingRef = await getTrackingRef(git); + return trackingRef?.remoteName ?? "origin"; +} + +export async function fetchCurrentBranch( + git: SimpleGit, + worktreePath: string, +): Promise<void> { + const localBranch = await getCurrentBranch(worktreePath); + const trackingRef = await getTrackingRef(git); + const branch = trackingRef?.branchName ?? localBranch; + if (!branch) { + return; + } + const remote = trackingRef?.remoteName ?? resolveTrackingRemoteName(null); + try { + await git.fetch([remote, branch]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isUpstreamMissingError(message)) { + try { + await git.fetch([remote]); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError); + if (!isUpstreamMissingError(fallbackMessage)) { + console.error( + `[git/fetch] failed fallback fetch for branch ${branch}:`, + fallbackError, + ); + throw fallbackError; + } + } + return; + } + throw error; + } +} + +async function pushWithSetUpstream({ + git, + targetBranch, + remote, +}: { + git: SimpleGit; + targetBranch: string; + remote?: string; +}): Promise<void> { + const trimmedBranch = targetBranch.trim(); + if (!trimmedBranch || trimmedBranch === "HEAD") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Cannot push from detached HEAD. Please checkout a branch and try again.", + }); + } + + const targetRemote = remote ?? (await getTrackingRemote(git)); + + // Use HEAD refspec to avoid resolving the branch name as a local ref. + // This is more reliable for worktrees where upstream tracking isn't set yet. + await git.push([ + "--set-upstream", + targetRemote, + `HEAD:refs/heads/${trimmedBranch}`, + ]); +} + +function toGitRemoteInfo(remote: RemoteWithRefs): GitRemoteInfo { + return { + name: remote.name, + fetchUrl: remote.refs.fetch, + pushUrl: remote.refs.push, + }; +} + +async function resolveExistingPullRequestPushTarget({ + git, + worktreePath, + fallbackRemote, +}: { + git: SimpleGit; + worktreePath: string; + fallbackRemote: string; +}): Promise<ExistingPullRequestPushTargetInfo | null> { + clearWorktreeStatusCaches(worktreePath); + const githubStatus = await fetchGitHubPRStatus(worktreePath); + const pr = githubStatus?.pr; + if (!pr || !isOpenPullRequestState(pr.state) || !pr.headRefName?.trim()) { + return null; + } + + const targetBranch = pr.headRefName.trim(); + const remotes = (await git.getRemotes(true)).map(toGitRemoteInfo); + const remote = resolveRemoteNameForExistingPRHead({ + remotes, + pr, + fallbackRemote, + }); + + if (remote) { + return { remote, targetBranch }; + } + + if (pr.isCrossRepository) { + const repoLabel = + pr.headRepositoryOwner && pr.headRepositoryName + ? `${pr.headRepositoryOwner}/${pr.headRepositoryName}` + : "the PR head repository"; + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Found open pull request ${pr.url}, but couldn't find a git remote for ${repoLabel}. Reattach the PR branch or add that remote before pushing.`, + }); + } + + return null; +} + +async function resolveMismatchedPullRequestPushTarget({ + git, + worktreePath, +}: { + git: SimpleGit; + worktreePath: string; +}): Promise<ExistingPullRequestPushTargetInfo | null> { + const fallbackRemote = await getTrackingRemote(git); + const [trackingRef, existingPullRequestTarget] = await Promise.all([ + getTrackingRef(git), + resolveExistingPullRequestPushTarget({ + git, + worktreePath, + fallbackRemote, + }), + ]); + + if (!existingPullRequestTarget) { + return null; + } + + return shouldRetargetPushToExistingPRHead({ + trackingRef, + target: existingPullRequestTarget, + }) + ? existingPullRequestTarget + : null; +} + +export async function pushWithResolvedUpstream({ + git, + worktreePath, + localBranch, +}: { + git: SimpleGit; + worktreePath: string; + localBranch: string; +}): Promise<void> { + const fallbackRemote = await getTrackingRemote(git); + const existingPullRequestTarget = await resolveExistingPullRequestPushTarget({ + git, + worktreePath, + fallbackRemote, + }); + + if (existingPullRequestTarget) { + await pushWithSetUpstream({ + git, + remote: existingPullRequestTarget.remote, + targetBranch: existingPullRequestTarget.targetBranch, + }); + return; + } + + await pushWithSetUpstream({ + git, + remote: fallbackRemote, + targetBranch: localBranch, + }); +} + +function shouldRetryPushWithUpstream(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("no upstream branch") || + lowerMessage.includes("no tracking information") || + lowerMessage.includes( + "upstream branch of your current branch does not match", + ) || + lowerMessage.includes("cannot be resolved to branch") || + lowerMessage.includes("couldn't find remote ref") + ); +} + +export async function pushCurrentBranch({ + git, + worktreePath, + localBranch, +}: { + git: SimpleGit; + worktreePath: string; + localBranch: string; +}): Promise<void> { + const mismatchedPullRequestTarget = + await resolveMismatchedPullRequestPushTarget({ + git, + worktreePath, + }); + + if (mismatchedPullRequestTarget) { + await pushWithSetUpstream({ + git, + remote: mismatchedPullRequestTarget.remote, + targetBranch: mismatchedPullRequestTarget.targetBranch, + }); + return; + } + + try { + await git.push(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (shouldRetryPushWithUpstream(message)) { + await pushWithResolvedUpstream({ + git, + worktreePath, + localBranch, + }); + return; + } + + throw error; + } +} + +export function isNonFastForwardPushError(message: string): boolean { + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("non-fast-forward") || + (lowerMessage.includes("failed to push some refs") && + (lowerMessage.includes("rejected") || + lowerMessage.includes("fetch first") || + lowerMessage.includes("tip of your current branch is behind") || + lowerMessage.includes("remote contains work"))) + ); +} + +export async function getTrackingBranchStatus( + git: SimpleGit, +): Promise<TrackingStatus> { + try { + const upstream = await git.raw([ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ]); + if (!upstream.trim()) { + return { pushCount: 0, pullCount: 0, hasUpstream: false }; + } + + const tracking = await git.raw([ + "rev-list", + "--left-right", + "--count", + "@{upstream}...HEAD", + ]); + const [pullStr, pushStr] = tracking.trim().split(/\s+/); + return { + pushCount: Number.parseInt(pushStr || "0", 10), + pullCount: Number.parseInt(pullStr || "0", 10), + hasUpstream: true, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isUpstreamMissingError(message)) { + return { pushCount: 0, pullCount: 0, hasUpstream: false }; + } + console.warn( + "[git/tracking] Failed to resolve upstream tracking status:", + message, + ); + return { pushCount: 0, pullCount: 0, hasUpstream: false }; + } +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts new file mode 100644 index 00000000000..68c4506db62 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.test.ts @@ -0,0 +1,239 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; + +type GetCurrentBranch = + typeof import("../../workspaces/utils/git").getCurrentBranch; +type ExecGitWithShellPath = + typeof import("../../workspaces/utils/git-client").execGitWithShellPath; +type GetPRForBranch = + typeof import("../../workspaces/utils/github").getPRForBranch; +type GetPullRequestRepoArgs = + typeof import("../../workspaces/utils/github").getPullRequestRepoArgs; +type GetRepoContext = + typeof import("../../workspaces/utils/github").getRepoContext; +type ExecWithShellEnv = + typeof import("../../workspaces/utils/shell-env").execWithShellEnv; +type IsNoPullRequestFoundMessage = + typeof import("../git-utils").isNoPullRequestFoundMessage; +type ClearWorktreeStatusCaches = + typeof import("./worktree-status-caches").clearWorktreeStatusCaches; + +const getCurrentBranchMock = mock( + (async (..._args: Parameters<GetCurrentBranch>) => null) as GetCurrentBranch, +); +const execGitWithShellPathMock = mock((async ( + ..._args: Parameters<ExecGitWithShellPath> +) => ({ + stdout: "", + stderr: "", +})) as ExecGitWithShellPath); +const getRepoContextMock = mock( + (async (..._args: Parameters<GetRepoContext>) => null) as GetRepoContext, +); +const getPRForBranchMock = mock( + (async (..._args: Parameters<GetPRForBranch>) => null) as GetPRForBranch, +); +const getPullRequestRepoArgsMock = mock((( + ..._args: Parameters<GetPullRequestRepoArgs> +) => []) as GetPullRequestRepoArgs); +const execWithShellEnvMock = mock((async ( + ..._args: Parameters<ExecWithShellEnv> +) => ({ + stdout: "", + stderr: "", +})) as ExecWithShellEnv); +const isNoPullRequestFoundMessageMock = mock( + ((..._args: Parameters<IsNoPullRequestFoundMessage>) => + false) as IsNoPullRequestFoundMessage, +); +const clearWorktreeStatusCachesMock = mock( + ((..._args: Parameters<ClearWorktreeStatusCaches>) => + undefined) as ClearWorktreeStatusCaches, +); +const openPullRequest = { + number: 42, + title: "Test PR", + url: "https://github.com/superset-sh/superset/pull/42", + state: "open" as const, + additions: 0, + deletions: 0, + reviewDecision: "pending" as const, + checksStatus: "none" as const, + checks: [], +}; +let mergePullRequest: typeof import("./merge-pull-request").mergePullRequest; + +describe("mergePullRequest", () => { + beforeAll(async () => { + const gitModule = await import("../../workspaces/utils/git"); + const gitClientModule = await import("../../workspaces/utils/git-client"); + const githubModule = await import("../../workspaces/utils/github"); + const shellEnvModule = await import("../../workspaces/utils/shell-env"); + const gitUtilsModule = await import("../git-utils"); + const worktreeStatusCachesModule = await import("./worktree-status-caches"); + + spyOn(gitModule, "getCurrentBranch").mockImplementation((( + ...args: Parameters<typeof gitModule.getCurrentBranch> + ) => getCurrentBranchMock(...args)) as typeof gitModule.getCurrentBranch); + spyOn(gitModule, "isUnbornHeadError").mockImplementation( + ((error: unknown) => + error instanceof Error && + error.message.includes( + "ambiguous argument 'HEAD'", + )) as typeof gitModule.isUnbornHeadError, + ); + spyOn(gitClientModule, "execGitWithShellPath").mockImplementation((( + ...args: Parameters<typeof gitClientModule.execGitWithShellPath> + ) => + execGitWithShellPathMock( + ...args, + )) as typeof gitClientModule.execGitWithShellPath); + spyOn(githubModule, "getPRForBranch").mockImplementation((( + ...args: Parameters<typeof githubModule.getPRForBranch> + ) => getPRForBranchMock(...args)) as typeof githubModule.getPRForBranch); + spyOn(githubModule, "getPullRequestRepoArgs").mockImplementation((( + ...args: Parameters<typeof githubModule.getPullRequestRepoArgs> + ) => + getPullRequestRepoArgsMock( + ...args, + )) as typeof githubModule.getPullRequestRepoArgs); + spyOn(githubModule, "getRepoContext").mockImplementation((( + ...args: Parameters<typeof githubModule.getRepoContext> + ) => getRepoContextMock(...args)) as typeof githubModule.getRepoContext); + spyOn(shellEnvModule, "execWithShellEnv").mockImplementation((( + ...args: Parameters<typeof shellEnvModule.execWithShellEnv> + ) => + execWithShellEnvMock(...args)) as typeof shellEnvModule.execWithShellEnv); + spyOn(gitUtilsModule, "isNoPullRequestFoundMessage").mockImplementation((( + ...args: Parameters<typeof gitUtilsModule.isNoPullRequestFoundMessage> + ) => + isNoPullRequestFoundMessageMock( + ...args, + )) as typeof gitUtilsModule.isNoPullRequestFoundMessage); + spyOn( + worktreeStatusCachesModule, + "clearWorktreeStatusCaches", + ).mockImplementation((( + ...args: Parameters< + typeof worktreeStatusCachesModule.clearWorktreeStatusCaches + > + ) => + clearWorktreeStatusCachesMock( + ...args, + )) as typeof worktreeStatusCachesModule.clearWorktreeStatusCaches); + + ({ mergePullRequest } = await import("./merge-pull-request")); + }); + + afterAll(() => { + mock.restore(); + }); + + beforeEach(() => { + getCurrentBranchMock.mockReset(); + getCurrentBranchMock.mockResolvedValue(null); + execGitWithShellPathMock.mockReset(); + execGitWithShellPathMock.mockResolvedValue({ + stdout: "abc123\n", + stderr: "", + }); + getRepoContextMock.mockReset(); + getRepoContextMock.mockResolvedValue({ + isFork: false, + repoUrl: "https://github.com/superset-sh/superset", + upstreamUrl: "https://github.com/superset-sh/superset", + }); + getPRForBranchMock.mockReset(); + getPRForBranchMock.mockResolvedValue(null); + getPullRequestRepoArgsMock.mockReset(); + getPullRequestRepoArgsMock.mockReturnValue([]); + execWithShellEnvMock.mockReset(); + execWithShellEnvMock.mockResolvedValue({ + stdout: "", + stderr: "", + }); + isNoPullRequestFoundMessageMock.mockReset(); + isNoPullRequestFoundMessageMock.mockReturnValue(false); + clearWorktreeStatusCachesMock.mockReset(); + }); + + test("falls back to legacy gh merge when HEAD is detached", async () => { + const result = await mergePullRequest({ + worktreePath: "/tmp/detached-worktree", + strategy: "squash", + }); + + expect(getRepoContextMock).toHaveBeenCalledWith("/tmp/detached-worktree"); + expect(getCurrentBranchMock).toHaveBeenCalledWith("/tmp/detached-worktree"); + expect(execGitWithShellPathMock).not.toHaveBeenCalled(); + expect(getPRForBranchMock).not.toHaveBeenCalled(); + expect(execWithShellEnvMock).toHaveBeenCalledWith( + "gh", + ["pr", "merge", "--squash"], + { cwd: "/tmp/detached-worktree" }, + ); + expect(clearWorktreeStatusCachesMock).toHaveBeenCalledWith( + "/tmp/detached-worktree", + ); + expect(result.success).toBe(true); + expect(Number.isNaN(Date.parse(result.mergedAt))).toBe(false); + }); + + test("resolves the PR by branch when HEAD has no commit yet", async () => { + getCurrentBranchMock.mockResolvedValue("feature/unborn"); + execGitWithShellPathMock.mockRejectedValueOnce( + new Error("fatal: ambiguous argument 'HEAD'"), + ); + getPRForBranchMock.mockResolvedValue(openPullRequest); + + const result = await mergePullRequest({ + worktreePath: "/tmp/unborn-worktree", + strategy: "rebase", + }); + + expect(execWithShellEnvMock).toHaveBeenCalledWith( + "gh", + ["pr", "merge", "42", "--rebase"], + { cwd: "/tmp/unborn-worktree" }, + ); + expect(getPRForBranchMock).toHaveBeenCalledWith( + "/tmp/unborn-worktree", + "feature/unborn", + { + isFork: false, + repoUrl: "https://github.com/superset-sh/superset", + upstreamUrl: "https://github.com/superset-sh/superset", + }, + undefined, + ); + expect(result.success).toBe(true); + }); + + test("falls back to legacy merge on unexpected HEAD lookup failures", async () => { + getCurrentBranchMock.mockResolvedValue("feature/branch"); + execGitWithShellPathMock.mockRejectedValueOnce( + new Error("fatal: permission denied"), + ); + + const result = await mergePullRequest({ + worktreePath: "/tmp/broken-worktree", + strategy: "merge", + }); + + expect(getPRForBranchMock).not.toHaveBeenCalled(); + expect(execWithShellEnvMock).toHaveBeenCalledWith( + "gh", + ["pr", "merge", "--merge"], + { cwd: "/tmp/broken-worktree" }, + ); + expect(result.success).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.ts index 0d563a467ee..0fd984db33c 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/merge-pull-request.ts @@ -1,3 +1,7 @@ +import { + getCurrentBranch, + isUnbornHeadError, +} from "../../workspaces/utils/git"; import { execGitWithShellPath } from "../../workspaces/utils/git-client"; import { getPRForBranch, @@ -36,15 +40,20 @@ export async function mergePullRequest({ let pr: Awaited<ReturnType<typeof getPRForBranch>> = null; try { - const [{ stdout: branchOutput }, { stdout: headOutput }] = - await Promise.all([ - execGitWithShellPath(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: worktreePath, - }), - execGitWithShellPath(["rev-parse", "HEAD"], { cwd: worktreePath }), - ]); - const localBranch = branchOutput.trim(); - const headSha = headOutput.trim(); + const localBranch = await getCurrentBranch(worktreePath); + if (!localBranch) { + return runMerge(legacyMergeArgs); + } + const { stdout: headOutput } = await execGitWithShellPath( + ["rev-parse", "HEAD"], + { cwd: worktreePath }, + ).catch((error) => { + if (isUnbornHeadError(error)) { + return { stdout: "", stderr: "" }; + } + throw error; + }); + const headSha = headOutput.trim() || undefined; pr = await getPRForBranch(worktreePath, localBranch, repoContext, headSha); } catch (error) { diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts index d1c72efea3c..e9a481ca4cc 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts @@ -274,6 +274,7 @@ describe("detectLanguage", () => { test("detects web files", () => { expect(detectLanguage("index.html")).toBe("html"); + expect(detectLanguage("page.astro")).toBe("html"); expect(detectLanguage("styles.css")).toBe("css"); expect(detectLanguage("styles.scss")).toBe("scss"); }); diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts new file mode 100644 index 00000000000..1e3d5ab00dd --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/pull-request-discovery.ts @@ -0,0 +1,187 @@ +import { TRPCError } from "@trpc/server"; +import type { SimpleGit } from "simple-git"; +import { z } from "zod"; +import { execGitWithShellPath } from "../../workspaces/utils/git-client"; +import { getRepoContext } from "../../workspaces/utils/github"; +import { getPullRequestRepoArgs } from "../../workspaces/utils/github/repo-context"; +import { execWithShellEnv } from "../../workspaces/utils/shell-env"; +import { + buildPullRequestCompareUrl, + normalizeGitHubRepoUrl, + parseUpstreamRef, +} from "./pull-request-url"; + +async function findOpenPRByHeadCommit( + worktreePath: string, +): Promise<string | null> { + try { + const { stdout: headOutput } = await execGitWithShellPath( + ["rev-parse", "HEAD"], + { cwd: worktreePath }, + ); + const headSha = headOutput.trim(); + if (!headSha) { + return null; + } + + const repoArgs = getPullRequestRepoArgs(await getRepoContext(worktreePath)); + + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + ...repoArgs, + "--state", + "open", + "--search", + `${headSha} is:pr`, + "--limit", + "20", + "--json", + "url,headRefOid", + ], + { cwd: worktreePath }, + ); + + const parsed = JSON.parse(stdout) as Array<{ + url?: string; + headRefOid?: string; + }>; + const match = parsed.find((candidate) => candidate.headRefOid === headSha); + return match?.url?.trim() || null; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + "[git/findExistingOpenPRUrl] Failed commit-based PR lookup:", + message, + ); + return null; + } +} + +export async function findExistingOpenPRUrl( + worktreePath: string, +): Promise<string | null> { + // Prefer tracking-based lookup first for fork/branch-name mismatch scenarios. + try { + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "view", + "--json", + "url,state", + "--jq", + 'if .state == "OPEN" then .url else "" end', + ], + { cwd: worktreePath }, + ); + const url = stdout.trim(); + if (url) { + return url; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const isNoPROpenError = message + .toLowerCase() + .includes("no pull requests found"); + if (!isNoPROpenError) { + console.warn( + "[git/findExistingOpenPRUrl] Failed tracking-branch PR lookup:", + message, + ); + } + // Fallback to commit-SHA search below. + } + + return findOpenPRByHeadCommit(worktreePath); +} + +const ghRepoMetadataSchema = z.object({ + url: z.string().url(), + isFork: z.boolean(), + parent: z + .object({ + url: z.string().url(), + }) + .nullable(), + defaultBranchRef: z.object({ + name: z.string().min(1), + }), +}); + +async function getMergeBaseBranch( + git: SimpleGit, + branch: string, +): Promise<string | null> { + try { + const configuredBaseBranch = await git.raw([ + "config", + "--get", + `branch.${branch}.gh-merge-base`, + ]); + return configuredBaseBranch.trim() || null; + } catch { + return null; + } +} + +export async function buildNewPullRequestUrl( + worktreePath: string, + git: SimpleGit, + branch: string, +): Promise<string> { + const { stdout } = await execWithShellEnv( + "gh", + ["repo", "view", "--json", "url,isFork,parent,defaultBranchRef"], + { cwd: worktreePath }, + ); + const repoMetadata = ghRepoMetadataSchema.parse(JSON.parse(stdout)); + const currentRepoUrl = normalizeGitHubRepoUrl(repoMetadata.url); + const baseRepoUrl = normalizeGitHubRepoUrl( + repoMetadata.isFork && repoMetadata.parent?.url + ? repoMetadata.parent.url + : repoMetadata.url, + ); + + if (!currentRepoUrl || !baseRepoUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "GitHub is not available for this workspace.", + }); + } + + const configuredBaseBranch = await getMergeBaseBranch(git, branch); + const baseBranch = configuredBaseBranch ?? repoMetadata.defaultBranchRef.name; + let headRepoOwner = currentRepoUrl.split("/").at(-2) ?? ""; + let headBranch = branch; + + try { + const upstreamRef = ( + await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]) + ).trim(); + const parsedUpstreamRef = parseUpstreamRef(upstreamRef); + + if (parsedUpstreamRef) { + headBranch = parsedUpstreamRef.branchName; + const upstreamRemoteUrl = await git.raw([ + "remote", + "get-url", + parsedUpstreamRef.remoteName, + ]); + headRepoOwner = + normalizeGitHubRepoUrl(upstreamRemoteUrl)?.split("/").at(-2) ?? + headRepoOwner; + } + } catch { + // Fall back to the current repository owner and local branch name. + } + + return buildPullRequestCompareUrl({ + baseRepoUrl, + baseBranch, + headRepoOwner, + headBranch, + }); +} diff --git a/apps/desktop/src/lib/trpc/routers/device.ts b/apps/desktop/src/lib/trpc/routers/device.ts new file mode 100644 index 00000000000..d44be13f707 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/device.ts @@ -0,0 +1,10 @@ +import { getHostId } from "@superset/shared/host-info"; +import { publicProcedure, router } from ".."; + +export const createDeviceRouter = () => { + return router({ + getMachineId: publicProcedure.query((): { machineId: string } => { + return { machineId: getHostId() }; + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts index 3d0254bcafd..19fe02ed3ce 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import os from "node:os"; import path from "node:path"; -import { getAppCommand, resolvePath, stripPathWrappers } from "./helpers"; +import { + getAppCommand, + RelativePathWithoutCwdError, + resolvePath, + stripPathWrappers, +} from "./helpers"; describe("getAppCommand", () => { const originalPlatform = process.platform; @@ -205,9 +210,8 @@ describe("resolvePath", () => { expect(result).toBe("/project/sibling/file.ts"); }); - test("resolves relative path against process.cwd() when no cwd provided", () => { - const result = resolvePath("file.ts"); - expect(result).toBe(path.resolve("file.ts")); + test("throws RelativePathWithoutCwdError when no cwd provided", () => { + expect(() => resolvePath("file.ts")).toThrow(RelativePathWithoutCwdError); }); }); @@ -581,3 +585,35 @@ describe("stripPathWrappers", () => { }); }); }); + +describe("resolvePath guards against process.cwd() fallback", () => { + test("throws RelativePathWithoutCwdError for a relative path with no cwd", () => { + expect(() => resolvePath("apps/desktop/src/index.ts")).toThrow( + RelativePathWithoutCwdError, + ); + }); + + test("throws for a wrapped/quoted relative path with no cwd", () => { + expect(() => resolvePath('"apps/desktop/src/index.ts"')).toThrow( + RelativePathWithoutCwdError, + ); + }); + + test("absolute paths do not need a cwd", () => { + expect(() => resolvePath("/Users/me/file.ts")).not.toThrow(); + }); + + test("~-prefixed paths do not need a cwd", () => { + expect(() => resolvePath("~/file.ts")).not.toThrow(); + }); + + test("file:// URLs do not need a cwd", () => { + expect(() => resolvePath("file:///Users/me/file.ts")).not.toThrow(); + }); + + test("a relative path with a cwd resolves correctly", () => { + expect(resolvePath("src/index.ts", "/workspace")).toBe( + "/workspace/src/index.ts", + ); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.ts index e083b3297b5..5b987202d15 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.ts @@ -267,10 +267,28 @@ export function stripPathWrappers(filePath: string): string { return result; } +export class RelativePathWithoutCwdError extends Error { + readonly originalPath: string; + constructor(originalPath: string) { + super( + `resolvePath received a relative path (${JSON.stringify(originalPath)}) without a cwd. ` + + "Pass an absolute path, or supply cwd (e.g. the workspace worktreePath). " + + "Falling back to process.cwd() would resolve against Electron's working directory and silently produce wrong paths.", + ); + this.name = "RelativePathWithoutCwdError"; + this.originalPath = originalPath; + } +} + /** * Resolve a path by expanding ~ and converting relative paths to absolute. * Also handles file:// URLs by converting them to regular file paths. * Strips wrapping characters like quotes, parentheses, brackets, etc. + * + * Throws `RelativePathWithoutCwdError` if the input resolves to a relative + * path and no `cwd` was supplied — callers must be explicit about what + * relative paths are relative to. (A silent `process.cwd()` fallback would + * point at Electron's working directory, not the workspace.) */ export function resolvePath(filePath: string, cwd?: string): string { let resolved = stripPathWrappers(filePath); @@ -293,9 +311,8 @@ export function resolvePath(filePath: string, cwd?: string): string { } if (!nodePath.isAbsolute(resolved)) { - resolved = cwd - ? nodePath.resolve(cwd, resolved) - : nodePath.resolve(resolved); + if (!cwd) throw new RelativePathWithoutCwdError(filePath); + resolved = nodePath.resolve(cwd, resolved); } return resolved; diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 4dde4b6e196..9de5daf3b47 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import nodePath from "node:path"; import { EXTERNAL_APPS, NON_EDITOR_APPS, @@ -8,15 +10,36 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { clipboard, shell } from "electron"; import { localDb } from "main/lib/local-db"; +import { externalUrlLogLabel, isSafeExternalUrl } from "main/lib/safe-url"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { getWorkspace } from "../workspaces/utils/db-helpers"; +import { getWorkspacePath } from "../workspaces/utils/worktree"; import { type ExternalApp, getAppCommand, + RelativePathWithoutCwdError, resolvePath, spawnAsync, } from "./helpers"; +/** + * Wraps a tRPC handler so a `RelativePathWithoutCwdError` (thrown by + * `resolvePath` when a relative path arrives without a `worktreePath`) + * surfaces as a clear BAD_REQUEST with the root-cause message instead + * of a generic 500. + */ +async function withResolveGuard<T>(fn: () => Promise<T> | T): Promise<T> { + try { + return await fn(); + } catch (err) { + if (err instanceof RelativePathWithoutCwdError) { + throw new TRPCError({ code: "BAD_REQUEST", message: err.message }); + } + throw err; + } +} + const ExternalAppSchema = z.enum(EXTERNAL_APPS); const nonEditorSet = new Set<ExternalApp>(NON_EDITOR_APPS); @@ -90,12 +113,26 @@ async function openPathInApp( export const createExternalRouter = () => { return router({ openUrl: publicProcedure.input(z.string()).mutation(async ({ input }) => { + if (!isSafeExternalUrl(input)) { + console.warn( + "[external/openUrl] Blocked unsafe URL scheme:", + externalUrlLogLabel(input), + ); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "URL scheme not allowed", + }); + } try { await shell.openExternal(input); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - console.error("[external/openUrl] Failed to open URL:", input, error); + console.error( + "[external/openUrl] Failed to open URL:", + externalUrlLogLabel(input), + error, + ); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: errorMessage, @@ -118,6 +155,16 @@ export const createExternalRouter = () => { }), ) .mutation(async ({ input }) => { + // openInApp hands `path` directly to the editor CLI / shell; with no + // cwd input there's no safe way to interpret a relative path, so we + // reject them loudly instead of silently resolving against Electron's + // working directory. + if (!nodePath.isAbsolute(input.path)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `openInApp requires an absolute path (got ${JSON.stringify(input.path)}).`, + }); + } await openPathInApp(input.path, input.app); // Persist defaults only after successful launch @@ -152,10 +199,41 @@ export const createExternalRouter = () => { .input( z.object({ path: z.string(), - cwd: z.string().optional(), + /** Absolute workspace worktree path — relative `path`s are resolved against this. */ + worktreePath: z.string().optional(), + }), + ) + .query(({ input }) => + withResolveGuard(() => resolvePath(input.path, input.worktreePath)), + ), + + statPath: publicProcedure + .input( + z.object({ + path: z.string(), + workspaceId: z.string().optional(), }), ) - .query(({ input }) => resolvePath(input.path, input.cwd)), + .mutation(({ input }) => + withResolveGuard(async () => { + const workspace = input.workspaceId + ? getWorkspace(input.workspaceId) + : null; + const cwd = workspace + ? (getWorkspacePath(workspace) ?? undefined) + : undefined; + const resolved = resolvePath(input.path, cwd); + try { + const stats = await fs.promises.stat(resolved); + return { + isDirectory: stats.isDirectory(), + resolvedPath: resolved, + }; + } catch { + return null; + } + }), + ), openFileInEditor: publicProcedure .input( @@ -163,24 +241,41 @@ export const createExternalRouter = () => { path: z.string(), line: z.number().optional(), column: z.number().optional(), - cwd: z.string().optional(), + /** + * Absolute workspace worktree path. Required when `path` is + * relative; ignored when `path` is already absolute. Using the + * workspace's worktreePath (rather than an arbitrary cwd) means + * relative diff/tree paths always resolve against the workspace + * the user is in, never Electron's process cwd. + */ + worktreePath: z.string().optional(), projectId: z.string().optional(), + /** + * Explicit app override from the caller (e.g. the v2 CMD+O + * choice stored client-side in tanstack-db). When provided, + * bypasses the server-side `resolveDefaultEditor` lookup — + * which only knows about v1 localDb tables and would + * otherwise return a stale global default for v2 projects. + */ + app: ExternalAppSchema.optional(), }), ) - .mutation(async ({ input }) => { - const filePath = resolvePath(input.path, input.cwd); - const app = resolveDefaultEditor(input.projectId); - - if (!app) { - // No preferred editor configured yet. - // Fall back to OS default file handler so Cmd/Ctrl+click still works - // even when Cursor (or any specific editor) isn't installed. - await shell.openPath(filePath); - return; - } + .mutation(({ input }) => + withResolveGuard(async () => { + const filePath = resolvePath(input.path, input.worktreePath); + const app = input.app ?? resolveDefaultEditor(input.projectId); - await openPathInApp(filePath, app); - }), + if (!app) { + // No preferred editor configured yet. + // Fall back to OS default file handler so Cmd/Ctrl+click still works + // even when Cursor (or any specific editor) isn't installed. + await shell.openPath(filePath); + return; + } + + await openPathInApp(filePath, app); + }), + ), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/host-service-coordinator/index.ts b/apps/desktop/src/lib/trpc/routers/host-service-coordinator/index.ts new file mode 100644 index 00000000000..5683a865f4f --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/host-service-coordinator/index.ts @@ -0,0 +1,58 @@ +import { observable } from "@trpc/server/observable"; +import { env } from "main/env.main"; +import { + getHostServiceCoordinator, + type HostServiceStatusEvent, +} from "main/lib/host-service-coordinator"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { loadToken } from "../auth/utils/auth-functions"; + +const orgInput = z.object({ organizationId: z.string() }); + +export const createHostServiceCoordinatorRouter = () => { + return router({ + start: publicProcedure.input(orgInput).mutation(async ({ input }) => { + const coordinator = getHostServiceCoordinator(); + const { token } = await loadToken(); + if (!token) { + throw new Error("No auth token available — user must be logged in"); + } + return coordinator.start(input.organizationId, { + authToken: token, + cloudApiUrl: env.NEXT_PUBLIC_API_URL, + }); + }), + + getConnection: publicProcedure.input(orgInput).query(({ input }) => { + const coordinator = getHostServiceCoordinator(); + return coordinator.getConnection(input.organizationId); + }), + + getProcessStatus: publicProcedure.input(orgInput).query(({ input }) => { + const coordinator = getHostServiceCoordinator(); + return { status: coordinator.getProcessStatus(input.organizationId) }; + }), + + restart: publicProcedure.input(orgInput).mutation(async ({ input }) => { + const coordinator = getHostServiceCoordinator(); + const { token } = await loadToken(); + if (!token) { + throw new Error("No auth token available — user must be logged in"); + } + return coordinator.restart(input.organizationId, { + authToken: token, + cloudApiUrl: env.NEXT_PUBLIC_API_URL, + }); + }), + + onStatusChange: publicProcedure.subscription(() => { + return observable<HostServiceStatusEvent>((emit) => { + const coordinator = getHostServiceCoordinator(); + const handler = (event: HostServiceStatusEvent) => emit.next(event); + coordinator.on("status-changed", handler); + return () => coordinator.off("status-changed", handler); + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts b/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts deleted file mode 100644 index eb29dbe3c49..00000000000 --- a/apps/desktop/src/lib/trpc/routers/host-service-manager/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { env } from "main/env.main"; -import { getHostServiceManager } from "main/lib/host-service-manager"; -import { z } from "zod"; -import { publicProcedure, router } from "../.."; -import { loadToken } from "../auth/utils/auth-functions"; - -export const createHostServiceManagerRouter = () => { - return router({ - getLocalPort: publicProcedure - .input(z.object({ organizationId: z.string() })) - .query(async ({ input }) => { - const manager = getHostServiceManager(); - const { token } = await loadToken(); - if (token) { - manager.setAuthToken(token); - } - manager.setCloudApiUrl(env.NEXT_PUBLIC_API_URL); - const port = await manager.start(input.organizationId); - return { port }; - }), - - getStatus: publicProcedure - .input(z.object({ organizationId: z.string() })) - .query(({ input }) => { - const manager = getHostServiceManager(); - const status = manager.getStatus(input.organizationId); - return { status }; - }), - }); -}; diff --git a/apps/desktop/src/lib/trpc/routers/hotkeys/index.ts b/apps/desktop/src/lib/trpc/routers/hotkeys/index.ts deleted file mode 100644 index 350b152be03..00000000000 --- a/apps/desktop/src/lib/trpc/routers/hotkeys/index.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { type BrowserWindow, dialog } from "electron"; -import { appState } from "main/lib/app-state"; -import { - buildHotkeysStateFromExport, - createHotkeysExport, - getCurrentPlatform, - getHotkeysSummary, - type HotkeysExportFile, - type HotkeysState, - normalizeBindingsWithDefaults, -} from "shared/hotkeys"; -import { z } from "zod"; -import { publicProcedure, router } from "../.."; - -const hotkeysExportSchema = z.object({ - schemaVersion: z.number(), - exportedAt: z.string(), - app: z.string(), - hotkeys: z - .object({ - darwin: z.record(z.string(), z.string().nullable()).optional(), - win32: z.record(z.string(), z.string().nullable()).optional(), - linux: z.record(z.string(), z.string().nullable()).optional(), - }) - .optional(), -}); - -export type HotkeysImportResult = - | { canceled: true } - | { - canceled: false; - path: string; - state: HotkeysState; - summary: { assigned: number; disabled: number }; - raw: HotkeysExportFile; - } - | { canceled: false; error: string }; - -type HotkeysExportResult = - | { canceled: true } - | { canceled: false; path: string } - | { canceled: false; error: string }; - -export const createHotkeysRouter = (getWindow: () => BrowserWindow | null) => { - return router({ - export: publicProcedure.mutation(async (): Promise<HotkeysExportResult> => { - const window = getWindow(); - if (!window) { - return { canceled: false, error: "No window available" }; - } - - const result = await dialog.showSaveDialog(window, { - title: "Export Keyboard Shortcuts", - defaultPath: "superset-hotkeys.json", - filters: [{ name: "JSON", extensions: ["json"] }], - }); - - if (result.canceled || !result.filePath) { - return { canceled: true }; - } - - const exportFile = createHotkeysExport(appState.data.hotkeysState); - try { - await writeFile( - result.filePath, - JSON.stringify(exportFile, null, 2), - "utf-8", - ); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to write file"; - return { canceled: false, error: message }; - } - - return { canceled: false, path: result.filePath }; - }), - - import: publicProcedure.mutation(async (): Promise<HotkeysImportResult> => { - const window = getWindow(); - if (!window) { - return { canceled: false, error: "No window available" }; - } - - const result = await dialog.showOpenDialog(window, { - title: "Import Keyboard Shortcuts", - properties: ["openFile"], - filters: [{ name: "JSON", extensions: ["json"] }], - }); - - if (result.canceled || result.filePaths.length === 0) { - return { canceled: true }; - } - - const filePath = result.filePaths[0]; - - try { - const raw = await readFile(filePath, "utf-8"); - const parsed = hotkeysExportSchema.parse(JSON.parse(raw)); - const exportFile: HotkeysExportFile = { - schemaVersion: parsed.schemaVersion, - exportedAt: parsed.exportedAt, - app: parsed.app, - hotkeys: { - darwin: parsed.hotkeys?.darwin ?? {}, - win32: parsed.hotkeys?.win32 ?? {}, - linux: parsed.hotkeys?.linux ?? {}, - }, - }; - - const state = buildHotkeysStateFromExport(exportFile); - const platform = getCurrentPlatform(); - const bindings = normalizeBindingsWithDefaults( - exportFile.hotkeys?.[platform] ?? {}, - platform, - ); - const summary = getHotkeysSummary(bindings); - - return { - canceled: false, - path: filePath, - state, - summary, - raw: exportFile, - }; - } catch (error) { - const message = - error instanceof Error ? error.message : "Invalid hotkeys file"; - return { canceled: false, error: message }; - } - }), - }); -}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 0aa455d02c4..939a1d207c3 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -10,12 +10,13 @@ import { createChangesRouter } from "./changes"; import { createChatRuntimeServiceRouter } from "./chat-runtime-service"; import { createChatServiceRouter } from "./chat-service"; import { createConfigRouter } from "./config"; +import { createDeviceRouter } from "./device"; import { createExternalRouter } from "./external"; import { createFilesystemRouter } from "./filesystem"; -import { createHostServiceManagerRouter } from "./host-service-manager"; -import { createHotkeysRouter } from "./hotkeys"; +import { createHostServiceCoordinatorRouter } from "./host-service-coordinator"; +import { createKeyboardLayoutRouter } from "./keyboardLayout"; import { createMenuRouter } from "./menu"; -import { createModelProvidersRouter } from "./model-providers"; +import { createMigrationRouter } from "./migration"; import { createNotificationsRouter } from "./notifications"; import { createPermissionsRouter } from "./permissions"; import { createPortsRouter } from "./ports"; @@ -38,25 +39,26 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { auth: createAuthRouter(), autoUpdate: createAutoUpdateRouter(), cache: createCacheRouter(), - modelProviders: createModelProvidersRouter(), window: createWindowRouter(getWindow), projects: createProjectsRouter(getWindow), workspaces: createWorkspacesRouter(), terminal: createTerminalRouter(), changes: createChangesRouter(), filesystem: createFilesystemRouter(), - notifications: createNotificationsRouter(), + notifications: createNotificationsRouter(getWindow), permissions: createPermissionsRouter(), ports: createPortsRouter(), resourceMetrics: createResourceMetricsRouter(), menu: createMenuRouter(), - hotkeys: createHotkeysRouter(getWindow), external: createExternalRouter(), settings: createSettingsRouter(), config: createConfigRouter(), + device: createDeviceRouter(), uiState: createUiStateRouter(), ringtone: createRingtoneRouter(getWindow), - hostServiceManager: createHostServiceManagerRouter(), + hostServiceCoordinator: createHostServiceCoordinatorRouter(), + keyboardLayout: createKeyboardLayoutRouter(), + migration: createMigrationRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/keyboardLayout.ts b/apps/desktop/src/lib/trpc/routers/keyboardLayout.ts new file mode 100644 index 00000000000..b698e15b73f --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/keyboardLayout.ts @@ -0,0 +1,25 @@ +import { observable } from "@trpc/server/observable"; +import { + getKeyboardLayoutSnapshot, + type KeyboardLayoutData, + onKeyboardLayoutChange, +} from "main/lib/keyboardLayout"; +import { publicProcedure, router } from ".."; + +export const createKeyboardLayoutRouter = () => { + return router({ + get: publicProcedure.query((): KeyboardLayoutData => { + return getKeyboardLayoutSnapshot(); + }), + // observable (not async generator) per apps/desktop/AGENTS.md — + // trpc-electron only supports observables for IPC subscriptions. + changes: publicProcedure.subscription(() => { + return observable<KeyboardLayoutData>((emit) => { + // Prime the subscriber with the current snapshot so the renderer + // store doesn't have to make a separate query on subscribe. + emit.next(getKeyboardLayoutSnapshot()); + return onKeyboardLayoutChange((data) => emit.next(data)); + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/migration/index.ts b/apps/desktop/src/lib/trpc/routers/migration/index.ts new file mode 100644 index 00000000000..f7d3bd2643b --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/migration/index.ts @@ -0,0 +1,100 @@ +import { + projects, + v1MigrationState, + workspaceSections, + workspaces, + worktrees, +} from "@superset/local-db"; +import { eq, isNotNull, isNull } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +const migrationStateRowSchema = z.object({ + v1Id: z.string().min(1), + kind: z.enum(["project", "workspace"]), + v2Id: z.string().nullable(), + organizationId: z.string().min(1), + status: z.enum(["success", "linked", "error", "skipped"]), + reason: z.string().nullable().optional(), +}); + +export const createMigrationRouter = () => { + return router({ + readV1Projects: publicProcedure.query(() => { + // Only migrate pinned projects. v1's `hideProject` nulls tab_order when + // the last workspace in a project is deleted, effectively abandoning the + // project — don't resurrect those in v2. + return localDb + .select() + .from(projects) + .where(isNotNull(projects.tabOrder)) + .all(); + }), + + readV1Workspaces: publicProcedure.query(() => { + return localDb + .select() + .from(workspaces) + .where(isNull(workspaces.deletingAt)) + .all(); + }), + + readV1Worktrees: publicProcedure.query(() => { + return localDb.select().from(worktrees).all(); + }), + + readV1WorkspaceSections: publicProcedure.query(() => { + return localDb.select().from(workspaceSections).all(); + }), + + listState: publicProcedure + .input(z.object({ organizationId: z.string().min(1) })) + .query(({ input }) => { + return localDb + .select() + .from(v1MigrationState) + .where(eq(v1MigrationState.organizationId, input.organizationId)) + .all(); + }), + + upsertState: publicProcedure + .input(migrationStateRowSchema) + .mutation(({ input }) => { + localDb + .insert(v1MigrationState) + .values({ + v1Id: input.v1Id, + kind: input.kind, + v2Id: input.v2Id, + organizationId: input.organizationId, + status: input.status, + reason: input.reason ?? null, + migratedAt: Date.now(), + }) + .onConflictDoUpdate({ + target: [ + v1MigrationState.organizationId, + v1MigrationState.v1Id, + v1MigrationState.kind, + ], + set: { + v2Id: input.v2Id, + status: input.status, + reason: input.reason ?? null, + migratedAt: Date.now(), + }, + }) + .run(); + }), + + clearState: publicProcedure + .input(z.object({ organizationId: z.string().min(1) })) + .mutation(({ input }) => { + localDb + .delete(v1MigrationState) + .where(eq(v1MigrationState.organizationId, input.organizationId)) + .run(); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/model-providers/index.ts b/apps/desktop/src/lib/trpc/routers/model-providers/index.ts deleted file mode 100644 index 511b3a4ef5c..00000000000 --- a/apps/desktop/src/lib/trpc/routers/model-providers/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - clearProviderIssue, - getProviderDiagnostic, -} from "lib/ai/provider-diagnostics"; -import { - deriveModelProviderStatus, - type ProviderId, -} from "shared/ai/provider-status"; -import { z } from "zod"; -import { publicProcedure, router } from "../.."; -import { chatService } from "../chat-service"; - -const providerIdSchema = z.enum(["anthropic", "openai"]); - -async function getProviderStatuses() { - const [anthropicAuthStatus, openAIAuthStatus] = await Promise.all([ - chatService.getAnthropicAuthStatus(), - chatService.getOpenAIAuthStatus(), - ]); - - return [ - deriveModelProviderStatus({ - providerId: "anthropic", - authStatus: anthropicAuthStatus, - diagnostic: getProviderDiagnostic("anthropic"), - }), - deriveModelProviderStatus({ - providerId: "openai", - authStatus: openAIAuthStatus, - diagnostic: getProviderDiagnostic("openai"), - }), - ]; -} - -export const createModelProvidersRouter = () => { - return router({ - getStatuses: publicProcedure.query(async () => { - return getProviderStatuses(); - }), - clearIssue: publicProcedure - .input(z.object({ providerId: providerIdSchema })) - .mutation(({ input }: { input: { providerId: ProviderId } }) => { - clearProviderIssue(input.providerId); - return { success: true }; - }), - }); -}; - -export type ModelProvidersRouter = ReturnType< - typeof createModelProvidersRouter ->; diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications.ts index 1294af276d8..c50e8383f3f 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications.ts @@ -1,10 +1,17 @@ import { observable } from "@trpc/server/observable"; +import type { + BrowserWindow, + Notification as ElectronNotification, +} from "electron"; +import { Notification } from "electron"; import { type AgentLifecycleEvent, type NotificationIds, notificationsEmitter, } from "main/lib/notifications/server"; import { NOTIFICATION_EVENTS } from "shared/constants"; +import type { V2NotificationSourceFocusTarget } from "shared/notification-types"; +import { z } from "zod"; import { publicProcedure, router } from ".."; type TerminalExitNotification = NotificationIds & { @@ -19,13 +26,101 @@ type NotificationEvent = data?: AgentLifecycleEvent; } | { type: typeof NOTIFICATION_EVENTS.FOCUS_TAB; data?: NotificationIds } + | { + type: typeof NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE; + data?: V2NotificationSourceFocusTarget; + } | { type: typeof NOTIFICATION_EVENTS.TERMINAL_EXIT; data?: TerminalExitNotification; }; -export const createNotificationsRouter = () => { +const v2NotificationSourceSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("terminal"), id: z.string().min(1) }), + z.object({ type: z.literal("chat"), id: z.string().min(1) }), +]); + +const showNativeInputSchema = z.object({ + title: z.string().min(1), + body: z.string(), + silent: z.boolean().default(true), + clickTarget: z + .object({ + workspaceId: z.string().min(1), + source: v2NotificationSourceSchema, + }) + .optional(), +}); +type ShowNativeInput = z.infer<typeof showNativeInputSchema>; + +const activeNativeNotifications = new Map<string, ElectronNotification>(); +let nativeNotificationCounter = 0; + +function focusWindow(getWindow: () => BrowserWindow | null): void { + const window = getWindow(); + if (!window) return; + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); +} + +function getNativeNotificationKey(input: ShowNativeInput): string { + const target = input.clickTarget; + if (!target) return `_native_${nativeNotificationCounter++}`; + return `${target.workspaceId}:${target.source.type}:${target.source.id}`; +} + +function trackNativeNotification( + key: string, + notification: ElectronNotification, +): void { + const previous = activeNativeNotifications.get(key); + previous?.close(); + activeNativeNotifications.set(key, notification); + + const untrack = () => { + if (activeNativeNotifications.get(key) === notification) { + activeNativeNotifications.delete(key); + } + }; + notification.on("click", untrack); + notification.on("close", untrack); +} + +export const createNotificationsRouter = ( + getWindow: () => BrowserWindow | null, +) => { return router({ + showNative: publicProcedure + .input(showNativeInputSchema) + .mutation(({ input }) => { + if (!Notification.isSupported()) { + return { success: false as const, reason: "unsupported" as const }; + } + + const notification = new Notification({ + title: input.title, + body: input.body, + silent: input.silent, + }); + const key = getNativeNotificationKey(input); + trackNativeNotification(key, notification); + + notification.on("click", () => { + focusWindow(getWindow); + if (!input.clickTarget) return; + notificationsEmitter.emit( + NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + input.clickTarget, + ); + }); + + notification.show(); + return { success: true as const }; + }), + subscribe: publicProcedure.subscription(() => { return observable<NotificationEvent>((emit) => { const onLifecycle = (data: AgentLifecycleEvent) => { @@ -36,6 +131,15 @@ export const createNotificationsRouter = () => { emit.next({ type: NOTIFICATION_EVENTS.FOCUS_TAB, data }); }; + const onFocusV2NotificationSource = ( + data: V2NotificationSourceFocusTarget, + ) => { + emit.next({ + type: NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + data, + }); + }; + const onTerminalExit = (data: TerminalExitNotification) => { emit.next({ type: NOTIFICATION_EVENTS.TERMINAL_EXIT, data }); }; @@ -45,6 +149,10 @@ export const createNotificationsRouter = () => { onLifecycle, ); notificationsEmitter.on(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); + notificationsEmitter.on( + NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + onFocusV2NotificationSource, + ); notificationsEmitter.on( NOTIFICATION_EVENTS.TERMINAL_EXIT, onTerminalExit, @@ -56,6 +164,10 @@ export const createNotificationsRouter = () => { onLifecycle, ); notificationsEmitter.off(NOTIFICATION_EVENTS.FOCUS_TAB, onFocusTab); + notificationsEmitter.off( + NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE, + onFocusV2NotificationSource, + ); notificationsEmitter.off( NOTIFICATION_EVENTS.TERMINAL_EXIT, onTerminalExit, diff --git a/apps/desktop/src/lib/trpc/routers/ports/label-cache.ts b/apps/desktop/src/lib/trpc/routers/ports/label-cache.ts new file mode 100644 index 00000000000..bee307b4032 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ports/label-cache.ts @@ -0,0 +1,153 @@ +import { statSync } from "node:fs"; +import { join } from "node:path"; +import { workspaces } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { loadStaticPorts } from "main/lib/static-ports"; +import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; +import { getWorkspacePath } from "../workspaces/utils/worktree"; + +interface LabelCacheEntry { + labels: Map<number, string> | null; + portsFileSignature: string | null; + worktreePath: string | null; +} + +function getPortsFileSignature(worktreePath: string): string | null { + try { + const stat = statSync( + join(worktreePath, PROJECT_SUPERSET_DIR_NAME, PORTS_FILE_NAME), + ); + return `${stat.mtimeMs}:${stat.size}`; + } catch (error) { + if (isMissingPathError(error)) return null; + throw error; + } +} + +function isMissingPathError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} + +function safeGetPortsFileSignature(worktreePath: string): string | null { + try { + return getPortsFileSignature(worktreePath); + } catch (error) { + console.warn("[ports] Failed to stat static port labels:", { + worktreePath, + error, + }); + return null; + } +} + +function safeLoadLabelsForWorktree( + worktreePath: string, +): Map<number, string> | null { + try { + return loadLabelsForWorktree(worktreePath); + } catch (error) { + console.warn("[ports] Failed to load static port labels:", { + worktreePath, + error, + }); + return null; + } +} + +/** + * Resolve `ports.json` labels per workspace on demand, then memoize. + * + * Why memoize: `getAll` runs on every `port:add`/`port:remove` event (the + * renderer calls `utils.ports.getAll.invalidate()` in usePortsData). A dev + * server that flaps 5 ports cascades into 5 `getAll` calls × N workspaces of + * sync SQLite reads on the main thread. Cache once; ports.json rarely changes. + * + * `labels: null` with a resolved worktree means "no labels file" — still + * cached so we don't re-check the filesystem every event. A missing worktree is + * not cached because workspace hydration can race first reads. + * + * Lives in its own module so workspace-delete paths in `workspaces/utils/*` + * can call `invalidatePortLabelCache` without creating a ports ↔ workspaces + * import cycle. + */ +const labelCache = new Map<string, LabelCacheEntry>(); + +function loadLabelsForWorktree( + worktreePath: string, +): Map<number, string> | null { + const result = loadStaticPorts(worktreePath); + if (!result.exists || result.error || !result.ports) { + return null; + } + + const labels = new Map<number, string>(); + for (const p of result.ports) { + labels.set(p.port, p.label); + } + return labels; +} + +function setLabelCache( + workspaceId: string, + worktreePath: string | null, + labels: Map<number, string> | null, +): Map<number, string> | null { + const portsFileSignature = worktreePath + ? safeGetPortsFileSignature(worktreePath) + : null; + labelCache.set(workspaceId, { + labels, + portsFileSignature, + worktreePath, + }); + return labels; +} + +export function getLabelsForWorkspace( + workspaceId: string, +): Map<number, string> | null { + const cached = labelCache.get(workspaceId); + if (cached) { + if (cached.worktreePath === null) { + labelCache.delete(workspaceId); + } else { + const currentSignature = safeGetPortsFileSignature(cached.worktreePath); + if (currentSignature === cached.portsFileSignature) return cached.labels; + return setLabelCache( + workspaceId, + cached.worktreePath, + safeLoadLabelsForWorktree(cached.worktreePath), + ); + } + } + + const ws = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); + const worktreePath = ws ? getWorkspacePath(ws) : null; + if (!worktreePath) { + return null; + } + + return setLabelCache( + workspaceId, + worktreePath, + safeLoadLabelsForWorktree(worktreePath), + ); +} + +/** + * Invalidate the label cache. Call when a workspace is deleted. Edits to + * `ports.json` are detected by the cached file signature. + */ +export function invalidatePortLabelCache(workspaceId?: string): void { + if (workspaceId === undefined) { + labelCache.clear(); + } else { + labelCache.delete(workspaceId); + } +} diff --git a/apps/desktop/src/lib/trpc/routers/ports/ports.ts b/apps/desktop/src/lib/trpc/routers/ports/ports.ts index d4432be08a9..4dc7d202bfd 100644 --- a/apps/desktop/src/lib/trpc/routers/ports/ports.ts +++ b/apps/desktop/src/lib/trpc/routers/ports/ports.ts @@ -1,52 +1,27 @@ -import { workspaces } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; -import { eq } from "drizzle-orm"; -import { localDb } from "main/lib/local-db"; -import { loadStaticPorts } from "main/lib/static-ports"; import { portManager } from "main/lib/terminal/port-manager"; import type { DetectedPort, EnrichedPort } from "shared/types"; import { z } from "zod"; import { publicProcedure, router } from "../.."; -import { getWorkspacePath } from "../workspaces/utils/worktree"; +import { getLabelsForWorkspace } from "./label-cache"; + +export { invalidatePortLabelCache } from "./label-cache"; type PortEvent = | { type: "add"; port: DetectedPort } | { type: "remove"; port: DetectedPort }; -function getLabelsForPath(worktreePath: string): Map<number, string> | null { - const result = loadStaticPorts(worktreePath); - if (!result.exists || result.error || !result.ports) return null; - - const labels = new Map<number, string>(); - for (const p of result.ports) { - labels.set(p.port, p.label); - } - return labels; -} - export const createPortsRouter = () => { return router({ getAll: publicProcedure.query((): EnrichedPort[] => { const detectedPorts = portManager.getAllPorts(); - - const labelCache = new Map<string, Map<number, string> | null>(); - return detectedPorts.map((port) => { - if (!labelCache.has(port.workspaceId)) { - const ws = localDb - .select() - .from(workspaces) - .where(eq(workspaces.id, port.workspaceId)) - .get(); - const wsPath = ws ? getWorkspacePath(ws) : null; - labelCache.set( - port.workspaceId, - wsPath ? getLabelsForPath(wsPath) : null, - ); - } - - const labels = labelCache.get(port.workspaceId); - return { ...port, label: labels?.get(port.port) ?? null }; + const labels = getLabelsForWorkspace(port.workspaceId); + return { + ...port, + label: labels?.get(port.port) ?? null, + hostUrl: null, + }; }); }), @@ -73,7 +48,8 @@ export const createPortsRouter = () => { kill: publicProcedure .input( z.object({ - paneId: z.string(), + workspaceId: z.string(), + terminalId: z.string(), port: z.number().int().positive(), }), ) diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 28ca08d8d98..a8e9dc411ae 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -26,6 +26,7 @@ import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { resolveDefaultEditor } from "../external"; +import { invalidatePortLabelCache } from "../ports/label-cache"; import { activateProject, getBranchWorkspace, @@ -58,6 +59,44 @@ type OpenNewResult = | { canceled: false; needsGitInit: true; selectedPath: string } | OpenNewError; +/** + * Parses and transforms raw GitHub PR data from CLI output. + * Filters valid PR objects and maps them to our internal format. + */ +function isRawPullRequest(item: unknown): item is { + number: number; + title: string; + url: string; + state: string; + isDraft: boolean; +} { + if (typeof item !== "object" || item === null) return false; + + const value = item as Record<string, unknown>; + return ( + typeof value.number === "number" && + typeof value.title === "string" && + typeof value.url === "string" && + typeof value.state === "string" && + typeof value.isDraft === "boolean" + ); +} + +function parsePullRequests(raw: unknown) { + if (!Array.isArray(raw)) return []; + + return raw.filter(isRawPullRequest).map((pr) => ({ + prNumber: pr.number, + title: pr.title, + url: pr.url, + state: pr.isDraft + ? "draft" + : pr.state === "OPEN" + ? "open" + : pr.state.toLowerCase(), + })); +} + type FolderOutcome = | { status: "success"; project: Project } | { status: "needsGitInit"; selectedPath: string } @@ -301,7 +340,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }), listPullRequests: publicProcedure - .input(z.object({ projectId: z.string() })) + .input( + z.object({ + projectId: z.string(), + includeClosed: z.boolean().optional(), + }), + ) .query(async ({ input }) => { const project = localDb .select() @@ -317,7 +361,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { "pr", "list", "--state", - "open", + input.includeClosed ? "all" : "open", "--limit", "30", "--json", @@ -326,42 +370,61 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { { cwd: project.mainRepoPath }, ); const raw: unknown = JSON.parse(stdout.trim() || "[]"); - if (!Array.isArray(raw)) return []; - return raw - .filter( - ( - item: unknown, - ): item is { - number: number; - title: string; - url: string; - state: string; - isDraft: boolean; - } => - typeof item === "object" && - item !== null && - "number" in item && - "title" in item && - "url" in item, - ) - .map((pr) => ({ - prNumber: pr.number, - title: pr.title, - url: pr.url, - state: pr.isDraft - ? "draft" - : pr.state === "OPEN" - ? "open" - : pr.state.toLowerCase(), - })); + return parsePullRequests(raw); } catch (err) { console.warn("[listPullRequests] Failed to list PRs:", err); return []; } }), + searchPullRequests: publicProcedure + .input( + z.object({ + projectId: z.string(), + query: z.string(), + includeClosed: z.boolean().optional(), + }), + ) + .query(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) return []; + + try { + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + "--state", + input.includeClosed ? "all" : "open", + "--search", + input.query, + "--limit", + "100", + "--json", + "number,title,url,state,isDraft", + ], + { cwd: project.mainRepoPath, timeout: 10_000 }, + ); + const raw: unknown = JSON.parse(stdout.trim() || "[]"); + return parsePullRequests(raw); + } catch (err) { + console.warn("[searchPullRequests] Failed to search PRs:", err); + return []; + } + }), + listIssues: publicProcedure - .input(z.object({ projectId: z.string() })) + .input( + z.object({ + projectId: z.string(), + includeClosed: z.boolean().optional(), + }), + ) .query(async ({ input }) => { const project = localDb .select() @@ -377,7 +440,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { "issue", "list", "--state", - "open", + input.includeClosed ? "all" : "open", "--limit", "30", "--json", @@ -1526,6 +1589,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .delete(workspaces) .where(inArray(workspaces.id, closedWorkspaceIds)) .run(); + for (const id of closedWorkspaceIds) invalidatePortLabelCache(id); } localDb diff --git a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts index 098cc239099..c760f3ad567 100644 --- a/apps/desktop/src/lib/trpc/routers/ringtone/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ringtone/index.ts @@ -1,6 +1,4 @@ import type { ChildProcess } from "node:child_process"; -import { execFile } from "node:child_process"; -import { existsSync } from "node:fs"; import { TRPCError } from "@trpc/server"; import type { BrowserWindow, OpenDialogOptions } from "electron"; import { dialog } from "electron"; @@ -9,9 +7,11 @@ import { getCustomRingtonePath, importCustomRingtoneFromPath, } from "main/lib/custom-ringtones"; +import { playSoundFile } from "main/lib/play-sound"; import { getSoundPath } from "main/lib/sound-paths"; import { CUSTOM_RINGTONE_ID, + DEFAULT_RINGTONE_ID, getRingtoneFilename, isBuiltInRingtoneId, } from "shared/ringtones"; @@ -43,58 +43,32 @@ function stopCurrentSound(): void { } /** - * Plays a sound file using platform-specific commands. - * Uses session tracking to prevent race conditions with fallback audio players. + * Plays a sound file with session tracking for stop/race-condition safety. */ -function playSoundFile(soundPath: string): void { - if (!existsSync(soundPath)) { - console.warn(`[ringtone] Sound file not found: ${soundPath}`); - return; - } - - // Stop any currently playing sound first +function playWithTracking(soundPath: string, volume: number = 100): void { stopCurrentSound(); - // Create a new session for this play operation const sessionId = nextSessionId++; currentSession = { id: sessionId, process: null }; - if (process.platform === "darwin") { - currentSession.process = execFile("afplay", [soundPath], () => { - // Only clear if this session is still active + const proc = playSoundFile(soundPath, volume, { + onComplete: () => { if (currentSession?.id === sessionId) { currentSession = null; } - }); - } else if (process.platform === "win32") { - currentSession.process = execFile( - "powershell", - ["-c", `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`], - () => { - if (currentSession?.id === sessionId) { - currentSession = null; - } - }, - ); - } else { - // Linux - try common audio players with race-safe fallback - currentSession.process = execFile("paplay", [soundPath], (error) => { - // Check if this session is still active before proceeding - if (currentSession?.id !== sessionId) { - return; // Session was stopped, don't start fallback + }, + isCanceled: () => currentSession?.id !== sessionId, + onProcessChange: (newProc) => { + if (currentSession?.id === sessionId) { + currentSession.process = newProc; } + }, + }); - if (error) { - // paplay failed, try aplay as fallback - currentSession.process = execFile("aplay", [soundPath], () => { - if (currentSession?.id === sessionId) { - currentSession = null; - } - }); - } else { - currentSession = null; - } - }); + if (proc) { + currentSession.process = proc; + } else { + currentSession = null; } } @@ -119,6 +93,15 @@ function getRingtoneSoundPath(ringtoneId: string): string | null { return getSoundPath(filename); } +function getNotificationRingtoneSoundPath(ringtoneId: string): string | null { + const soundPath = getRingtoneSoundPath(ringtoneId); + if (soundPath) return soundPath; + + if (ringtoneId !== CUSTOM_RINGTONE_ID) return null; + const fallbackFilename = getRingtoneFilename(DEFAULT_RINGTONE_ID); + return fallbackFilename ? getSoundPath(fallbackFilename) : null; +} + /** * Ringtone router for audio preview and playback operations */ @@ -128,14 +111,40 @@ export const createRingtoneRouter = (getWindow: () => BrowserWindow | null) => { * Preview a ringtone by ringtone ID. */ preview: publicProcedure - .input(z.object({ ringtoneId: z.string() })) + .input( + z.object({ + ringtoneId: z.string(), + volume: z.number().min(0).max(100).optional(), + }), + ) .mutation(({ input }) => { const soundPath = getRingtoneSoundPath(input.ringtoneId); if (!soundPath) { return { success: true as const }; } - playSoundFile(soundPath); + playWithTracking(soundPath, input.volume ?? 100); + return { success: true as const }; + }), + + /** + * Play the selected notification ringtone from main when the renderer cannot + * access the backing asset directly, namely imported custom audio files. + */ + playNotification: publicProcedure + .input( + z.object({ + ringtoneId: z.string(), + volume: z.number().min(0).max(100).optional(), + }), + ) + .mutation(({ input }) => { + const soundPath = getNotificationRingtoneSoundPath(input.ringtoneId); + if (!soundPath) { + return { success: true as const }; + } + + playSoundFile(soundPath, input.volume ?? 100); return { success: true as const }; }), diff --git a/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.test.ts b/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.test.ts deleted file mode 100644 index abcbf78285b..00000000000 --- a/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { getBuiltinAgentDefinition } from "@superset/shared/agent-catalog"; -import { TRPCError } from "@trpc/server"; -import { - normalizeAgentPresetPatch, - updateAgentPresetInputSchema, -} from "./agent-preset-router.utils"; - -describe("updateAgentPresetInputSchema", () => { - test("rejects empty patches", () => { - const result = updateAgentPresetInputSchema.safeParse({ - id: "claude", - patch: {}, - }); - - expect(result.success).toBe(false); - }); -}); - -describe("normalizeAgentPresetPatch", () => { - test("trims terminal fields and normalizes empty optional strings to null", () => { - const patch = normalizeAgentPresetPatch({ - definition: getBuiltinAgentDefinition("claude"), - patch: { - label: " Claude Custom ", - description: " Custom description ", - command: " claude-custom ", - promptCommand: " claude-custom --prompt ", - promptCommandSuffix: " ", - taskPromptTemplate: " Task {{slug}} ", - }, - }); - - expect(patch).toEqual({ - label: "Claude Custom", - description: "Custom description", - command: "claude-custom", - promptCommand: "claude-custom --prompt", - promptCommandSuffix: null, - taskPromptTemplate: "Task {{slug}}", - }); - }); - - test("normalizes empty chat model to null", () => { - const patch = normalizeAgentPresetPatch({ - definition: getBuiltinAgentDefinition("superset-chat"), - patch: { - model: " ", - }, - }); - - expect(patch).toEqual({ - model: null, - }); - }); - - test("rejects unknown task template variables", () => { - expect(() => - normalizeAgentPresetPatch({ - definition: getBuiltinAgentDefinition("superset-chat"), - patch: { - taskPromptTemplate: "Hello {{unknown}}", - }, - }), - ).toThrow(TRPCError); - }); - - test("rejects patches that do not apply to the agent kind", () => { - expect(() => - normalizeAgentPresetPatch({ - definition: getBuiltinAgentDefinition("superset-chat"), - patch: { - command: "codex", - }, - }), - ).toThrow(TRPCError); - }); -}); diff --git a/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.ts b/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.ts index 6b6e6b68b5d..db3e864799a 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/agent-preset-router.utils.ts @@ -1,7 +1,11 @@ +import { PROMPT_TRANSPORTS } from "@superset/local-db"; import type { AgentDefinition } from "@superset/shared/agent-catalog"; +import type { + AgentPresetPatch, + CustomAgentDefinitionPatch, +} from "@superset/shared/agent-settings"; +import { validateTaskPromptTemplate } from "@superset/shared/agent-settings"; import { TRPCError } from "@trpc/server"; -import type { AgentPresetPatch } from "shared/utils/agent-settings"; -import { validateTaskPromptTemplate } from "shared/utils/agent-settings"; import { z } from "zod"; export const updateAgentPresetInputSchema = z.object({ @@ -22,6 +26,35 @@ export const updateAgentPresetInputSchema = z.object({ }), }); +export const createCustomAgentInputSchema = z.object({ + label: z.string(), + description: z.string().nullable().optional(), + command: z.string(), + promptCommand: z.string().optional(), + promptCommandSuffix: z.string().nullable().optional(), + promptTransport: z.enum(PROMPT_TRANSPORTS).optional(), + taskPromptTemplate: z.string(), + enabled: z.boolean().optional(), +}); + +export const updateCustomAgentInputSchema = z.object({ + id: z.string().regex(/^custom:/), + patch: z + .object({ + label: z.string().optional(), + description: z.string().nullable().optional(), + command: z.string().optional(), + promptCommand: z.string().nullable().optional(), + promptCommandSuffix: z.string().nullable().optional(), + promptTransport: z.enum(PROMPT_TRANSPORTS).nullable().optional(), + taskPromptTemplate: z.string().optional(), + enabled: z.boolean().optional(), + }) + .refine((patch) => Object.keys(patch).length > 0, { + message: "Patch must include at least one field", + }), +}); + function toTrimmedRequiredValue(field: string, value: string): string { const trimmed = value.trim(); if (!trimmed) { @@ -95,3 +128,100 @@ export function normalizeAgentPresetPatch({ return normalized; } + +function normalizeOptionalText( + value: string | null | undefined, +): string | null { + const normalized = value?.trim() ?? ""; + return normalized ? normalized : null; +} + +export function normalizeCreateCustomAgentInput( + input: z.infer<typeof createCustomAgentInputSchema>, +) { + const command = toTrimmedRequiredValue("Command", input.command); + const taskPromptTemplate = toTrimmedRequiredValue( + "Task prompt template", + input.taskPromptTemplate, + ); + const validation = validateTaskPromptTemplate(taskPromptTemplate); + if (!validation.valid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown task prompt variables: ${validation.unknownVariables.join(", ")}`, + }); + } + + const promptCommand = normalizeOptionalText(input.promptCommand) ?? undefined; + + return { + label: toTrimmedRequiredValue("Label", input.label), + description: normalizeOptionalText(input.description) ?? undefined, + command, + promptCommand: promptCommand === command ? undefined : promptCommand, + promptCommandSuffix: + normalizeOptionalText(input.promptCommandSuffix) ?? undefined, + promptTransport: + input.promptTransport && input.promptTransport !== "argv" + ? input.promptTransport + : undefined, + taskPromptTemplate, + enabled: input.enabled, + } as const; +} + +export function normalizeCustomAgentPatch( + patch: z.infer<typeof updateCustomAgentInputSchema>["patch"], +): CustomAgentDefinitionPatch { + const normalized: CustomAgentDefinitionPatch = {}; + + if (patch.enabled !== undefined) { + normalized.enabled = patch.enabled; + } + if (patch.label !== undefined) { + normalized.label = toTrimmedRequiredValue("Label", patch.label); + } + if (patch.description !== undefined) { + normalized.description = normalizeOptionalText(patch.description); + } + if (patch.command !== undefined) { + normalized.command = toTrimmedRequiredValue("Command", patch.command); + } + if (patch.promptCommand !== undefined) { + normalized.promptCommand = normalizeOptionalText(patch.promptCommand); + } + if (patch.promptCommandSuffix !== undefined) { + normalized.promptCommandSuffix = normalizeOptionalText( + patch.promptCommandSuffix, + ); + } + if (patch.promptTransport !== undefined) { + normalized.promptTransport = + patch.promptTransport && patch.promptTransport !== "argv" + ? patch.promptTransport + : null; + } + if (patch.taskPromptTemplate !== undefined) { + const taskPromptTemplate = toTrimmedRequiredValue( + "Task prompt template", + patch.taskPromptTemplate, + ); + const validation = validateTaskPromptTemplate(taskPromptTemplate); + if (!validation.valid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown task prompt variables: ${validation.unknownVariables.join(", ")}`, + }); + } + normalized.taskPromptTemplate = taskPromptTemplate; + } + + if (Object.keys(normalized).length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Patch must include at least one supported field", + }); + } + + return normalized; +} diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index c59032c3772..7a871239483 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -13,15 +13,36 @@ import { import { AGENT_PRESET_COMMANDS, AGENT_PRESET_DESCRIPTIONS, + DEFAULT_TERMINAL_PRESET_AGENT_TYPES, } from "@superset/shared/agent-command"; +import { + applyLegacyPermissionsOverrides, + terminalPresetsMatchPre3546Seed, +} from "@superset/shared/agent-permissions-migration"; +import { + type AgentDefinitionId, + applyCustomAgentDefinitionPatch, + createOverrideEnvelopeWithPatch, + deleteCustomAgentDefinition, + getAgentDefinitionById, + getCustomAgentDefinitionById, + readAgentPresetOverrides, + resetAgentPresetOverride, + resetAllAgentPresetOverrides, + resolveAgentConfigs, + upsertCustomAgentDefinition, +} from "@superset/shared/agent-settings"; import { TRPCError } from "@trpc/server"; import { app } from "electron"; -import { quitWithoutConfirmation } from "main/index"; +import { env } from "main/env.main"; +import { exitImmediately } from "main/index"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; +import { getHostServiceCoordinator } from "main/lib/host-service-coordinator"; import { localDb } from "main/lib/local-db"; import { DEFAULT_AUTO_APPLY_DEFAULT_PRESET, DEFAULT_CONFIRM_ON_QUIT, + DEFAULT_EXPOSE_HOST_SERVICE_VIA_RELAY, DEFAULT_FILE_OPEN_MODE, DEFAULT_OPEN_LINKS_IN_APP, DEFAULT_SHOW_PRESETS_BAR, @@ -35,20 +56,17 @@ import { DEFAULT_RINGTONE_ID, isBuiltInRingtoneId, } from "shared/ringtones"; -import { - type AgentDefinitionId, - createOverrideEnvelopeWithPatch, - getAgentDefinitionById, - readAgentPresetOverrides, - resetAgentPresetOverride, - resolveAgentConfigs, -} from "shared/utils/agent-settings"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { loadToken } from "../auth/utils/auth-functions"; import { getGitAuthorName, getGitHubUsername } from "../workspaces/utils/git"; import { + createCustomAgentInputSchema, normalizeAgentPresetPatch, + normalizeCreateCustomAgentInput, + normalizeCustomAgentPatch, updateAgentPresetInputSchema, + updateCustomAgentInputSchema, } from "./agent-preset-router.utils"; import { setFontSettingsSchema, @@ -112,7 +130,42 @@ function saveTerminalPresets( .run(); } +let agentPresetPermissionsMigrationChecked = false; + +function runAgentPresetPermissionsMigration() { + if (agentPresetPermissionsMigrationChecked) return; + const row = getSettings(); + if (row.agentPresetPermissionsMigratedAt) { + agentPresetPermissionsMigrationChecked = true; + return; + } + + const isExistingUser = + row.terminalPresetsInitialized === true && + terminalPresetsMatchPre3546Seed(row.terminalPresets); + + const nextOverrides = isExistingUser + ? applyLegacyPermissionsOverrides( + readAgentPresetOverrides(row.agentPresetOverrides), + ) + : undefined; + + const now = Date.now(); + const setFields = { + agentPresetPermissionsMigratedAt: now, + ...(nextOverrides ? { agentPresetOverrides: nextOverrides } : {}), + }; + localDb + .insert(settings) + .values({ id: 1, ...setFields }) + .onConflictDoUpdate({ target: settings.id, set: setFields }) + .run(); + + agentPresetPermissionsMigrationChecked = true; +} + function readRawAgentPresetOverrides(): AgentPresetOverrideEnvelope { + runAgentPresetPermissionsMigration(); const row = getSettings(); return readAgentPresetOverrides(row.agentPresetOverrides); } @@ -136,6 +189,29 @@ function saveAgentPresetOverrides(overrides: AgentPresetOverrideEnvelope) { .run(); } +function saveAgentCustomDefinitions(definitions: AgentCustomDefinition[]) { + localDb + .insert(settings) + .values({ + id: 1, + agentCustomDefinitions: definitions, + }) + .onConflictDoUpdate({ + target: settings.id, + set: { agentCustomDefinitions: definitions }, + }) + .run(); +} + +function clearCustomAgentPresetOverride(id: `custom:${string}`) { + saveAgentPresetOverrides( + resetAgentPresetOverride({ + currentOverrides: readRawAgentPresetOverrides(), + id, + }), + ); +} + function getResolvedAgentPresets() { return resolveAgentConfigs({ customDefinitions: readRawAgentCustomDefinitions(), @@ -143,24 +219,13 @@ function getResolvedAgentPresets() { }); } -const DEFAULT_PRESET_AGENTS = [ - "claude", - "codex", - "copilot", - "mastracode", - "opencode", - "pi", - "gemini", -] as const; - -const DEFAULT_PRESETS: Omit<TerminalPreset, "id">[] = DEFAULT_PRESET_AGENTS.map( - (name) => ({ +const DEFAULT_PRESETS: Omit<TerminalPreset, "id">[] = + DEFAULT_TERMINAL_PRESET_AGENT_TYPES.map((name) => ({ name, description: AGENT_PRESET_DESCRIPTIONS[name], cwd: "", commands: AGENT_PRESET_COMMANDS[name], - }), -); + })); function initializeDefaultPresets() { const row = getSettings(); @@ -204,6 +269,84 @@ export const createSettingsRouter = () => { return getNormalizedTerminalPresets(); }), getAgentPresets: publicProcedure.query(() => getResolvedAgentPresets()), + createCustomAgent: publicProcedure + .input(createCustomAgentInputSchema) + .mutation(({ input }) => { + const definition = { + id: `custom:${crypto.randomUUID()}` as const, + kind: "terminal" as const, + ...normalizeCreateCustomAgentInput(input), + }; + const nextDefinitions = upsertCustomAgentDefinition({ + currentDefinitions: readRawAgentCustomDefinitions(), + definition, + }); + + saveAgentCustomDefinitions(nextDefinitions); + clearCustomAgentPresetOverride(definition.id); + + return getResolvedAgentPresets().find( + (preset) => preset.id === definition.id, + ); + }), + updateCustomAgent: publicProcedure + .input(updateCustomAgentInputSchema) + .mutation(({ input }) => { + const definition = getCustomAgentDefinitionById({ + customDefinitions: readRawAgentCustomDefinitions(), + id: input.id as `custom:${string}`, + }); + if (!definition) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Custom agent ${input.id} not found`, + }); + } + + const nextDefinitions = upsertCustomAgentDefinition({ + currentDefinitions: readRawAgentCustomDefinitions(), + definition: applyCustomAgentDefinitionPatch({ + definition, + patch: normalizeCustomAgentPatch(input.patch), + }), + }); + + saveAgentCustomDefinitions(nextDefinitions); + clearCustomAgentPresetOverride(input.id as `custom:${string}`); + + return getResolvedAgentPresets().find( + (preset) => preset.id === input.id, + ); + }), + deleteCustomAgent: publicProcedure + .input(z.object({ id: z.string().regex(/^custom:/) })) + .mutation(({ input }) => { + const existingDefinition = getCustomAgentDefinitionById({ + customDefinitions: readRawAgentCustomDefinitions(), + id: input.id as `custom:${string}`, + }); + if (!existingDefinition) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Custom agent ${input.id} not found`, + }); + } + + saveAgentCustomDefinitions( + deleteCustomAgentDefinition({ + currentDefinitions: readRawAgentCustomDefinitions(), + id: input.id as `custom:${string}`, + }), + ); + saveAgentPresetOverrides( + resetAgentPresetOverride({ + currentOverrides: readRawAgentPresetOverrides(), + id: input.id as AgentDefinitionId, + }), + ); + + return { success: true }; + }), updateAgentPreset: publicProcedure .input(updateAgentPresetInputSchema) .mutation(({ input }) => { @@ -217,6 +360,12 @@ export const createSettingsRouter = () => { message: `Agent preset ${input.id} not found`, }); } + if (definition.source === "user") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Custom agent ${input.id} must be edited through custom-agent settings`, + }); + } const normalizedPatch = normalizeAgentPresetPatch({ definition, @@ -246,7 +395,7 @@ export const createSettingsRouter = () => { return { success: true }; }), resetAllAgentPresets: publicProcedure.mutation(() => { - saveAgentPresetOverrides({ version: 1, presets: [] }); + saveAgentPresetOverrides(resetAllAgentPresetOverrides()); return { success: true }; }), createTerminalPreset: publicProcedure @@ -485,6 +634,42 @@ export const createSettingsRouter = () => { return { success: true }; }), + getExposeHostServiceViaRelay: publicProcedure.query(() => { + const row = getSettings(); + return ( + row.exposeHostServiceViaRelay ?? DEFAULT_EXPOSE_HOST_SERVICE_VIA_RELAY + ); + }), + + setExposeHostServiceViaRelay: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(async ({ input }) => { + localDb + .insert(settings) + .values({ id: 1, exposeHostServiceViaRelay: input.enabled }) + .onConflictDoUpdate({ + target: settings.id, + set: { exposeHostServiceViaRelay: input.enabled }, + }) + .run(); + + // Restart active host-service children so they pick up the new + // RELAY_URL from buildEnv(). No-op if the user isn't signed in. + const { token } = await loadToken(); + if (!token) { + return { restartedOrgCount: 0 }; + } + + const coordinator = getHostServiceCoordinator(); + const restartedOrgCount = coordinator.getActiveOrganizationIds().length; + await coordinator.restartAll({ + authToken: token, + cloudApiUrl: env.NEXT_PUBLIC_API_URL, + }); + + return { restartedOrgCount }; + }), + getShowPresetsBar: publicProcedure.query(() => { const row = getSettings(); return row.showPresetsBar ?? DEFAULT_SHOW_PRESETS_BAR; @@ -590,7 +775,7 @@ export const createSettingsRouter = () => { restartApp: publicProcedure.mutation(() => { app.relaunch(); - quitWithoutConfirmation(); + exitImmediately(); return { success: true }; }), @@ -679,6 +864,26 @@ export const createSettingsRouter = () => { return { success: true }; }), + getNotificationVolume: publicProcedure.query(() => { + const row = getSettings(); + return row.notificationVolume ?? 100; + }), + + setNotificationVolume: publicProcedure + .input(z.object({ volume: z.number().min(0).max(100) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, notificationVolume: input.volume }) + .onConflictDoUpdate({ + target: settings.id, + set: { notificationVolume: input.volume }, + }) + .run(); + + return { success: true }; + }), + getFontSettings: publicProcedure.query(() => { const row = getSettings(); return { diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 8786d33d35d..40b752eb072 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -1,12 +1,5 @@ -import { observable } from "@trpc/server/observable"; import { appState } from "main/lib/app-state"; import type { TabsState, ThemeState } from "main/lib/app-state/schemas"; -import { hotkeysEmitter } from "main/lib/hotkeys-events"; -import { - buildOverridesFromBindings, - HOTKEYS_STATE_VERSION, - type HotkeysState, -} from "shared/hotkeys"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -232,15 +225,8 @@ const themeSchema = z.object({ const themeStateSchema = z.object({ activeThemeId: z.string(), customThemes: z.array(themeSchema), -}); - -const hotkeysStateSchema = z.object({ - version: z.number(), - byPlatform: z.object({ - darwin: z.record(z.string(), z.string().nullable()).default({}), - win32: z.record(z.string(), z.string().nullable()).default({}), - linux: z.record(z.string(), z.string().nullable()).default({}), - }), + systemLightThemeId: z.string().optional(), + systemDarkThemeId: z.string().optional(), }); /** @@ -278,58 +264,11 @@ export const createUiStateRouter = () => { }), }), - // Hotkeys state procedures + // Legacy hotkeys state (read-only, for one-time migration to localStorage) hotkeys: router({ - get: publicProcedure.query((): HotkeysState => { + get: publicProcedure.query(() => { return appState.data.hotkeysState; }), - - set: publicProcedure - .input(hotkeysStateSchema) - .mutation(async ({ input }) => { - const version = - input.version === HOTKEYS_STATE_VERSION - ? input.version - : HOTKEYS_STATE_VERSION; - - const normalized: HotkeysState = { - version, - byPlatform: { - darwin: buildOverridesFromBindings( - input.byPlatform.darwin ?? {}, - "darwin", - ), - win32: buildOverridesFromBindings( - input.byPlatform.win32 ?? {}, - "win32", - ), - linux: buildOverridesFromBindings( - input.byPlatform.linux ?? {}, - "linux", - ), - }, - }; - - appState.data.hotkeysState = normalized; - await appState.write(); - hotkeysEmitter.emit("change", { - version: normalized.version, - updatedAt: new Date().toISOString(), - }); - return { success: true }; - }), - - subscribe: publicProcedure.subscription(() => { - return observable<{ version: number; updatedAt: string }>((emit) => { - const onChange = (data: { version: number; updatedAt: string }) => { - emit.next(data); - }; - hotkeysEmitter.on("change", onChange); - return () => { - hotkeysEmitter.off("change", onChange); - }; - }); - }), }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/window.ts b/apps/desktop/src/lib/trpc/routers/window.ts index 71ca4ee92b9..9923a0113ce 100644 --- a/apps/desktop/src/lib/trpc/routers/window.ts +++ b/apps/desktop/src/lib/trpc/routers/window.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import { homedir } from "node:os"; -import path from "node:path"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; +import { getImageMimeType } from "shared/file-types"; import { z } from "zod"; import { publicProcedure, router } from ".."; @@ -119,10 +119,9 @@ export const createWindowRouter = (getWindow: () => BrowserWindow | null) => { const filePath = result.filePaths[0]; const buffer = await fs.readFile(filePath); - const ext = path.extname(filePath).slice(1).toLowerCase(); - const mimeType = ext === "jpg" ? "jpeg" : ext; + const mimeType = getImageMimeType(filePath) ?? "image/png"; const base64 = buffer.toString("base64"); - const dataUrl = `data:image/${mimeType};base64,${base64}`; + const dataUrl = `data:${mimeType};base64,${base64}`; return { canceled: false, dataUrl }; }), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts index 100c7028f48..fc4a0729296 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts @@ -1,7 +1,10 @@ import { existsSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; import type { SelectWorktree } from "@superset/local-db"; +import { worktrees } from "@superset/local-db"; +import { eq } from "drizzle-orm"; import { track } from "main/lib/analytics"; +import { localDb } from "main/lib/local-db"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { z } from "zod"; @@ -26,11 +29,6 @@ import { } from "../utils/git"; import { removeWorktreeFromDisk, runTeardown } from "../utils/teardown"; -/** - * Normalize a filesystem path for comparison. - * Uses realpathSync to resolve symlinks and get canonical path. - * Falls back to resolve if realpathSync fails (e.g., path doesn't exist). - */ const normalizePath = (p: string): string => { try { return realpathSync(p); @@ -267,43 +265,46 @@ export const createDeleteProcedures = () => { await workspaceInitManager.acquireProjectLock(project.id); try { - // Only delete from disk if this worktree was created by Superset - // External worktrees should only have their DB records removed - if (worktree.createdBySuperset) { - // Safety: Double-check it's not actually external (catches race conditions) - const externalWorktrees = await listExternalWorktrees( - project.mainRepoPath, - ); - const worktreePathNorm = normalizePath(worktree.path); - const isActuallyExternal = externalWorktrees.some( - (wt) => normalizePath(wt.path) === worktreePathNorm, - ); + // Safety: prevent deletion of worktrees not tracked in our DB + const allGitWorktrees = await listExternalWorktrees( + project.mainRepoPath, + ); - if (isActuallyExternal) { - console.warn( - `[workspace/delete] Worktree at ${worktree.path} marked as created by Superset but found in external list - preserving as safety measure`, - ); - track("worktree_delete_safety_trigger", { - workspace_id: input.id, - worktree_id: worktree.id, - worktree_path: worktree.path, - reason: "external_detection_mismatch", - }); - } else { - // Confirmed safe to delete - const removeResult = await removeWorktreeFromDisk({ - mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, - }); - if (!removeResult.success) { - clearWorkspaceDeletingStatus(input.id); - return removeResult; - } - } - } else { - console.log( - `[workspace/delete] Skipping disk deletion for external worktree at ${worktree.path}`, + const trackedWorktrees = localDb + .select({ path: worktrees.path }) + .from(worktrees) + .where(eq(worktrees.projectId, project.id)) + .all(); + const trackedPaths = new Set( + trackedWorktrees.map((wt) => normalizePath(wt.path)), + ); + + const worktreePathNorm = normalizePath(worktree.path); + const existsInGit = allGitWorktrees.some( + (wt) => normalizePath(wt.path) === worktreePathNorm, + ); + const isActuallyExternal = + existsInGit && !trackedPaths.has(worktreePathNorm); + + if (isActuallyExternal) { + console.warn( + `[workspace/delete] Worktree at ${worktree.path} exists in git but not tracked in database - preserving as safety measure`, ); + track("worktree_delete_safety_trigger", { + workspace_id: input.id, + worktree_id: worktree.id, + worktree_path: worktree.path, + reason: "untracked_worktree_detected", + }); + } else { + const removeResult = await removeWorktreeFromDisk({ + mainRepoPath: project.mainRepoPath, + worktreePath: worktree.path, + }); + if (!removeResult.success) { + clearWorkspaceDeletingStatus(input.id); + return removeResult; + } } } finally { workspaceInitManager.releaseProjectLock(project.id); @@ -486,68 +487,73 @@ export const createDeleteProcedures = () => { worktree.path, ); - // Only delete from disk if this worktree was created by Superset - if (worktree.createdBySuperset) { - // Safety: Double-check it's not actually external (catches race conditions) - const externalWorktrees = await listExternalWorktrees( - project.mainRepoPath, - ); - const isActuallyExternal = externalWorktrees.some( - (wt) => wt.path === worktree.path, - ); + // Safety: prevent deletion of worktrees not tracked in our DB + const allGitWorktrees = await listExternalWorktrees( + project.mainRepoPath, + ); - if (isActuallyExternal) { - console.warn( - `[worktree/delete] Worktree at ${worktree.path} marked as created by Superset but found in external list - preserving as safety measure`, - ); - track("worktree_delete_safety_trigger", { - worktree_id: input.worktreeId, - worktree_path: worktree.path, - reason: "external_detection_mismatch", + const trackedWorktrees = localDb + .select({ path: worktrees.path }) + .from(worktrees) + .where(eq(worktrees.projectId, project.id)) + .all(); + const trackedPaths = new Set( + trackedWorktrees.map((wt) => normalizePath(wt.path)), + ); + + const worktreePathNorm = normalizePath(worktree.path); + const existsInGit = allGitWorktrees.some( + (wt) => normalizePath(wt.path) === worktreePathNorm, + ); + const isActuallyExternal = + existsInGit && !trackedPaths.has(worktreePathNorm); + + if (isActuallyExternal) { + console.warn( + `[worktree/delete] Worktree at ${worktree.path} exists in git but not tracked in database - preserving as safety measure`, + ); + track("worktree_delete_safety_trigger", { + worktree_id: input.worktreeId, + worktree_path: worktree.path, + reason: "untracked_worktree_detected", + }); + } else { + if (exists) { + const teardownResult = await runTeardown({ + mainRepoPath: project.mainRepoPath, + worktreePath: worktree.path, + workspaceName: worktree.branch, + projectId: project.id, }); - } else { - // Confirmed safe to delete - if (exists) { - const teardownResult = await runTeardown({ - mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, - workspaceName: worktree.branch, - projectId: project.id, - }); - if (!teardownResult.success) { - if (input.force) { - console.warn( - `[worktree/delete] Teardown failed but force=true, continuing deletion:`, - teardownResult.error, - ); - } else { - return { - success: false, - error: `Teardown failed: ${teardownResult.error}`, - output: teardownResult.output, - }; - } + if (!teardownResult.success) { + if (input.force) { + console.warn( + `[worktree/delete] Teardown failed but force=true, continuing deletion:`, + teardownResult.error, + ); + } else { + return { + success: false, + error: `Teardown failed: ${teardownResult.error}`, + output: teardownResult.output, + }; } } + } - if (exists) { - const removeResult = await removeWorktreeFromDisk({ - mainRepoPath: project.mainRepoPath, - worktreePath: worktree.path, - }); - if (!removeResult.success) { - return removeResult; - } - } else { - console.warn( - `Worktree ${worktree.path} not found in git, skipping removal`, - ); + if (exists) { + const removeResult = await removeWorktreeFromDisk({ + mainRepoPath: project.mainRepoPath, + worktreePath: worktree.path, + }); + if (!removeResult.success) { + return removeResult; } + } else { + console.warn( + `Worktree ${worktree.path} not found in git, skipping removal`, + ); } - } else { - console.log( - `[worktree/delete] Skipping disk deletion for external worktree at ${worktree.path}`, - ); } } finally { workspaceInitManager.releaseProjectLock(project.id); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/external-worktree-import.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/external-worktree-import.test.ts index 8ddf2edae7d..9cba6c0dd98 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/external-worktree-import.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/external-worktree-import.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { listExternalWorktrees } from "../utils/git"; /** * Integration tests for external worktree auto-import feature @@ -116,10 +117,6 @@ describe("External worktree detection and import", () => { // Create external worktree createExternalWorktree(mainRepoPath, "feature-test", externalWorktreePath); - // Import the listExternalWorktrees function - const { listExternalWorktrees } = await import("../utils/git"); - - // List external worktrees const externalWorktrees = await listExternalWorktrees(mainRepoPath); // Find our external worktree diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts index 07843a68cf6..c3229e1dc1e 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/git-status.ts @@ -19,10 +19,13 @@ import { refreshDefaultBranch, } from "../utils/git"; import { + clearGitHubCachesForWorktree, fetchGitHubPRComments, fetchGitHubPRStatus, type PullRequestCommentsTarget, + resolveReviewThread, } from "../utils/github"; +import { getWorkspacePath } from "../utils/worktree"; const gitHubPRCommentsInputSchema = z.object({ workspaceId: z.string(), @@ -99,12 +102,10 @@ export const createGitStatusProcedures = () => { throw new Error(`Workspace ${input.workspaceId} not found`); } - const worktree = workspace.worktreeId - ? getWorktree(workspace.worktreeId) - : null; - if (!worktree) { + const repoPath = getWorkspacePath(workspace); + if (!repoPath) { throw new Error( - `Worktree for workspace ${input.workspaceId} not found`, + `Could not resolve path for workspace ${input.workspaceId}`, ); } @@ -132,23 +133,25 @@ export const createGitStatusProcedures = () => { await fetchDefaultBranch(project.mainRepoPath, defaultBranch); const { ahead, behind } = await getAheadBehindCount({ - repoPath: worktree.path, + repoPath, defaultBranch, }); const gitStatus = { - branch: worktree.branch, + branch: workspace.branch, needsRebase: behind > 0, ahead, behind, lastRefreshed: Date.now(), }; - localDb - .update(worktrees) - .set({ gitStatus }) - .where(eq(worktrees.id, worktree.id)) - .run(); + if (workspace.worktreeId) { + localDb + .update(worktrees) + .set({ gitStatus }) + .where(eq(worktrees.id, workspace.worktreeId)) + .run(); + } return { gitStatus, defaultBranch }; }), @@ -180,27 +183,31 @@ export const createGitStatusProcedures = () => { return null; } - const worktree = workspace.worktreeId - ? getWorktree(workspace.worktreeId) - : null; - if (!worktree) { + const repoPath = getWorkspacePath(workspace); + if (!repoPath) { return null; } - const freshStatus = await fetchGitHubPRStatus(worktree.path); - - if ( - freshStatus && - hasMeaningfulGitHubStatusChange({ - current: worktree.githubStatus, - next: freshStatus, - }) - ) { - localDb - .update(worktrees) - .set({ githubStatus: freshStatus }) - .where(eq(worktrees.id, worktree.id)) - .run(); + const branchOverride = + workspace.type === "branch" ? workspace.branch : null; + + const freshStatus = await fetchGitHubPRStatus(repoPath, branchOverride); + + if (freshStatus && workspace.worktreeId) { + const worktree = getWorktree(workspace.worktreeId); + if ( + worktree && + hasMeaningfulGitHubStatusChange({ + current: worktree.githubStatus, + next: freshStatus, + }) + ) { + localDb + .update(worktrees) + .set({ githubStatus: freshStatus }) + .where(eq(worktrees.id, workspace.worktreeId)) + .run(); + } } return freshStatus; @@ -214,24 +221,56 @@ export const createGitStatusProcedures = () => { return []; } - const worktree = workspace.worktreeId - ? getWorktree(workspace.worktreeId) - : null; - if (!worktree) { + const repoPath = getWorkspacePath(workspace); + if (!repoPath) { return []; } - const cachedGitHubStatus = worktree.githubStatus ?? null; + const worktree = workspace.worktreeId + ? getWorktree(workspace.worktreeId) + : null; + const cachedGitHubStatus = worktree?.githubStatus ?? null; return fetchGitHubPRComments({ - worktreePath: worktree.path, + worktreePath: repoPath, pullRequest: resolveCommentsPullRequestTarget({ input, githubStatus: cachedGitHubStatus, }), + branchName: workspace.type === "branch" ? workspace.branch : null, }); }), + resolveReviewThread: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + threadId: z.string(), + resolve: z.boolean(), + }), + ) + .mutation(async ({ input }) => { + const workspace = getWorkspace(input.workspaceId); + if (!workspace) { + throw new Error(`Workspace ${input.workspaceId} not found`); + } + + const repoPath = getWorkspacePath(workspace); + if (!repoPath) { + throw new Error( + `Could not resolve path for workspace ${input.workspaceId}`, + ); + } + + await resolveReviewThread({ + worktreePath: repoPath, + threadId: input.threadId, + resolve: input.resolve, + }); + + clearGitHubCachesForWorktree(repoPath); + }), + getWorktreeInfo: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(({ input }) => { @@ -240,6 +279,16 @@ export const createGitStatusProcedures = () => { return null; } + if (workspace.type === "branch") { + return { + worktreeName: workspace.name, + branchName: workspace.branch, + createdAt: workspace.createdAt, + gitStatus: null, + githubStatus: null, + }; + } + const worktree = workspace.worktreeId ? getWorktree(workspace.worktreeId) : null; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts index ffa81cf4886..8e347ca5e00 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/init.ts @@ -1,10 +1,10 @@ import { workspaces, worktrees } from "@superset/local-db"; +import { deduplicateBranchName } from "@superset/shared/workspace-launch"; import { observable } from "@trpc/server/observable"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; import type { WorkspaceInitProgress } from "shared/types/workspace-init"; -import { deduplicateBranchName } from "shared/utils/branch"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { getPresetsForTrigger } from "../../settings"; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 4fa87789c46..2bd22f3e28f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -158,6 +158,7 @@ export const createQueryProcedures = () => { mainRepoPath: string; hideImage: boolean; iconUrl: string | null; + neonProjectId: string | null; }; workspaces: WorkspaceItem[]; sections: SectionItem[]; @@ -190,6 +191,7 @@ export const createQueryProcedures = () => { mainRepoPath: project.mainRepoPath, hideImage: project.hideImage ?? false, iconUrl: project.iconUrl ?? null, + neonProjectId: project.neonProjectId ?? null, }, workspaces: [], sections: projectSections, diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts index 4e0ab490faf..6ec9cabb5e6 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts @@ -199,7 +199,12 @@ export const createStatusProcedures = () => { .mutation(({ input }) => { const { workspaceId, branch } = input; - if (!branch || branch === "HEAD") { + if ( + !branch || + branch === "HEAD" || + branch.startsWith("[") || + branch.includes(" ") + ) { return { success: false as const, reason: "invalid-branch" as const }; } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts index 6997101917e..46b547972c9 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-branch-name.ts @@ -1,18 +1,12 @@ -import { - generateTitleFromMessage, - generateTitleFromMessageWithStreamingModel, -} from "@superset/chat/server/desktop"; -import { callSmallModel } from "lib/ai/call-small-model"; -import { sanitizeBranchNameWithMaxLength } from "shared/utils/branch"; +import { generateTitleFromMessage } from "@superset/chat/server/desktop"; +import { getSmallModel } from "@superset/chat/server/shared"; +import { sanitizeBranchNameWithMaxLength } from "@superset/shared/workspace-launch"; const BRANCH_NAME_INSTRUCTIONS = - "Generate a concise git branch name (2-4 words, kebab-case, descriptive). Return ONLY the branch name, nothing else."; + "Generate a concise git branch name (2-4 words, kebab-case, descriptive, 20 characters or less). Return ONLY the branch name, nothing else."; const MAX_CONFLICT_RESOLUTION_ATTEMPTS = 1000; -const INITIAL_CONFLICT_SUFFIX = 2; // Start at -2 since -1 is implicit (no suffix) +const INITIAL_CONFLICT_SUFFIX = 2; -/** - * Checks if a branch name conflicts with existing branches (case-insensitive) - */ function hasConflict( branchName: string, existingBranchesSet: Set<string>, @@ -20,29 +14,21 @@ function hasConflict( return existingBranchesSet.has(branchName.toLowerCase()); } -/** - * Resolves branch name conflicts by appending a number (-2, -3, etc.) - * IMPORTANT: Checks conflicts with prefix applied to match server behavior - */ function resolveConflict( baseName: string, existingBranches: string[], branchPrefix: string | undefined, ): string { - // Apply prefix to match what the server will do const prefixedBase = branchPrefix ? `${branchPrefix}/${baseName}` : baseName; - - // Quick check without creating Set (covers 90% of cases where no conflict exists) const lowerPrefixedBase = prefixedBase.toLowerCase(); const hasInitialConflict = existingBranches.some( (b) => b.toLowerCase() === lowerPrefixedBase, ); if (!hasInitialConflict) { - return baseName; // Return unprefixed - server will apply prefix + return baseName; } - // Only create Set if we need to loop through conflicts const existingSet = new Set(existingBranches.map((b) => b.toLowerCase())); let counter = INITIAL_CONFLICT_SUFFIX; @@ -64,54 +50,34 @@ function resolveConflict( : candidate; } - return candidate; // Return unprefixed - server will apply prefix + return candidate; } -/** - * Generates an AI-powered branch name from a user prompt with automatic conflict resolution. - * - * @param prompt - User's workspace description - * @param existingBranches - List of existing branch names to check for conflicts - * @param branchPrefix - Optional prefix that will be applied by the server (e.g., "avi") - * @returns Generated branch name WITHOUT prefix (server will apply it) or null if generation fails - * @throws Error if conflict resolution exceeds max attempts - */ export async function generateBranchNameFromPrompt( prompt: string, existingBranches: string[], branchPrefix?: string, ): Promise<string | null> { - const { result } = await callSmallModel<string>({ - invoke: async ({ credentials, providerId, providerName, model }) => { - if (providerId === "openai" && credentials.kind === "oauth") { - return generateTitleFromMessageWithStreamingModel({ - message: prompt, - model: model as never, - instructions: BRANCH_NAME_INSTRUCTIONS, - }); - } - - return generateTitleFromMessage({ - message: prompt, - agentModel: model, - agentId: `branch-namer-${providerId}`, - agentName: "Branch Namer", - instructions: BRANCH_NAME_INSTRUCTIONS, - tracingContext: { - surface: "workspace-branch-name", - provider: providerName, - }, - }); - }, - }); + const model = await getSmallModel(); + if (!model) return null; - if (result !== null && result !== undefined) { - const sanitized = sanitizeBranchNameWithMaxLength(result); - if (sanitized) { - // Resolve conflicts with prefix applied (matches server behavior) - return resolveConflict(sanitized, existingBranches, branchPrefix); - } + let generated: string | null; + try { + generated = await generateTitleFromMessage({ + message: prompt, + agentModel: model, + agentId: "branch-namer", + agentName: "Branch Namer", + instructions: BRANCH_NAME_INSTRUCTIONS, + tracingContext: { surface: "workspace-branch-name" }, + }); + } catch (error) { + console.warn("[generateBranchNameFromPrompt] generation failed:", error); + return null; } - return null; + if (!generated) return null; + const sanitized = sanitizeBranchNameWithMaxLength(generated); + if (!sanitized) return null; + return resolveConflict(sanitized, existingBranches, branchPrefix); } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts index 0636b859f23..7926fa98075 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.test.ts @@ -1,17 +1,9 @@ import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; -import type { SmallModelAttempt } from "lib/ai/call-small-model"; -const callSmallModelMock = mock((async () => ({ - result: null, - attempts: [], -})) as (...args: unknown[]) => Promise<{ - result: string | null; - attempts: SmallModelAttempt[]; -}>); -const generateTitleFromMessageMock = mock( - (async () => null) as (...args: unknown[]) => Promise<string | null>, +const getSmallModelMock = mock( + (async () => null) as (...args: unknown[]) => Promise<unknown | null>, ); -const generateTitleFromMessageWithStreamingModelMock = mock( +const generateTitleFromMessageMock = mock( (async () => null) as (...args: unknown[]) => Promise<string | null>, ); @@ -31,15 +23,12 @@ type SelectedWorkspace = } | null; -mock.module("lib/ai/call-small-model", () => ({ - callSmallModel: callSmallModelMock, +mock.module("@superset/chat/server/shared", () => ({ + getSmallModel: getSmallModelMock, })); mock.module("@superset/chat/server/desktop", () => ({ - __esModule: true, generateTitleFromMessage: generateTitleFromMessageMock, - generateTitleFromMessageWithStreamingModel: - generateTitleFromMessageWithStreamingModelMock, })); mock.module("drizzle-orm", () => ({ @@ -89,100 +78,32 @@ const { describe("generateWorkspaceNameFromPrompt", () => { beforeEach(() => { - callSmallModelMock.mockClear(); - callSmallModelMock.mockImplementation(async () => ({ - result: null, - attempts: [], - })); + getSmallModelMock.mockClear(); + getSmallModelMock.mockResolvedValue(null); + generateTitleFromMessageMock.mockClear(); + generateTitleFromMessageMock.mockResolvedValue(null); selectGetMock.mockReset(); selectGetMock.mockReturnValue(null); updateRunMock.mockReset(); updateRunMock.mockReturnValue({ changes: 1 }); localDbMock.select.mockClear(); localDbMock.update.mockClear(); - generateTitleFromMessageMock.mockClear(); - generateTitleFromMessageWithStreamingModelMock.mockClear(); }); - it("falls back to a prompt-derived title when no providers are available", async () => { + it("falls back to a prompt-derived title when no model is available", async () => { await expect( generateWorkspaceNameFromPrompt(" debug prod rename failure "), ).resolves.toEqual({ name: "debug prod rename failure", usedPromptFallback: true, warning: - "No model account was connected, so a prompt-based title was used.", - }); - }); - - it("uses the last relevant provider issue in the fallback warning", async () => { - callSmallModelMock.mockImplementation(async () => ({ - result: null, - attempts: [ - { - providerId: "anthropic", - providerName: "Anthropic", - outcome: "failed", - issue: { - code: "unknown_error", - message: "Anthropic could not complete this request", - }, - }, - { - providerId: "openai", - providerName: "OpenAI", - outcome: "failed", - issue: { - code: "missing_scope", - message: "OpenAI needs permission model.request", - }, - }, - ], - })); - - await expect( - generateWorkspaceNameFromPrompt("rename this workspace from prompt"), - ).resolves.toEqual({ - name: "rename this workspace from prompt", - usedPromptFallback: true, - warning: - "OpenAI needs permission model.request, so a prompt-based title was used.", + "A prompt-based title was used because model naming was unavailable.", }); }); - it("uses streaming title generation for OpenAI OAuth naming", async () => { - generateTitleFromMessageWithStreamingModelMock.mockResolvedValue( - "Checking In", - ); - callSmallModelMock.mockImplementationOnce((async ({ - invoke, - }: { - invoke: (context: { - providerId: "openai"; - providerName: string; - model: { id: string }; - credentials: { - apiKey: string; - kind: "oauth"; - source: string; - }; - }) => Promise<string | null>; - }) => ({ - result: await invoke({ - providerId: "openai", - providerName: "OpenAI", - model: { id: "openai-model" }, - credentials: { - apiKey: "oauth-token", - kind: "oauth", - source: "auth-storage", - }, - }), - attempts: [], - })) as (...args: unknown[]) => Promise<{ - result: string | null; - attempts: SmallModelAttempt[]; - }>); + it("returns the model-generated title when a model is available", async () => { + getSmallModelMock.mockResolvedValueOnce({ id: "test-model" }); + generateTitleFromMessageMock.mockResolvedValueOnce("Checking In"); await expect( generateWorkspaceNameFromPrompt("hey boss how are you"), @@ -190,21 +111,20 @@ describe("generateWorkspaceNameFromPrompt", () => { name: "Checking In", usedPromptFallback: false, }); - expect(generateTitleFromMessageWithStreamingModelMock).toHaveBeenCalledWith( - { - message: "hey boss how are you", - model: { id: "openai-model" }, - instructions: "You generate concise workspace titles.", - }, - ); - expect(generateTitleFromMessageMock).not.toHaveBeenCalled(); + expect(generateTitleFromMessageMock).toHaveBeenCalledWith({ + message: "hey boss how are you", + agentModel: { id: "test-model" }, + agentId: "workspace-namer", + agentName: "Workspace Namer", + instructions: + "You generate concise workspace titles. 20 characters or less. Return ONLY the title, nothing else.", + tracingContext: { surface: "workspace-auto-name" }, + }); }); it("preserves empty-string model results instead of forcing fallback", async () => { - callSmallModelMock.mockImplementationOnce(async () => ({ - result: "", - attempts: [], - })); + getSmallModelMock.mockResolvedValueOnce({ id: "test-model" }); + generateTitleFromMessageMock.mockResolvedValueOnce(""); await expect( generateWorkspaceNameFromPrompt("name this workspace"), @@ -213,6 +133,20 @@ describe("generateWorkspaceNameFromPrompt", () => { usedPromptFallback: false, }); }); + + it("falls back when generation throws", async () => { + getSmallModelMock.mockResolvedValueOnce({ id: "test-model" }); + generateTitleFromMessageMock.mockRejectedValueOnce(new Error("boom")); + + await expect( + generateWorkspaceNameFromPrompt("rename this workspace from prompt"), + ).resolves.toEqual({ + name: "rename this workspace from prompt", + usedPromptFallback: true, + warning: + "A prompt-based title was used because model naming was unavailable.", + }); + }); }); afterAll(() => { @@ -221,11 +155,10 @@ afterAll(() => { describe("attemptWorkspaceAutoRenameFromPrompt", () => { beforeEach(() => { - callSmallModelMock.mockClear(); - callSmallModelMock.mockImplementation(async () => ({ - result: null, - attempts: [], - })); + getSmallModelMock.mockClear(); + getSmallModelMock.mockResolvedValue(null); + generateTitleFromMessageMock.mockClear(); + generateTitleFromMessageMock.mockResolvedValue(null); selectGetMock.mockReset(); selectGetMock.mockReturnValue(null); updateRunMock.mockReset(); @@ -252,7 +185,7 @@ describe("attemptWorkspaceAutoRenameFromPrompt", () => { status: "skipped", reason: "workspace-named", }); - expect(callSmallModelMock).not.toHaveBeenCalled(); + expect(getSmallModelMock).not.toHaveBeenCalled(); expect(localDbMock.update).not.toHaveBeenCalled(); }); @@ -264,10 +197,8 @@ describe("attemptWorkspaceAutoRenameFromPrompt", () => { isUnnamed: true, deletingAt: null, }); - callSmallModelMock.mockImplementationOnce(async () => ({ - result: "", - attempts: [], - })); + getSmallModelMock.mockResolvedValueOnce({ id: "test-model" }); + generateTitleFromMessageMock.mockResolvedValueOnce(""); await expect( attemptWorkspaceAutoRenameFromPrompt({ diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts index 02849066beb..19b7f876fe6 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/ai-name.ts @@ -1,15 +1,9 @@ -import { - generateTitleFromMessage, - generateTitleFromMessageWithStreamingModel, -} from "@superset/chat/server/desktop"; +import { generateTitleFromMessage } from "@superset/chat/server/desktop"; +import { getSmallModel } from "@superset/chat/server/shared"; import { workspaces } from "@superset/local-db"; +import { deriveWorkspaceTitleFromPrompt } from "@superset/shared/workspace-launch"; import { and, eq, isNull } from "drizzle-orm"; -import { - callSmallModel, - type SmallModelAttempt, -} from "lib/ai/call-small-model"; import { localDb } from "main/lib/local-db"; -import { deriveWorkspaceTitleFromPrompt } from "shared/utils/workspace-naming"; import { getWorkspaceAutoRenameDecision } from "./workspace-auto-rename"; export type WorkspaceAutoRenameResult = @@ -32,66 +26,40 @@ export type WorkspaceAutoRenameResult = warning?: string; }; +const FALLBACK_WARNING = + "A prompt-based title was used because model naming was unavailable."; + export async function generateWorkspaceNameFromPrompt(prompt: string): Promise<{ name: string | null; usedPromptFallback: boolean; warning?: string; }> { - const { result, attempts } = await callSmallModel<string>({ - invoke: async ({ credentials, providerId, providerName, model }) => { - if (providerId === "openai" && credentials.kind === "oauth") { - return generateTitleFromMessageWithStreamingModel({ - message: prompt, - model: model as never, - instructions: "You generate concise workspace titles.", - }); - } - - return generateTitleFromMessage({ + const model = await getSmallModel(); + if (model) { + try { + const generated = await generateTitleFromMessage({ message: prompt, agentModel: model, - agentId: `workspace-namer-${providerId}`, + agentId: "workspace-namer", agentName: "Workspace Namer", - instructions: "You generate concise workspace titles.", - tracingContext: { - surface: "workspace-auto-name", - provider: providerName, - }, + instructions: + "You generate concise workspace titles. 20 characters or less. Return ONLY the title, nothing else.", + tracingContext: { surface: "workspace-auto-name" }, }); - }, - }); - if (result !== null && result !== undefined) { - return { name: result, usedPromptFallback: false }; - } - - for (const attempt of attempts) { - if (attempt.outcome === "failed") { - console.error( - `[workspace-ai-name] ${attempt.providerName} title generation failed`, - { - issue: attempt.issue ?? null, - reason: attempt.reason ?? null, - }, - ); - continue; - } - if (attempt.outcome === "unsupported-credentials") { - console.info( - `[workspace-ai-name] Skipping ${attempt.providerName} for title generation`, - { - issue: attempt.issue ?? attempt.reason, - }, - ); + if (generated !== null && generated !== undefined) { + return { name: generated, usedPromptFallback: false }; + } + } catch (error) { + console.error("[workspace-ai-name] title generation failed", error); } } const fallbackTitle = deriveWorkspaceTitleFromPrompt(prompt); if (fallbackTitle) { - console.info("[workspace-ai-name] Falling back to prompt-derived title"); return { name: fallbackTitle, usedPromptFallback: true, - warning: buildWorkspaceAutoNameFallbackWarning(attempts), + warning: FALLBACK_WARNING, }; } @@ -203,33 +171,3 @@ export async function attemptWorkspaceAutoRenameFromPrompt({ : "workspace-name-changed", }; } - -function buildWorkspaceAutoNameFallbackWarning( - attempts: SmallModelAttempt[], -): string { - if (attempts.length === 0) { - return "No model account was connected, so a prompt-based title was used."; - } - - for (let index = attempts.length - 1; index >= 0; index -= 1) { - const attempt = attempts[index]; - if (attempt.outcome === "expired-credentials") { - return `${attempt.issue?.message ?? `${attempt.providerName} needs to be reconnected`}, so a prompt-based title was used.`; - } - if (attempt.outcome === "failed") { - return `${attempt.issue?.message ?? `${attempt.providerName} couldn't generate a title`}, so a prompt-based title was used.`; - } - if (attempt.outcome === "unsupported-credentials") { - return `${attempt.issue?.message ?? "No compatible model account was available"}, so a prompt-based title was used.`; - } - } - - const missingCredentials = attempts.every( - (attempt) => attempt.outcome === "missing-credentials", - ); - if (missingCredentials) { - return "No model account was connected, so a prompt-based title was used."; - } - - return "A prompt-based title was used because model naming was unavailable."; -} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts index 785ec815871..b5cd9415695 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts @@ -11,6 +11,7 @@ import { import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; +import { invalidatePortLabelCache } from "../../ports/label-cache"; import { computeNextProjectChildTabOrder } from "./project-children-order"; /** @@ -273,6 +274,7 @@ export function clearWorkspaceDeletingStatus(workspaceId: string): void { */ export function deleteWorkspace(workspaceId: string): void { localDb.delete(workspaces).where(eq(workspaces.id, workspaceId)).run(); + invalidatePortLabelCache(workspaceId); } /** diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts index e92716919e1..dbb62151dc9 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts @@ -15,6 +15,8 @@ import { createWorktree, getCurrentBranch, hasUnpushedCommits, + isUnbornHeadError, + parsePorcelainStatusV2, parsePrUrl, } from "./git"; @@ -439,6 +441,89 @@ describe("createWorktree hook tolerance", () => { createWorktree(repoPath, "feature/existing-path", worktreePath, "HEAD"), ).rejects.toThrow("already exists"); }, 10_000); + + test("works with remote-tracking ref as start point (no-track prevents upstream)", async () => { + // Set up a "remote" repo with a commit, then clone it so we have origin/<branch> refs + const originPath = join(TEST_DIR, "worktree-notrack-origin"); + mkdirSync(originPath, { recursive: true }); + execSync("git init -b main", { cwd: originPath, stdio: "ignore" }); + execSync("git config user.email 'test@test.com'", { + cwd: originPath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { + cwd: originPath, + stdio: "ignore", + }); + writeFileSync(join(originPath, "README.md"), "# test\n"); + execSync("git add . && git commit -m 'init'", { + cwd: originPath, + stdio: "ignore", + }); + + const clonePath = join(TEST_DIR, "worktree-notrack-clone"); + execSync(`git clone "${originPath}" "${clonePath}"`, { + stdio: "ignore", + }); + execSync("git config user.email 'test@test.com'", { + cwd: clonePath, + stdio: "ignore", + }); + execSync("git config user.name 'Test'", { + cwd: clonePath, + stdio: "ignore", + }); + + const worktreePath = join(TEST_DIR, "worktree-notrack-wt"); + await createWorktree( + clonePath, + "feature/no-track-test", + worktreePath, + "origin/main", + ); + + expect(existsSync(worktreePath)).toBe(true); + + // Verify the new branch does NOT track origin/main + const trackingResult = execSync( + "git config --get branch.feature/no-track-test.remote 2>&1 || true", + { cwd: worktreePath }, + ) + .toString() + .trim(); + expect(trackingResult).toBe(""); + }, 15_000); + + test("works with a branch name containing slashes as start point", async () => { + // Reproduces #3448: createWorktree previously appended ^{commit} to the + // start point, which can fail with "fatal: invalid reference" when the ref + // is not locally resolvable with that suffix. Using --no-track avoids this. + const repoPath = createTestRepo("worktree-slash-branch"); + seedCommit(repoPath); + + // Create a branch with slashes (like feat/workstreams-view) + execSync("git checkout -b feat/workstreams-view", { + cwd: repoPath, + stdio: "ignore", + }); + execSync("git checkout -", { cwd: repoPath, stdio: "ignore" }); + + const worktreePath = join(TEST_DIR, "worktree-slash-branch-wt"); + await createWorktree( + repoPath, + "feature/new-workspace", + worktreePath, + "feat/workstreams-view", + ); + + expect(existsSync(worktreePath)).toBe(true); + const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { + cwd: worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("feature/new-workspace"); + }, 10_000); }); describe("getCurrentBranch", () => { @@ -504,6 +589,129 @@ describe("getCurrentBranch", () => { }); }); +describe("parsePorcelainStatusV2", () => { + test("parses branch headers for unborn branches with upstream tracking", () => { + const status = parsePorcelainStatusV2( + [ + "# branch.oid (initial)", + "# branch.head feature/gone-fix", + "# branch.upstream origin/feature/gone-fix", + "# branch.ab +0 -0", + "? path with spaces.txt", + ].join("\0"), + ); + + expect(status.current).toBe("feature/gone-fix"); + expect(status.tracking).toBe("origin/feature/gone-fix"); + expect(status.ahead).toBe(0); + expect(status.behind).toBe(0); + expect(status.not_added).toEqual(["path with spaces.txt"]); + expect(status.files).toEqual([ + { + path: "path with spaces.txt", + from: "path with spaces.txt", + index: "?", + working_dir: "?", + }, + ]); + }); + + test("parses rename and modified entries from porcelain v2 output", () => { + const status = parsePorcelainStatusV2( + [ + "# branch.oid abcdef1234567890", + "# branch.head feature/rename", + "# branch.upstream origin/feature/rename", + "# branch.ab +2 -3", + "2 R. N... 100644 100644 100644 43dd47ea691c90a5fa7827892c70241913351963 43dd47ea691c90a5fa7827892c70241913351963 R100 new.txt", + "old.txt", + "1 .M N... 100644 100644 100644 43dd47ea691c90a5fa7827892c70241913351963 43dd47ea691c90a5fa7827892c70241913351963 edited.txt", + ].join("\0"), + ); + + expect(status.current).toBe("feature/rename"); + expect(status.tracking).toBe("origin/feature/rename"); + expect(status.ahead).toBe(2); + expect(status.behind).toBe(3); + expect(status.renamed).toEqual([{ from: "old.txt", to: "new.txt" }]); + expect(status.files).toEqual([ + { + path: "new.txt", + from: "old.txt", + index: "R", + working_dir: " ", + }, + { + path: "edited.txt", + from: "edited.txt", + index: " ", + working_dir: "M", + }, + ]); + expect(status.staged).toEqual(["new.txt"]); + expect(status.modified).toEqual(["edited.txt"]); + }); + + test("parses unmerged conflict entries from porcelain v2 output", () => { + const status = parsePorcelainStatusV2( + [ + "# branch.oid abcdef1234567890", + "# branch.head main", + "u UU N... 100644 100644 100644 100644 43dd47ea691c90a5fa7827892c70241913351963 43dd47ea691c90a5fa7827892c70241913351963 43dd47ea691c90a5fa7827892c70241913351963 conflict.txt", + ].join("\0"), + ); + + expect(status.current).toBe("main"); + expect(status.conflicted).toEqual(["conflict.txt"]); + expect(status.files).toEqual([ + { + path: "conflict.txt", + from: "conflict.txt", + index: "U", + working_dir: "U", + }, + ]); + }); + + test("marks all porcelain v2 unmerged records as conflicted", () => { + const status = parsePorcelainStatusV2( + [ + "# branch.oid abcdef1234567890", + "# branch.head main", + "u AA N... 100644 100644 100644 100644 43dd47ea691c90a5fa7827892c70241913351963 43dd47ea691c90a5fa7827892c70241913351963 43dd47ea691c90a5fa7827892c70241913351963 both-added.txt", + ].join("\0"), + ); + + expect(status.conflicted).toEqual(["both-added.txt"]); + expect(status.files).toEqual([ + { + path: "both-added.txt", + from: "both-added.txt", + index: "A", + working_dir: "A", + }, + ]); + }); +}); + +describe("isUnbornHeadError", () => { + test("matches the standard unborn HEAD rev-parse failure", () => { + expect( + isUnbornHeadError( + new Error( + "fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.", + ), + ), + ).toBe(true); + }); + + test("does not hide unrelated git failures", () => { + expect(isUnbornHeadError(new Error("fatal: not a git repository"))).toBe( + false, + ); + }); +}); + describe("branchExistsOnRemote", () => { test("checks the requested remote instead of always origin", async () => { const repoPath = createTestRepo("branch-exists-on-remote"); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index 0df864005b5..dc1d834820d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -4,12 +4,12 @@ import { mkdir, rename } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import type { BranchPrefixMode } from "@superset/local-db"; -import friendlyWords from "friendly-words"; import { sanitizeAuthorPrefix, sanitizeBranchName, sanitizeBranchNameWithMaxLength, -} from "shared/utils/branch"; +} from "@superset/shared/workspace-launch"; +import friendlyWords from "friendly-words"; import type { StatusResult } from "simple-git"; import { runWithPostCheckoutHookTolerance } from "../../utils/git-hook-tolerance"; import { execGitWithShellPath, getSimpleGitWithShellPath } from "./git-client"; @@ -25,6 +25,14 @@ export class NotGitRepoError extends Error { } } +const UNBORN_HEAD_ERROR_PATTERNS = [ + "ambiguous argument 'head'", + "unknown revision or path not in the working tree", + "bad revision 'head'", + "not a valid object name head", + "needed a single revision", +]; + /** * Error thrown by execFile when the command fails. * `code` can be a number (exit code) or string (spawn error like "ENOENT"). @@ -45,6 +53,21 @@ function isExecFileException(error: unknown): error is ExecFileException { ); } +export function isUnbornHeadError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const stderr = + isExecFileException(error) && typeof error.stderr === "string" + ? error.stderr + : ""; + const message = `${error.message}\n${stderr}`.toLowerCase(); + return UNBORN_HEAD_ERROR_PATTERNS.some((pattern) => + message.includes(pattern), + ); +} + async function isWorktreeRegistered({ mainRepoPath, worktreePath, @@ -135,25 +158,25 @@ export async function getStatusNoLock(repoPath: string): Promise<StatusResult> { try { // Run git status with --no-optional-locks to avoid holding locks - // Use porcelain=v1 for machine-parseable output, -b for branch info + // Use porcelain=v2 for stable machine-parseable output with branch headers // Use -z for NUL-terminated output (handles filenames with special chars) // Use -uall to show individual files in untracked directories (not just the directory) - // Note: porcelain=v1 already includes rename info (R/C status codes) without needing -M + // Note: porcelain=v2 includes structured rename/copy records without needing -M const { stdout } = await execGitWithShellPath( [ "--no-optional-locks", "-C", repoPath, "status", - "--porcelain=v1", - "-b", + "--porcelain=v2", + "--branch", "-z", "-uall", ], { env, timeout: 30_000, maxBuffer: 10 * 1024 * 1024 }, ); - return parsePortelainStatus(stdout); + return parsePorcelainStatusV2(stdout); } catch (error) { // Provide more descriptive error messages if (isExecFileException(error)) { @@ -172,17 +195,19 @@ export async function getStatusNoLock(repoPath: string): Promise<StatusResult> { } /** - * Parses git status --porcelain=v1 -z output into a StatusResult-compatible object. + * Parses git status --porcelain=v2 --branch -z output into a StatusResult-compatible object. * The -z format uses NUL characters to separate entries, which safely handles * filenames containing spaces, newlines, or other special characters. */ -function parsePortelainStatus(stdout: string): StatusResult { +export function parsePorcelainStatusV2(stdout: string): StatusResult { // Split by NUL character - the -z format separates entries with NUL const entries = stdout.split("\0").filter(Boolean); let current: string | null = null; let tracking: string | null = null; let isDetached = false; + let ahead = 0; + let behind = 0; // Parse file status entries const files: StatusResult["files"] = []; @@ -195,6 +220,49 @@ function parsePortelainStatus(stdout: string): StatusResult { const conflictedSet = new Set<string>(); const notAddedSet = new Set<string>(); + const normalizeStatusCode = (code: string): string => + code === "." ? " " : code; + const addFile = ({ + path, + indexStatus, + workingStatus, + from, + }: { + path: string; + indexStatus: string; + workingStatus: string; + from?: string; + }) => { + files.push({ + path, + from: from ?? path, + index: indexStatus, + working_dir: workingStatus, + }); + + if (indexStatus === "?" && workingStatus === "?") { + notAddedSet.add(path); + return; + } + + // Index status (staged changes) + if (indexStatus === "A") createdSet.add(path); + else if (indexStatus === "M") { + stagedSet.add(path); + modifiedSet.add(path); + } else if (indexStatus === "D") { + stagedSet.add(path); + deletedSet.add(path); + } else if (indexStatus === "R" || indexStatus === "C") stagedSet.add(path); + else if (indexStatus === "U") conflictedSet.add(path); + else if (indexStatus !== " " && indexStatus !== "?") stagedSet.add(path); + + // Working tree status (unstaged changes) + if (workingStatus === "M") modifiedSet.add(path); + else if (workingStatus === "D") deletedSet.add(path); + else if (workingStatus === "U") conflictedSet.add(path); + }; + let i = 0; while (i < entries.length) { const entry = entries[i]; @@ -203,84 +271,100 @@ function parsePortelainStatus(stdout: string): StatusResult { continue; } - // Parse branch line: ## branch...tracking or ## branch - if (entry.startsWith("## ")) { - const branchInfo = entry.slice(3); - - // Check for detached HEAD states - if (branchInfo.startsWith("HEAD (no branch)") || branchInfo === "HEAD") { - isDetached = true; - current = "HEAD"; - } else if ( - // Handle empty repo: "No commits yet on BRANCH" or "Initial commit on BRANCH" - branchInfo.startsWith("No commits yet on ") || - branchInfo.startsWith("Initial commit on ") - ) { - // Extract branch name from the end - const parts = branchInfo.split(" "); - current = parts[parts.length - 1] || null; - } else { - // Check for tracking info: "branch...origin/branch [ahead 1, behind 2]" - const trackingMatch = branchInfo.match(/^(.+?)\.\.\.(.+?)(?:\s|$)/); - if (trackingMatch) { - current = trackingMatch[1]; - tracking = trackingMatch[2].split(" ")[0] || null; + if (entry.startsWith("# ")) { + const header = entry.slice(2); + if (header.startsWith("branch.head ")) { + const branchHead = header.slice("branch.head ".length); + if (branchHead === "(detached)") { + isDetached = true; + current = "HEAD"; } else { - // No tracking branch, just get branch name (before any space) - current = branchInfo.split(" ")[0] || null; + current = branchHead || null; + } + } else if (header.startsWith("branch.upstream ")) { + tracking = header.slice("branch.upstream ".length) || null; + } else if (header.startsWith("branch.ab ")) { + const match = header.match(/^branch\.ab \+(\d+) -(\d+)$/); + if (match) { + ahead = Number.parseInt(match[1] || "0", 10); + behind = Number.parseInt(match[2] || "0", 10); } } i++; continue; } - // Parse file status: "XY path" where X=index, Y=working tree - if (entry.length < 3) { + if (entry.startsWith("? ")) { + const path = entry.slice(2); + addFile({ + path, + indexStatus: "?", + workingStatus: "?", + }); i++; continue; } - const indexStatus = entry[0]; - const workingStatus = entry[1]; - // entry[2] is a space separator - const path = entry.slice(3); - let from: string | undefined; + // Ignored entries should not affect clean status. + if (entry.startsWith("! ")) { + i++; + continue; + } - // For renames/copies, the next entry is the original path - if (indexStatus === "R" || indexStatus === "C") { + if (entry.startsWith("1 ")) { + const match = entry.match(/^1 (\S{2}) \S+ \S+ \S+ \S+ \S+ \S+ (.+)$/); + if (match) { + const xy = match[1] || ".."; + const path = match[2]; + if (path) { + addFile({ + path, + indexStatus: normalizeStatusCode(xy[0] || "."), + workingStatus: normalizeStatusCode(xy[1] || "."), + }); + } + } i++; - from = entries[i]; - renamed.push({ from: from || path, to: path }); + continue; } - files.push({ - path, - from: from ?? path, - index: indexStatus, - working_dir: workingStatus, - }); + if (entry.startsWith("2 ")) { + const match = entry.match(/^2 (\S{2}) \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.+)$/); + const from = entries[i + 1]; + if (match) { + const xy = match[1] || ".."; + const path = match[2]; + if (path) { + const originalPath = from || path; + renamed.push({ from: originalPath, to: path }); + addFile({ + path, + from: originalPath, + indexStatus: normalizeStatusCode(xy[0] || "."), + workingStatus: normalizeStatusCode(xy[1] || "."), + }); + } + } + i += 2; + continue; + } - // Populate convenience arrays for checkBranchCheckoutSafety compatibility - if (indexStatus === "?" && workingStatus === "?") { - notAddedSet.add(path); - } else { - // Index status (staged changes) - if (indexStatus === "A") createdSet.add(path); - else if (indexStatus === "M") { - stagedSet.add(path); - modifiedSet.add(path); - } else if (indexStatus === "D") { - stagedSet.add(path); - deletedSet.add(path); - } else if (indexStatus === "R" || indexStatus === "C") - stagedSet.add(path); - else if (indexStatus === "U") conflictedSet.add(path); - else if (indexStatus !== " " && indexStatus !== "?") stagedSet.add(path); - - // Working tree status (unstaged changes) - if (workingStatus === "M") modifiedSet.add(path); - else if (workingStatus === "D") deletedSet.add(path); - else if (workingStatus === "U") conflictedSet.add(path); + if (entry.startsWith("u ")) { + const match = entry.match( + /^u (\S{2}) \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ (.+)$/, + ); + if (match) { + const xy = match[1] || ".."; + const path = match[2]; + if (path) { + conflictedSet.add(path); + addFile({ + path, + indexStatus: normalizeStatusCode(xy[0] || "."), + workingStatus: normalizeStatusCode(xy[1] || "."), + }); + } + } } i++; @@ -296,8 +380,8 @@ function parsePortelainStatus(stdout: string): StatusResult { renamed, files, staged: [...stagedSet], - ahead: 0, - behind: 0, + ahead, + behind, current, tracking, detached: isDetached, @@ -471,13 +555,13 @@ export async function createWorktree( mainRepoPath, "worktree", "add", - worktreePath, + // --no-track prevents the new branch from tracking the remote ref + // (e.g. origin/main); push.autoSetupRemote handles first-push tracking. + "--no-track", "-b", branch, - // Append ^{commit} to force Git to treat the startPoint as a commit, - // not a branch ref. This prevents implicit upstream tracking when - // creating a new branch from a remote branch like origin/main. - `${startPoint}^{commit}`, + worktreePath, + startPoint, ], worktreePath, }); @@ -1693,18 +1777,46 @@ export async function createWorktreeFromPr({ }); } - await execWithShellEnv( - "gh", - [ - "pr", - "checkout", - String(prInfo.number), - "--branch", - localBranchName, - "--force", - ], - { cwd: worktreePath, timeout: 120_000 }, - ); + try { + await execWithShellEnv( + "gh", + [ + "pr", + "checkout", + String(prInfo.number), + "--branch", + localBranchName, + "--force", + ], + { cwd: worktreePath, timeout: 120_000 }, + ); + } catch (ghError) { + const ghMsg = + ghError instanceof Error ? ghError.message : String(ghError); + // `gh pr checkout` can fail with "is not a branch" when the branch name + // contains '/' (e.g. "user/feature-branch"). Git has trouble resolving + // "origin/user/feature-branch" as a tracking ref inside a worktree. + // gh already fetched the remote successfully, so FETCH_HEAD points to + // the right commit — fall back to creating the branch without tracking. + if (!ghMsg.includes("is not a branch")) { + throw ghError; + } + console.log( + `[git] gh pr checkout failed with tracking error for PR #${prInfo.number}, falling back to FETCH_HEAD checkout`, + ); + await execGitWithShellPath( + [ + "-C", + worktreePath, + "checkout", + "-B", + localBranchName, + "--no-track", + "FETCH_HEAD", + ], + { timeout: 30_000 }, + ); + } // Enable autoSetupRemote so `git push` just works without -u flag. await execGitWithShellPath( diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts index adda1913d44..f1712477e25 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/cache.ts @@ -133,7 +133,7 @@ export function readCachedRepoContext( } export function clearGitHubCachesForWorktree(worktreePath: string): void { - githubStatusResource.invalidate(worktreePath); + githubStatusResource.invalidatePrefix(worktreePath); repoContextResource.invalidate(worktreePath); pullRequestCommentsResource.invalidatePrefix( makePullRequestCommentsCachePrefix(worktreePath), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts index 56c8a333454..c4b7a7538b4 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/comments.ts @@ -113,9 +113,11 @@ function getReviewThreadCommentId( function parseReviewThreadCommentNode({ comment, isResolved, + threadId, }: { comment: ReviewThreadCommentNode; isResolved: boolean; + threadId?: string; }): PullRequestComment | null { const id = getReviewThreadCommentId(comment); const body = comment.body?.trim(); @@ -136,6 +138,7 @@ function parseReviewThreadCommentNode({ path: comment.path, line: comment.line ?? comment.originalLine ?? undefined, isResolved, + ...(threadId ? { threadId } : {}), }; } @@ -164,9 +167,11 @@ export function parsePaginatedApiArray(stdout: string): unknown[] { export function parseReviewThreadCommentsConnection({ comments, isResolved, + threadId, }: { comments: unknown; isResolved: boolean; + threadId?: string; }): PullRequestComment[] { const parsed = GHReviewThreadCommentsConnectionSchema.safeParse(comments); if (!parsed.success) { @@ -182,6 +187,7 @@ export function parseReviewThreadCommentsConnection({ const parsedComment = parseReviewThreadCommentNode({ comment, isResolved, + threadId, }); return parsedComment ? [parsedComment] : []; }) ?? [] @@ -201,6 +207,7 @@ export function parseReviewThreadCommentsResponse( return parseReviewThreadCommentsConnection({ comments: result.data.comments, isResolved: result.data.isResolved === true, + threadId: result.data.id, }); }), ); @@ -254,6 +261,56 @@ export function mergePullRequestComments( return sortPullRequestComments([...commentsById.values()]); } +const RESOLVE_REVIEW_THREAD_MUTATION = ` +mutation ResolveReviewThread($threadId: ID!) { + resolveReviewThread(input: {threadId: $threadId}) { + thread { + id + isResolved + } + } +} +`; + +const UNRESOLVE_REVIEW_THREAD_MUTATION = ` +mutation UnresolveReviewThread($threadId: ID!) { + unresolveReviewThread(input: {threadId: $threadId}) { + thread { + id + isResolved + } + } +} +`; + +export async function resolveReviewThread({ + worktreePath, + threadId, + resolve, +}: { + worktreePath: string; + threadId: string; + resolve: boolean; +}): Promise<void> { + const mutation = resolve + ? RESOLVE_REVIEW_THREAD_MUTATION + : UNRESOLVE_REVIEW_THREAD_MUTATION; + + const { stdout } = await execWithShellEnv( + "gh", + ["api", "graphql", "-f", `query=${mutation}`, "-F", `threadId=${threadId}`], + { cwd: worktreePath }, + ); + + const json = JSON.parse(stdout.trim()); + if (Array.isArray(json.errors) && json.errors.length > 0) { + const msg = json.errors + .map((e: { message?: string }) => e.message) + .join("; "); + throw new Error(msg || "GraphQL mutation failed"); + } +} + async function fetchPaginatedCommentsEndpoint( worktreePath: string, endpoint: string, @@ -360,6 +417,7 @@ async function fetchAdditionalReviewThreadCommentsForThread({ ...parseReviewThreadCommentsConnection({ comments, isResolved, + threadId, }), ); afterCursor = @@ -453,6 +511,7 @@ async function fetchReviewThreadCommentsForPullRequest( ...parseReviewThreadCommentsConnection({ comments: thread.comments, isResolved, + threadId: thread.id, }), ); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts index 56ffbe24169..a8794a67daa 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.test.ts @@ -10,6 +10,7 @@ import { branchMatchesPR, getPRHeadBranchCandidates, prMatchesLocalBranch, + shouldAcceptPRMatch, } from "./pr-resolution"; import { getPullRequestRepoArgs, @@ -407,6 +408,53 @@ describe("prMatchesLocalBranch", () => { }); }); +describe("shouldAcceptPRMatch", () => { + test("keeps open PR matches even when local HEAD differs", () => { + expect( + shouldAcceptPRMatch({ + localBranch: "feature/my-thing", + headSha: "local-head-sha", + pr: { + headRefName: "feature/my-thing", + headRefOid: "remote-head-sha", + headRepositoryOwner: null, + state: "OPEN", + }, + }), + ).toBe(true); + }); + + test("rejects historical PR matches when the head commit differs", () => { + expect( + shouldAcceptPRMatch({ + localBranch: "feature/my-thing", + headSha: "new-head-sha", + pr: { + headRefName: "feature/my-thing", + headRefOid: "old-pr-head-sha", + headRepositoryOwner: null, + state: "MERGED", + }, + }), + ).toBe(false); + }); + + test("accepts historical PR matches when the head commit still matches", () => { + expect( + shouldAcceptPRMatch({ + localBranch: "feature/my-thing", + headSha: "same-head-sha", + pr: { + headRefName: "feature/my-thing", + headRefOid: "same-head-sha", + headRepositoryOwner: null, + state: "MERGED", + }, + }), + ).toBe(true); + }); +}); + describe("resolveRemoteBranchNameForGitHubStatus", () => { test("prefers the tracked upstream branch name", () => { expect( diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index 3c8c640ff14..e4291c632a2 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -1,5 +1,9 @@ import type { GitHubStatus, PullRequestComment } from "@superset/local-db"; -import { branchExistsOnRemote } from "../git"; +import { + branchExistsOnRemote, + getCurrentBranch, + isUnbornHeadError, +} from "../git"; import { execGitWithShellPath } from "../git-client"; import { execWithShellEnv } from "../shell-env"; import { parseUpstreamRef } from "../upstream-ref"; @@ -10,7 +14,7 @@ import { readCachedGitHubStatus, readCachedPullRequestComments, } from "./cache"; -import { fetchPullRequestComments } from "./comments"; +import { fetchPullRequestComments, resolveReviewThread } from "./comments"; import { getPRForBranch } from "./pr-resolution"; import { extractNwoFromUrl, getRepoContext } from "./repo-context"; import { @@ -24,7 +28,7 @@ export interface PullRequestCommentsTarget { repoContext: Pick<RepoContext, "repoUrl" | "upstreamUrl" | "isFork">; } -export { clearGitHubCachesForWorktree }; +export { clearGitHubCachesForWorktree, resolveReviewThread }; function getPullRequestCommentsRepoNameWithOwner( target: PullRequestCommentsTarget, @@ -38,22 +42,36 @@ function getPullRequestCommentsRepoNameWithOwner( async function resolvePullRequestCommentsTarget( worktreePath: string, + branchOverride?: string | null, ): Promise<PullRequestCommentsTarget | null> { const repoContext = await getRepoContext(worktreePath); if (!repoContext) { return null; } - const [branchResult, shaResult] = await Promise.all([ - execGitWithShellPath(["rev-parse", "--abbrev-ref", "HEAD"], { - cwd: worktreePath, - }), - execGitWithShellPath(["rev-parse", "HEAD"], { - cwd: worktreePath, - }), - ]); - const branchName = branchResult.stdout.trim(); - const headSha = shaResult.stdout.trim(); + const branchName = + branchOverride?.trim() || (await getCurrentBranch(worktreePath)); + if (!branchName) { + return null; + } + + const revParseTarget = branchOverride ? `refs/heads/${branchName}` : "HEAD"; + const shaResult = await execGitWithShellPath(["rev-parse", revParseTarget], { + cwd: worktreePath, + }).catch((error) => { + if (isUnbornHeadError(error)) { + return { stdout: "", stderr: "" }; + } + if (branchOverride) { + return { stdout: "", stderr: "" }; + } + throw error; + }); + const headSha = shaResult.stdout.trim() || undefined; + + if (branchOverride && !headSha) { + return null; + } const prInfo = await getPRForBranch( worktreePath, branchName, @@ -84,6 +102,7 @@ export function resolveRemoteBranchNameForGitHubStatus({ async function refreshGitHubPRStatus( worktreePath: string, + branchOverride?: string | null, ): Promise<GitHubStatus | null> { try { const repoContext = await getRepoContext(worktreePath); @@ -91,17 +110,41 @@ async function refreshGitHubPRStatus( return null; } - const [branchResult, shaResult, upstreamResult] = await Promise.all([ - execGitWithShellPath(["rev-parse", "--abbrev-ref", "HEAD"], { + const branchName = + branchOverride?.trim() || (await getCurrentBranch(worktreePath)); + if (!branchName) { + return null; + } + + const revParseTarget = branchOverride ? `refs/heads/${branchName}` : "HEAD"; + const upstreamTarget = branchOverride + ? `${branchName}@{upstream}` + : "@{upstream}"; + + const [shaResult, upstreamResult] = await Promise.all([ + execGitWithShellPath(["rev-parse", revParseTarget], { cwd: worktreePath, + }).catch((error) => { + if (isUnbornHeadError(error)) { + return { stdout: "", stderr: "" }; + } + if (branchOverride) { + return { stdout: "", stderr: "" }; + } + throw error; }), - execGitWithShellPath(["rev-parse", "HEAD"], { cwd: worktreePath }), - execGitWithShellPath(["rev-parse", "--abbrev-ref", "@{upstream}"], { + execGitWithShellPath(["rev-parse", "--abbrev-ref", upstreamTarget], { cwd: worktreePath, }).catch(() => ({ stdout: "", stderr: "" })), ]); - const branchName = branchResult.stdout.trim(); - const headSha = shaResult.stdout.trim(); + const headSha = shaResult.stdout.trim() || undefined; + + // When using a branch override, we must have a valid SHA to avoid + // getPRForBranch falling back to HEAD (which is a different branch). + if (branchOverride && !headSha) { + return null; + } + const parsedUpstreamRef = parseUpstreamRef(upstreamResult.stdout.trim()); const trackingRemote = parsedUpstreamRef?.remoteName ?? "origin"; const previewBranchName = resolveRemoteBranchNameForGitHubStatus({ @@ -179,27 +222,36 @@ async function refreshGitHubPRComments({ } /** - * Fetches GitHub PR status for a worktree using the `gh` CLI. + * Fetches GitHub PR status for a worktree or branch workspace using the `gh` CLI. * Returns null if `gh` is not installed, not authenticated, or on error. + * + * @param branchName - Optional branch name override. When provided (for branch + * workspaces), resolves the SHA and upstream for that branch instead of using + * HEAD / the checked-out branch. Also used to scope the cache key. */ export async function fetchGitHubPRStatus( worktreePath: string, + branchName?: string | null, ): Promise<GitHubStatus | null> { - return readCachedGitHubStatus(worktreePath, () => - refreshGitHubPRStatus(worktreePath), + const cacheKey = branchName ? `${worktreePath}::${branchName}` : worktreePath; + return readCachedGitHubStatus(cacheKey, () => + refreshGitHubPRStatus(worktreePath, branchName), ); } export async function fetchGitHubPRComments({ worktreePath, pullRequest, + branchName, }: { worktreePath: string; pullRequest?: PullRequestCommentsTarget | null; + branchName?: string | null; }): Promise<PullRequestComment[]> { try { const pullRequestTarget = - pullRequest ?? (await resolvePullRequestCommentsTarget(worktreePath)); + pullRequest ?? + (await resolvePullRequestCommentsTarget(worktreePath, branchName)); if (!pullRequestTarget) { return []; } @@ -324,7 +376,7 @@ async function queryDeploymentUrl( */ async function fetchPreviewDeploymentUrl( worktreePath: string, - headSha: string, + headSha: string | undefined, branchName: string, repoContext: RepoContext, ): Promise<string | undefined> { @@ -337,10 +389,16 @@ async function fetchPreviewDeploymentUrl( return undefined; } - // Try by commit SHA (works for Vercel, Netlify official integrations) - const bySha = await queryDeploymentUrl(worktreePath, nwo, `sha=${headSha}`); - if (bySha) { - return bySha; + if (headSha) { + // Try by commit SHA (works for Vercel, Netlify official integrations) + const bySha = await queryDeploymentUrl( + worktreePath, + nwo, + `sha=${headSha}`, + ); + if (bySha) { + return bySha; + } } // Fall back to branch name (works for some CI configurations) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts index 2253ee3f295..37e7dca32a3 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/index.ts @@ -3,6 +3,7 @@ export { clearGitHubCachesForWorktree, fetchGitHubPRComments, fetchGitHubPRStatus, + resolveReviewThread, } from "./github"; export { getPRForBranch } from "./pr-resolution"; export { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts index 9366bd3c689..60808ff763f 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/pr-resolution.ts @@ -1,3 +1,8 @@ +// v1-only. Dies with the v1 UI sunset. Don't evolve this module — v2 already +// resolves PRs via host-service (`packages/host-service/src/runtime/pull-requests` +// backing `git.getPullRequest` + `pullRequests.getByWorkspaces`). Everything +// under `renderer/screens/main/` + `routes/_authenticated/_dashboard/workspace/` +// gets deleted together; no port needed. import type { CheckItem, GitHubStatus } from "@superset/local-db"; import { execGitWithShellPath } from "../git-client"; import { execWithShellEnv } from "../shell-env"; @@ -17,7 +22,11 @@ export async function getPRForBranch( repoContext?: RepoContext, headSha?: string, ): Promise<GitHubStatus["pr"]> { - const byTracking = await getPRByBranchTracking(worktreePath, localBranch); + const byTracking = await getPRByBranchTracking( + worktreePath, + localBranch, + headSha, + ); if (byTracking) { return byTracking; } @@ -76,7 +85,10 @@ function getForkOwnerPrefix( export function prMatchesLocalBranch( localBranch: string, - pr: Pick<GHPRResponse, "headRefName" | "headRepositoryOwner">, + pr: Pick< + GHPRResponse, + "headRefName" | "headRepositoryOwner" | "isCrossRepository" + >, ): boolean { if (!branchMatchesPR(localBranch, pr.headRefName)) { return false; @@ -84,12 +96,45 @@ export function prMatchesLocalBranch( const ownerPrefix = getForkOwnerPrefix(localBranch, pr.headRefName); if (!ownerPrefix) { + // Without a fork-owner prefix in the local branch, a cross-fork PR whose + // headRefName collides (e.g. fork:main → base:main) would misattribute. + if (pr.isCrossRepository) return false; return localBranch === pr.headRefName; } return pr.headRepositoryOwner?.login?.toLowerCase() === ownerPrefix; } +function isHistoricalPullRequestState(state: GHPRResponse["state"]): boolean { + return state === "CLOSED" || state === "MERGED"; +} + +export function shouldAcceptPRMatch({ + localBranch, + pr, + headSha, +}: { + localBranch: string; + pr: Pick< + GHPRResponse, + "headRefName" | "headRefOid" | "headRepositoryOwner" | "state" + >; + headSha?: string; +}): boolean { + if (!prMatchesLocalBranch(localBranch, pr)) { + return false; + } + + // Historical PRs should only attach when this workspace still points at the + // exact PR head commit. Otherwise, reusing a branch name can surface an old, + // unrelated closed or merged PR. + if (headSha && isHistoricalPullRequestState(pr.state)) { + return pr.headRefOid === headSha; + } + + return true; +} + function sortPRCandidates( candidates: GHPRResponse[], headSha?: string, @@ -133,6 +178,7 @@ function sortPRCandidates( async function getPRByBranchTracking( worktreePath: string, localBranch: string, + headSha?: string, ): Promise<GitHubStatus["pr"]> { try { const { stdout } = await execWithShellEnv( @@ -150,7 +196,7 @@ async function getPRByBranchTracking( // `gh pr view` can match via stale tracking refs (e.g. refs/pull/N/head) // left over from a previous `gh pr checkout`, causing a new workspace // to incorrectly show an old, unrelated PR. - if (!prMatchesLocalBranch(localBranch, data)) { + if (!shouldAcceptPRMatch({ localBranch, pr: data, headSha })) { return null; } @@ -199,7 +245,7 @@ async function findPRByHeadBranch( ); for (const candidate of parsePRListResponse(stdout)) { - if (prMatchesLocalBranch(localBranch, candidate)) { + if (shouldAcceptPRMatch({ localBranch, pr: candidate, headSha })) { matches.set(candidate.number, candidate); } } diff --git a/apps/desktop/src/main/env.main.ts b/apps/desktop/src/main/env.main.ts index 70ef6587564..13fe2e1af5a 100644 --- a/apps/desktop/src/main/env.main.ts +++ b/apps/desktop/src/main/env.main.ts @@ -20,10 +20,12 @@ export const env = createEnv({ .url() .default("https://electric-proxy.avi-6ac.workers.dev"), NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), + NEXT_PUBLIC_MARKETING_URL: z.url().default("https://superset.sh"), NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), SENTRY_DSN_DESKTOP: z.string().optional(), STREAMS_URL: z.url().default("https://superset-stream.fly.dev"), + RELAY_URL: z.url().default("https://relay.superset.sh"), }, runtimeEnv: { @@ -35,10 +37,12 @@ export const env = createEnv({ NEXT_PUBLIC_STREAMS_URL: process.env.NEXT_PUBLIC_STREAMS_URL, NEXT_PUBLIC_ELECTRIC_URL: process.env.NEXT_PUBLIC_ELECTRIC_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, + NEXT_PUBLIC_MARKETING_URL: process.env.NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, SENTRY_DSN_DESKTOP: process.env.SENTRY_DSN_DESKTOP, STREAMS_URL: process.env.STREAMS_URL, + RELAY_URL: process.env.RELAY_URL, }, emptyStringAsUndefined: true, // Only allow skipping validation in development (never in production) diff --git a/apps/desktop/src/main/host-service/env.ts b/apps/desktop/src/main/host-service/env.ts new file mode 100644 index 00000000000..dfe77059d67 --- /dev/null +++ b/apps/desktop/src/main/host-service/env.ts @@ -0,0 +1,18 @@ +import { createEnv } from "@t3-oss/env-core"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + AUTH_TOKEN: z.string().min(1), + CLOUD_API_URL: z.string().url(), + HOST_DB_PATH: z.string().min(1), + HOST_MIGRATIONS_FOLDER: z.string().min(1), + HOST_SERVICE_SECRET: z.string().min(1), + HOST_SERVICE_PORT: z.coerce.number().int().positive(), + ORGANIZATION_ID: z.string().min(1), + DESKTOP_VITE_PORT: z.coerce.number().int().positive(), + RELAY_URL: z.string().url().optional(), + }, + runtimeEnv: process.env, + emptyStringAsUndefined: true, +}); diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 6d624fc4c84..b4b8983b206 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -1,62 +1,102 @@ /** * Workspace Service — Desktop Entry Point * - * Run with: ELECTRON_RUN_AS_NODE=1 electron dist/main/host-service.js - * - * Starts the host-service HTTP server on a random local port. - * The parent Electron process reads the port from the IPC channel. + * Starts the host-service HTTP server on a port assigned by the coordinator. + * The coordinator polls health.check to know when it's ready. */ import { serve } from "@hono/node-server"; import { createApp, - JwtAuthProvider, + installProcessSafetyNet, + JwtApiAuthProvider, LocalGitCredentialProvider, + LocalModelProvider, + PskHostAuthProvider, } from "@superset/host-service"; +import { + initTerminalBaseEnv, + resolveTerminalBaseEnv, +} from "@superset/host-service/terminal-env"; +import { connectRelay } from "@superset/host-service/tunnel"; +import { writeManifest } from "main/lib/host-service-manifest"; +import { env } from "./env"; -const authToken = process.env.AUTH_TOKEN; -const cloudApiUrl = process.env.CLOUD_API_URL; -const dbPath = process.env.HOST_DB_PATH; -const deviceClientId = process.env.DEVICE_CLIENT_ID; -const deviceName = process.env.DEVICE_NAME; - -const auth = - authToken && cloudApiUrl ? new JwtAuthProvider(authToken) : undefined; - -const { app, injectWebSocket } = createApp({ - credentials: new LocalGitCredentialProvider(), - auth, - cloudApiUrl, - dbPath, - deviceClientId, - deviceName, -}); +async function main(): Promise<void> { + const terminalBaseEnv = await resolveTerminalBaseEnv(); + initTerminalBaseEnv(terminalBaseEnv); + + const authProvider = new JwtApiAuthProvider( + env.AUTH_TOKEN, + env.CLOUD_API_URL, + ); + + const { app, injectWebSocket, api } = createApp({ + config: { + organizationId: env.ORGANIZATION_ID, + dbPath: env.HOST_DB_PATH, + cloudApiUrl: env.CLOUD_API_URL, + migrationsFolder: env.HOST_MIGRATIONS_FOLDER, + allowedOrigins: [ + `http://localhost:${env.DESKTOP_VITE_PORT}`, + `http://127.0.0.1:${env.DESKTOP_VITE_PORT}`, + ], + }, + providers: { + auth: authProvider, + hostAuth: new PskHostAuthProvider(env.HOST_SERVICE_SECRET), + credentials: new LocalGitCredentialProvider(), + modelResolver: new LocalModelProvider(), + }, + }); + + const startedAt = Date.now(); + const server = serve( + { fetch: app.fetch, port: env.HOST_SERVICE_PORT, hostname: "127.0.0.1" }, + (info: { port: number }) => { + // Install only after the server is listening so startup throws still + // reach `main().catch(...)` and exit with a non-zero code. + installProcessSafetyNet(); -const server = serve( - { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, - (info: { port: number }) => { - process.send?.({ type: "ready", port: info.port }); - }, -); -injectWebSocket(server); - -const shutdown = () => { - server.close(); - process.exit(0); -}; - -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); - -// Orphan cleanup: exit if parent Electron process dies -const parentPid = process.ppid; -const parentCheck = setInterval(() => { - try { - process.kill(parentPid, 0); - } catch { - clearInterval(parentCheck); - console.log("[host-service] Parent process exited, shutting down"); - shutdown(); - } -}, 2000); -parentCheck.unref(); + if (env.ORGANIZATION_ID) { + try { + writeManifest({ + pid: process.pid, + endpoint: `http://127.0.0.1:${info.port}`, + authToken: env.HOST_SERVICE_SECRET, + startedAt, + organizationId: env.ORGANIZATION_ID, + }); + } catch (error) { + console.error("[host-service] Failed to write manifest:", error); + } + } + + if (env.RELAY_URL && env.ORGANIZATION_ID) { + void connectRelay({ + api, + relayUrl: env.RELAY_URL, + localPort: info.port, + organizationId: env.ORGANIZATION_ID, + authProvider, + hostServiceSecret: env.HOST_SERVICE_SECRET, + }); + } + }, + ); + injectWebSocket(server); + + // Manifest lifecycle belongs to the coordinator, not the child. + const shutdown = () => { + server.close(); + process.exit(0); + }; + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} + +void main().catch((error) => { + console.error("[host-service] Failed to start:", error); + process.exit(1); +}); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8e41a9236c7..d300f08b7bf 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -13,9 +13,11 @@ import { import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { handleAuthCallback, + loadToken, parseAuthDeepLink, } from "lib/trpc/routers/auth/utils/auth-functions"; import { applyShellEnvToProcess } from "lib/trpc/routers/workspaces/utils/shell-env"; +import { env as mainEnv } from "main/env.main"; import { DEFAULT_CONFIRM_ON_QUIT, PLATFORM, @@ -28,8 +30,13 @@ import { setupAutoUpdater } from "./lib/auto-updater"; import { resolveDevWorkspaceName } from "./lib/dev-workspace-name"; import { setWorkspaceDockIcon } from "./lib/dock-icon"; import { loadWebviewBrowserExtension } from "./lib/extensions"; -import { getHostServiceManager } from "./lib/host-service-manager"; +import { getHostServiceCoordinator } from "./lib/host-service-coordinator"; import { localDb } from "./lib/local-db"; +import { requestLocalNetworkAccess } from "./lib/local-network-permission"; +import { + initTanstackDbPersistence, + shutdownTanstackDbPersistence, +} from "./lib/persistence/persistence"; import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { initSentry } from "./lib/sentry"; import { @@ -94,7 +101,7 @@ function findDeepLinkInArgv(argv: string[]): string | undefined { return argv.find((arg) => arg.startsWith(`${PROTOCOL_SCHEME}://`)); } -function focusMainWindow(): void { +export function focusMainWindow(): void { const windows = BrowserWindow.getAllWindows(); if (windows.length > 0) { const mainWindow = windows[0]; @@ -103,6 +110,9 @@ function focusMainWindow(): void { } mainWindow.show(); mainWindow.focus(); + } else { + // Triggers window creation via makeAppSetup's activate handler + app.emit("activate"); } } @@ -148,7 +158,21 @@ app.on("open-url", async (event, url) => { }); let isQuitting = false; -let skipConfirmation = false; +let skipQuitConfirmation = false; + +export function setSkipQuitConfirmation(): void { + skipQuitConfirmation = true; +} + +export function quitApp(): void { + setSkipQuitConfirmation(); + app.quit(); +} + +/** Bypasses before-quit — services are left running for re-adoption on next launch. */ +export function exitImmediately(): void { + app.exit(0); +} function getConfirmOnQuitSetting(): boolean { try { @@ -159,23 +183,11 @@ function getConfirmOnQuitSetting(): boolean { } } -export function setSkipQuitConfirmation(): void { - skipConfirmation = true; -} - -export function quitWithoutConfirmation(): void { - skipConfirmation = true; - app.exit(0); -} - app.on("before-quit", async (event) => { if (isQuitting) return; const isDev = process.env.NODE_ENV === "development"; - const shouldConfirm = - !skipConfirmation && !isDev && getConfirmOnQuitSetting(); - - if (shouldConfirm) { + if (!skipQuitConfirmation && !isDev && getConfirmOnQuitSetting()) { event.preventDefault(); try { @@ -188,17 +200,22 @@ app.on("before-quit", async (event) => { message: "Are you sure you want to quit?", }); - if (response === 1) return; + if (response === 1) { + return; + } } catch (error) { console.error("[main] Quit confirmation dialog failed:", error); } } - // Quit confirmed or no confirmation needed - exit immediately - // Let OS clean up child processes, tray, etc. isQuitting = true; - getHostServiceManager().stopAll(); - disposeTray(); + try { + getHostServiceCoordinator().releaseAll(); + shutdownTanstackDbPersistence(); + disposeTray(); + } catch (error) { + console.error("[main] Cleanup during quit failed:", error); + } app.exit(0); }); @@ -282,6 +299,7 @@ if (!gotTheLock) { await app.whenReady(); registerWithMacOSNotificationCenter(); requestAppleEventsAccess(); + requestLocalNetworkAccess(); // Must register on both default session and the app's custom partition const iconProtocolHandler = (request: Request) => { @@ -317,7 +335,7 @@ if (!gotTheLock) { try { return await net.fetch(pathToFileURL(fontPath).toString()); } catch { - // Font not in this directory, try next + // Not in this directory } } return new Response("Not found", { status: 404 }); @@ -332,6 +350,7 @@ if (!gotTheLock) { setWorkspaceDockIcon(); initSentry(); await initAppState(); + initTanstackDbPersistence(); await loadWebviewBrowserExtension(); @@ -345,11 +364,22 @@ if (!gotTheLock) { console.error("[main] Failed to set up agent hooks:", error); } + // Discover and adopt host-services that survived a previous quit + // before the tray initializes, so it shows accurate status immediately. + await getHostServiceCoordinator().discoverAll(); + + if (IS_DEV) { + getHostServiceCoordinator().enableDevReload(async () => { + const { token } = await loadToken(); + if (!token) return null; + return { authToken: token, cloudApiUrl: mainEnv.NEXT_PUBLIC_API_URL }; + }); + } + await makeAppSetup(() => MainWindow()); setupAutoUpdater(); initTray(); - // Process any deep links from cold start const coldStartUrl = findDeepLinkInArgv(process.argv); if (coldStartUrl) { await processDeepLink(coldStartUrl); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts new file mode 100644 index 00000000000..0ab3df0902f --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-amp.ts @@ -0,0 +1,11 @@ +import { buildWrapperScript, createWrapper } from "./agent-wrappers-common"; + +/** + * Creates the Amp wrapper that preserves Superset's terminal environment. + * Amp does not currently expose stable hook support, so this wrapper is a + * pass-through binary shim only. + */ +export function createAmpWrapper(): void { + const script = buildWrapperScript("amp", `exec "$REAL_BIN" "$@"`); + createWrapper("amp", script); +} diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts index 779bbf32bac..db889900cd5 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-claude-codex-opencode.ts @@ -117,16 +117,16 @@ function readExistingClaudeSettings( } } -function removeManagedClaudeHooksFromDefinition( +function removeManagedHooksFromDefinition( definition: ClaudeHookDefinition, - notifyScriptPath: string, + isManagedCommand: (command: string | undefined) => boolean, ): ClaudeHookDefinition | null { if (!Array.isArray(definition.hooks)) { return definition; } const filteredHooks = definition.hooks.filter( - (hook) => !isManagedClaudeHookCommand(hook.command, notifyScriptPath), + (hook) => !isManagedCommand(hook.command), ); if (filteredHooks.length === definition.hooks.length) { @@ -218,9 +218,8 @@ export function getClaudeGlobalSettingsJsonContent( const current = existing.hooks[eventName]; if (Array.isArray(current)) { const filtered = current.flatMap((def: ClaudeHookDefinition) => { - const cleaned = removeManagedClaudeHooksFromDefinition( - def, - notifyScriptPath, + const cleaned = removeManagedHooksFromDefinition(def, (command) => + isManagedClaudeHookCommand(command, notifyScriptPath), ); return cleaned ? [cleaned] : []; }); @@ -295,6 +294,16 @@ export function buildCodexWrapperExecLine(notifyPath: string): string { return template.replaceAll("{{NOTIFY_PATH}}", notifyPath); } +function isManagedCodexHookCommand( + command: string | undefined, + notifyScriptPath: string, +): boolean { + return ( + command?.includes(notifyScriptPath) || + isSupersetManagedHookCommand(command, NOTIFY_SCRIPT_NAME) + ); +} + // --------------------------------------------------------------------------- // Codex ~/.codex/hooks.json direct merge // --------------------------------------------------------------------------- @@ -339,14 +348,11 @@ export function getCodexGlobalHooksJsonPath(): string { * Codex hooks.json uses the same nested structure as Claude/Droid: * { hooks: { EventName: [{ matcher?, hooks: [{ type, command }] }] } } * - * Superset intentionally keeps this native Codex hook registration narrow. - * The primary integration path is still the wrapper + notify/session-log - * watcher, which works inside Superset-managed terminal sessions and covers - * richer lifecycle events like per-turn Start and PermissionRequest. - * - * This hooks.json merge is only a fallback for cases where the wrapper is - * bypassed, so we only register the minimal SessionStart + Stop notifications - * here rather than trying to mirror Codex's full native hook surface. + * Superset uses native Codex hooks as the durable lifecycle integration path. + * Recent Codex builds no longer emit the older session-log shapes our wrapper + * watcher depended on, so we register prompt/tool lifecycle hooks directly in + * ~/.codex/hooks.json and treat the wrapper session-log watcher as best-effort + * compatibility for older releases. */ export function getCodexGlobalHooksJsonContent( notifyScriptPath: string, @@ -359,8 +365,27 @@ export function getCodexGlobalHooksJsonContent( existing.hooks = {}; } + // Remove all stale Superset-managed Codex hook commands, including events we + // no longer manage natively (for example UserPromptSubmit from older builds). + for (const [eventName, current] of Object.entries(existing.hooks)) { + if (!Array.isArray(current)) continue; + const filtered = current.flatMap((def: ClaudeHookDefinition) => { + const cleaned = removeManagedHooksFromDefinition(def, (command) => + isManagedCodexHookCommand(command, notifyScriptPath), + ); + return cleaned ? [cleaned] : []; + }); + + if (filtered.length === 0) { + delete existing.hooks[eventName]; + continue; + } + + existing.hooks[eventName] = filtered; + } + const managedEvents: Array<{ - eventName: "SessionStart" | "Stop"; + eventName: "SessionStart" | "UserPromptSubmit" | "Stop"; definition: ClaudeHookDefinition; }> = [ { @@ -369,6 +394,12 @@ export function getCodexGlobalHooksJsonContent( hooks: [{ type: "command", command: notifyScriptPath }], }, }, + { + eventName: "UserPromptSubmit", + definition: { + hooks: [{ type: "command", command: notifyScriptPath }], + }, + }, { eventName: "Stop", definition: { @@ -380,15 +411,8 @@ export function getCodexGlobalHooksJsonContent( for (const { eventName, definition } of managedEvents) { const current = existing.hooks[eventName]; if (Array.isArray(current)) { - const filtered = current.flatMap((def: ClaudeHookDefinition) => { - const cleaned = removeManagedClaudeHooksFromDefinition( - def, - notifyScriptPath, - ); - return cleaned ? [cleaned] : []; - }); - filtered.push(definition); - existing.hooks[eventName] = filtered; + current.push(definition); + existing.hooks[eventName] = current; } else { existing.hooks[eventName] = [definition]; } @@ -403,10 +427,10 @@ export function getCodexGlobalHooksJsonContent( * binary wrapper is not in PATH (e.g. user runs codex from outside * a Superset terminal). * - * The wrapper remains the primary integration path for Superset-managed - * terminals because it can synthesize richer lifecycle events from Codex's - * notify callback and session log (task_started, approval_request, - * exec_command_begin) without mutating project-local CODEX_HOME state. + * The wrapper still injects Codex's native notify callback and keeps the + * session-log watcher as a best-effort bridge for older releases, but the + * native hooks.json registration is now the primary source for prompt/tool + * lifecycle events. */ export function createCodexHooksJson(): void { const notifyScriptPath = getNotifyScriptPath(); diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts index 26e5720a8fc..deff26ad578 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers-common.ts @@ -1,19 +1,16 @@ import fs from "node:fs"; import path from "node:path"; +import { SUPERSET_MANAGED_BINARIES } from "./desktop-agent-capabilities"; import { BIN_DIR } from "./paths"; export const WRAPPER_MARKER = "# Superset agent-wrapper v1"; -export const SUPERSET_MANAGED_BINARIES = [ - "claude", - "codex", - "droid", - "opencode", - "gemini", - "copilot", - "mastracode", -] as const; - -const SUPERSET_MANAGED_HOOK_PATH_PATTERN = /\/\.superset(?:-[^/'"\s\\]+)?\//; +export { SUPERSET_MANAGED_BINARIES }; + +// Dev setup (.superset/lib/setup/steps.sh) points SUPERSET_HOME_DIR at +// $PWD/superset-dev-data — without a leading dot — so we must recognize that +// variant to reap stale notify.sh paths from deleted worktrees. +const SUPERSET_MANAGED_HOOK_PATH_PATTERN = + /\/(?:\.superset(?:-[^/'"\s\\]+)?|superset-dev-data)\//; export function writeFileIfChanged( filePath: string, diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts index c1e714a131b..7ccbafbfa59 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts @@ -56,6 +56,7 @@ mock.module("node:os", () => ({ })); const { + createAmpWrapper, buildCodexWrapperExecLine, buildCopilotWrapperExecLine, buildWrapperScript, @@ -197,7 +198,7 @@ describe("agent-wrappers copilot", () => { expect(wrapper).toContain('_superset_emit_event "Start"'); expect(wrapper).toContain('_superset_emit_event "PermissionRequest"'); expect(wrapper).toContain( - `"$REAL_BIN" -c 'notify=["bash","${path.join(TEST_HOOKS_DIR, "notify.sh")}"]' "$@"`, + `"$REAL_BIN" --enable codex_hooks -c 'notify=["bash","${path.join(TEST_HOOKS_DIR, "notify.sh")}"]' "$@"`, ); expect(wrapper).toContain("SUPERSET_CODEX_START_WATCHER_PID"); expect(wrapper).toContain('kill "$SUPERSET_CODEX_START_WATCHER_PID"'); @@ -209,6 +210,46 @@ describe("agent-wrappers copilot", () => { expect(wrapper).toContain(execLine); }); + it("forwards codex_hooks enablement through the codex wrapper for manual launches", () => { + const realBinDir = path.join(TEST_ROOT, "real-bin"); + const realCodex = path.join(realBinDir, "codex"); + const wrapperPath = path.join(TEST_BIN_DIR, "codex"); + const argsFile = path.join(TEST_ROOT, "codex-args.txt"); + + mkdirSync(realBinDir, { recursive: true }); + writeFileSync( + realCodex, + `#!/bin/bash +printf '%s\n' "$@" > "${argsFile}" +exit 0 +`, + { mode: 0o755 }, + ); + chmodSync(realCodex, 0o755); + + createCodexWrapper(); + + execFileSync(wrapperPath, ["exec", "Reply with exactly OK."], { + env: { + ...process.env, + PATH: `${TEST_BIN_DIR}:${realBinDir}:${process.env.PATH || ""}`, + SUPERSET_TAB_ID: "tab-1", + }, + encoding: "utf-8", + }); + + expect(readFileSync(argsFile, "utf-8")).toBe( + `${[ + "--enable", + "codex_hooks", + "-c", + `notify=["bash","${path.join(TEST_HOOKS_DIR, "notify.sh")}"]`, + "exec", + "Reply with exactly OK.", + ].join("\n")}\n`, + ); + }); + it("creates mastracode wrapper passthrough", () => { createMastraWrapper(); @@ -220,6 +261,17 @@ describe("agent-wrappers copilot", () => { expect(wrapper).toContain('exec "$REAL_BIN" "$@"'); }); + it("creates amp wrapper passthrough", () => { + createAmpWrapper(); + + const wrapperPath = path.join(TEST_BIN_DIR, "amp"); + const wrapper = readFileSync(wrapperPath, "utf-8"); + + expect(wrapper).toContain("# Superset wrapper for amp"); + expect(wrapper).toContain('REAL_BIN="$(find_real_binary "amp")"'); + expect(wrapper).toContain('exec "$REAL_BIN" "$@"'); + }); + it("creates droid wrapper passthrough", () => { createDroidWrapper(); @@ -836,7 +888,7 @@ describe("agent-wrappers codex hooks.json", () => { rmSync(TEST_ROOT, { recursive: true, force: true }); }); - it("creates Codex hooks.json with SessionStart and Stop when no file exists", () => { + it("creates Codex hooks.json with prompt and lifecycle hooks when no file exists", () => { const notifyPath = "/tmp/.superset/hooks/notify.sh"; const content = getCodexGlobalHooksJsonContent(notifyPath); expect(content).not.toBeNull(); @@ -852,7 +904,11 @@ describe("agent-wrappers codex hooks.json", () => { >; }; - for (const eventName of ["SessionStart", "Stop"] as const) { + for (const eventName of [ + "SessionStart", + "UserPromptSubmit", + "Stop", + ] as const) { const hooks = parsed.hooks[eventName]; expect(Array.isArray(hooks)).toBe(true); expect( @@ -861,6 +917,9 @@ describe("agent-wrappers codex hooks.json", () => { ), ).toBe(true); } + + expect(parsed.hooks.PreToolUse).toBeUndefined(); + expect(parsed.hooks.PostToolUse).toBeUndefined(); }); it("preserves user hooks when merging", () => { @@ -871,6 +930,38 @@ describe("agent-wrappers codex hooks.json", () => { JSON.stringify( { hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: "command", + command: "/opt/my-custom-prompt-hook.sh", + }, + ], + }, + ], + PreToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: "/opt/my-custom-pre-tool-hook.sh", + }, + ], + }, + ], + PostToolUse: [ + { + matcher: "*", + hooks: [ + { + type: "command", + command: "/opt/my-custom-post-tool-hook.sh", + }, + ], + }, + ], Stop: [ { hooks: [{ type: "command", command: "/opt/my-custom-hook.sh" }], @@ -890,7 +981,7 @@ describe("agent-wrappers codex hooks.json", () => { const parsed = JSON.parse(content); - // Preserves user hook + // Preserves user hooks (including PreToolUse/PostToolUse which we don't manage) expect( parsed.hooks.Stop.some((def: { hooks: Array<{ command: string }> }) => def.hooks.some( @@ -899,44 +990,63 @@ describe("agent-wrappers codex hooks.json", () => { ), ), ).toBe(true); - - // Adds managed hook expect( - parsed.hooks.Stop.some((def: { hooks: Array<{ command: string }> }) => - def.hooks.some( - (hook: { command: string }) => hook.command === notifyPath, - ), + parsed.hooks.UserPromptSubmit.some( + (def: { hooks: Array<{ command: string }> }) => + def.hooks.some( + (hook: { command: string }) => + hook.command === "/opt/my-custom-prompt-hook.sh", + ), ), ).toBe(true); - - // Also creates SessionStart expect( - parsed.hooks.SessionStart.some( + parsed.hooks.PreToolUse.some( (def: { hooks: Array<{ command: string }> }) => def.hooks.some( - (hook: { command: string }) => hook.command === notifyPath, + (hook: { command: string }) => + hook.command === "/opt/my-custom-pre-tool-hook.sh", + ), + ), + ).toBe(true); + expect( + parsed.hooks.PostToolUse.some( + (def: { hooks: Array<{ command: string }> }) => + def.hooks.some( + (hook: { command: string }) => + hook.command === "/opt/my-custom-post-tool-hook.sh", ), ), ).toBe(true); - }); - - it("does not add UserPromptSubmit to the Codex fallback hooks.json merge", () => { - const notifyPath = "/tmp/.superset/hooks/notify.sh"; - const content = getCodexGlobalHooksJsonContent(notifyPath); - expect(content).not.toBeNull(); - if (content === null) throw new Error("Expected content"); - const parsed = JSON.parse(content) as { - hooks: Record< - string, - Array<{ - matcher?: string; - hooks: Array<{ type: string; command: string }>; - }> - >; - }; + // Adds managed hooks for SessionStart, UserPromptSubmit, Stop + for (const eventName of ["SessionStart", "UserPromptSubmit", "Stop"]) { + expect( + parsed.hooks[eventName].some( + (def: { hooks: Array<{ command: string }> }) => + def.hooks.some( + (hook: { command: string }) => hook.command === notifyPath, + ), + ), + ).toBe(true); + } - expect(parsed.hooks.UserPromptSubmit).toBeUndefined(); + // Does NOT inject managed hooks for PreToolUse/PostToolUse + expect( + parsed.hooks.PreToolUse.some( + (def: { hooks: Array<{ command: string }> }) => + def.hooks.some( + (hook: { command: string }) => hook.command === notifyPath, + ), + ), + ).toBe(false); + expect( + parsed.hooks.PostToolUse.some( + (def: { hooks: Array<{ command: string }> }) => + def.hooks.some( + (hook: { command: string }) => hook.command === notifyPath, + ), + ), + ).toBe(false); }); it("replaces stale Codex hook commands from old superset paths", () => { @@ -988,7 +1098,11 @@ describe("agent-wrappers codex hooks.json", () => { >; }; - for (const eventName of ["SessionStart", "Stop"] as const) { + for (const eventName of [ + "SessionStart", + "UserPromptSubmit", + "Stop", + ] as const) { const hooks = parsed.hooks[eventName]; expect(Array.isArray(hooks)).toBe(true); expect( @@ -1015,6 +1129,133 @@ describe("agent-wrappers codex hooks.json", () => { expect(JSON.parse(content2 as string)).toEqual(JSON.parse(content)); }); + it("removes stale Superset-managed UserPromptSubmit hooks without touching user hooks", () => { + const codexHooksPath = path.join(mockedHomeDir, ".codex", "hooks.json"); + const staleHookPath = + "/Users/test/.superset/worktrees/repo/superset-dev-data/hooks/notify.sh"; + const currentHookPath = "/tmp/.superset-new/hooks/notify.sh"; + + mkdirSync(path.dirname(codexHooksPath), { recursive: true }); + writeFileSync( + codexHooksPath, + JSON.stringify( + { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { type: "command", command: staleHookPath }, + { + type: "command", + command: "/opt/my-custom-prompt-hook.sh", + }, + ], + }, + ], + }, + }, + null, + 2, + ), + ); + + const content = getCodexGlobalHooksJsonContent(currentHookPath); + expect(content).not.toBeNull(); + if (content === null) throw new Error("Expected content"); + + const parsed = JSON.parse(content) as { + hooks: Record< + string, + Array<{ + matcher?: string; + hooks: Array<{ type: string; command: string }>; + }> + >; + }; + + expect(parsed.hooks.UserPromptSubmit).toBeDefined(); + expect( + parsed.hooks.UserPromptSubmit?.some((def) => + def.hooks.some( + (hook) => hook.command === "/opt/my-custom-prompt-hook.sh", + ), + ), + ).toBe(true); + expect( + parsed.hooks.UserPromptSubmit?.some((def) => + def.hooks.some((hook) => hook.command.includes(staleHookPath)), + ), + ).toBe(false); + expect( + parsed.hooks.UserPromptSubmit?.some((def) => + def.hooks.some((hook) => hook.command === currentHookPath), + ), + ).toBe(true); + }); + + it("reaps stale notify.sh paths from in-repo dev worktrees", () => { + const codexHooksPath = path.join(mockedHomeDir, ".codex", "hooks.json"); + // Real-world layout: a dev worktree lives under <repo>/.worktrees/<name> + // and its dev setup writes SUPERSET_HOME_DIR=<worktree>/superset-dev-data. + // There is no /.superset/ segment anywhere in the path. + const staleHookPath = + "/Users/test/code/superset/.worktrees/old-branch/superset-dev-data/hooks/notify.sh"; + const currentHookPath = "/tmp/.superset-new/hooks/notify.sh"; + + mkdirSync(path.dirname(codexHooksPath), { recursive: true }); + writeFileSync( + codexHooksPath, + JSON.stringify( + { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: staleHookPath }] }, + ], + UserPromptSubmit: [ + { hooks: [{ type: "command", command: staleHookPath }] }, + ], + Stop: [{ hooks: [{ type: "command", command: staleHookPath }] }], + }, + }, + null, + 2, + ), + ); + + const content = getCodexGlobalHooksJsonContent(currentHookPath); + expect(content).not.toBeNull(); + if (content === null) throw new Error("Expected content"); + + const parsed = JSON.parse(content) as { + hooks: Record< + string, + Array<{ + matcher?: string; + hooks: Array<{ type: string; command: string }>; + }> + >; + }; + + for (const eventName of [ + "SessionStart", + "UserPromptSubmit", + "Stop", + ] as const) { + const hooks = parsed.hooks[eventName]; + expect(Array.isArray(hooks)).toBe(true); + expect( + hooks.some((def) => + def.hooks.some((hook) => hook.command === currentHookPath), + ), + ).toBe(true); + expect( + hooks.some((def) => + def.hooks.some((hook) => hook.command === staleHookPath), + ), + ).toBe(false); + } + }); + it("skips Codex hooks writes when existing JSON is invalid", () => { const codexHooksPath = path.join(mockedHomeDir, ".codex", "hooks.json"); const invalidJson = "{not-json"; diff --git a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts index aa66274d808..4162078113c 100644 --- a/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/agent-wrappers.ts @@ -1,3 +1,4 @@ +export { createAmpWrapper } from "./agent-wrappers-amp"; export { buildCodexWrapperExecLine, cleanupGlobalOpenCodePlugin, diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts new file mode 100644 index 00000000000..2f0c60a93de --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts @@ -0,0 +1,100 @@ +import type { AgentType } from "@superset/shared/agent-command"; + +export type SupersetManagedBinary = AgentType | "droid"; + +export const DESKTOP_AGENT_SETUP_ACTIONS = [ + "notify-script", + "cleanup-global-opencode-plugin", + "amp-wrapper", + "claude-settings-json", + "claude-wrapper", + "codex-hooks-json", + "codex-wrapper", + "droid-wrapper", + "droid-settings-json", + "opencode-plugin", + "opencode-wrapper", + "cursor-hook-script", + "cursor-agent-wrapper", + "cursor-hooks-json", + "gemini-hook-script", + "gemini-wrapper", + "gemini-settings-json", + "mastra-wrapper", + "mastra-hooks-json", + "copilot-hook-script", + "copilot-wrapper", +] as const; + +export type DesktopAgentSetupAction = + (typeof DESKTOP_AGENT_SETUP_ACTIONS)[number]; + +interface DesktopAgentSetupTarget { + id: AgentType | "droid"; + setupActions: readonly DesktopAgentSetupAction[]; + managedBinary?: boolean; +} + +export const DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS = [ + "cleanup-global-opencode-plugin", + "notify-script", +] as const satisfies readonly DesktopAgentSetupAction[]; + +export const DESKTOP_AGENT_SETUP_TARGETS = [ + { + id: "amp", + setupActions: ["amp-wrapper"], + managedBinary: true, + }, + { + id: "claude", + setupActions: ["claude-settings-json", "claude-wrapper"], + managedBinary: true, + }, + { + id: "codex", + setupActions: ["codex-hooks-json", "codex-wrapper"], + managedBinary: true, + }, + { + id: "droid", + setupActions: ["droid-wrapper", "droid-settings-json"], + managedBinary: true, + }, + { + id: "opencode", + setupActions: ["opencode-plugin", "opencode-wrapper"], + managedBinary: true, + }, + { + id: "cursor-agent", + setupActions: [ + "cursor-hook-script", + "cursor-agent-wrapper", + "cursor-hooks-json", + ], + }, + { + id: "gemini", + setupActions: [ + "gemini-hook-script", + "gemini-wrapper", + "gemini-settings-json", + ], + managedBinary: true, + }, + { + id: "mastracode", + setupActions: ["mastra-wrapper", "mastra-hooks-json"], + managedBinary: true, + }, + { + id: "copilot", + setupActions: ["copilot-hook-script", "copilot-wrapper"], + managedBinary: true, + }, +] as const satisfies readonly DesktopAgentSetupTarget[]; + +export const SUPERSET_MANAGED_BINARIES = DESKTOP_AGENT_SETUP_TARGETS.filter( + (target) => "managedBinary" in target && target.managedBinary, +).map((target) => target.id) satisfies SupersetManagedBinary[]; diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts new file mode 100644 index 00000000000..e25e4baa66e --- /dev/null +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts @@ -0,0 +1,65 @@ +import { + cleanupGlobalOpenCodePlugin, + createAmpWrapper, + createClaudeSettingsJson, + createClaudeWrapper, + createCodexHooksJson, + createCodexWrapper, + createCopilotHookScript, + createCopilotWrapper, + createCursorAgentWrapper, + createCursorHookScript, + createCursorHooksJson, + createDroidSettingsJson, + createDroidWrapper, + createGeminiHookScript, + createGeminiSettingsJson, + createGeminiWrapper, + createMastraHooksJson, + createMastraWrapper, + createOpenCodePlugin, + createOpenCodeWrapper, +} from "./agent-wrappers"; +import { + DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS, + DESKTOP_AGENT_SETUP_TARGETS, + type DesktopAgentSetupAction, +} from "./desktop-agent-capabilities"; +import { createNotifyScript } from "./notify-hook"; + +const DESKTOP_AGENT_SETUP_RUNNERS: Record<DesktopAgentSetupAction, () => void> = + { + "notify-script": createNotifyScript, + "cleanup-global-opencode-plugin": cleanupGlobalOpenCodePlugin, + "amp-wrapper": createAmpWrapper, + "claude-settings-json": createClaudeSettingsJson, + "claude-wrapper": createClaudeWrapper, + "codex-hooks-json": createCodexHooksJson, + "codex-wrapper": createCodexWrapper, + "droid-wrapper": createDroidWrapper, + "droid-settings-json": createDroidSettingsJson, + "opencode-plugin": createOpenCodePlugin, + "opencode-wrapper": createOpenCodeWrapper, + "cursor-hook-script": createCursorHookScript, + "cursor-agent-wrapper": createCursorAgentWrapper, + "cursor-hooks-json": createCursorHooksJson, + "gemini-hook-script": createGeminiHookScript, + "gemini-wrapper": createGeminiWrapper, + "gemini-settings-json": createGeminiSettingsJson, + "mastra-wrapper": createMastraWrapper, + "mastra-hooks-json": createMastraHooksJson, + "copilot-hook-script": createCopilotHookScript, + "copilot-wrapper": createCopilotWrapper, + }; + +export function setupDesktopAgentCapabilities(): void { + for (const action of DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS) { + DESKTOP_AGENT_SETUP_RUNNERS[action](); + } + + for (const target of DESKTOP_AGENT_SETUP_TARGETS) { + for (const action of target.setupActions) { + DESKTOP_AGENT_SETUP_RUNNERS[action](); + } + } +} diff --git a/apps/desktop/src/main/lib/agent-setup/index.ts b/apps/desktop/src/main/lib/agent-setup/index.ts index 8476c7bfcd3..3b8ddd33324 100644 --- a/apps/desktop/src/main/lib/agent-setup/index.ts +++ b/apps/desktop/src/main/lib/agent-setup/index.ts @@ -1,26 +1,5 @@ import fs from "node:fs"; -import { - cleanupGlobalOpenCodePlugin, - createClaudeSettingsJson, - createClaudeWrapper, - createCodexHooksJson, - createCodexWrapper, - createCopilotHookScript, - createCopilotWrapper, - createCursorAgentWrapper, - createCursorHookScript, - createCursorHooksJson, - createDroidSettingsJson, - createDroidWrapper, - createGeminiHookScript, - createGeminiSettingsJson, - createGeminiWrapper, - createMastraHooksJson, - createMastraWrapper, - createOpenCodePlugin, - createOpenCodeWrapper, -} from "./agent-wrappers"; -import { createNotifyScript } from "./notify-hook"; +import { setupDesktopAgentCapabilities } from "./desktop-agent-setup"; import { BASH_DIR, BIN_DIR, @@ -45,27 +24,7 @@ export function setupAgentHooks(): void { fs.mkdirSync(BASH_DIR, { recursive: true }); fs.mkdirSync(OPENCODE_PLUGIN_DIR, { recursive: true }); - cleanupGlobalOpenCodePlugin(); - - createNotifyScript(); - createClaudeSettingsJson(); - createClaudeWrapper(); - createCodexHooksJson(); - createCodexWrapper(); - createDroidWrapper(); - createDroidSettingsJson(); - createOpenCodePlugin(); - createOpenCodeWrapper(); - createCursorHookScript(); - createCursorAgentWrapper(); - createCursorHooksJson(); - createGeminiHookScript(); - createGeminiWrapper(); - createGeminiSettingsJson(); - createMastraWrapper(); - createMastraHooksJson(); - createCopilotHookScript(); - createCopilotWrapper(); + setupDesktopAgentCapabilities(); createZshWrapper(); createBashWrapper(); diff --git a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts index 29db9b10656..ed81ec70bbb 100644 --- a/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/notify-hook.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; describe("getNotifyScriptContent", () => { - it("prefers Mastra resourceId over internal session_id", () => { + it("keeps v1 fallback session ids out of the v2 host-service payload", () => { const script = readFileSync( path.join(import.meta.dir, "templates", "notify-hook.template.sh"), "utf-8", @@ -13,12 +13,47 @@ describe("getNotifyScriptContent", () => { expect(script).toContain( "SESSION_ID=" + "\u0024{RESOURCE_ID:-$HOOK_SESSION_ID}", ); + expect(script).toContain( + 'PAYLOAD="{\\"json\\":{\\"terminalId\\":\\"$(json_escape "$SUPERSET_TERMINAL_ID")\\",\\"eventType\\":\\"$(json_escape "$EVENT_TYPE")\\"}}"', + ); expect(script).toContain('--data-urlencode "resourceId=$RESOURCE_ID"'); expect(script).toContain( '--data-urlencode "hookSessionId=$HOOK_SESSION_ID"', ); expect(script).toContain( - "event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID", + "event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID", + ); + }); + + it("gives the v2 host-service hook enough time to avoid false fallback", () => { + const script = readFileSync( + path.join(import.meta.dir, "templates", "notify-hook.template.sh"), + "utf-8", + ); + + expect(script).toContain( + 'curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \\\n --connect-timeout 2 --max-time 5', + ); + }); + + it("keeps the legacy v1 fallback path when no host-service hook URL exists", () => { + const script = readFileSync( + path.join(import.meta.dir, "templates", "notify-hook.template.sh"), + "utf-8", + ); + + expect(script).toContain('if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then'); + expect(script).toContain( + '[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0', + ); + expect(script).toContain( + 'curl -sG "http://127.0.0.1:' + + "$" + + "{SUPERSET_PORT:-{{DEFAULT_PORT}}}" + + '/hook/complete"', ); + expect(script).toContain('--data-urlencode "paneId=$SUPERSET_PANE_ID"'); + expect(script).toContain('--data-urlencode "tabId=$SUPERSET_TAB_ID"'); + expect(script).toContain('--data-urlencode "sessionId=$SESSION_ID"'); }); }); diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts index 3baa8ab0d64..88cd78a3951 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.test.ts @@ -836,11 +836,13 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" it("uses --init-command to prepend BIN_DIR to PATH for fish", () => { const args = getShellArgs("/opt/homebrew/bin/fish", TEST_PATHS); - expect(args).toEqual([ - "-l", - "--init-command", - `set -l _superset_bin "${TEST_BIN_DIR}"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, - ]); + expect(args[0]).toBe("-l"); + expect(args[1]).toBe("--init-command"); + expect(args[2]).toContain(`set -l _superset_bin "${TEST_BIN_DIR}"`); + // Both markers are emitted so old v1 daemons (777 scanner) and new + // scanners (133;A) both detect readiness without a daemon restart. + expect(args[2]).toContain("\\033]777;superset-shell-ready\\007"); + expect(args[2]).toContain("\\033]133;A\\007"); }); it("escapes fish init-command BIN_DIR safely", () => { @@ -850,11 +852,26 @@ export SUPERSET_WORKSPACE_PATH="/wrong/path" BIN_DIR: fishPath, }); - expect(args).toEqual([ - "-l", - "--init-command", - `set -l _superset_bin "/tmp/with space/quote\\"buck\\$slash\\\\bin"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, - ]); + expect(args[0]).toBe("-l"); + expect(args[1]).toBe("--init-command"); + expect(args[2]).toContain( + 'set -l _superset_bin "/tmp/with space/quote\\"buck\\$slash\\\\bin"', + ); + expect(args[2]).toContain("777;superset-shell-ready"); + expect(args[2]).toContain("133;A"); + }); + + it("zsh/bash wrappers emit both legacy 777 and current 133;A markers", () => { + createZshWrapper(TEST_PATHS); + createBashWrapper(TEST_PATHS); + + const zlogin = readFileSync(path.join(TEST_ZSH_DIR, ".zlogin"), "utf-8"); + const rcfile = readFileSync(path.join(TEST_BASH_DIR, "rcfile"), "utf-8"); + + for (const wrapper of [zlogin, rcfile]) { + expect(wrapper).toContain("\\033]777;superset-shell-ready\\007"); + expect(wrapper).toContain("\\033]133;A\\007"); + } }); }); }); diff --git a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts index 30a73a4f631..3c8ae59f329 100644 --- a/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts +++ b/apps/desktop/src/main/lib/agent-setup/shell-wrappers.ts @@ -1,7 +1,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { SUPERSET_MANAGED_BINARIES } from "./agent-wrappers-common"; +import { + SUPERSET_MANAGED_BINARIES, + type SupersetManagedBinary, +} from "./desktop-agent-capabilities"; import { BASH_DIR, BIN_DIR, ZSH_DIR } from "./paths"; export interface ShellWrapperPaths { @@ -85,7 +88,7 @@ function buildManagedCommandPrelude(shellName: string, binDir: string): string { if (shellName === "fish") { const escapedBinDir = escapeFishDoubleQuoted(binDir); return SUPERSET_MANAGED_BINARIES.map( - (name) => + (name: SupersetManagedBinary) => `functions -q ${name}; and functions -e ${name} function ${name} set -l _superset_wrapper "${escapedBinDir}/${name}" @@ -99,7 +102,7 @@ end`, } return SUPERSET_MANAGED_BINARIES.map( - (name) => + (name: SupersetManagedBinary) => `unalias ${name} 2>/dev/null || true ${name}() { _superset_wrapper=${quoteShellLiteral(`${binDir}/${name}`)} @@ -215,15 +218,18 @@ ${SUPERSET_ENV_RESTORE} ${buildZshPrecmdHook(paths.BIN_DIR)} ${buildPathPrependFunction(paths.BIN_DIR)} rehash 2>/dev/null || true -# One-shot shell-ready marker for preset command timing. -# Uses precmd so it fires AFTER direnv and other hooks complete, -# right before the first prompt is displayed. -_superset_shell_ready() { - precmd_functions=(\${precmd_functions:#_superset_shell_ready}) - printf '\\033]777;superset-shell-ready\\007' +# Shell readiness markers. Emitting both keeps us compatible across daemon +# versions: the legacy v1 daemon scans for OSC 777, the current scanner (v1 +# post-refactor + v2 host-service) scans for OSC 133;A (FinalTerm standard). +# Wrappers are rewritten on every app launch, so main always ships the +# superset of markers; daemons that only get restarted on protocol bumps +# still match against their own scanner. +# Protocol ref: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +__superset_prompt_mark() { + printf "\\033]777;superset-shell-ready\\007\\033]133;A\\007" } # Keep our hook LAST so it fires after direnv and other precmd hooks complete. -precmd_functions=(\${precmd_functions[@]} _superset_shell_ready) +precmd_functions=(\${precmd_functions[@]} __superset_prompt_mark) export ZDOTDIR="$_superset_home" `; const wroteZlogin = writeFileIfChanged(zloginPath, zloginScript, 0o644); @@ -267,31 +273,20 @@ ${buildPathPrependFunction(paths.BIN_DIR)} hash -r 2>/dev/null || true # Minimal prompt (path/env shown in toolbar) - emerald to match app theme export PS1=$'\\[\\e[1;38;2;52;211;153m\\]❯\\[\\e[0m\\] ' -# One-shot shell-ready marker for preset command timing. -# Uses PROMPT_COMMAND so it fires AFTER direnv and other hooks complete. -# Supports both scalar and array PROMPT_COMMAND (Bash 5.1+). -_superset_shell_ready() { - printf '\\033]777;superset-shell-ready\\007' - if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then - local -a _new=() - for _cmd in "\${PROMPT_COMMAND[@]}"; do - [[ "$_cmd" != "_superset_shell_ready" ]] && _new+=("$_cmd") - done - PROMPT_COMMAND=("\${_new[@]}") - else - PROMPT_COMMAND="\${_superset_orig_prompt_cmd}" - unset _superset_orig_prompt_cmd - fi - unset -f _superset_shell_ready +# Shell readiness markers — see zsh wrapper for rationale on emitting both. +# Protocol ref: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +__superset_prompt_mark() { + printf "\\033]777;superset-shell-ready\\007\\033]133;A\\007" } +# Hook via PROMPT_COMMAND. Supports both scalar and array forms (Bash 5.1+). if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then - PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "_superset_shell_ready") + PROMPT_COMMAND=("\${PROMPT_COMMAND[@]}" "__superset_prompt_mark") else _superset_orig_prompt_cmd="\${PROMPT_COMMAND}" if [[ -n "\${_superset_orig_prompt_cmd}" ]]; then - PROMPT_COMMAND="\${_superset_orig_prompt_cmd};_superset_shell_ready" + PROMPT_COMMAND="\${_superset_orig_prompt_cmd};__superset_prompt_mark" else - PROMPT_COMMAND="_superset_shell_ready" + PROMPT_COMMAND="__superset_prompt_mark" fi fi `; @@ -325,11 +320,20 @@ export function getShellArgs( if (shellName === "fish") { // Use --init-command to prepend BIN_DIR to PATH after config is loaded. // Use fish list-aware checks to avoid duplicate PATH entries across nested shells. + // Emit both OSC 777 (legacy v1 daemon) and OSC 133;A (current scanner) + // on fish_prompt. See zsh wrapper for rationale. const escapedBinDir = escapeFishDoubleQuoted(paths.BIN_DIR); return [ "-l", "--init-command", - `set -l _superset_bin "${escapedBinDir}"; contains -- "$_superset_bin" $PATH; or set -gx PATH "$_superset_bin" $PATH; function _superset_shell_ready --on-event fish_prompt; printf '\\033]777;superset-shell-ready\\007'; functions -e _superset_shell_ready; end`, + [ + `set -l _superset_bin "${escapedBinDir}"`, + `contains -- "$_superset_bin" $PATH`, + `or set -gx PATH "$_superset_bin" $PATH`, + `function _superset_prompt_mark --on-event fish_prompt`, + `printf '\\033]777;superset-shell-ready\\007\\033]133;A\\007'`, + `end`, + ].join("; "), ]; } if (["zsh", "sh", "ksh"].includes(shellName)) { diff --git a/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh index 42124f3184a..8aa12395105 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/codex-wrapper-exec.template.sh @@ -72,7 +72,7 @@ if [ -n "$SUPERSET_TAB_ID" ] && [ -f "{{NOTIFY_PATH}}" ]; then SUPERSET_CODEX_START_WATCHER_PID=$! fi -"$REAL_BIN" -c 'notify=["bash","{{NOTIFY_PATH}}"]' "$@" +"$REAL_BIN" --enable codex_hooks -c 'notify=["bash","{{NOTIFY_PATH}}"]' "$@" SUPERSET_CODEX_STATUS=$? if [ -n "$SUPERSET_CODEX_START_WATCHER_PID" ]; then diff --git a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh index 3bcde2f3504..7259ad9c509 100644 --- a/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh +++ b/apps/desktop/src/main/lib/agent-setup/templates/notify-hook.template.sh @@ -19,18 +19,32 @@ if [ -z "$RESOURCE_ID" ]; then fi SESSION_ID=${RESOURCE_ID:-$HOOK_SESSION_ID} -# Skip if this isn't a Superset terminal hook and no Mastra session context exists -[ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0 +# v2 terminal hooks identify the runtime by terminalId. The v1 fallback still +# uses pane/tab/session fields, so keep its legacy guard when no host-service +# hook URL is available. +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then + [ -z "$SUPERSET_TERMINAL_ID" ] && exit 0 +else + [ -z "$SUPERSET_TAB_ID" ] && [ -z "$SESSION_ID" ] && exit 0 +fi # Extract event type - Claude uses "hook_event_name", Codex uses "type" # Use flexible pattern to handle optional whitespace: "key": "value" or "key":"value" EVENT_TYPE=$(echo "$INPUT" | grep -oE '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') if [ -z "$EVENT_TYPE" ]; then - # Check for Codex "type" field (e.g., "agent-turn-complete") + # Check for Codex "type" field when no native hook_event_name is present. CODEX_TYPE=$(echo "$INPUT" | grep -oE '"type"[[:space:]]*:[[:space:]]*"[^"]*"' | grep -oE '"[^"]*"$' | tr -d '"') - if [ "$CODEX_TYPE" = "agent-turn-complete" ]; then - EVENT_TYPE="Stop" - fi + case "$CODEX_TYPE" in + agent-turn-complete|task_complete) + EVENT_TYPE="Stop" + ;; + task_started) + EVENT_TYPE="Start" + ;; + exec_approval_request|apply_patch_approval_request|request_user_input) + EVENT_TYPE="PermissionRequest" + ;; + esac fi # NOTE: We intentionally do NOT default to "Stop" if EVENT_TYPE is empty. @@ -60,9 +74,40 @@ elif [ "$SUPERSET_ENV" = "development" ] || [ "$NODE_ENV" = "development" ]; the fi if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then - echo "[notify-hook] event=$EVENT_TYPE sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 + echo "[notify-hook] event=$EVENT_TYPE terminalId=$SUPERSET_TERMINAL_ID sessionId=$SESSION_ID hookSessionId=$HOOK_SESSION_ID resourceId=$RESOURCE_ID paneId=$SUPERSET_PANE_ID tabId=$SUPERSET_TAB_ID workspaceId=$SUPERSET_WORKSPACE_ID" >&2 +fi + +# Escape backslashes and double quotes for safe JSON embedding. +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +# v2: host-service tRPC endpoint. The renderer subscribes over the event +# bus and plays the ringtone. Preferred when the URL is provided by +# host-service's terminal env. Endpoint is unauthenticated — it only +# broadcasts chimes, no auth header needed. Always captures the status +# so we can fall back to v1 when host-service is unreachable or the +# mutation returns non-2xx (restarts, crashes, transient errors). +if [ -n "$SUPERSET_HOST_AGENT_HOOK_URL" ]; then + PAYLOAD="{\"json\":{\"terminalId\":\"$(json_escape "$SUPERSET_TERMINAL_ID")\",\"eventType\":\"$(json_escape "$EVENT_TYPE")\"}}" + + STATUS_CODE=$(curl -sX POST "$SUPERSET_HOST_AGENT_HOOK_URL" \ + --connect-timeout 2 --max-time 5 \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + -o /dev/null -w "%{http_code}" 2>/dev/null) + + if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then + echo "[notify-hook] host-service dispatched status=$STATUS_CODE" >&2 + fi + + case "$STATUS_CODE" in + 2*) exit 0 ;; + esac fi +# v1 fallback: electron localhost server. Used by v1 terminals and when +# host-service is unreachable from the agent's shell. # Timeouts prevent blocking agent completion if notification server is unresponsive if [ "$DEBUG_HOOKS_ENABLED" = "1" ]; then STATUS_CODE=$(curl -sG "http://127.0.0.1:${SUPERSET_PORT:-{{DEFAULT_PORT}}}/hook/complete" \ diff --git a/apps/desktop/src/main/lib/app-state/schemas.ts b/apps/desktop/src/main/lib/app-state/schemas.ts index e93767a761d..d381e08b2f9 100644 --- a/apps/desktop/src/main/lib/app-state/schemas.ts +++ b/apps/desktop/src/main/lib/app-state/schemas.ts @@ -1,7 +1,6 @@ /** * UI state schemas (persisted from renderer zustand stores) */ -import { createDefaultHotkeysState, type HotkeysState } from "shared/hotkeys"; import type { BaseTabsState } from "shared/tabs-types"; import type { Theme } from "shared/themes"; @@ -11,12 +10,20 @@ export type { BaseTabsState as TabsState, Pane } from "shared/tabs-types"; export interface ThemeState { activeThemeId: string; customThemes: Theme[]; + systemLightThemeId?: string; + systemDarkThemeId?: string; +} + +/** Legacy hotkeys state shape (kept for reading old app-state.json during migration) */ +interface LegacyHotkeysState { + version: number; + byPlatform: Record<string, Record<string, string | null>>; } export interface AppState { tabsState: BaseTabsState; themeState: ThemeState; - hotkeysState: HotkeysState; + hotkeysState: LegacyHotkeysState; } export const defaultAppState: AppState = { @@ -30,6 +37,11 @@ export const defaultAppState: AppState = { themeState: { activeThemeId: "dark", customThemes: [], + systemLightThemeId: "light", + systemDarkThemeId: "dark", + }, + hotkeysState: { + version: 1, + byPlatform: { darwin: {}, win32: {}, linux: {} }, }, - hotkeysState: createDefaultHotkeysState(), }; diff --git a/apps/desktop/src/main/lib/auto-updater.test.ts b/apps/desktop/src/main/lib/auto-updater.test.ts new file mode 100644 index 00000000000..6788b44e54c --- /dev/null +++ b/apps/desktop/src/main/lib/auto-updater.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { EventEmitter } from "node:events"; + +class FakeAutoUpdater extends EventEmitter { + autoDownload = false; + autoInstallOnAppQuit = false; + disableDifferentialDownload = false; + allowDowngrade = false; + setFeedURL = mock(() => {}); + checkForUpdates = mock(() => Promise.resolve(null)); + quitAndInstall = mock(() => {}); +} + +const fakeAutoUpdater = new FakeAutoUpdater(); + +mock.module("electron-updater", () => ({ + autoUpdater: fakeAutoUpdater, +})); + +mock.module("electron", () => ({ + app: { + getPath: mock(() => ""), + getName: mock(() => "test-app"), + getVersion: mock(() => "1.0.0"), + getAppPath: mock(() => ""), + isPackaged: false, + isReady: mock(() => true), + whenReady: mock(() => Promise.resolve()), + }, + dialog: { + showMessageBox: mock(() => Promise.resolve({ response: 0 })), + }, +})); + +mock.module("main/index", () => ({ + setSkipQuitConfirmation: mock(() => {}), +})); + +// auto-updater short-circuits setupAutoUpdater on non-mac/linux hosts, so +// pin the platform here to keep the tests portable across CI runners. +mock.module("shared/constants", () => ({ + PLATFORM: { IS_MAC: true, IS_WINDOWS: false, IS_LINUX: false }, +})); + +const autoUpdater = await import("./auto-updater"); +const { AUTO_UPDATE_STATUS } = await import("shared/auto-update"); + +describe("installUpdate", () => { + beforeEach(() => { + fakeAutoUpdater.removeAllListeners(); + fakeAutoUpdater.quitAndInstall.mockClear(); + fakeAutoUpdater.checkForUpdates.mockClear(); + fakeAutoUpdater.setFeedURL.mockClear(); + autoUpdater.setupAutoUpdater(); + // The module is a singleton; emit a network-shaped error so the + // handler resets isInstalling and maps status back to IDLE without + // tripping the real ERROR path (which would also clear the cache). + fakeAutoUpdater.emit("error", new Error("ECONNRESET reset")); + }); + + test("ignores install requests when no update is ready", () => { + expect(autoUpdater.getUpdateStatus().status).not.toBe( + AUTO_UPDATE_STATUS.READY, + ); + + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + test("collapses repeat install clicks into a single quitAndInstall call", () => { + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + expect(autoUpdater.getUpdateStatus().status).toBe(AUTO_UPDATE_STATUS.READY); + + autoUpdater.installUpdate(); + autoUpdater.installUpdate(); + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + }); + + test("clears the in-flight guard when Squirrel surfaces an error", () => { + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + autoUpdater.installUpdate(); + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + + fakeAutoUpdater.emit("error", new Error("squirrel failed")); + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 1d95ca60805..74535630215 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -7,6 +7,26 @@ import { prerelease } from "semver"; import { AUTO_UPDATE_STATUS, type AutoUpdateStatus } from "shared/auto-update"; import { PLATFORM } from "shared/constants"; +// electron-updater's internal cache only self-invalidates when the remote +// sha512 differs from cached metadata, so a corrupt cached download (e.g. +// failed Squirrel install) gets retried indefinitely until the user +// manually reinstalls. Reach into the protected helper to clear it. +interface AppUpdaterInternals { + downloadedUpdateHelper: { clear(): Promise<void> } | null; +} + +async function clearCachedUpdate(reason: string): Promise<void> { + const helper = (autoUpdater as unknown as AppUpdaterInternals) + .downloadedUpdateHelper; + if (!helper) return; + try { + await helper.clear(); + console.info(`[auto-updater] Cleared cached update (${reason})`); + } catch (error) { + console.error("[auto-updater] Failed to clear cached update:", error); + } +} + const UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 4; // 4 hours /** @@ -62,6 +82,7 @@ function isNetworkError(error: Error | string): boolean { let currentStatus: AutoUpdateStatus = AUTO_UPDATE_STATUS.IDLE; let currentVersion: string | undefined; let isDismissed = false; +let isInstalling = false; function emitStatus( status: AutoUpdateStatus, @@ -91,7 +112,24 @@ export function installUpdate(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); return; } - // Skip confirmation dialog - quitAndInstall internally calls app.quit() + // MacUpdater.quitAndInstall() registers a fresh native-updater + // `update-downloaded` listener each time it runs before Squirrel.Mac has + // finished staging. Without this guard, repeat clicks fan out into + // parallel quitAndInstall calls once Squirrel fires — racing to swap + // the binary and leaving the app on the old version. + if (isInstalling) { + console.info( + "[auto-updater] Install already in progress, ignoring duplicate request", + ); + return; + } + if (currentStatus !== AUTO_UPDATE_STATUS.READY) { + console.warn( + `[auto-updater] Install ignored: update not ready (status=${currentStatus})`, + ); + return; + } + isInstalling = true; setSkipQuitConfirmation(); autoUpdater.quitAndInstall(false, true); } @@ -223,6 +261,8 @@ export function setupAutoUpdater(): void { ); autoUpdater.on("error", (error) => { + // Allow retry if Squirrel surfaces an error instead of actually quitting. + isInstalling = false; if (isNetworkError(error)) { console.info("[auto-updater] Network unavailable, will retry later"); emitStatus(AUTO_UPDATE_STATUS.IDLE); @@ -232,6 +272,7 @@ export function setupAutoUpdater(): void { `[auto-updater] Error during update (currentVersion=${app.getVersion()}):`, error?.message || error, ); + void clearCachedUpdate(`error: ${error?.message ?? "unknown"}`); emitStatus(AUTO_UPDATE_STATUS.ERROR, undefined, error.message); }); diff --git a/apps/desktop/src/main/lib/browser/browser-manager.ts b/apps/desktop/src/main/lib/browser/browser-manager.ts index 43945aa23f4..cbb884815b3 100644 --- a/apps/desktop/src/main/lib/browser/browser-manager.ts +++ b/apps/desktop/src/main/lib/browser/browser-manager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "node:events"; -import { app, clipboard, Menu, shell, webContents } from "electron"; +import { clipboard, Menu, webContents } from "electron"; +import { safeOpenExternal } from "main/lib/safe-url"; interface ConsoleEntry { level: "log" | "warn" | "error" | "info" | "debug"; @@ -113,56 +114,6 @@ class BrowserManager extends EventEmitter { wc.openDevTools({ mode: "detach" }); } - async getDevToolsUrl(browserPaneId: string): Promise<string | null> { - const wc = this.getWebContents(browserPaneId); - if (!wc) return null; - - const cdpPort = app.commandLine.getSwitchValue("remote-debugging-port"); - if (!cdpPort) return null; - - try { - const targetUrl = wc.getURL(); - const res = await fetch(`http://127.0.0.1:${cdpPort}/json`); - const targets = (await res.json()) as Array<{ - id: string; - url: string; - type: string; - webSocketDebuggerUrl?: string; - }>; - - const webviewTargets = targets.filter( - (t) => t.type === "page" || t.type === "webview", - ); - - // Strategy 1: Exact URL match - let target = webviewTargets.find((t) => t.url === targetUrl); - - // Strategy 2: Match ignoring trailing slash / fragment differences - if (!target && targetUrl) { - const normalize = (u: string) => - u.replace(/\/?(#.*)?$/, "").toLowerCase(); - const normalizedTarget = normalize(targetUrl); - target = webviewTargets.find( - (t) => normalize(t.url) === normalizedTarget, - ); - } - - // Strategy 3: If only one webview target exists, use it - if (!target) { - const webviewOnly = webviewTargets.filter((t) => t.type === "webview"); - if (webviewOnly.length === 1) { - target = webviewOnly[0]; - } - } - - if (!target) return null; - - return `http://127.0.0.1:${cdpPort}/devtools/inspector.html?ws=127.0.0.1:${cdpPort}/devtools/page/${target.id}`; - } catch { - return null; - } - } - private setupContextMenu(paneId: string, wc: Electron.WebContents): void { const handler = ( _event: Electron.Event, @@ -176,7 +127,9 @@ class BrowserManager extends EventEmitter { menuItems.push( { label: "Open Link in Default Browser", - click: () => shell.openExternal(linkURL), + click: () => { + void safeOpenExternal(linkURL); + }, }, { label: "Open Link as New Split", @@ -244,7 +197,7 @@ class BrowserManager extends EventEmitter { label: "Open Page in Default Browser", click: () => { if (pageURL && pageURL !== "about:blank") { - shell.openExternal(pageURL); + void safeOpenExternal(pageURL); } }, enabled: !!pageURL && pageURL !== "about:blank", diff --git a/apps/desktop/src/main/lib/device-info.ts b/apps/desktop/src/main/lib/device-info.ts deleted file mode 100644 index abd369c4479..00000000000 --- a/apps/desktop/src/main/lib/device-info.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { createHmac } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { homedir, hostname, platform } from "node:os"; - -const APP_DEVICE_SALT = "superset-desktop-device-id-v1"; - -function getRawMachineId(): string { - try { - const os = platform(); - - if (os === "darwin") { - const output = execFileSync( - "ioreg", - ["-rd1", "-c", "IOPlatformExpertDevice"], - { encoding: "utf8" }, - ); - const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); - if (match?.[1]) return match[1]; - } else if (os === "linux") { - try { - return readFileSync("/etc/machine-id", "utf8").trim(); - } catch { - return readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); - } - } else if (os === "win32") { - const output = execFileSync( - "reg", - [ - "query", - "HKLM\\SOFTWARE\\Microsoft\\Cryptography", - "/v", - "MachineGuid", - ], - { encoding: "utf8" }, - ); - const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/); - if (match?.[1]) return match[1]; - } - } catch { - // Fallback if platform-specific method fails - } - - return `${hostname()}-${homedir()}-superset-fallback`; -} - -let cachedMachineId: string | null = null; - -/** - * Raw machine ID for local encryption key derivation. - * Do NOT send this to the cloud - use getHashedDeviceId() instead. - */ -export function getMachineId(): string { - if (!cachedMachineId) { - cachedMachineId = getRawMachineId(); - } - return cachedMachineId; -} - -let cachedHashedId: string | null = null; - -/** - * Hashed device ID safe for cloud transmission. - * Non-reversible, stable, and app-specific. - */ -export function getHashedDeviceId(): string { - if (!cachedHashedId) { - const machineId = getMachineId(); - cachedHashedId = createHmac("sha256", APP_DEVICE_SALT) - .update(machineId) - .digest("hex") - .slice(0, 32); - } - return cachedHashedId; -} - -/** - * Sanitized device name for cloud transmission. - * Returns generic identifier instead of potentially PII-containing hostname. - */ -export function getDeviceName(): string { - const os = platform(); - const osName = os === "darwin" ? "Mac" : os === "win32" ? "Windows" : "Linux"; - const rawHostname = hostname(); - - // Use just the first segment if it looks like a local hostname - // e.g., "johns-macbook-pro.local" -> "johns-macbook-pro" - const shortName = rawHostname.split(".")[0] || rawHostname; - - // If hostname looks generic or is very short, use OS name - if (shortName.length < 3 || shortName === "localhost") { - return `${osName} Desktop`; - } - - return shortName; -} diff --git a/apps/desktop/src/main/lib/dock-icon.ts b/apps/desktop/src/main/lib/dock-icon.ts index c40b48df31f..e3e53d801fc 100644 --- a/apps/desktop/src/main/lib/dock-icon.ts +++ b/apps/desktop/src/main/lib/dock-icon.ts @@ -1,136 +1,110 @@ +import { existsSync } from "node:fs"; import { join } from "node:path"; import { app, nativeImage } from "electron"; import { env } from "main/env.main"; +import { prerelease } from "semver"; import { getWorkspaceName } from "shared/env.shared"; -import twColors from "tailwindcss/colors"; -/** - * Deterministic hash of a string, returned as a non-negative integer. - */ -function hashString(seed: string): number { - let hash = 0; - for (let i = 0; i < seed.length; i++) { - hash = seed.charCodeAt(i) + ((hash << 5) - hash); - hash |= 0; - } - return Math.abs(hash); -} +type RGB = [number, number, number]; -/** - * Parses an OKLCH CSS string like "oklch(63.7% 0.237 25.331)". - */ -function parseOklch(str: string): { l: number; c: number; h: number } | null { - const match = str.match(/oklch\(([\d.]+)%\s+([\d.]+)\s+([\d.]+)\)/); - if (!match) return null; - return { - l: Number(match[1]) / 100, - c: Number(match[2]), - h: Number(match[3]), - }; -} +type Bounds = { top: number; left: number; bottom: number; right: number }; /** - * Converts OKLCH to sRGB (all values 0-255). + * Deterministic workspace-name → RGB picker. Hashes the name to a hue via the + * golden angle (137.508°) so successive workspaces land far apart on the color + * wheel, then converts a fixed-lightness/chroma OKLCH point to sRGB. */ -function oklchToRgb(l: number, c: number, h: number): [number, number, number] { - const hRad = (h * Math.PI) / 180; - const a = c * Math.cos(hRad); - const b = c * Math.sin(hRad); - - // OKLab → LMS (cube-root space) - const l_ = l + 0.3963377774 * a + 0.2158037573 * b; - const m_ = l - 0.1055613458 * a - 0.0638541728 * b; - const s_ = l - 0.0894841775 * a - 1.291485548 * b; +const pickWorkspaceColor = (() => { + const L = 0.68; + const C = 0.18; + const GOLDEN_ANGLE = 137.508; - const lc = l_ * l_ * l_; - const mc = m_ * m_ * m_; - const sc = s_ * s_ * s_; + function oklchToRgb(l: number, c: number, h: number): RGB { + const hRad = (h * Math.PI) / 180; + const a = c * Math.cos(hRad); + const b = c * Math.sin(hRad); + const l_ = l + 0.3963377774 * a + 0.2158037573 * b; + const m_ = l - 0.1055613458 * a - 0.0638541728 * b; + const s_ = l - 0.0894841775 * a - 1.291485548 * b; + const lc = l_ ** 3; + const mc = m_ ** 3; + const sc = s_ ** 3; + const rLin = +4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc; + const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc; + const bLin = -0.0041960863 * lc + 0.7034186147 * mc + 0.2967775076 * sc; + const toSrgb = (v: number) => { + const x = Math.max(0, Math.min(1, v)); + return x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055; + }; + return [ + Math.round(toSrgb(rLin) * 255), + Math.round(toSrgb(gLin) * 255), + Math.round(toSrgb(bLin) * 255), + ]; + } - // LMS → linear sRGB - const rLin = +4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc; - const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc; - const bLin = -0.0041960863 * lc + 0.7034186147 * mc + 0.2967775076 * sc; + function hash(seed: string): number { + let h = 0; + for (let i = 0; i < seed.length; i++) { + h = seed.charCodeAt(i) + ((h << 5) - h); + h |= 0; + } + return Math.abs(h); + } - const toSrgb = (v: number) => { - const clamped = Math.max(0, Math.min(1, v)); - return clamped <= 0.0031308 - ? 12.92 * clamped - : 1.055 * clamped ** (1 / 2.4) - 0.055; + return (workspaceName: string): RGB => { + const hue = (hash(workspaceName) * GOLDEN_ANGLE) % 360; + return oklchToRgb(L, C, hue); }; +})(); - return [ - Math.round(toSrgb(rLin) * 255), - Math.round(toSrgb(gLin) * 255), - Math.round(toSrgb(bLin) * 255), - ]; +/** + * Returns true for prerelease versions like "0.0.53-canary". + */ +function isCanaryBuild(): boolean { + const components = prerelease(app.getVersion()); + return components !== null && components.length > 0; } -/** All Tailwind 500-level colors as RGB tuples. */ -const TAILWIND_500_COLORS: [number, number, number][] = (() => { - const skip = new Set(["inherit", "current", "transparent", "black", "white"]); - const result: [number, number, number][] = []; - for (const [name, val] of Object.entries(twColors)) { - if (skip.has(name) || typeof val !== "object" || !("500" in val)) continue; - const parsed = parseOklch((val as Record<string, string>)["500"] as string); - if (parsed) result.push(oklchToRgb(parsed.l, parsed.c, parsed.h)); - } - return result; -})(); - /** - * Gets the path to the app icon PNG. + * Root directory of packaged/bundled icon assets. */ -function getIconPath(): string { +function getIconsDir(): string { if (app.isPackaged) { - return join( - process.resourcesPath, - "app.asar/resources/build/icons/icon.png", - ); + return join(process.resourcesPath, "app.asar/resources/build/icons"); } - if (env.NODE_ENV === "development") { - return join(app.getAppPath(), "src/resources/build/icons/icon.png"); + return join(app.getAppPath(), "src/resources/build/icons"); } - - return join(__dirname, "../resources/build/icons/icon.png"); + return join(__dirname, "../resources/build/icons"); } /** - * Signed distance function for a rounded rectangle. - * Negative = inside, positive = outside, zero = on boundary. + * Picks the dock icon PNG for the current build type, falling back to the + * stable icon if a build-specific variant is missing. */ -function sdfRoundedRect( - px: number, - py: number, - left: number, - top: number, - right: number, - bottom: number, - radius: number, -): number { - const cx = (left + right) / 2; - const cy = (top + bottom) / 2; - const halfW = (right - left) / 2; - const halfH = (bottom - top) / 2; +function getIconPath(): string { + const dir = getIconsDir(); - const dx = Math.abs(px - cx) - halfW + radius; - const dy = Math.abs(py - cy) - halfH + radius; + if (env.NODE_ENV === "development") { + const devIcon = join(dir, "icon-dev.png"); + if (existsSync(devIcon)) return devIcon; + } else if (isCanaryBuild()) { + const canaryIcon = join(dir, "icon-canary.png"); + if (existsSync(canaryIcon)) return canaryIcon; + } - return ( - Math.sqrt(Math.max(dx, 0) ** 2 + Math.max(dy, 0) ** 2) + - Math.min(Math.max(dx, dy), 0) - - radius - ); + return join(dir, "icon.png"); } /** - * Finds the bounding box of non-transparent pixels in a bitmap. + * Bounding box of non-transparent pixels in a bitmap. */ function findContentBounds( bitmap: Buffer, width: number, height: number, -): { top: number; left: number; bottom: number; right: number } { +): Bounds { let top = height; let left = width; let bottom = 0; @@ -151,89 +125,91 @@ function findContentBounds( } /** - * Draws a rounded-rectangle border on raw RGBA bitmap data. - * The border is drawn inward from the specified bounds. + * Source-over alpha compositing of a single RGBA pixel into the bitmap. */ -function drawBorder({ +function blendPixel( + bitmap: Buffer, + width: number, + height: number, + x: number, + y: number, + rgb: RGB, + alpha: number, +) { + if (alpha <= 0) return; + if (x < 0 || y < 0 || x >= width || y >= height) return; + + const offset = (y * width + x) * 4; + const dr = bitmap[offset] ?? 0; + const dg = bitmap[offset + 1] ?? 0; + const db = bitmap[offset + 2] ?? 0; + const da = (bitmap[offset + 3] ?? 0) / 255; + + const outA = alpha + da * (1 - alpha); + if (outA <= 0) return; + + bitmap[offset] = Math.round((rgb[0] * alpha + dr * da * (1 - alpha)) / outA); + bitmap[offset + 1] = Math.round( + (rgb[1] * alpha + dg * da * (1 - alpha)) / outA, + ); + bitmap[offset + 2] = Math.round( + (rgb[2] * alpha + db * da * (1 - alpha)) / outA, + ); + bitmap[offset + 3] = Math.round(outA * 255); +} + +/** + * Paints a top-right corner fold onto the bitmap: a colored triangle whose + * two legs run along the icon's top and right edges, with a 45° hypotenuse + * `cornerSize` pixels from the corner. The fill is masked by the icon's + * existing alpha so the fold hugs its rounded shape. + */ +function drawCornerFold({ bitmap, width, - thickness, - left, - top, - right, - bottom, - cornerRadius, + height, + bounds, + cornerSize, rgb, }: { bitmap: Buffer; width: number; - thickness: number; - left: number; - top: number; - right: number; - bottom: number; - cornerRadius: number; - rgb: [number, number, number]; + height: number; + bounds: Bounds; + cornerSize: number; + rgb: RGB; }) { - const innerRadius = Math.max(0, cornerRadius - thickness); + const minX = Math.max(0, bounds.right - cornerSize - 2); + const maxX = Math.min(width - 1, bounds.right + 2); + const minY = Math.max(0, bounds.top - 2); + const maxY = Math.min(height - 1, bounds.top + cornerSize + 2); - for (let y = top; y <= bottom; y++) { - for (let x = left; x <= right; x++) { - const outerDist = sdfRoundedRect( - x, - y, - left, - top, - right, - bottom, - cornerRadius, - ); - const innerDist = sdfRoundedRect( - x, - y, - left + thickness, - top + thickness, - right - thickness, - bottom - thickness, - innerRadius, - ); + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + // Perpendicular signed distance to the 45° cut line + // (bounds.right - x) + (y - bounds.top) = cornerSize. + // Negative = inside the triangle (toward the corner). + const signedDist = + (bounds.right - x + (y - bounds.top) - cornerSize) / Math.SQRT2; + const diagAlpha = Math.max(0, Math.min(1, 0.5 - signedDist)); + if (diagAlpha <= 0.001) continue; - // Anti-aliased edges - const outerAlpha = Math.max(0, Math.min(1, 0.5 - outerDist)); - const innerAlpha = Math.max(0, Math.min(1, innerDist + 0.5)); - const borderAlpha = outerAlpha * innerAlpha; + const iconAlpha = (bitmap[(y * width + x) * 4 + 3] ?? 0) / 255; + if (iconAlpha <= 0) continue; - if (borderAlpha > 0.001) { - const offset = (y * width + x) * 4; - const r = bitmap[offset] ?? 0; - const g = bitmap[offset + 1] ?? 0; - const b = bitmap[offset + 2] ?? 0; - const a = bitmap[offset + 3] ?? 0; - bitmap[offset] = Math.round( - rgb[0] * borderAlpha + r * (1 - borderAlpha), - ); - bitmap[offset + 1] = Math.round( - rgb[1] * borderAlpha + g * (1 - borderAlpha), - ); - bitmap[offset + 2] = Math.round( - rgb[2] * borderAlpha + b * (1 - borderAlpha), - ); - bitmap[offset + 3] = Math.max(a, Math.round(borderAlpha * 255)); - } + blendPixel(bitmap, width, height, x, y, rgb, diagAlpha * iconAlpha); } } } /** - * Sets the macOS dock icon with a colored border based on the workspace name. - * No-op on non-macOS platforms or when no workspace name is set. + * Sets the macOS dock icon based on the current build type. + * In development with a workspace name set, overlays a workspace-colored + * corner fold so simultaneous workspaces are visually distinguishable. + * No-op on non-macOS platforms. */ export function setWorkspaceDockIcon(): void { if (process.platform !== "darwin") return; - if (env.NODE_ENV !== "development") return; - - const workspaceName = getWorkspaceName(); - if (!workspaceName) return; try { const iconPath = getIconPath(); @@ -243,29 +219,27 @@ export function setWorkspaceDockIcon(): void { return; } - const size = icon.getSize(); - const bitmap = icon.toBitmap(); + const workspaceName = + env.NODE_ENV === "development" ? getWorkspaceName() : null; - const hash = hashString(workspaceName); - const rgb = - TAILWIND_500_COLORS[hash % TAILWIND_500_COLORS.length] ?? - ([59, 130, 246] as [number, number, number]); // blue-500 fallback + if (!workspaceName) { + app.dock?.setIcon(icon); + console.log(`[dock-icon] Set dock icon from: ${iconPath}`); + return; + } - // Find the actual icon content area (skip transparent padding) + const size = icon.getSize(); + const bitmap = icon.toBitmap(); const bounds = findContentBounds(bitmap, size.width, size.height); - const thickness = Math.round(size.width * 0.038); - const cornerRadius = Math.round(size.width * 0.22); + const boundsWidth = bounds.right - bounds.left; + const rgb = pickWorkspaceColor(workspaceName); - // Draw border flush with the content edges, overlapping inward - drawBorder({ + drawCornerFold({ bitmap, width: size.width, - thickness, - left: bounds.left, - top: bounds.top, - right: bounds.right, - bottom: bounds.bottom, - cornerRadius, + height: size.height, + bounds, + cornerSize: Math.round(boundsWidth * 0.47), rgb, }); @@ -276,7 +250,7 @@ export function setWorkspaceDockIcon(): void { app.dock?.setIcon(newIcon); console.log( - `[dock-icon] Set workspace dock icon border rgb(${rgb.join(",")}) for "${workspaceName}"`, + `[dock-icon] Set workspace dock icon corner fold rgb(${rgb.join(",")}) for "${workspaceName}" from ${iconPath}`, ); } catch (error) { console.error("[dock-icon] Failed to set dock icon:", error); diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts new file mode 100644 index 00000000000..779627d3e5f --- /dev/null +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -0,0 +1,550 @@ +import * as childProcess from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs"; +import path from "node:path"; +import { settings } from "@superset/local-db"; +import { getHostId, getHostName } from "@superset/shared/host-info"; +import { app } from "electron"; +import { env } from "main/env.main"; +import semver from "semver"; +import { env as sharedEnv } from "shared/env.shared"; +import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; +import { SUPERSET_HOME_DIR } from "./app-environment"; +import { + type HostServiceManifest, + isProcessAlive, + listManifests, + manifestDir, + readManifest, + removeManifest, +} from "./host-service-manifest"; +import { + findFreePort, + HEALTH_POLL_TIMEOUT_MS, + MAX_HOST_LOG_BYTES, + openRotatingLogFd, + pollHealthCheck, +} from "./host-service-utils"; +import { localDb } from "./local-db"; +import { HOOK_PROTOCOL_VERSION } from "./terminal/env"; + +/** + * Minimum host-service version this app can work with. Bumping this forces + * the coordinator to kill + respawn any adopted service older than this, + * which is how we prevent the renderer from talking to a stale host-service + * that's missing newly-added procedures/params. + * + * 0.4.0: terminal launch moved from `terminal.ensureSession` to + * `terminal.launchSession` plus WebSocket attach params. + * 0.3.0: host-service registers via cloud `host.ensure` (was + * `device.ensureV2Host`); v2_hosts/v2_users_hosts/v2_workspaces use + * machineId text instead of uuid surrogates. + * 0.2.0: `workspaceCreation.adopt` gained optional `worktreePath`. + */ +const MIN_HOST_SERVICE_VERSION = "0.4.0"; + +export type HostServiceStatus = "starting" | "running" | "stopped"; + +export interface Connection { + port: number; + secret: string; + machineId: string; +} + +export interface HostServiceStatusEvent { + organizationId: string; + status: HostServiceStatus; + previousStatus: HostServiceStatus | null; +} + +export interface SpawnConfig { + authToken: string; + cloudApiUrl: string; +} + +interface HostServiceProcess { + pid: number; + port: number; + secret: string; + status: HostServiceStatus; +} + +const ADOPTED_LIVENESS_INTERVAL = 5_000; + +export class HostServiceCoordinator extends EventEmitter { + private instances = new Map<string, HostServiceProcess>(); + private pendingStarts = new Map<string, Promise<Connection>>(); + private adoptedLivenessTimers = new Map< + string, + ReturnType<typeof setInterval> + >(); + private scriptPath = path.join(__dirname, "host-service.js"); + private machineId = getHostId(); + private devReloadWatcher: fs.FSWatcher | null = null; + + async start( + organizationId: string, + config: SpawnConfig, + ): Promise<Connection> { + const existing = this.instances.get(organizationId); + if (existing?.status === "running") { + return { + port: existing.port, + secret: existing.secret, + machineId: this.machineId, + }; + } + + const pending = this.pendingStarts.get(organizationId); + if (pending) return pending; + + const startPromise = (async (): Promise<Connection> => { + const adopted = await this.tryAdopt(organizationId); + if (adopted) return adopted; + return this.spawn(organizationId, config); + })(); + this.pendingStarts.set(organizationId, startPromise); + + try { + return await startPromise; + } finally { + this.pendingStarts.delete(organizationId); + } + } + + stop(organizationId: string): void { + const instance = this.instances.get(organizationId); + this.stopAdoptedLivenessCheck(organizationId); + + if (!instance) return; + + const previousStatus = instance.status; + instance.status = "stopped"; + + try { + process.kill(instance.pid, "SIGTERM"); + } catch {} + + this.instances.delete(organizationId); + removeManifest(organizationId); + this.emitStatus(organizationId, "stopped", previousStatus); + } + + stopAll(): void { + for (const [id] of this.instances) { + this.stop(id); + } + } + + releaseAll(): void { + for (const [id] of this.instances) { + this.stopAdoptedLivenessCheck(id); + } + this.instances.clear(); + } + + async discoverAll(): Promise<void> { + const manifests = listManifests(); + for (const manifest of manifests) { + if (this.instances.has(manifest.organizationId)) continue; + try { + await this.tryAdopt(manifest.organizationId); + } catch { + removeManifest(manifest.organizationId); + } + } + } + + async restart( + organizationId: string, + config: SpawnConfig, + ): Promise<Connection> { + this.stop(organizationId); + return this.start(organizationId, config); + } + + getConnection(organizationId: string): Connection | null { + const instance = this.instances.get(organizationId); + if (!instance || instance.status !== "running") return null; + return { + port: instance.port, + secret: instance.secret, + machineId: this.machineId, + }; + } + + getProcessStatus(organizationId: string): HostServiceStatus { + if (this.pendingStarts.has(organizationId)) return "starting"; + return this.instances.get(organizationId)?.status ?? "stopped"; + } + + hasActiveInstances(): boolean { + for (const instance of this.instances.values()) { + if (instance.status === "running" || instance.status === "starting") + return true; + } + return this.pendingStarts.size > 0; + } + + getActiveOrganizationIds(): string[] { + return [...this.instances.entries()] + .filter(([, i]) => i.status !== "stopped") + .map(([id]) => id); + } + + async restartAll(config: SpawnConfig): Promise<void> { + await Promise.all( + this.getActiveOrganizationIds().map((orgId) => + this.restart(orgId, config), + ), + ); + } + + /** + * Dev-only: watch the built host-service bundle and restart running + * instances when it changes. Gives a fast edit→reload loop for code + * under packages/host-service and src/main/host-service without + * restarting Electron. In-memory host-service state (PTYs, watchers, + * chat streams) is torn down on each reload — this is not true HMR. + */ + enableDevReload( + configProvider: () => Promise<SpawnConfig | null>, + ): () => void { + if (this.devReloadWatcher) return () => {}; + + const scriptDir = path.dirname(this.scriptPath); + const scriptFile = path.basename(this.scriptPath); + let debounce: ReturnType<typeof setTimeout> | null = null; + let reloading = false; + + const waitForStableBundle = async (): Promise<boolean> => { + const deadline = Date.now() + 5_000; + let lastSize = -1; + let stableSince = 0; + while (Date.now() < deadline) { + try { + const stat = fs.statSync(this.scriptPath); + if (stat.size > 0 && stat.size === lastSize) { + if (Date.now() - stableSince >= 150) return true; + } else { + lastSize = stat.size; + stableSince = Date.now(); + } + } catch { + lastSize = -1; + stableSince = 0; + } + await new Promise((r) => setTimeout(r, 50)); + } + return false; + }; + + const trigger = () => { + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + void (async () => { + if (reloading) return; + if (this.getActiveOrganizationIds().length === 0) return; + reloading = true; + try { + const ready = await waitForStableBundle(); + if (!ready) { + console.warn( + "[host-service] bundle did not stabilize, skipping reload", + ); + return; + } + const config = await configProvider(); + if (!config) return; + console.log( + "[host-service] bundle changed, restarting running instances", + ); + await this.restartAll(config); + } catch (error) { + console.error("[host-service] dev reload failed:", error); + } finally { + reloading = false; + } + })(); + }, 250); + }; + + try { + this.devReloadWatcher = fs.watch(scriptDir, (_event, filename) => { + if (filename && filename !== scriptFile) return; + trigger(); + }); + } catch (error) { + console.error("[host-service] failed to enable dev reload:", error); + return () => {}; + } + + return () => { + if (debounce) clearTimeout(debounce); + this.devReloadWatcher?.close(); + this.devReloadWatcher = null; + }; + } + + // ── Adoption ────────────────────────────────────────────────────── + + private async tryAdopt(organizationId: string): Promise<Connection | null> { + const manifest = this.readAndValidateManifest(organizationId); + if (!manifest) return null; + + const url = new URL(manifest.endpoint); + const port = Number(url.port); + + const version = await this.fetchHostVersion( + manifest.endpoint, + manifest.authToken, + ); + if ( + !version || + !semver.satisfies(version, `>=${MIN_HOST_SERVICE_VERSION}`) + ) { + const reason = version + ? `version ${version} < ${MIN_HOST_SERVICE_VERSION}` + : "version unknown"; + console.log( + `[host-service:${organizationId}] Adopted service ${reason}, killing`, + ); + try { + process.kill(manifest.pid, "SIGTERM"); + } catch {} + removeManifest(organizationId); + return null; + } + + this.instances.set(organizationId, { + pid: manifest.pid, + port, + secret: manifest.authToken, + status: "running", + }); + this.startAdoptedLivenessCheck(organizationId, manifest.pid); + + console.log( + `[host-service:${organizationId}] Adopted pid=${manifest.pid} port=${port}`, + ); + this.emitStatus(organizationId, "running", null); + return { port, secret: manifest.authToken, machineId: this.machineId }; + } + + private async fetchHostVersion( + endpoint: string, + secret: string, + ): Promise<string | null> { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3_000); + const response = await fetch(`${endpoint}/trpc/host.info`, { + signal: controller.signal, + headers: { Authorization: `Bearer ${secret}` }, + }); + clearTimeout(timeout); + if (!response.ok) return null; + const data = await response.json(); + const result = data?.result?.data; + return result?.json?.version ?? result?.version ?? null; + } catch { + return null; + } + } + + private readAndValidateManifest( + organizationId: string, + ): HostServiceManifest | null { + const manifest = readManifest(organizationId); + if (!manifest) return null; + + if (!isProcessAlive(manifest.pid)) { + removeManifest(organizationId); + return null; + } + + return manifest; + } + + // ── Spawn ───────────────────────────────────────────────────────── + + private async spawn( + organizationId: string, + config: SpawnConfig, + ): Promise<Connection> { + const port = await findFreePort(); + const secret = randomBytes(32).toString("hex"); + + const instance: HostServiceProcess = { + pid: 0, + port, + secret, + status: "starting", + }; + this.instances.set(organizationId, instance); + this.emitStatus(organizationId, "starting", null); + + const childEnv = await this.buildEnv(organizationId, port, secret, config); + // Host-service owns v2 PTYs, so it must survive Electron restarts in + // every environment. This mirrors the terminal-host daemon: detach the + // child and back stdio with real files so parent teardown cannot close + // pipes and take the service down with the app. + const logFd = openRotatingLogFd( + path.join(manifestDir(organizationId), "host-service.log"), + MAX_HOST_LOG_BYTES, + ); + const stdio: childProcess.StdioOptions = + logFd >= 0 ? ["ignore", logFd, logFd] : ["ignore", "ignore", "ignore"]; + + let child: ReturnType<typeof childProcess.spawn>; + try { + child = childProcess.spawn(process.execPath, [this.scriptPath], { + detached: true, + stdio, + env: childEnv, + // Avoid a flashing CMD window on Windows for the detached child. + windowsHide: true, + }); + } finally { + if (logFd >= 0) { + try { + fs.closeSync(logFd); + } catch { + // Best-effort — child has its own dup of the fd. + } + } + } + + const childPid = child.pid; + if (!childPid) { + this.instances.delete(organizationId); + throw new Error("Failed to spawn host service process"); + } + + instance.pid = childPid; + child.on("exit", (code) => { + console.log(`[host-service:${organizationId}] exited with code ${code}`); + const current = this.instances.get(organizationId); + if (!current || current.pid !== childPid || current.status === "stopped") + return; + + this.instances.delete(organizationId); + removeManifest(organizationId); + this.emitStatus(organizationId, "stopped", "running"); + }); + child.unref(); + + const endpoint = `http://127.0.0.1:${port}`; + const healthy = await pollHealthCheck(endpoint, secret); + if (!healthy) { + child.kill("SIGTERM"); + this.instances.delete(organizationId); + throw new Error( + `Host service failed to start within ${HEALTH_POLL_TIMEOUT_MS}ms`, + ); + } + + instance.status = "running"; + + console.log(`[host-service:${organizationId}] listening on port ${port}`); + this.emitStatus(organizationId, "running", "starting"); + return { port, secret, machineId: this.machineId }; + } + + private async buildEnv( + organizationId: string, + port: number, + secret: string, + config: SpawnConfig, + ): Promise<Record<string, string>> { + const organizationDir = manifestDir(organizationId); + const row = localDb.select().from(settings).get(); + const exposeViaRelay = row?.exposeHostServiceViaRelay ?? false; + + const childEnv = await getProcessEnvWithShellPath({ + ...(process.env as Record<string, string>), + ELECTRON_RUN_AS_NODE: "1", + ORGANIZATION_ID: organizationId, + HOST_CLIENT_ID: getHostId(), + HOST_NAME: getHostName(), + HOST_SERVICE_SECRET: secret, + HOST_SERVICE_PORT: String(port), + HOST_MANIFEST_DIR: organizationDir, + HOST_DB_PATH: path.join(organizationDir, "host.db"), + HOST_MIGRATIONS_FOLDER: app.isPackaged + ? path.join(process.resourcesPath, "resources/host-migrations") + : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), + DESKTOP_VITE_PORT: String(sharedEnv.DESKTOP_VITE_PORT), + SUPERSET_HOME_DIR: SUPERSET_HOME_DIR, + SUPERSET_AGENT_HOOK_PORT: String(sharedEnv.DESKTOP_NOTIFICATIONS_PORT), + SUPERSET_AGENT_HOOK_VERSION: HOOK_PROTOCOL_VERSION, + AUTH_TOKEN: config.authToken, + CLOUD_API_URL: config.cloudApiUrl, + }); + + // `getProcessEnvWithShellPath` merges in the user's interactive shell env, + // which in dev has `RELAY_URL` set. Enforce the toggle *after* that merge + // so the child definitely doesn't see a relay URL when disabled. + if (exposeViaRelay && env.RELAY_URL) { + childEnv.RELAY_URL = env.RELAY_URL; + } else { + delete childEnv.RELAY_URL; + } + + return childEnv; + } + + // ── Liveness ────────────────────────────────────────────────────── + + private startAdoptedLivenessCheck(organizationId: string, pid: number): void { + this.stopAdoptedLivenessCheck(organizationId); + const timer = setInterval(() => { + if (!isProcessAlive(pid)) { + clearInterval(timer); + this.adoptedLivenessTimers.delete(organizationId); + const instance = this.instances.get(organizationId); + if (instance && instance.status !== "stopped") { + console.log( + `[host-service:${organizationId}] Adopted process ${pid} died`, + ); + this.instances.delete(organizationId); + removeManifest(organizationId); + this.emitStatus(organizationId, "stopped", "running"); + } + } + }, ADOPTED_LIVENESS_INTERVAL); + this.adoptedLivenessTimers.set(organizationId, timer); + } + + private stopAdoptedLivenessCheck(organizationId: string): void { + const timer = this.adoptedLivenessTimers.get(organizationId); + if (timer) { + clearInterval(timer); + this.adoptedLivenessTimers.delete(organizationId); + } + } + + // ── Events ──────────────────────────────────────────────────────── + + private emitStatus( + organizationId: string, + status: HostServiceStatus, + previousStatus: HostServiceStatus | null, + ): void { + this.emit("status-changed", { + organizationId, + status, + previousStatus, + } satisfies HostServiceStatusEvent); + } +} + +let coordinator: HostServiceCoordinator | null = null; + +export function getHostServiceCoordinator(): HostServiceCoordinator { + if (!coordinator) { + coordinator = new HostServiceCoordinator(); + } + return coordinator; +} diff --git a/apps/desktop/src/main/lib/host-service-manager.test.ts b/apps/desktop/src/main/lib/host-service-manager.test.ts deleted file mode 100644 index aa0342cbeba..00000000000 --- a/apps/desktop/src/main/lib/host-service-manager.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - mock, - spyOn, -} from "bun:test"; -import type { ChildProcess } from "node:child_process"; -import { EventEmitter } from "node:events"; - -function createDeferred<T>() { - let resolve!: (value: T) => void; - let reject!: (error?: unknown) => void; - const promise = new Promise<T>((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; -} - -class MockChildProcess extends EventEmitter { - stdout = new EventEmitter(); - stderr = new EventEmitter(); - kill = mock(() => true); -} - -const getProcessEnvWithShellPathMock = mock( - async (env: Record<string, string>) => env, -); -let lastChild: MockChildProcess | null = null; -const spawnMock = mock((..._args: unknown[]) => { - lastChild = new MockChildProcess(); - return lastChild as unknown as ChildProcess; -}); -let HostServiceManager: typeof import("./host-service-manager").HostServiceManager; - -describe("HostServiceManager", () => { - beforeAll(async () => { - const childProcessModule = await import("node:child_process"); - const shellEnvModule = await import( - "../../lib/trpc/routers/workspaces/utils/shell-env" - ); - - spyOn(childProcessModule, "spawn").mockImplementation(((..._args) => - spawnMock(..._args)) as typeof childProcessModule.spawn); - spyOn(shellEnvModule, "getProcessEnvWithShellPath").mockImplementation((( - baseEnv: NodeJS.ProcessEnv = process.env, - ) => - getProcessEnvWithShellPathMock( - baseEnv as Record<string, string>, - )) as typeof shellEnvModule.getProcessEnvWithShellPath); - - mock.module("electron", () => ({ - app: { - isPackaged: false, - getAppPath: () => "/tmp/app", - }, - })); - - ({ HostServiceManager } = await import("./host-service-manager")); - }); - - afterAll(() => { - mock.restore(); - }); - - beforeEach(() => { - getProcessEnvWithShellPathMock.mockReset(); - getProcessEnvWithShellPathMock.mockImplementation( - async (env: Record<string, string>) => env, - ); - spawnMock.mockReset(); - spawnMock.mockImplementation(() => { - lastChild = new MockChildProcess(); - return lastChild as unknown as ChildProcess; - }); - lastChild = null; - }); - - it("dedupes concurrent starts while shell PATH is resolving", async () => { - const manager = new HostServiceManager(); - const pendingEnv = createDeferred<Record<string, string>>(); - getProcessEnvWithShellPathMock.mockImplementation(() => pendingEnv.promise); - - const firstStart = manager.start("org-1"); - const secondStart = manager.start("org-1"); - - expect(manager.getStatus("org-1")).toBe("starting"); - expect(getProcessEnvWithShellPathMock.mock.calls).toHaveLength(1); - - pendingEnv.resolve({ PATH: "/usr/bin:/bin" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(spawnMock.mock.calls).toHaveLength(1); - expect(lastChild).not.toBeNull(); - expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({ - stdio: ["ignore", "pipe", "pipe", "ipc"], - }); - - lastChild?.emit("message", { type: "ready", port: 4242 }); - - expect(await firstStart).toBe(4242); - expect(await secondStart).toBe(4242); - expect(manager.getPort("org-1")).toBe(4242); - }); -}); diff --git a/apps/desktop/src/main/lib/host-service-manager.ts b/apps/desktop/src/main/lib/host-service-manager.ts deleted file mode 100644 index 7fe115fd8ec..00000000000 --- a/apps/desktop/src/main/lib/host-service-manager.ts +++ /dev/null @@ -1,325 +0,0 @@ -import type { ChildProcess } from "node:child_process"; -import * as childProcess from "node:child_process"; -import path from "node:path"; -import { app } from "electron"; -import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env"; -import { SUPERSET_HOME_DIR } from "./app-environment"; -import { getDeviceName, getHashedDeviceId } from "./device-info"; - -type HostServiceStatus = "starting" | "running" | "crashed"; - -interface HostServiceProcess { - process: ChildProcess | null; - port: number | null; - status: HostServiceStatus; - restartCount: number; - lastCrash?: number; - organizationId: string; -} - -interface PendingStart { - promise: Promise<number>; - resolve: (port: number) => void; - reject: (error: Error) => void; - startupTimeout?: ReturnType<typeof setTimeout>; - onMessage?: (message: unknown) => void; -} - -const MAX_RESTART_DELAY = 30_000; -const BASE_RESTART_DELAY = 1_000; - -function createPortDeferred(): { - promise: Promise<number>; - resolve: (port: number) => void; - reject: (error: Error) => void; -} { - let resolve!: (port: number) => void; - let reject!: (error: Error) => void; - const promise = new Promise<number>((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; -} - -export class HostServiceManager { - private instances = new Map<string, HostServiceProcess>(); - private pendingStarts = new Map<string, PendingStart>(); - private scriptPath = path.join(__dirname, "host-service.js"); - private authToken: string | null = null; - private cloudApiUrl: string | null = null; - - setAuthToken(token: string | null): void { - this.authToken = token; - } - - setCloudApiUrl(url: string | null): void { - this.cloudApiUrl = url; - } - - async start(organizationId: string): Promise<number> { - const existing = this.instances.get(organizationId); - if (existing?.status === "running" && existing.port !== null) { - return existing.port; - } - const pendingStart = this.pendingStarts.get(organizationId); - if (pendingStart) { - return pendingStart.promise; - } - - return this.spawn(organizationId); - } - - stop(organizationId: string): void { - const instance = this.instances.get(organizationId); - if (!instance) return; - - instance.status = "crashed"; // prevent restart - this.cancelPendingStart(organizationId, new Error("Host service stopped")); - instance.process?.kill("SIGTERM"); - this.instances.delete(organizationId); - } - - stopAll(): void { - for (const [id] of this.instances) { - this.stop(id); - } - } - - getPort(organizationId: string): number | null { - return this.instances.get(organizationId)?.port ?? null; - } - - getStatus(organizationId: string): HostServiceStatus | null { - if (this.pendingStarts.has(organizationId)) { - return "starting"; - } - return this.instances.get(organizationId)?.status ?? null; - } - - private async spawn(organizationId: string): Promise<number> { - const pendingStart = createPortDeferred(); - const instance: HostServiceProcess = { - process: null, - port: null, - status: "starting", - restartCount: 0, - organizationId, - }; - this.instances.set(organizationId, instance); - this.pendingStarts.set(organizationId, pendingStart); - - try { - const env = await this.buildHostServiceEnv(organizationId); - if (this.authToken) { - env.AUTH_TOKEN = this.authToken; - } - if (this.cloudApiUrl) { - env.CLOUD_API_URL = this.cloudApiUrl; - } - - if ( - this.instances.get(organizationId) !== instance || - this.pendingStarts.get(organizationId) !== pendingStart - ) { - throw new Error("Host service start cancelled"); - } - - const child = childProcess.spawn(process.execPath, [this.scriptPath], { - stdio: ["ignore", "pipe", "pipe", "ipc"], - env, - }); - instance.process = child; - - this.attachProcessHandlers(instance, child); - this.attachStartupReadyListener(instance, pendingStart); - return pendingStart.promise; - } catch (error) { - if ( - this.instances.get(organizationId) === instance && - instance.port === null - ) { - this.instances.delete(organizationId); - } - this.clearPendingStart(organizationId, pendingStart); - pendingStart.reject( - error instanceof Error ? error : new Error(String(error)), - ); - throw error; - } - } - - private async buildHostServiceEnv( - organizationId: string, - ): Promise<Record<string, string>> { - return getProcessEnvWithShellPath({ - ...(process.env as Record<string, string>), - ELECTRON_RUN_AS_NODE: "1", - ORGANIZATION_ID: organizationId, - DEVICE_CLIENT_ID: getHashedDeviceId(), - DEVICE_NAME: getDeviceName(), - HOST_DB_PATH: path.join( - SUPERSET_HOME_DIR, - "host", - organizationId, - "host.db", - ), - HOST_MIGRATIONS_PATH: app.isPackaged - ? path.join(process.resourcesPath, "resources/host-migrations") - : path.join(app.getAppPath(), "../../packages/host-service/drizzle"), - }); - } - - private attachProcessHandlers( - instance: HostServiceProcess, - child: ChildProcess, - ): void { - const { organizationId } = instance; - - child.stdout?.on("data", (data: Buffer) => { - console.log(`[host-service:${organizationId}] ${data.toString().trim()}`); - }); - - child.stderr?.on("data", (data: Buffer) => { - console.error( - `[host-service:${organizationId}] ${data.toString().trim()}`, - ); - }); - - child.on("exit", (code) => { - console.log(`[host-service:${organizationId}] exited with code ${code}`); - const current = this.instances.get(organizationId); - if ( - !current || - current.process !== child || - current.status === "crashed" - ) { - return; - } - - if (current.port === null) { - this.cancelPendingStart( - organizationId, - new Error("Host service exited before reporting port"), - ); - } - current.status = "crashed"; - current.lastCrash = Date.now(); - this.scheduleRestart(organizationId); - }); - } - - private failStartup( - instance: HostServiceProcess, - pendingStart: PendingStart, - error: Error, - ): void { - this.clearPendingStart(instance.organizationId, pendingStart); - instance.status = "crashed"; - pendingStart.reject(error); - instance.process?.kill("SIGTERM"); - instance.lastCrash = Date.now(); - this.scheduleRestart(instance.organizationId); - } - - private attachStartupReadyListener( - instance: HostServiceProcess, - pendingStart: PendingStart, - ): void { - const onMessage = (message: unknown) => { - if ( - typeof message !== "object" || - message === null || - !("type" in message) || - !("port" in message) || - message.type !== "ready" || - typeof message.port !== "number" - ) { - return; - } - - this.clearPendingStart(instance.organizationId, pendingStart); - instance.port = message.port; - instance.status = "running"; - console.log( - `[host-service:${instance.organizationId}] listening on port ${message.port}`, - ); - pendingStart.resolve(message.port); - }; - - pendingStart.onMessage = onMessage; - instance.process?.on("message", onMessage); - pendingStart.startupTimeout = setTimeout(() => { - this.failStartup( - instance, - pendingStart, - new Error("Timeout waiting for host-service port"), - ); - }, 10_000); - } - - private cancelPendingStart(organizationId: string, error: Error): void { - const pendingStart = this.pendingStarts.get(organizationId); - if (!pendingStart) return; - - this.clearPendingStart(organizationId, pendingStart); - pendingStart.reject(error); - } - - private clearPendingStart( - organizationId: string, - pendingStart: PendingStart, - ): void { - const instance = this.instances.get(organizationId); - - if (pendingStart.onMessage) { - instance?.process?.off("message", pendingStart.onMessage); - pendingStart.onMessage = undefined; - } - if (pendingStart.startupTimeout) { - clearTimeout(pendingStart.startupTimeout); - pendingStart.startupTimeout = undefined; - } - if (this.pendingStarts.get(organizationId) === pendingStart) { - this.pendingStarts.delete(organizationId); - } - } - - private scheduleRestart(organizationId: string): void { - const instance = this.instances.get(organizationId); - if (!instance) return; - - const delay = Math.min( - BASE_RESTART_DELAY * 2 ** instance.restartCount, - MAX_RESTART_DELAY, - ); - instance.restartCount++; - - console.log( - `[host-service:${organizationId}] restarting in ${delay}ms (attempt ${instance.restartCount})`, - ); - - setTimeout(() => { - const current = this.instances.get(organizationId); - if (current?.status === "crashed") { - this.instances.delete(organizationId); - this.spawn(organizationId).catch((err) => { - console.error( - `[host-service:${organizationId}] restart failed:`, - err, - ); - }); - } - }, delay); - } -} - -let manager: HostServiceManager | null = null; - -export function getHostServiceManager(): HostServiceManager { - if (!manager) { - manager = new HostServiceManager(); - } - return manager; -} diff --git a/apps/desktop/src/main/lib/host-service-manifest.ts b/apps/desktop/src/main/lib/host-service-manifest.ts new file mode 100644 index 00000000000..57b55fda736 --- /dev/null +++ b/apps/desktop/src/main/lib/host-service-manifest.ts @@ -0,0 +1,108 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { SUPERSET_HOME_DIR } from "./app-environment"; + +export interface HostServiceManifest { + pid: number; + endpoint: string; + authToken: string; + startedAt: number; + organizationId: string; +} + +export function manifestDir(organizationId: string): string { + return join(SUPERSET_HOME_DIR, "host", organizationId); +} + +function manifestPath(organizationId: string): string { + return join(manifestDir(organizationId), "manifest.json"); +} + +export function writeManifest(manifest: HostServiceManifest): void { + const dir = manifestDir(manifest.organizationId); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + writeFileSync( + manifestPath(manifest.organizationId), + JSON.stringify(manifest), + { + encoding: "utf-8", + mode: 0o600, + }, + ); +} + +export function readManifest( + organizationId: string, +): HostServiceManifest | null { + const filePath = manifestPath(organizationId); + if (!existsSync(filePath)) return null; + + try { + const raw = readFileSync(filePath, "utf-8"); + const data = JSON.parse(raw); + + if ( + typeof data.pid !== "number" || + typeof data.endpoint !== "string" || + typeof data.authToken !== "string" || + typeof data.startedAt !== "number" || + typeof data.organizationId !== "string" + ) { + return null; + } + + return data as HostServiceManifest; + } catch { + return null; + } +} + +/** Scan the host directory for all valid manifests on disk. */ +export function listManifests(): HostServiceManifest[] { + const hostDir = join(SUPERSET_HOME_DIR, "host"); + if (!existsSync(hostDir)) return []; + + const manifests: HostServiceManifest[] = []; + try { + for (const entry of readdirSync(hostDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const manifest = readManifest(entry.name); + if (manifest) { + manifests.push(manifest); + } + } + } catch { + // Best-effort scan + } + return manifests; +} + +export function removeManifest(organizationId: string): void { + const filePath = manifestPath(organizationId); + try { + if (existsSync(filePath)) { + unlinkSync(filePath); + } + } catch { + // Best-effort removal + } +} + +/** Check whether a process with the given PID is alive. */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/lib/host-service-utils.ts b/apps/desktop/src/main/lib/host-service-utils.ts new file mode 100644 index 00000000000..e638c47a4d1 --- /dev/null +++ b/apps/desktop/src/main/lib/host-service-utils.ts @@ -0,0 +1,85 @@ +import * as fs from "node:fs"; +import { createServer } from "node:net"; +import path from "node:path"; + +/** Rotate per-org host-service.log once it exceeds this size. */ +export const MAX_HOST_LOG_BYTES = 5 * 1024 * 1024; + +export const HEALTH_POLL_TIMEOUT_MS = 10_000; + +const HEALTH_POLL_INTERVAL_MS = 200; + +/** + * Open an append-mode log fd, truncating first if it exceeds maxBytes. + * Returns -1 on failure so callers can fall back to ignoring child stdio. + */ +export function openRotatingLogFd(logPath: string, maxBytes: number): number { + try { + fs.mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 }); + if (fs.existsSync(logPath)) { + try { + const { size } = fs.statSync(logPath); + if (size > maxBytes) { + fs.writeFileSync(logPath, "", { mode: 0o600 }); + } + } catch { + // Best-effort rotate + } + } + const fd = fs.openSync(logPath, "a", 0o600); + // openSync's mode arg only applies on create — normalize an existing + // file's perms in case it was rotated out-of-band with laxer bits. + try { + fs.chmodSync(logPath, 0o600); + } catch (error) { + console.warn( + `[host-service] Failed to chmod log file ${logPath}: ${error}`, + ); + } + return fd; + } catch (error) { + console.warn(`[host-service] Failed to open log file ${logPath}: ${error}`); + return -1; + } +} + +export async function findFreePort(): Promise<number> { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr && typeof addr === "object") { + const { port } = addr; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error("Could not get port"))); + } + }); + server.on("error", reject); + }); +} + +export async function pollHealthCheck( + endpoint: string, + secret: string, + timeoutMs = HEALTH_POLL_TIMEOUT_MS, +): Promise<boolean> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + const res = await fetch(`${endpoint}/trpc/health.check`, { + signal: controller.signal, + headers: { Authorization: `Bearer ${secret}` }, + }); + if (res.ok) return true; + } catch { + // Not ready yet + } finally { + clearTimeout(timeout); + } + await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS)); + } + return false; +} diff --git a/apps/desktop/src/main/lib/hotkeys-events.ts b/apps/desktop/src/main/lib/hotkeys-events.ts deleted file mode 100644 index bf34063b195..00000000000 --- a/apps/desktop/src/main/lib/hotkeys-events.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { EventEmitter } from "node:events"; - -export interface HotkeysStateChangedEvent { - version: number; - updatedAt: string; -} - -export const hotkeysEmitter = new EventEmitter(); diff --git a/apps/desktop/src/main/lib/keyboardLayout.ts b/apps/desktop/src/main/lib/keyboardLayout.ts new file mode 100644 index 00000000000..ba88ac51b39 --- /dev/null +++ b/apps/desktop/src/main/lib/keyboardLayout.ts @@ -0,0 +1,101 @@ +import { EventEmitter } from "node:events"; + +// Wraps native-keymap for the renderer (mirrors VSCode's +// keyboardLayoutMainService). Lazy-loads on first read so the native module +// only initializes when actually needed. On macOS, native-keymap hooks +// Apple's kTISNotifySelectedKeyboardInputSourceChanged distributed +// notification — input-source switches fire onChange within milliseconds, +// which navigator.keyboard.layoutchange does not do in Chromium. + +export interface KeyboardLayoutData { + /** OS-specific layout id, e.g. "com.apple.keylayout.German". Empty if unavailable. */ + layoutId: string; + /** Localized human-readable name, e.g. "German". Empty if unavailable. */ + layoutName: string; + /** Map<event.code, unshifted glyph>. Phase 2 may extend with shifted/altgr layers. */ + unshifted: Record<string, string>; +} + +const EMPTY: KeyboardLayoutData = { + layoutId: "", + layoutName: "", + unshifted: {}, +}; + +const emitter = new EventEmitter(); +let cached: KeyboardLayoutData = EMPTY; +let initialized = false; + +type NativeKeymapModule = typeof import("native-keymap"); + +let nativeKeymap: NativeKeymapModule | null = null; + +function loadNative(): NativeKeymapModule | null { + if (nativeKeymap) return nativeKeymap; + try { + nativeKeymap = require("native-keymap") as NativeKeymapModule; + return nativeKeymap; + } catch (err) { + console.error("[keyboardLayout] failed to load native-keymap:", err); + return null; + } +} + +function read(): KeyboardLayoutData { + const mod = loadNative(); + if (!mod) return EMPTY; + try { + const info = mod.getCurrentKeyboardLayout() as { + id?: string; + name?: string; + localizedName?: string; + lang?: string; + } | null; + const map = mod.getKeyMap() as Record<string, { value?: string }>; + const unshifted: Record<string, string> = {}; + for (const [code, entry] of Object.entries(map)) { + if (entry?.value) unshifted[code] = entry.value; + } + return { + layoutId: info?.id ?? info?.name ?? "", + layoutName: info?.localizedName ?? info?.name ?? "", + unshifted, + }; + } catch (err) { + console.error("[keyboardLayout] read failed:", err); + return EMPTY; + } +} + +function ensureInitialized(): void { + if (initialized) return; + initialized = true; + const mod = loadNative(); + if (!mod) return; + cached = read(); + try { + mod.onDidChangeKeyboardLayout(() => { + cached = read(); + emitter.emit("change", cached); + }); + } catch (err) { + console.error("[keyboardLayout] failed to register listener:", err); + } +} + +/** Current layout snapshot. Initializes native-keymap on first call. */ +export function getKeyboardLayoutSnapshot(): KeyboardLayoutData { + ensureInitialized(); + return cached; +} + +/** Subscribe to layout changes. Returns an unsubscribe function. */ +export function onKeyboardLayoutChange( + cb: (data: KeyboardLayoutData) => void, +): () => void { + ensureInitialized(); + emitter.on("change", cb); + return () => { + emitter.off("change", cb); + }; +} diff --git a/apps/desktop/src/main/lib/menu.ts b/apps/desktop/src/main/lib/menu.ts index a312d4124ac..d1915156d46 100644 --- a/apps/desktop/src/main/lib/menu.ts +++ b/apps/desktop/src/main/lib/menu.ts @@ -1,15 +1,7 @@ import { COMPANY } from "@superset/shared/constants"; import { app, BrowserWindow, Menu, shell } from "electron"; import { env } from "main/env.main"; -import { appState } from "main/lib/app-state"; -import { hotkeysEmitter } from "main/lib/hotkeys-events"; import { resetTerminalStateDev } from "main/lib/terminal/dev-reset"; -import { - getCurrentPlatform, - getEffectiveHotkey, - type HotkeyId, - toElectronAccelerator, -} from "shared/hotkeys"; import { checkForUpdatesInteractive, simulateDownloading, @@ -18,29 +10,11 @@ import { } from "./auto-updater"; import { menuEmitter } from "./menu-events"; -let isHotkeyListenerRegistered = false; - -function getMenuAccelerator(id: HotkeyId): string | undefined { - const platform = getCurrentPlatform(); - const overrides = appState.data.hotkeysState.byPlatform[platform]; - const keys = getEffectiveHotkey(id, overrides, platform); - const accelerator = toElectronAccelerator(keys, platform); - return accelerator ?? undefined; -} - -export function registerMenuHotkeyUpdates() { - if (isHotkeyListenerRegistered) return; - isHotkeyListenerRegistered = true; - hotkeysEmitter.on("change", () => { - createApplicationMenu(); - }); -} - export function createApplicationMenu() { - const reloadAccelerator = getMenuAccelerator("RELOAD_WINDOW"); - const closeAccelerator = getMenuAccelerator("CLOSE_WINDOW"); - const showHotkeysAccelerator = getMenuAccelerator("SHOW_HOTKEYS"); - const openSettingsAccelerator = getMenuAccelerator("OPEN_SETTINGS"); + const reloadAccelerator = "CmdOrCtrl+R"; + const closeAccelerator = "CmdOrCtrl+Shift+Q"; + const showHotkeysAccelerator = "CmdOrCtrl+/"; + const openSettingsAccelerator = "CmdOrCtrl+,"; const template: Electron.MenuItemConstructorOptions[] = [ { diff --git a/apps/desktop/src/main/lib/notification-sound.ts b/apps/desktop/src/main/lib/notification-sound.ts index 869559ee711..9a530aa6daa 100644 --- a/apps/desktop/src/main/lib/notification-sound.ts +++ b/apps/desktop/src/main/lib/notification-sound.ts @@ -1,5 +1,3 @@ -import { execFile } from "node:child_process"; -import { existsSync } from "node:fs"; import { settings } from "@superset/local-db"; import { CUSTOM_RINGTONE_ID, @@ -8,6 +6,7 @@ import { } from "../../shared/ringtones"; import { getCustomRingtonePath } from "./custom-ringtones"; import { localDb } from "./local-db"; +import { playSoundFile } from "./play-sound"; import { getSoundPath } from "./sound-paths"; /** @@ -51,32 +50,6 @@ function getSelectedRingtonePath(): string | null { } } -/** - * Plays a sound file using platform-specific commands - */ -function playSoundFile(soundPath: string): void { - if (!existsSync(soundPath)) { - console.warn(`[notification-sound] Sound file not found: ${soundPath}`); - return; - } - - if (process.platform === "darwin") { - execFile("afplay", [soundPath]); - } else if (process.platform === "win32") { - execFile("powershell", [ - "-c", - `(New-Object Media.SoundPlayer '${soundPath}').PlaySync()`, - ]); - } else { - // Linux - try common audio players - execFile("paplay", [soundPath], (error) => { - if (error) { - execFile("aplay", [soundPath]); - } - }); - } -} - /** * Plays the notification sound based on user's selected ringtone. * Uses platform-specific commands to play the audio file. @@ -94,5 +67,22 @@ export function playNotificationSound(): void { return; } - playSoundFile(soundPath); + // Get volume from settings + let volume = 100; + try { + const settingsRow = localDb.select().from(settings).get(); + const raw = settingsRow?.notificationVolume; + volume = + typeof raw === "number" && Number.isFinite(raw) + ? Math.max(0, Math.min(100, raw)) + : 100; + } catch (err) { + console.warn( + "[notification-sound] Failed to read notification volume setting", + err, + ); + volume = 100; + } + + playSoundFile(soundPath, volume); } diff --git a/apps/desktop/src/main/lib/notifications/map-event-type.ts b/apps/desktop/src/main/lib/notifications/map-event-type.ts index 48da8020fb7..37fe1ced189 100644 --- a/apps/desktop/src/main/lib/notifications/map-event-type.ts +++ b/apps/desktop/src/main/lib/notifications/map-event-type.ts @@ -6,29 +6,42 @@ export function mapEventType( } if ( eventType === "Start" || + eventType === "SessionStart" || eventType === "UserPromptSubmit" || eventType === "PostToolUse" || eventType === "PostToolUseFailure" || eventType === "BeforeAgent" || eventType === "AfterTool" || eventType === "sessionStart" || + eventType === "session_start" || eventType === "userPromptSubmitted" || - eventType === "postToolUse" + eventType === "user_prompt_submit" || + eventType === "postToolUse" || + eventType === "post_tool_use" || + eventType === "task_started" ) { return "Start"; } if ( eventType === "PermissionRequest" || eventType === "Notification" || - eventType === "preToolUse" + eventType === "PreToolUse" || + eventType === "preToolUse" || + eventType === "pre_tool_use" || + eventType === "exec_approval_request" || + eventType === "apply_patch_approval_request" || + eventType === "request_user_input" ) { return "PermissionRequest"; } if ( eventType === "Stop" || + eventType === "stop" || eventType === "agent-turn-complete" || eventType === "AfterAgent" || - eventType === "sessionEnd" + eventType === "sessionEnd" || + eventType === "session_end" || + eventType === "task_complete" ) { return "Stop"; } diff --git a/apps/desktop/src/main/lib/notifications/notification-manager.test.ts b/apps/desktop/src/main/lib/notifications/notification-manager.test.ts index 7671d38fa1e..412345fd2c8 100644 --- a/apps/desktop/src/main/lib/notifications/notification-manager.test.ts +++ b/apps/desktop/src/main/lib/notifications/notification-manager.test.ts @@ -297,8 +297,8 @@ describe("NotificationManager", () => { expect(createNotification).toHaveBeenCalledWith( expect.objectContaining({ - title: "Input Needed — Test Workspace", - body: '"Test Title" needs your attention', + title: "Awaiting Response — Test Workspace", + body: '"Test Title" is waiting for your reply', }), ); }); diff --git a/apps/desktop/src/main/lib/notifications/notification-manager.ts b/apps/desktop/src/main/lib/notifications/notification-manager.ts index 0ad7facd07b..3434b9ee84a 100644 --- a/apps/desktop/src/main/lib/notifications/notification-manager.ts +++ b/apps/desktop/src/main/lib/notifications/notification-manager.ts @@ -64,13 +64,16 @@ export class NotificationManager { const title = this.deps.getNotificationTitle(event); const isPermissionRequest = event.eventType === "PermissionRequest"; + const isPendingQuestion = event.eventType === "PendingQuestion"; const notification = this.deps.createNotification({ - title: isPermissionRequest - ? `Input Needed — ${workspaceName}` - : `Agent Complete — ${workspaceName}`, - body: isPermissionRequest - ? `"${title}" needs your attention` - : `"${title}" has finished its task`, + title: + isPermissionRequest || isPendingQuestion + ? `Awaiting Response — ${workspaceName}` + : `Agent Complete — ${workspaceName}`, + body: + isPermissionRequest || isPendingQuestion + ? `"${title}" is waiting for your reply` + : `"${title}" has finished its task`, silent: true, }); diff --git a/apps/desktop/src/main/lib/notifications/server.test.ts b/apps/desktop/src/main/lib/notifications/server.test.ts index 94e848728fe..3c5743ece68 100644 --- a/apps/desktop/src/main/lib/notifications/server.test.ts +++ b/apps/desktop/src/main/lib/notifications/server.test.ts @@ -16,10 +16,21 @@ describe("notifications/server", () => { expect(mapEventType("Start")).toBe("Start"); }); + it("should map 'SessionStart' to 'Start'", () => { + expect(mapEventType("SessionStart")).toBe("Start"); + }); + it("should map 'UserPromptSubmit' to 'Start'", () => { expect(mapEventType("UserPromptSubmit")).toBe("Start"); }); + it("should map Codex snake_case start events to 'Start'", () => { + expect(mapEventType("session_start")).toBe("Start"); + expect(mapEventType("user_prompt_submit")).toBe("Start"); + expect(mapEventType("post_tool_use")).toBe("Start"); + expect(mapEventType("task_started")).toBe("Start"); + }); + it("should map 'Stop' to 'Stop'", () => { expect(mapEventType("Stop")).toBe("Stop"); }); @@ -28,6 +39,11 @@ describe("notifications/server", () => { expect(mapEventType("agent-turn-complete")).toBe("Stop"); }); + it("should map Codex native stop events to 'Stop'", () => { + expect(mapEventType("stop")).toBe("Stop"); + expect(mapEventType("task_complete")).toBe("Stop"); + }); + it("should map 'PostToolUse' to 'Start'", () => { expect(mapEventType("PostToolUse")).toBe("Start"); }); @@ -52,6 +68,16 @@ describe("notifications/server", () => { expect(mapEventType("PermissionRequest")).toBe("PermissionRequest"); }); + it("should map Codex tool approval events to 'PermissionRequest'", () => { + expect(mapEventType("PreToolUse")).toBe("PermissionRequest"); + expect(mapEventType("pre_tool_use")).toBe("PermissionRequest"); + expect(mapEventType("exec_approval_request")).toBe("PermissionRequest"); + expect(mapEventType("apply_patch_approval_request")).toBe( + "PermissionRequest", + ); + expect(mapEventType("request_user_input")).toBe("PermissionRequest"); + }); + it("should map Factory Droid 'Notification' to 'PermissionRequest'", () => { expect(mapEventType("Notification")).toBe("PermissionRequest"); }); diff --git a/apps/desktop/src/main/lib/persistence/persistence.ts b/apps/desktop/src/main/lib/persistence/persistence.ts new file mode 100644 index 00000000000..fb2a34d4afc --- /dev/null +++ b/apps/desktop/src/main/lib/persistence/persistence.ts @@ -0,0 +1,26 @@ +import { join } from "node:path"; +import { exposeElectronSQLitePersistence } from "@tanstack/electron-db-sqlite-persistence/main"; +import { createNodeSQLitePersistence } from "@tanstack/node-db-sqlite-persistence"; +import Database from "better-sqlite3"; +import { ipcMain } from "electron"; +import { + ensureSupersetHomeDirExists, + SUPERSET_HOME_DIR, +} from "../app-environment"; + +let dispose: (() => void) | null = null; +let database: Database.Database | null = null; + +export function initTanstackDbPersistence(): void { + ensureSupersetHomeDirExists(); + database = new Database(join(SUPERSET_HOME_DIR, "tanstack-db.sqlite")); + const persistence = createNodeSQLitePersistence({ database }); + dispose = exposeElectronSQLitePersistence({ ipcMain, persistence }); +} + +export function shutdownTanstackDbPersistence(): void { + dispose?.(); + dispose = null; + database?.close(); + database = null; +} diff --git a/apps/desktop/src/main/lib/play-sound.ts b/apps/desktop/src/main/lib/play-sound.ts new file mode 100644 index 00000000000..17fdf7a5dcc --- /dev/null +++ b/apps/desktop/src/main/lib/play-sound.ts @@ -0,0 +1,60 @@ +import type { ChildProcess } from "node:child_process"; +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; + +interface PlaySoundCallbacks { + onComplete?: () => void; + isCanceled?: () => boolean; + onProcessChange?: (process: ChildProcess) => void; +} + +/** + * Plays a sound file at the given volume using platform-specific commands. + * Returns the primary ChildProcess, or null if playback was skipped. + * + * On macOS, volume is controlled via afplay -v (0.0-1.0). + * On Linux, volume is controlled via paplay --volume (0-65536), with aplay fallback. + */ +export function playSoundFile( + soundPath: string, + volume: number = 100, + callbacks?: PlaySoundCallbacks, +): ChildProcess | null { + if (!existsSync(soundPath)) { + console.warn(`[play-sound] Sound file not found: ${soundPath}`); + return null; + } + + const volumeDecimal = volume / 100; + + if (process.platform === "darwin") { + return execFile("afplay", ["-v", volumeDecimal.toString(), soundPath], () => + callbacks?.onComplete?.(), + ); + } + + // Linux: paplay --volume accepts 0-65536 (65536 = 100%) + const paVolume = Math.round(volumeDecimal * 65536); + return execFile( + "paplay", + ["--volume", paVolume.toString(), soundPath], + (error) => { + if (error) { + if (callbacks?.isCanceled?.()) { + callbacks?.onComplete?.(); + return; + } + if (volume === 0) { + callbacks?.onComplete?.(); + return; + } + const fallback = execFile("aplay", [soundPath], () => + callbacks?.onComplete?.(), + ); + callbacks?.onProcessChange?.(fallback); + return; + } + callbacks?.onComplete?.(); + }, + ); +} diff --git a/apps/desktop/src/main/lib/project-icons.test.ts b/apps/desktop/src/main/lib/project-icons.test.ts new file mode 100644 index 00000000000..5cbec967541 --- /dev/null +++ b/apps/desktop/src/main/lib/project-icons.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { parseProjectIconDataUrl } from "./project-icons"; + +const ICON_BASE64 = Buffer.from("icon").toString("base64"); + +describe("parseProjectIconDataUrl", () => { + test("parses PNG data URLs", () => { + const result = parseProjectIconDataUrl( + `data:image/png;base64,${ICON_BASE64}`, + ); + + expect(result.ext).toBe("png"); + expect(result.buffer.toString()).toBe("icon"); + }); + + test("normalizes JPEG MIME types to the jpg extension", () => { + const result = parseProjectIconDataUrl( + `data:image/jpeg;base64,${ICON_BASE64}`, + ); + + expect(result.ext).toBe("jpg"); + expect(result.buffer.toString()).toBe("icon"); + }); + + test("parses SVG data URLs with extra MIME parameters", () => { + const result = parseProjectIconDataUrl( + `data:image/svg+xml;charset=utf-8;base64,${ICON_BASE64}`, + ); + + expect(result.ext).toBe("svg"); + expect(result.buffer.toString()).toBe("icon"); + }); + + test("maps ICO MIME types to the ico extension", () => { + const xIcon = parseProjectIconDataUrl( + `data:image/x-icon;base64,${ICON_BASE64}`, + ); + const microsoftIcon = parseProjectIconDataUrl( + `data:image/vnd.microsoft.icon;base64,${ICON_BASE64}`, + ); + + expect(xIcon.ext).toBe("ico"); + expect(microsoftIcon.ext).toBe("ico"); + }); + + test("rejects unsupported image MIME types", () => { + expect(() => + parseProjectIconDataUrl(`data:image/webp;base64,${ICON_BASE64}`), + ).toThrow("Unsupported icon format"); + }); + + test("rejects malformed data URLs", () => { + expect(() => parseProjectIconDataUrl("not-a-data-url")).toThrow( + "Invalid data URL format", + ); + }); +}); diff --git a/apps/desktop/src/main/lib/project-icons.ts b/apps/desktop/src/main/lib/project-icons.ts index e4603ff04a7..3c3067b9206 100644 --- a/apps/desktop/src/main/lib/project-icons.ts +++ b/apps/desktop/src/main/lib/project-icons.ts @@ -2,12 +2,17 @@ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"; import { copyFile, writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; +import { + getImageExtensionFromMimeType, + parseBase64DataUrl, +} from "shared/file-types"; import { SUPERSET_HOME_DIR } from "./app-environment"; export const PROJECT_ICONS_DIR = join(SUPERSET_HOME_DIR, "project-icons"); /** Max icon file size: 512KB */ const MAX_ICON_SIZE = 512 * 1024; +const PROJECT_ICON_EXTENSIONS = new Set(["png", "jpg", "svg", "ico"]); /** * Ensures the project icons directory exists. @@ -52,6 +57,25 @@ export function getProjectIconProtocolUrl(projectId: string): string { return `superset-icon://projects/${projectId}?v=${encodeURIComponent(randomUUID())}`; } +export function parseProjectIconDataUrl(dataUrl: string): { + buffer: Buffer; + ext: string; +} { + const { base64Data, mimeType } = parseBase64DataUrl(dataUrl); + const ext = getImageExtensionFromMimeType(mimeType); + + if (!ext || !PROJECT_ICON_EXTENSIONS.has(ext)) { + throw new Error( + "Unsupported icon format. Supported formats are PNG, JPEG, SVG, and ICO.", + ); + } + + return { + buffer: Buffer.from(base64Data, "base64"), + ext, + }; +} + /** * Saves an icon file for a project from a local file path. * Copies the file to PROJECT_ICONS_DIR/{projectId}.{ext}. @@ -89,14 +113,7 @@ export async function saveProjectIconFromDataUrl({ ensureProjectIconsDir(); removeExistingIcon(projectId); - // Parse data URL: data:image/png;base64,<data> - const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/); - if (!match) { - throw new Error("Invalid data URL format"); - } - - const ext = match[1] === "jpeg" ? "jpg" : match[1]; - const buffer = Buffer.from(match[2], "base64"); + const { buffer, ext } = parseProjectIconDataUrl(dataUrl); if (buffer.length > MAX_ICON_SIZE) { throw new Error( diff --git a/apps/desktop/src/main/lib/safe-url/index.ts b/apps/desktop/src/main/lib/safe-url/index.ts new file mode 100644 index 00000000000..cec73bbf341 --- /dev/null +++ b/apps/desktop/src/main/lib/safe-url/index.ts @@ -0,0 +1,2 @@ +export { safeOpenExternal } from "./safe-url"; +export { externalUrlLogLabel, isSafeExternalUrl } from "./scheme"; diff --git a/apps/desktop/src/main/lib/safe-url/safe-url.test.ts b/apps/desktop/src/main/lib/safe-url/safe-url.test.ts new file mode 100644 index 00000000000..33340bd175f --- /dev/null +++ b/apps/desktop/src/main/lib/safe-url/safe-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import { externalUrlLogLabel, isSafeExternalUrl } from "./scheme"; + +describe("isSafeExternalUrl", () => { + it("allows http, https, and mailto URLs", () => { + expect(isSafeExternalUrl("http://example.com")).toBe(true); + expect(isSafeExternalUrl("https://example.com/path?q=1")).toBe(true); + expect(isSafeExternalUrl("mailto:user@example.com")).toBe(true); + expect(isSafeExternalUrl("HTTPS://EXAMPLE.COM")).toBe(true); + }); + + it("blocks file, javascript, data, and custom-scheme URLs", () => { + expect( + isSafeExternalUrl("file:///System/Applications/Calculator.app"), + ).toBe(false); + expect(isSafeExternalUrl("file:///etc/passwd")).toBe(false); + expect(isSafeExternalUrl("javascript:alert(1)")).toBe(false); + expect(isSafeExternalUrl("data:text/html,<script>alert(1)</script>")).toBe( + false, + ); + expect(isSafeExternalUrl("vscode://open?url=evil")).toBe(false); + expect(isSafeExternalUrl("ssh://user@host")).toBe(false); + expect(isSafeExternalUrl("ftp://example.com")).toBe(false); + }); + + it("blocks malformed input", () => { + expect(isSafeExternalUrl("")).toBe(false); + expect(isSafeExternalUrl("not a url")).toBe(false); + expect(isSafeExternalUrl("/etc/passwd")).toBe(false); + }); +}); + +describe("externalUrlLogLabel", () => { + it("returns only the scheme, never the full URL", () => { + expect(externalUrlLogLabel("https://example.com/path?token=secret")).toBe( + "https:", + ); + expect(externalUrlLogLabel("file:///etc/passwd")).toBe("file:"); + expect(externalUrlLogLabel("mailto:user@example.com")).toBe("mailto:"); + }); + + it("returns sentinels for empty and malformed input", () => { + expect(externalUrlLogLabel("")).toBe("empty"); + expect(externalUrlLogLabel("not a url")).toBe("malformed"); + }); +}); diff --git a/apps/desktop/src/main/lib/safe-url/safe-url.ts b/apps/desktop/src/main/lib/safe-url/safe-url.ts new file mode 100644 index 00000000000..3ac8fb349d5 --- /dev/null +++ b/apps/desktop/src/main/lib/safe-url/safe-url.ts @@ -0,0 +1,29 @@ +import { shell } from "electron"; +import { externalUrlLogLabel, isSafeExternalUrl } from "./scheme"; + +/** + * Wraps `shell.openExternal` with a scheme allowlist. Returns false and + * refuses to dispatch when the URL is not http(s)/mailto. Catches + * `shell.openExternal` rejections so callers can fire-and-forget without + * risking an unhandled rejection in the Electron main process. + */ +export async function safeOpenExternal(url: string): Promise<boolean> { + if (!isSafeExternalUrl(url)) { + console.warn( + "[safeOpenExternal] blocked unsafe URL scheme:", + externalUrlLogLabel(url), + ); + return false; + } + try { + await shell.openExternal(url); + return true; + } catch (error) { + console.error( + "[safeOpenExternal] openExternal failed:", + externalUrlLogLabel(url), + error, + ); + return false; + } +} diff --git a/apps/desktop/src/main/lib/safe-url/scheme.ts b/apps/desktop/src/main/lib/safe-url/scheme.ts new file mode 100644 index 00000000000..0ca05a82aac --- /dev/null +++ b/apps/desktop/src/main/lib/safe-url/scheme.ts @@ -0,0 +1,24 @@ +/** + * Schemes safe to hand to Electron's `shell.openExternal`. + * Anything else (file:, javascript:, custom handlers, etc.) can execute + * binaries or scripts via the OS URL handler registry. + */ +const ALLOWED_SCHEMES = new Set(["http:", "https:", "mailto:"]); + +export function isSafeExternalUrl(url: string): boolean { + if (typeof url !== "string" || url.length === 0) return false; + try { + return ALLOWED_SCHEMES.has(new URL(url).protocol); + } catch { + return false; + } +} + +export function externalUrlLogLabel(url: string): string { + if (typeof url !== "string" || url.length === 0) return "empty"; + try { + return new URL(url).protocol || "unknown:"; + } catch { + return "malformed"; + } +} diff --git a/apps/desktop/src/main/lib/static-ports/loader.test.ts b/apps/desktop/src/main/lib/static-ports/loader.test.ts index c738b291795..6abd101a371 100644 --- a/apps/desktop/src/main/lib/static-ports/loader.test.ts +++ b/apps/desktop/src/main/lib/static-ports/loader.test.ts @@ -236,6 +236,23 @@ describe("loadStaticPorts", () => { expect(result.error).toBe("ports[1].port must be an integer"); }); + test("returns error when a port entry is duplicated", () => { + writeFileSync( + PORTS_FILE, + JSON.stringify({ + ports: [ + { port: 3000, label: "Frontend" }, + { port: 3000, label: "Duplicate" }, + ], + }), + ); + + const result = loadStaticPorts(WORKTREE_PATH); + expect(result.exists).toBe(true); + expect(result.ports).toBeNull(); + expect(result.error).toBe("ports[1].port duplicates an earlier entry"); + }); + test("accepts valid boundary port numbers", () => { const config = { ports: [ diff --git a/apps/desktop/src/main/lib/static-ports/loader.ts b/apps/desktop/src/main/lib/static-ports/loader.ts index b03684183de..e1b918c17ad 100644 --- a/apps/desktop/src/main/lib/static-ports/loader.ts +++ b/apps/desktop/src/main/lib/static-ports/loader.ts @@ -1,72 +1,9 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { parseStaticPortsConfig } from "@superset/port-scanner"; import { PORTS_FILE_NAME, PROJECT_SUPERSET_DIR_NAME } from "shared/constants"; import type { StaticPortsResult } from "shared/types"; -interface PortEntry { - port: unknown; - label: unknown; -} - -interface PortsConfig { - ports: unknown; -} - -/** - * Validate a single port entry from the ports.json configuration. - * - * @param entry - The port entry object to validate - * @param index - The index of the entry in the ports array (for error messages) - * @returns Validation result with either the validated port/label or an error message - */ -function validatePortEntry( - entry: PortEntry, - index: number, -): - | { valid: true; port: number; label: string } - | { valid: false; error: string } { - if (typeof entry !== "object" || entry === null) { - return { valid: false, error: `ports[${index}] must be an object` }; - } - - if (!("port" in entry)) { - return { - valid: false, - error: `ports[${index}] is missing required field 'port'`, - }; - } - - if (!("label" in entry)) { - return { - valid: false, - error: `ports[${index}] is missing required field 'label'`, - }; - } - - const { port, label } = entry; - - if (typeof port !== "number" || !Number.isInteger(port)) { - return { valid: false, error: `ports[${index}].port must be an integer` }; - } - - if (port < 1 || port > 65535) { - return { - valid: false, - error: `ports[${index}].port must be between 1 and 65535`, - }; - } - - if (typeof label !== "string") { - return { valid: false, error: `ports[${index}].label must be a string` }; - } - - if (label.trim() === "") { - return { valid: false, error: `ports[${index}].label cannot be empty` }; - } - - return { valid: true, port, label: label.trim() }; -} - /** * Load and validate static ports configuration from a worktree's .superset/ports.json file. * @@ -96,56 +33,12 @@ export function loadStaticPorts(worktreePath: string): StaticPortsResult { }; } - let parsed: PortsConfig; - try { - parsed = JSON.parse(content) as PortsConfig; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - exists: true, - ports: null, - error: `Invalid JSON in ports.json: ${message}`, - }; - } - - if (typeof parsed !== "object" || parsed === null) { - return { - exists: true, - ports: null, - error: "ports.json must contain a JSON object", - }; - } - - if (!("ports" in parsed)) { - return { - exists: true, - ports: null, - error: "ports.json is missing required field 'ports'", - }; - } - - if (!Array.isArray(parsed.ports)) { - return { - exists: true, - ports: null, - error: "'ports' field must be an array", - }; - } - - const validatedPorts: Array<{ port: number; label: string }> = []; - - for (let i = 0; i < parsed.ports.length; i++) { - const entry = parsed.ports[i] as PortEntry; - const result = validatePortEntry(entry, i); - - if (!result.valid) { - return { exists: true, ports: null, error: result.error }; - } - - validatedPorts.push({ port: result.port, label: result.label }); + const parsed = parseStaticPortsConfig(content); + if (parsed.ports === null) { + return { exists: true, ports: null, error: parsed.error }; } - return { exists: true, ports: validatedPorts, error: null }; + return { exists: true, ports: parsed.ports, error: null }; } /** diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts index d1a7fb8b5eb..e3ce361b08d 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.test.ts @@ -201,8 +201,8 @@ mock.module("@superset/local-db", () => ({ mock.module("../port-manager", () => ({ portManager: { - upsertDaemonSession: () => {}, - unregisterDaemonSession: () => {}, + upsertSession: () => {}, + unregisterSession: () => {}, checkOutputForHint: () => {}, }, })); diff --git a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts index 10e6bf685a4..2974e9db3eb 100644 --- a/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts +++ b/apps/desktop/src/main/lib/terminal/daemon/daemon-manager.ts @@ -138,7 +138,7 @@ export class DaemonTerminalManager extends EventEmitter { // Enable port scanning before user opens terminal tabs for (const session of preservedSessions) { - portManager.upsertDaemonSession( + portManager.upsertSession( session.paneId, session.workspaceId, session.pid, @@ -191,7 +191,7 @@ export class DaemonTerminalManager extends EventEmitter { session.lastActive = Date.now(); } - portManager.checkOutputForHint(data, paneId); + portManager.checkOutputForHint(data); this.historyManager.writeToHistory(paneId, data, () => this.sessions.get(paneId), ); @@ -210,7 +210,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterDaemonSession(paneId); + portManager.unregisterSession(paneId); this.historyManager.closeHistoryWriter(paneId, exitCode); const reason = session?.exitReason ?? @@ -511,7 +511,7 @@ export class DaemonTerminalManager extends EventEmitter { rows: effectiveRows, }); - portManager.upsertDaemonSession(paneId, workspaceId, response.pid); + portManager.upsertSession(paneId, workspaceId, response.pid); const snapshotAnsi = response.snapshot.snapshotAnsi || ""; const snapshotAnsiBytes = Buffer.byteLength(snapshotAnsi, "utf8"); @@ -722,7 +722,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterDaemonSession(paneId); + portManager.unregisterSession(paneId); if (deleteHistory && session) { await this.historyManager.cleanupHistory(paneId, session.workspaceId); @@ -855,7 +855,7 @@ export class DaemonTerminalManager extends EventEmitter { session.pid = null; } - portManager.unregisterDaemonSession(paneId); + portManager.unregisterSession(paneId); await this.historyManager.cleanupHistory(paneId, workspaceId); await this.client.kill({ sessionId: paneId, deleteHistory: true }); }), @@ -974,7 +974,7 @@ export class DaemonTerminalManager extends EventEmitter { await this.client.killAll({}); } for (const paneId of sessionIds) { - portManager.unregisterDaemonSession(paneId); + portManager.unregisterSession(paneId); } this.daemonAliveSessionIds.clear(); this.daemonSessionIdsHydrated = true; diff --git a/apps/desktop/src/main/lib/terminal/env.test.ts b/apps/desktop/src/main/lib/terminal/env.test.ts index a0c661abe2c..98e7fd7386f 100644 --- a/apps/desktop/src/main/lib/terminal/env.test.ts +++ b/apps/desktop/src/main/lib/terminal/env.test.ts @@ -658,9 +658,9 @@ describe("env", () => { }); describe("terminal metadata", () => { - it("should set TERM_PROGRAM to Superset", () => { + it("should set TERM_PROGRAM to kitty", () => { const result = buildTerminalEnv(baseParams); - expect(result.TERM_PROGRAM).toBe("Superset"); + expect(result.TERM_PROGRAM).toBe("kitty"); }); it("should set COLORTERM to truecolor", () => { diff --git a/apps/desktop/src/main/lib/terminal/env.ts b/apps/desktop/src/main/lib/terminal/env.ts index 883582ba935..302d2725f32 100644 --- a/apps/desktop/src/main/lib/terminal/env.ts +++ b/apps/desktop/src/main/lib/terminal/env.ts @@ -464,7 +464,7 @@ export function buildTerminalEnv(params: { const terminalEnv: Record<string, string> = { ...baseEnv, ...shellEnv, - TERM_PROGRAM: "Superset", + TERM_PROGRAM: "kitty", TERM_PROGRAM_VERSION: process.env.npm_package_version || "1.0.0", COLORTERM: "truecolor", COLORFGBG: colorFgBg, diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index 4b7fdaf8e7b..7b6dcbbbf1a 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -1,504 +1,6 @@ -import { EventEmitter } from "node:events"; -import type { DetectedPort } from "shared/types"; +import { PortManager } from "@superset/port-scanner"; import { treeKillWithEscalation } from "../tree-kill"; -import { - getListeningPortsForPids, - getProcessTree, - type PortInfo, -} from "./port-scanner"; -import type { TerminalSession } from "./types"; -// How often to poll for port changes (in ms) -const SCAN_INTERVAL_MS = 2500; - -// Delay before scanning after a port hint is detected (in ms) -const HINT_SCAN_DELAY_MS = 500; - -// Ports to ignore (common system ports that are usually not dev servers) -const IGNORED_PORTS = new Set([22, 80, 443, 5432, 3306, 6379, 27017]); - -/** - * Check if terminal output contains hints that a port may have been opened. - * Common patterns from dev servers, test frameworks, etc. - */ -function containsPortHint(data: string): boolean { - // Common patterns: "listening on port X", "server started on :X", etc. - const portPatterns = [ - /listening\s+on\s+(?:port\s+)?(\d+)/i, - /server\s+(?:started|running)\s+(?:on|at)\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, - /ready\s+on\s+(?:http:\/\/)?(?:localhost|127\.0\.0\.1|0\.0\.0\.0)?:?(\d+)/i, - /port\s+(\d+)/i, - /:(\d{4,5})\s*$/, - ]; - return portPatterns.some((pattern) => pattern.test(data)); -} - -interface RegisteredSession { - session: TerminalSession; - workspaceId: string; -} - -/** - * Daemon session registration for port scanning. - * Unlike RegisteredSession, this tracks sessions in the daemon process - * where we only have the PID (not a TerminalSession object). - */ -interface DaemonSession { - workspaceId: string; - /** PTY process ID - null if not yet spawned or exited */ - pid: number | null; -} - -interface ScanState { - panePortMap: Map<string, { workspaceId: string; pids: number[] }>; - pidOwnerMap: Map<number, { paneId: string; workspaceId: string }>; - allPids: Set<number>; - emptyTreePanes: Set<string>; -} - -class PortManager extends EventEmitter { - private ports = new Map<string, DetectedPort>(); - private sessions = new Map<string, RegisteredSession>(); - /** Daemon-mode sessions: paneId → { workspaceId, pid } */ - private daemonSessions = new Map<string, DaemonSession>(); - private scanInterval: ReturnType<typeof setInterval> | null = null; - private pendingHintScans = new Map<string, ReturnType<typeof setTimeout>>(); - private isScanning = false; - - constructor() { - super(); - this.startPeriodicScan(); - } - - /** - * Register a terminal session for port scanning - */ - registerSession(session: TerminalSession, workspaceId: string): void { - this.sessions.set(session.paneId, { session, workspaceId }); - } - - /** - * Unregister a terminal session and remove its ports - */ - unregisterSession(paneId: string): void { - this.sessions.delete(paneId); - this.removePortsForPane(paneId); - this.clearPendingHintScan(paneId); - } - - /** - * Register or update a daemon-mode terminal session for port scanning. - * Use this when the terminal runs in the daemon process (terminal persistence mode). - * Can be called multiple times to update the PID when it becomes available or changes. - */ - upsertDaemonSession( - paneId: string, - workspaceId: string, - pid: number | null, - ): void { - this.daemonSessions.set(paneId, { workspaceId, pid }); - } - - /** - * Unregister a daemon-mode terminal session and remove its ports - */ - unregisterDaemonSession(paneId: string): void { - this.daemonSessions.delete(paneId); - this.removePortsForPane(paneId); - this.clearPendingHintScan(paneId); - } - - checkOutputForHint(data: string, paneId: string): void { - if (!containsPortHint(data)) return; - this.scheduleHintScan(paneId); - } - - private startPeriodicScan(): void { - if (this.scanInterval) return; - - this.scanInterval = setInterval(() => { - this.scanAllSessions().catch((error) => { - console.error("[PortManager] Scan error:", error); - }); - }, SCAN_INTERVAL_MS); - - // Don't prevent Node from exiting - this.scanInterval.unref(); - } - - stopPeriodicScan(): void { - if (this.scanInterval) { - clearInterval(this.scanInterval); - this.scanInterval = null; - } - - for (const timeout of this.pendingHintScans.values()) { - clearTimeout(timeout); - } - this.pendingHintScans.clear(); - } - - private clearPendingHintScan(paneId: string): void { - const pendingTimeout = this.pendingHintScans.get(paneId); - if (pendingTimeout) { - clearTimeout(pendingTimeout); - this.pendingHintScans.delete(paneId); - } - } - - private scheduleHintScan(paneId: string): void { - this.clearPendingHintScan(paneId); - - const timeout = setTimeout(() => { - this.pendingHintScans.delete(paneId); - this.scanPane(paneId).catch(() => {}); - }, HINT_SCAN_DELAY_MS); - // Don't keep Electron alive just for port scanning - timeout.unref(); - - this.pendingHintScans.set(paneId, timeout); - } - - private async scanPidTreeAndUpdate({ - paneId, - workspaceId, - pid, - errorContext, - }: { - paneId: string; - workspaceId: string; - pid: number; - errorContext: string; - }): Promise<void> { - try { - const pids = await getProcessTree(pid); - if (pids.length === 0) { - this.removePortsForPane(paneId); - return; - } - - const portInfos = await getListeningPortsForPids(pids); - this.updatePortsForPane({ paneId, workspaceId, portInfos }); - } catch (error) { - console.error(`[PortManager] Error scanning ${errorContext}:`, error); - } - } - - private async scanPane(paneId: string): Promise<void> { - const registered = this.sessions.get(paneId); - if (registered) { - const { session, workspaceId } = registered; - if (!session.isAlive) return; - await this.scanPidTreeAndUpdate({ - paneId, - workspaceId, - pid: session.pty.pid, - errorContext: `pane ${paneId}`, - }); - return; - } - - const daemonSession = this.daemonSessions.get(paneId); - if (daemonSession) { - const { workspaceId, pid } = daemonSession; - if (pid === null) return; - await this.scanPidTreeAndUpdate({ - paneId, - workspaceId, - pid, - errorContext: `daemon pane ${paneId}`, - }); - } - } - - private createScanState(): ScanState { - return { - panePortMap: new Map<string, { workspaceId: string; pids: number[] }>(), - pidOwnerMap: new Map<number, { paneId: string; workspaceId: string }>(), - allPids: new Set<number>(), - emptyTreePanes: new Set<string>(), - }; - } - - private async collectRegularSessionPids(scanState: ScanState): Promise<void> { - const tasks: Promise<void>[] = []; - for (const [paneId, { session, workspaceId }] of this.sessions) { - if (!session.isAlive) continue; - tasks.push( - this.collectPidTree({ - paneId, - workspaceId, - pid: session.pty.pid, - scanState, - }), - ); - } - await Promise.all(tasks); - } - - private async collectDaemonSessionPids(scanState: ScanState): Promise<void> { - const tasks: Promise<void>[] = []; - for (const [paneId, { workspaceId, pid }] of this.daemonSessions) { - if (pid === null) continue; - tasks.push( - this.collectPidTree({ - paneId, - workspaceId, - pid, - scanState, - }), - ); - } - await Promise.all(tasks); - } - - private async collectPidTree({ - paneId, - workspaceId, - pid, - scanState, - }: { - paneId: string; - workspaceId: string; - pid: number; - scanState: ScanState; - }): Promise<void> { - try { - const pids = await getProcessTree(pid); - if (pids.length === 0) { - scanState.emptyTreePanes.add(paneId); - return; - } - - scanState.panePortMap.set(paneId, { workspaceId, pids }); - this.addPanePids({ paneId, workspaceId, pids, scanState }); - } catch { - // Session may have exited - } - } - - private addPanePids({ - paneId, - workspaceId, - pids, - scanState, - }: { - paneId: string; - workspaceId: string; - pids: number[]; - scanState: ScanState; - }): void { - for (const childPid of pids) { - scanState.allPids.add(childPid); - if (!scanState.pidOwnerMap.has(childPid)) { - scanState.pidOwnerMap.set(childPid, { paneId, workspaceId }); - } - } - } - - private async buildPortsByPane({ - allPids, - pidOwnerMap, - }: { - allPids: Set<number>; - pidOwnerMap: ScanState["pidOwnerMap"]; - }): Promise<Map<string, PortInfo[]>> { - const portsByPane = new Map<string, PortInfo[]>(); - const allPidList = Array.from(allPids); - if (allPidList.length === 0) return portsByPane; - - const portInfos = await getListeningPortsForPids(allPidList); - for (const info of portInfos) { - const owner = pidOwnerMap.get(info.pid); - if (!owner) continue; - const existing = portsByPane.get(owner.paneId); - if (existing) { - existing.push(info); - } else { - portsByPane.set(owner.paneId, [info]); - } - } - - return portsByPane; - } - - private updatePortsFromScan({ - panePortMap, - portsByPane, - }: { - panePortMap: ScanState["panePortMap"]; - portsByPane: Map<string, PortInfo[]>; - }): void { - for (const [paneId, { workspaceId }] of panePortMap) { - const portInfos = portsByPane.get(paneId) ?? []; - this.updatePortsForPane({ paneId, workspaceId, portInfos }); - } - } - - private clearEmptyTreePanes(emptyTreePanes: Set<string>): void { - for (const paneId of emptyTreePanes) { - this.removePortsForPane(paneId); - } - } - - private cleanupUnregisteredPorts(): void { - for (const [key, port] of this.ports) { - const isRegistered = - this.sessions.has(port.paneId) || this.daemonSessions.has(port.paneId); - if (!isRegistered) { - this.ports.delete(key); - this.emit("port:remove", port); - } - } - } - - private async scanAllSessions(): Promise<void> { - if (this.isScanning) return; - this.isScanning = true; - - try { - const scanState = this.createScanState(); - await this.collectRegularSessionPids(scanState); - await this.collectDaemonSessionPids(scanState); - - const portsByPane = await this.buildPortsByPane({ - allPids: scanState.allPids, - pidOwnerMap: scanState.pidOwnerMap, - }); - - this.updatePortsFromScan({ - panePortMap: scanState.panePortMap, - portsByPane, - }); - this.clearEmptyTreePanes(scanState.emptyTreePanes); - this.cleanupUnregisteredPorts(); - } finally { - this.isScanning = false; - } - } - - private updatePortsForPane({ - paneId, - workspaceId, - portInfos, - }: { - paneId: string; - workspaceId: string; - portInfos: PortInfo[]; - }): void { - const now = Date.now(); - - const validPortInfos = portInfos.filter( - (info) => !IGNORED_PORTS.has(info.port), - ); - - const seenKeys = new Set<string>(); - - for (const info of validPortInfos) { - const key = this.makeKey(paneId, info.port); - seenKeys.add(key); - - const existing = this.ports.get(key); - if (!existing) { - const detectedPort: DetectedPort = { - port: info.port, - pid: info.pid, - processName: info.processName, - paneId, - workspaceId, - detectedAt: now, - address: info.address, - }; - this.ports.set(key, detectedPort); - this.emit("port:add", detectedPort); - } else if ( - existing.pid !== info.pid || - existing.processName !== info.processName - ) { - const updatedPort: DetectedPort = { - ...existing, - pid: info.pid, - processName: info.processName, - address: info.address, - }; - this.ports.set(key, updatedPort); - this.emit("port:remove", existing); - this.emit("port:add", updatedPort); - } - } - - for (const [key, port] of this.ports) { - if (port.paneId === paneId && !seenKeys.has(key)) { - this.ports.delete(key); - this.emit("port:remove", port); - } - } - } - - private makeKey(paneId: string, port: number): string { - return `${paneId}:${port}`; - } - - removePortsForPane(paneId: string): void { - const portsToRemove: DetectedPort[] = []; - - for (const [key, port] of this.ports) { - if (port.paneId === paneId) { - portsToRemove.push(port); - this.ports.delete(key); - } - } - - for (const port of portsToRemove) { - this.emit("port:remove", port); - } - } - - getAllPorts(): DetectedPort[] { - return Array.from(this.ports.values()).sort( - (a, b) => b.detectedAt - a.detectedAt, - ); - } - - getPortsByWorkspace(workspaceId: string): DetectedPort[] { - return this.getAllPorts().filter((p) => p.workspaceId === workspaceId); - } - - async forceScan(): Promise<void> { - await this.scanAllSessions(); - } - - /** - * Kill a process tree listening on a tracked port. - * Refuses to kill the terminal's shell process itself. - */ - killPort({ paneId, port }: { paneId: string; port: number }): Promise<{ - success: boolean; - error?: string; - }> { - const key = this.makeKey(paneId, port); - const detectedPort = this.ports.get(key); - - if (!detectedPort) { - return Promise.resolve({ - success: false, - error: "Port not found in tracked ports", - }); - } - - const session = this.sessions.get(paneId); - const daemonSession = this.daemonSessions.get(paneId); - const shellPid = session?.session.pty.pid ?? daemonSession?.pid; - - if (shellPid != null && detectedPort.pid === shellPid) { - return Promise.resolve({ - success: false, - error: "Cannot kill the terminal shell process", - }); - } - - return treeKillWithEscalation({ pid: detectedPort.pid }); - } -} - -export const portManager = new PortManager(); +export const portManager = new PortManager({ + killFn: treeKillWithEscalation, +}); diff --git a/apps/desktop/src/main/lib/terminal/port-scanner.test.ts b/apps/desktop/src/main/lib/terminal/port-scanner.test.ts deleted file mode 100644 index ce36d111bcc..00000000000 --- a/apps/desktop/src/main/lib/terminal/port-scanner.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { describe, expect, it } from "bun:test"; - -/** - * Tests for lsof output parsing logic. - * - * The lsof output format is: - * COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME - * Example: node 12345 user 23u IPv4 0x1234 0t0 TCP *:3000 (LISTEN) - * - * The NAME column (e.g., "*:3000") is the second-to-last column, - * with "(LISTEN)" being the last column. - */ - -interface PortInfo { - port: number; - pid: number; - address: string; - processName: string; -} - -/** - * Parse lsof output to extract port information. - * Extracted from getListeningPortsLsof for testability. - * - * @param output - Raw lsof output - * @param allowedPids - Set of PIDs to filter by. If provided, only ports from these PIDs are returned. - * This is critical because lsof ignores -p filter when PIDs don't exist, - * returning ALL listening ports instead. - */ -function parseLsofOutput( - output: string, - allowedPids?: Set<number>, -): PortInfo[] { - if (!output.trim()) return []; - - const ports: PortInfo[] = []; - const lines = output.trim().split("\n").slice(1); // Skip header - - for (const line of lines) { - if (!line.trim()) continue; - - // Format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME (LISTEN) - // Example: node 12345 user 23u IPv4 0x1234 0t0 TCP *:3000 (LISTEN) - const columns = line.split(/\s+/); - if (columns.length < 10) continue; - - const processName = columns[0]; - const pid = Number.parseInt(columns[1], 10); - - // Filter by allowed PIDs if provided - // This guards against lsof returning all ports when -p filter is ignored - if (allowedPids && !allowedPids.has(pid)) continue; - - const name = columns[columns.length - 2]; // NAME column (e.g., *:3000), before (LISTEN) - - // Parse address:port from NAME column - // Formats: *:3000, 127.0.0.1:3000, [::1]:3000, [::]:3000 - const match = name.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/); - if (match) { - const address = match[1] || match[2] || "*"; - const port = Number.parseInt(match[3], 10); - - // Skip invalid ports - if (port < 1 || port > 65535) continue; - - ports.push({ - port, - pid, - address: address === "*" ? "0.0.0.0" : address, - processName, - }); - } - } - - return ports; -} - -describe("port-scanner", () => { - describe("parseLsofOutput", () => { - it("should parse standard lsof output with (LISTEN) suffix", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(1); - expect(ports[0]).toEqual({ - port: 3000, - pid: 12345, - address: "0.0.0.0", - processName: "node", - }); - }); - - it("should parse localhost address", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP 127.0.0.1:8080 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(1); - expect(ports[0]).toEqual({ - port: 8080, - pid: 12345, - address: "127.0.0.1", - processName: "node", - }); - }); - - it("should parse IPv6 addresses", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv6 0x1234567890ab 0t0 TCP [::1]:3000 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(1); - expect(ports[0]).toEqual({ - port: 3000, - pid: 12345, - address: "::1", - processName: "node", - }); - }); - - it("should parse IPv6 wildcard addresses", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv6 0x1234567890ab 0t0 TCP [::]:8000 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(1); - expect(ports[0]).toEqual({ - port: 8000, - pid: 12345, - address: "::", - processName: "node", - }); - }); - - it("should parse multiple ports", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) -node 12345 user 24u IPv4 0x1234567890ac 0t0 TCP *:3001 (LISTEN) -python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP 127.0.0.1:8000 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(3); - expect(ports[0]).toEqual({ - port: 3000, - pid: 12345, - address: "0.0.0.0", - processName: "node", - }); - expect(ports[1]).toEqual({ - port: 3001, - pid: 12345, - address: "0.0.0.0", - processName: "node", - }); - expect(ports[2]).toEqual({ - port: 8000, - pid: 67890, - address: "127.0.0.1", - processName: "python", - }); - }); - - it("should handle empty output", () => { - const ports = parseLsofOutput(""); - expect(ports).toHaveLength(0); - }); - - it("should handle header-only output", () => { - const output = - "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME"; - const ports = parseLsofOutput(output); - expect(ports).toHaveLength(0); - }); - - it("should skip lines with insufficient columns", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) -short line -node 67890 user 24u IPv4 0x1234567890ac 0t0 TCP *:4000 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(2); - expect(ports[0].port).toBe(3000); - expect(ports[1].port).toBe(4000); - }); - - it("should skip invalid port numbers", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:0 (LISTEN) -node 12345 user 24u IPv4 0x1234567890ac 0t0 TCP *:70000 (LISTEN) -node 12345 user 25u IPv4 0x1234567890ad 0t0 TCP *:3000 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(1); - expect(ports[0].port).toBe(3000); - }); - - it("should handle real-world lsof output format", () => { - // Real output from macOS lsof command - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -rapportd 947 kietho 8u IPv4 0x9e27f4f0c86f6338 0t0 TCP *:59251 (LISTEN) -ControlCe 1020 kietho 8u IPv4 0xe6bd39002aa591ca 0t0 TCP *:7000 (LISTEN) -postgres 3457 kietho 8u IPv4 0xb4db4c0cd4dfeb63 0t0 TCP 127.0.0.1:5432 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(3); - expect(ports[0]).toEqual({ - port: 59251, - pid: 947, - address: "0.0.0.0", - processName: "rapportd", - }); - expect(ports[1]).toEqual({ - port: 7000, - pid: 1020, - address: "0.0.0.0", - processName: "ControlCe", - }); - expect(ports[2]).toEqual({ - port: 5432, - pid: 3457, - address: "127.0.0.1", - processName: "postgres", - }); - }); - - it("should not parse (LISTEN) as the port name", () => { - // This was the bug: using columns[columns.length - 1] would get "(LISTEN)" - // instead of the actual NAME field like "*:3000" - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(1); - // Should extract port 3000, not fail to parse "(LISTEN)" - expect(ports[0].port).toBe(3000); - expect(ports[0].address).toBe("0.0.0.0"); - }); - - it("should handle process names with different lengths", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -n 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) -verylongprocessname 67890 user 24u IPv4 0x1234567890ac 0t0 TCP *:4000 (LISTEN)`; - - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(2); - expect(ports[0].processName).toBe("n"); - expect(ports[0].port).toBe(3000); - expect(ports[1].processName).toBe("verylongprocessname"); - expect(ports[1].port).toBe(4000); - }); - }); - - describe("parseLsofOutput with PID filtering", () => { - it("should filter ports by allowed PIDs", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) -python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP *:8000 (LISTEN) -ruby 99999 user 6u IPv4 0x1234567890ae 0t0 TCP *:9000 (LISTEN)`; - - // Only allow PID 12345 and 99999 - const allowedPids = new Set([12345, 99999]); - const ports = parseLsofOutput(output, allowedPids); - - expect(ports).toHaveLength(2); - expect(ports[0].pid).toBe(12345); - expect(ports[0].port).toBe(3000); - expect(ports[1].pid).toBe(99999); - expect(ports[1].port).toBe(9000); - }); - - it("should return empty when no PIDs match", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) -python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP *:8000 (LISTEN)`; - - // Request PIDs that don't exist in output - const allowedPids = new Set([11111, 22222]); - const ports = parseLsofOutput(output, allowedPids); - - expect(ports).toHaveLength(0); - }); - - it("should return all ports when allowedPids is not provided", () => { - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -node 12345 user 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN) -python 67890 user 5u IPv4 0x1234567890ad 0t0 TCP *:8000 (LISTEN)`; - - // No PID filter - const ports = parseLsofOutput(output); - - expect(ports).toHaveLength(2); - }); - - it("should handle lsof returning unrelated ports when -p filter fails", () => { - // This simulates the bug: we request PID 12345, but lsof ignores -p - // and returns ALL listening ports (947, 1020, 3457, etc.) - const output = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME -rapportd 947 kietho 8u IPv4 0x9e27f4f0c86f6338 0t0 TCP *:59251 (LISTEN) -ControlCe 1020 kietho 8u IPv4 0xe6bd39002aa591ca 0t0 TCP *:7000 (LISTEN) -postgres 3457 kietho 8u IPv4 0xb4db4c0cd4dfeb63 0t0 TCP 127.0.0.1:5432 (LISTEN) -node 12345 kietho 23u IPv4 0x1234567890ab 0t0 TCP *:3000 (LISTEN)`; - - // We only requested PID 12345 (our terminal's process tree) - const allowedPids = new Set([12345]); - const ports = parseLsofOutput(output, allowedPids); - - // Should ONLY return port 3000 from PID 12345 - // NOT the system ports from rapportd, ControlCenter, postgres - expect(ports).toHaveLength(1); - expect(ports[0].port).toBe(3000); - expect(ports[0].pid).toBe(12345); - }); - }); -}); diff --git a/apps/desktop/src/main/lib/terminal/port-scanner.ts b/apps/desktop/src/main/lib/terminal/port-scanner.ts deleted file mode 100644 index 0b508e0dd3e..00000000000 --- a/apps/desktop/src/main/lib/terminal/port-scanner.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { exec } from "node:child_process"; -import os from "node:os"; -import { promisify } from "node:util"; -import pidtree from "pidtree"; - -const execAsync = promisify(exec); - -/** Timeout for shell commands to prevent hanging (ms) */ -const EXEC_TIMEOUT_MS = 5000; - -export interface PortInfo { - port: number; - pid: number; - address: string; - processName: string; -} - -/** - * Get all child PIDs of a process (including the process itself) - */ -export async function getProcessTree(pid: number): Promise<number[]> { - try { - return await pidtree(pid, { root: true }); - } catch { - // Process may have exited - return []; - } -} - -/** - * Get listening TCP ports for a set of PIDs - * Cross-platform implementation using lsof (macOS/Linux) or netstat (Windows) - */ -export async function getListeningPortsForPids( - pids: number[], -): Promise<PortInfo[]> { - if (pids.length === 0) return []; - - const platform = os.platform(); - - if (platform === "darwin" || platform === "linux") { - return getListeningPortsLsof(pids); - } - if (platform === "win32") { - return getListeningPortsWindows(pids); - } - - return []; -} - -/** - * macOS/Linux implementation using lsof - */ -async function getListeningPortsLsof(pids: number[]): Promise<PortInfo[]> { - try { - const pidArg = pids.join(","); - const pidSet = new Set(pids); - // -p: filter by PIDs - // -iTCP: only TCP connections - // -sTCP:LISTEN: only listening sockets - // -P: don't convert port numbers to names - // -n: don't resolve hostnames - // Note: lsof may ignore -p filter if PIDs don't exist or have no matches, - // so we must validate PIDs in the output against our requested set - const { stdout: output } = await execAsync( - `lsof -p ${pidArg} -iTCP -sTCP:LISTEN -P -n 2>/dev/null || true`, - { maxBuffer: 10 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS }, - ); - - if (!output.trim()) return []; - - const ports: PortInfo[] = []; - const lines = output.trim().split("\n").slice(1); - - for (const line of lines) { - if (!line.trim()) continue; - - // Format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME - // Example: node 12345 user 23u IPv4 0x1234 0t0 TCP *:3000 (LISTEN) - const columns = line.split(/\s+/); - if (columns.length < 10) continue; - - const processName = columns[0]; - const pid = Number.parseInt(columns[1], 10); - - // CRITICAL: Verify the PID is in our requested set - // lsof ignores -p filter when PIDs don't exist, returning all TCP listeners - if (!pidSet.has(pid)) continue; - - const name = columns[columns.length - 2]; // NAME column (e.g., *:3000), before (LISTEN) - - // Parse address:port from NAME column - // Formats: *:3000, 127.0.0.1:3000, [::1]:3000, [::]:3000 - const match = name.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/); - if (match) { - const address = match[1] || match[2] || "*"; - const port = Number.parseInt(match[3], 10); - - if (port < 1 || port > 65535) continue; - - ports.push({ - port, - pid, - address: address === "*" ? "0.0.0.0" : address, - processName, - }); - } - } - - return ports; - } catch { - return []; - } -} - -/** - * Windows implementation using netstat - */ -async function getListeningPortsWindows(pids: number[]): Promise<PortInfo[]> { - try { - const { stdout: output } = await execAsync("netstat -ano", { - maxBuffer: 10 * 1024 * 1024, - timeout: EXEC_TIMEOUT_MS, - }); - - const pidSet = new Set(pids); - const ports: PortInfo[] = []; - const processNames = new Map<number, string>(); - - // Collect unique PIDs that we need to look up names for - const pidsToLookup: number[] = []; - - for (const line of output.split("\n")) { - if (!line.includes("LISTENING")) continue; - - // Format: TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345 - const columns = line.trim().split(/\s+/); - if (columns.length < 5) continue; - - const pid = Number.parseInt(columns[columns.length - 1], 10); - if (!pidSet.has(pid)) continue; - - if (!processNames.has(pid) && !pidsToLookup.includes(pid)) { - pidsToLookup.push(pid); - } - } - - // Fetch process names in parallel - const nameResults = await Promise.all( - pidsToLookup.map(async (pid) => ({ - pid, - name: await getProcessNameWindows(pid), - })), - ); - for (const { pid, name } of nameResults) { - processNames.set(pid, name); - } - - // Now build the ports array - for (const line of output.split("\n")) { - if (!line.includes("LISTENING")) continue; - - const columns = line.trim().split(/\s+/); - if (columns.length < 5) continue; - - const pid = Number.parseInt(columns[columns.length - 1], 10); - if (!pidSet.has(pid)) continue; - - const localAddr = columns[1]; - // Parse address:port - handles both IPv4 and IPv6 - // IPv4: 0.0.0.0:3000 - // IPv6: [::]:3000 - const match = localAddr.match(/^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/); - if (match) { - const address = match[1] || match[2] || "0.0.0.0"; - const port = Number.parseInt(match[3], 10); - - if (port < 1 || port > 65535) continue; - - ports.push({ - port, - pid, - address, - processName: processNames.get(pid) || "unknown", - }); - } - } - - return ports; - } catch { - return []; - } -} - -/** - * Get process name for a PID on Windows - */ -async function getProcessNameWindows(pid: number): Promise<string> { - try { - const { stdout: output } = await execAsync( - `wmic process where processid=${pid} get name 2>nul`, - { timeout: EXEC_TIMEOUT_MS }, - ); - const lines = output.trim().split("\n"); - if (lines.length >= 2) { - const name = lines[1].trim(); - return name.replace(/\.exe$/i, "") || "unknown"; - } - } catch { - // wmic is deprecated, try PowerShell as fallback - try { - const { stdout: output } = await execAsync( - `powershell -Command "(Get-Process -Id ${pid}).ProcessName"`, - { timeout: EXEC_TIMEOUT_MS }, - ); - return output.trim() || "unknown"; - } catch {} - } - return "unknown"; -} - -/** - * Get process name for a PID (cross-platform) - */ -export async function getProcessName(pid: number): Promise<string> { - const platform = os.platform(); - - if (platform === "win32") { - return getProcessNameWindows(pid); - } - - // macOS/Linux - try { - const { stdout: output } = await execAsync( - `ps -p ${pid} -o comm= 2>/dev/null || true`, - { timeout: EXEC_TIMEOUT_MS }, - ); - const name = output.trim(); - // On macOS, comm may be truncated. The full path can be gotten with -o command= - // but comm is usually sufficient for display purposes - return name || "unknown"; - } catch { - return "unknown"; - } -} diff --git a/apps/desktop/src/main/lib/tray/index.ts b/apps/desktop/src/main/lib/tray/index.ts index 0ab352c1fc2..278ed131fbb 100644 --- a/apps/desktop/src/main/lib/tray/index.ts +++ b/apps/desktop/src/main/lib/tray/index.ts @@ -1,26 +1,20 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { workspaces } from "@superset/local-db"; -import { eq } from "drizzle-orm"; import { app, - BrowserWindow, - dialog, Menu, type MenuItemConstructorOptions, nativeImage, Tray, } from "electron"; -import { localDb } from "main/lib/local-db"; -import { menuEmitter } from "main/lib/menu-events"; +import { loadToken } from "lib/trpc/routers/auth/utils/auth-functions"; +import { env } from "main/env.main"; +import { focusMainWindow, quitApp } from "main/index"; import { - restartDaemon as restartDaemonShared, - tryListExistingDaemonSessions, -} from "main/lib/terminal"; -import { getTerminalHostClient } from "main/lib/terminal-host/client"; -import type { ListSessionsResponse } from "main/lib/terminal-host/types"; - -const POLL_INTERVAL_MS = 5000; + getHostServiceCoordinator, + type HostServiceStatusEvent, +} from "main/lib/host-service-coordinator"; +import { menuEmitter } from "main/lib/menu-events"; /** Must have "Template" suffix for macOS dark/light mode support */ const TRAY_ICON_FILENAME = "iconTemplate.png"; @@ -55,7 +49,6 @@ function getTrayIconPath(): string | null { } let tray: Tray | null = null; -let pollIntervalId: ReturnType<typeof setInterval> | null = null; function createTrayIcon(): Electron.NativeImage | null { const iconPath = getTrayIconPath(); @@ -85,214 +78,159 @@ function createTrayIcon(): Electron.NativeImage | null { } } -function showWindow(): void { - const windows = BrowserWindow.getAllWindows(); - - if (windows.length > 0) { - const mainWindow = windows[0]; - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.show(); - mainWindow.focus(); - } else { - // Triggers window creation via makeAppSetup's activate handler - app.emit("activate"); - } -} - function openSettings(): void { - showWindow(); + focusMainWindow(); menuEmitter.emit("open-settings"); } -function openTerminalSettings(): void { - showWindow(); - menuEmitter.emit("open-settings", "terminal"); +interface HostInfo { + organizationName: string; + version: string; } -function openSessionInSuperset(workspaceId: string): void { - showWindow(); - menuEmitter.emit("open-workspace", workspaceId); -} +async function fetchHostInfo(organizationId: string): Promise<HostInfo | null> { + const connection = getHostServiceCoordinator().getConnection(organizationId); + if (!connection) return null; -async function killSession(paneId: string): Promise<void> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2000); try { - const client = getTerminalHostClient(); - const connected = await client.tryConnectAndAuthenticate(); - if (connected) { - await client.kill({ sessionId: paneId }); - console.log(`[Tray] Killed session: ${paneId}`); - } - } catch (error) { - console.error(`[Tray] Failed to kill session ${paneId}:`, error); - } - - await updateTrayMenu(); -} - -function getWorkspaceName(workspaceId: string): string { - try { - const workspace = localDb - .select({ name: workspaces.name }) - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .get(); - return workspace?.name || workspaceId.slice(0, 8); + const res = await fetch( + `http://127.0.0.1:${connection.port}/trpc/host.info`, + { + headers: { Authorization: `Bearer ${connection.secret}` }, + signal: controller.signal, + }, + ); + if (!res.ok) return null; + const data = await res.json(); + const info = data?.result?.data?.json; + if (!info?.organization?.name) return null; + return { + organizationName: info.organization.name, + version: info.version ?? "", + }; } catch { - return workspaceId.slice(0, 8); + return null; + } finally { + clearTimeout(timeout); } } -function formatSessionLabel( - session: ListSessionsResponse["sessions"][0], -): string { - const attached = session.attachedClients > 0 ? " (attached)" : ""; - const shellName = session.shell?.split("/").pop() || "shell"; - return `${shellName}${attached}`; -} - -function buildSessionsSubmenu( - sessions: ListSessionsResponse["sessions"], +function buildHostServiceSubmenu( + orgIds: string[], + infos: Map<string, HostInfo>, ): MenuItemConstructorOptions[] { - const aliveSessions = sessions.filter((s) => s.isAlive); + const coordinator = getHostServiceCoordinator(); const menuItems: MenuItemConstructorOptions[] = []; - if (aliveSessions.length === 0) { - menuItems.push({ label: "No active sessions", enabled: false }); - } else { - const byWorkspace = new Map<string, ListSessionsResponse["sessions"]>(); - for (const session of aliveSessions) { - const existing = byWorkspace.get(session.workspaceId) || []; - existing.push(session); - byWorkspace.set(session.workspaceId, existing); - } + if (orgIds.length === 0) { + menuItems.push({ label: "No active services", enabled: false }); + return menuItems; + } - let isFirst = true; - for (const [workspaceId, workspaceSessions] of byWorkspace) { - const workspaceName = getWorkspaceName(workspaceId); - - if (!isFirst) { - menuItems.push({ type: "separator" }); - } - menuItems.push({ - label: workspaceName, - enabled: false, - }); - - for (const session of workspaceSessions) { - menuItems.push({ - label: formatSessionLabel(session), - submenu: [ - { - label: "Open in Superset", - click: () => openSessionInSuperset(session.workspaceId), - }, - { - label: "Kill", - click: () => killSession(session.paneId), - }, - ], - }); - } - - isFirst = false; + let isFirst = true; + for (const orgId of orgIds) { + if (!isFirst) { + menuItems.push({ type: "separator" }); } + isFirst = false; + + const status = coordinator.getProcessStatus(orgId); + const info = infos.get(orgId); + const isRunning = status === "running"; + const label = info?.organizationName ?? "Loading…"; + const versionSuffix = info?.version ? ` (v${info.version})` : ""; + + menuItems.push({ label, enabled: false }); + menuItems.push({ + label: ` ${status}${versionSuffix}`, + enabled: false, + }); + menuItems.push({ + label: " Restart", + enabled: isRunning, + click: () => { + void (async () => { + try { + const { token } = await loadToken(); + if (!token) return; + await coordinator.restart(orgId, { + authToken: token, + cloudApiUrl: env.NEXT_PUBLIC_API_URL, + }); + } catch (error) { + console.error( + `[Tray] Failed to restart host-service for ${orgId}:`, + error, + ); + } + void updateTrayMenu(); + })(); + }, + }); + menuItems.push({ + label: " Stop", + enabled: isRunning, + click: () => { + coordinator.stop(orgId); + void updateTrayMenu(); + }, + }); } - menuItems.push({ type: "separator" }); - menuItems.push({ - label: "Terminal Settings", - click: openTerminalSettings, - }); - return menuItems; } -async function quitApp(): Promise<void> { - const { sessions } = await tryListExistingDaemonSessions(); - const hasActiveSessions = sessions.some((s) => s.isAlive); - - if (!hasActiveSessions) { - app.quit(); - return; - } +async function updateTrayMenu(): Promise<void> { + if (!tray) return; - const { response } = await dialog.showMessageBox({ - type: "question", - buttons: ["Cancel", "Keep Sessions", "Kill Sessions"], - defaultId: 1, - cancelId: 0, - title: "Quit Superset?", - message: "Quit Superset?", - detail: - "Keep sessions running in the background, or kill all sessions and shut down the daemon?", - }); - - if (response === 0) { - return; - } + const coordinator = getHostServiceCoordinator(); + const orgIds = coordinator.getActiveOrganizationIds(); - if (response === 2) { - try { - await restartDaemonShared(); - } catch (error) { - console.warn( - "[Tray] Failed to restart terminal daemon during quit:", - error, - ); - await dialog - .showMessageBox({ - type: "error", - buttons: ["OK"], - defaultId: 0, - title: "Failed to kill sessions", - message: "Superset could not kill terminal sessions.", - detail: - "The app will stay open so you can retry or quit while keeping sessions running in the background.", - }) - .catch((dialogError) => { - console.warn( - "[Tray] Failed to show terminal quit error dialog:", - dialogError, - ); - }); - return; - } + const infoEntries = await Promise.all( + orgIds.map(async (orgId) => [orgId, await fetchHostInfo(orgId)] as const), + ); + const infos = new Map<string, HostInfo>(); + for (const [orgId, info] of infoEntries) { + if (info) infos.set(orgId, info); } - app.quit(); -} - -async function updateTrayMenu(): Promise<void> { if (!tray) return; - const { sessions } = await tryListExistingDaemonSessions(); - const sessionCount = sessions.filter((s) => s.isAlive).length; + const hasActive = orgIds.length > 0; + const hostServiceLabel = hasActive + ? `Host Service (${orgIds.length})` + : "Host Service"; - const sessionsSubmenu = buildSessionsSubmenu(sessions); - const sessionsLabel = - sessionCount > 0 - ? `Background Sessions (${sessionCount})` - : "Background Sessions"; + const hostServiceSubmenu = buildHostServiceSubmenu(orgIds, infos); const menu = Menu.buildFromTemplate([ { - label: sessionsLabel, - submenu: sessionsSubmenu, + label: hostServiceLabel, + submenu: hostServiceSubmenu, }, { type: "separator" }, { label: "Open Superset", - click: showWindow, + click: focusMainWindow, }, { label: "Settings", click: openSettings, }, { - label: "Quit", - click: quitApp, + label: "Check for Updates", + click: () => { + // Imported lazily to avoid circular dependency + const { checkForUpdatesInteractive } = require("../auto-updater"); + checkForUpdatesInteractive(); + }, + }, + { type: "separator" }, + { + label: "Quit Superset", + click: () => quitApp(), }, ]); @@ -320,17 +258,16 @@ export function initTray(): void { tray = new Tray(icon); tray.setToolTip("Superset"); - updateTrayMenu().catch((error) => { - console.error("[Tray] Failed to build initial menu:", error); + void updateTrayMenu(); + + const manager = getHostServiceCoordinator(); + manager.on("status-changed", (_event: HostServiceStatusEvent) => { + void updateTrayMenu(); }); - pollIntervalId = setInterval(() => { - updateTrayMenu().catch((error) => { - console.error("[Tray] Failed to update menu:", error); - }); - }, POLL_INTERVAL_MS); - // Don't keep Electron alive just for tray updates - pollIntervalId.unref(); + tray.on("mouse-enter", () => { + void updateTrayMenu(); + }); console.log("[Tray] Initialized successfully"); } catch (error) { @@ -340,11 +277,6 @@ export function initTray(): void { /** Call on app quit */ export function disposeTray(): void { - if (pollIntervalId) { - clearInterval(pollIntervalId); - pollIntervalId = null; - } - if (tray) { tray.destroy(); tray = null; diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts b/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts index d4ce9cf0efe..d4d0680ee7a 100644 --- a/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts +++ b/apps/desktop/src/main/terminal-host/pty-subprocess-ipc.ts @@ -127,15 +127,3 @@ export class PtySubprocessFrameDecoder { return frames; } } - -/** - * OSC 777 escape sequence emitted once by shell prompt hooks (precmd in - * zsh, PROMPT_COMMAND in bash, fish_prompt in fish) right before the - * first interactive prompt is displayed. {@link Session} scans PTY - * output for this marker to know when the shell is ready for stdin, - * then strips it so it never reaches the terminal renderer. - * - * Uses the private-use OSC 777 code to avoid conflicts with VS Code - * (OSC 133), iTerm2 (OSC 1337), or Warp (OSC 9001). - */ -export const SHELL_READY_MARKER = "\x1b]777;superset-shell-ready\x07"; diff --git a/apps/desktop/src/main/terminal-host/pty-subprocess.ts b/apps/desktop/src/main/terminal-host/pty-subprocess.ts index ea8a45d6676..5778288dc84 100644 --- a/apps/desktop/src/main/terminal-host/pty-subprocess.ts +++ b/apps/desktop/src/main/terminal-host/pty-subprocess.ts @@ -57,6 +57,7 @@ const INPUT_QUEUE_HARD_LIMIT_BYTES = 64 * 1024 * 1024; // 64MB let outputChunks: string[] = []; let outputBytesQueued = 0; let outputFlushScheduled = false; +const OUTPUT_FLUSH_INTERVAL_MS = 16; // Match terminal-style frame batching (~60fps) const MAX_OUTPUT_BATCH_SIZE_BYTES = 128 * 1024; // 128KB max per flush // Backpressure - track if stdout is draining @@ -91,10 +92,6 @@ function sendError(message: string): void { send(PtySubprocessIpcType.Error, Buffer.from(message, "utf8")); } -/** - * Queue PTY output for batched sending. - * Flushes immediately if batch exceeds MAX_OUTPUT_BATCH_SIZE_BYTES. - */ function queueOutput(data: string): void { outputChunks.push(data); outputBytesQueued += Buffer.byteLength(data, "utf8"); @@ -107,9 +104,9 @@ function queueOutput(data: string): void { if (!outputFlushScheduled) { outputFlushScheduled = true; - // Flush on the next event-loop turn so interactive echo feels immediate, - // while still coalescing bursts that arrive in the same PTY callback cycle. - setImmediate(flushOutput); + // Timed batching keeps TUI redraws coherent and avoids flooding the renderer + // with tiny per-turn frames while still staying under a single display frame. + setTimeout(flushOutput, OUTPUT_FLUSH_INTERVAL_MS); } } @@ -281,7 +278,6 @@ function handleSpawn(payload: Buffer): void { } if (DEBUG_OUTPUT_BATCHING) { - // Debug: Log spawn parameters console.error("[pty-subprocess] Spawning PTY:", { shell: msg.shell, args: msg.args, @@ -336,6 +332,8 @@ function handleSpawn(payload: Buffer): void { sendError( `Spawn failed: ${error instanceof Error ? error.message : String(error)}`, ); + // Exit so the daemon does not keep a live subprocess with no PTY. + setTimeout(() => process.exit(1), 100); } } diff --git a/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts b/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts index 791ef23e10e..6d5876eb8fe 100644 --- a/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts +++ b/apps/desktop/src/main/terminal-host/session-shell-ready.test.ts @@ -5,8 +5,10 @@ import { createFrameHeader, PtySubprocessFrameDecoder, PtySubprocessIpcType, - SHELL_READY_MARKER, } from "./pty-subprocess-ipc"; + +/** OSC 133;A marker emitted by shell wrappers (FinalTerm standard). */ +const SHELL_READY_MARKER = "\x1b]133;A\x07"; import "./xterm-env-polyfill"; const { Session } = await import("./session"); @@ -119,24 +121,22 @@ function spawnAndReady( // Tests // ============================================================================= -describe("Session shell-ready: write buffering", () => { - it("buffers writes while shell is pending and flushes after marker", () => { +describe("Session shell-ready: write pass-through", () => { + it("passes writes through immediately while shell is pending (#3478)", () => { const { session, proc } = createTestSession("/bin/zsh"); spawnAndReady(session, proc); - // Write before shell is ready — should be buffered - session.write("echo hello\n"); - session.write("echo world\n"); + // User keystrokes answering a shell-init prompt (e.g. fnm's + // "install missing Node version?") must reach the PTY without + // waiting for OSC 133;A. + session.write("y\n"); + session.write("echo ready\n"); - // No write frames should have been sent yet - expect(getWrittenData(proc)).toEqual([]); + expect(getWrittenData(proc)).toEqual(["y\n", "echo ready\n"]); - // Shell emits the ready marker + // The ready marker arriving later must not re-emit anything. sendData(proc, `direnv output...${SHELL_READY_MARKER}prompt$ `); - - // Now the buffered writes should be flushed in order - const writes = getWrittenData(proc); - expect(writes).toEqual(["echo hello\n", "echo world\n"]); + expect(getWrittenData(proc)).toEqual(["y\n", "echo ready\n"]); }); it("passes writes through immediately for unsupported shells (sh)", () => { @@ -166,32 +166,28 @@ describe("Session shell-ready: write buffering", () => { session.write("\x1b[?62;4;9;22c"); // Simulate cursor position report session.write("\x1b[1;1R"); - // Queue a real preset command + // Regular command also arriving during pending session.write("claude\n"); - // Only the preset command should be in the queue - expect(getWrittenData(proc)).toEqual([]); + // Escape sequences are dropped; the command passes through. + expect(getWrittenData(proc)).toEqual(["claude\n"]); sendData(proc, SHELL_READY_MARKER); - // Only the command should flush — escape sequences dropped + // Nothing new to emit after the marker. expect(getWrittenData(proc)).toEqual(["claude\n"]); }); - it("flushes buffered writes on subprocess exit", async () => { + it("forwards escape sequences once shell is ready", () => { const { session, proc } = createTestSession("/bin/zsh"); spawnAndReady(session, proc); - session.write("echo delayed\n"); - expect(getWrittenData(proc)).toEqual([]); - - // Simulate exit which resolves shell readiness as timed_out - sendExit(proc, 0); - proc.emit("exit", 0); + sendData(proc, SHELL_READY_MARKER); - // Buffered write should now be flushed - const writes = getWrittenData(proc); - expect(writes).toEqual(["echo delayed\n"]); + // After the marker, escape sequences are no longer stale init noise, + // so they pass through (e.g. user pressing arrow keys). + session.write("\x1b[A"); + expect(getWrittenData(proc)).toEqual(["\x1b[A"]); }); }); @@ -220,14 +216,16 @@ describe("Session shell-ready: marker detection", () => { // Send first half — shell should still be pending sendData(proc, `output${firstHalf}`); - session.write("buffered\n"); - expect(getWrittenData(proc)).toEqual([]); + // Writes pass through even while pending + session.write("first\n"); + expect(getWrittenData(proc)).toEqual(["first\n"]); // Send second half — should complete the marker sendData(proc, `${secondHalf}prompt`); - // Now writes should flush - expect(getWrittenData(proc)).toEqual(["buffered\n"]); + // Post-marker writes still pass through + session.write("second\n"); + expect(getWrittenData(proc)).toEqual(["first\n", "second\n"]); }); it("handles marker at start of data frame", () => { @@ -258,45 +256,66 @@ describe("Session shell-ready: marker detection", () => { const partialMarker = SHELL_READY_MARKER.slice(0, 5); sendData(proc, `${partialMarker}not-a-marker`); - // Shell should still be pending - session.write("buffered\n"); - expect(getWrittenData(proc)).toEqual([]); + // Writes pass through regardless of marker state. + session.write("first\n"); + expect(getWrittenData(proc)).toEqual(["first\n"]); - // Now send the real marker + // Now send the real marker — no backlog to flush. sendData(proc, SHELL_READY_MARKER); - expect(getWrittenData(proc)).toEqual(["buffered\n"]); + expect(getWrittenData(proc)).toEqual(["first\n"]); + }); + + // Wrappers now emit both the legacy OSC 777 and the current OSC 133;A in + // a single printf so either daemon version can detect readiness without a + // restart. The scanner only matches 133;A — 777 passes through to the + // emulator, which drops unknown OSC sequences silently. This test guards + // against a future wrapper regression that swaps the order (which would + // leave 133;A in the pre-777 slice and still work) or drops 133;A + // entirely (which would regress readiness on the current scanner). + it("resolves readiness when wrapper emits both 777 and 133;A markers together", () => { + const { session, proc } = createTestSession("/bin/zsh"); + spawnAndReady(session, proc); + + const COMBINED_MARKER = "\x1b]777;superset-shell-ready\x07\x1b]133;A\x07"; + sendData(proc, `direnv output...${COMBINED_MARKER}prompt$ `); + + // Writes after the combined marker pass through (marker detection + // guards future behaviors that may depend on the ready state). + session.write("test\n"); + expect(getWrittenData(proc)).toEqual(["test\n"]); }); }); describe("Session shell-ready: kill/exit before readiness", () => { - it("flushes queue when subprocess exits before marker", () => { + it("accepts writes when subprocess exits before marker", () => { const { session, proc } = createTestSession("/bin/bash"); spawnAndReady(session, proc); + // Writes pass through even during pending. session.write("echo pending\n"); - expect(getWrittenData(proc)).toEqual([]); + expect(getWrittenData(proc)).toEqual(["echo pending\n"]); - // Subprocess exits without ever sending the marker + // Subprocess exits without ever sending the marker — no replay, + // no duplicate writes. sendExit(proc, 1); proc.emit("exit", 1); - // Queue should be flushed on exit expect(getWrittenData(proc)).toEqual(["echo pending\n"]); }); - it("resolves readiness when session is killed", () => { + it("accepts writes when session is killed before marker", () => { const { session, proc } = createTestSession("/bin/zsh"); spawnAndReady(session, proc); session.write("echo pending\n"); - expect(getWrittenData(proc)).toEqual([]); + expect(getWrittenData(proc)).toEqual(["echo pending\n"]); - // Kill triggers termination → subprocess exit → readiness resolved + // Kill triggers termination → subprocess exit → readiness resolved. + // No buffered replay on exit. session.kill(); sendExit(proc, 0); proc.emit("exit", 0); - // Writes should be flushed expect(getWrittenData(proc)).toEqual(["echo pending\n"]); }); }); @@ -308,12 +327,12 @@ describe("Session shell-ready: supported shells", () => { "/bin/bash", "/usr/local/bin/fish", ]) { - it(`buffers writes for supported shell: ${shell}`, () => { + it(`passes writes through while pending for supported shell: ${shell}`, () => { const { session, proc } = createTestSession(shell); spawnAndReady(session, proc); session.write("test\n"); - expect(getWrittenData(proc)).toEqual([]); + expect(getWrittenData(proc)).toEqual(["test\n"]); sendData(proc, SHELL_READY_MARKER); expect(getWrittenData(proc)).toEqual(["test\n"]); diff --git a/apps/desktop/src/main/terminal-host/session.test.ts b/apps/desktop/src/main/terminal-host/session.test.ts index 625d472ad10..8163a4cb657 100644 --- a/apps/desktop/src/main/terminal-host/session.test.ts +++ b/apps/desktop/src/main/terminal-host/session.test.ts @@ -12,7 +12,20 @@ import "./xterm-env-polyfill"; const { Session } = await import("./session"); -class FakeStdout extends EventEmitter {} +class FakeStdout extends EventEmitter { + pauseCalls = 0; + resumeCalls = 0; + + pause(): this { + this.pauseCalls++; + return this; + } + + resume(): this { + this.resumeCalls++; + return this; + } +} class FakeStdin extends EventEmitter { readonly writes: Buffer[] = []; @@ -37,6 +50,26 @@ class FakeChildProcess extends EventEmitter { let fakeChildProcess: FakeChildProcess; let spawnCalls: Array<{ command: string; args: string[] }> = []; +function sendFrame( + proc: FakeChildProcess, + type: PtySubprocessIpcType, + payload?: Buffer, +): void { + const buf = payload ?? Buffer.alloc(0); + const header = createFrameHeader(type, buf.length); + proc.stdout.emit("data", Buffer.concat([header, buf])); +} + +function sendReady(proc: FakeChildProcess): void { + sendFrame(proc, PtySubprocessIpcType.Ready); +} + +function sendSpawned(proc: FakeChildProcess, pid = 1234): void { + const buf = Buffer.allocUnsafe(4); + buf.writeUInt32LE(pid, 0); + sendFrame(proc, PtySubprocessIpcType.Spawned, buf); +} + function getSpawnPayload(fakeChild: FakeChildProcess) { fakeChild.stdout.emit( "data", @@ -54,6 +87,17 @@ function getSpawnPayload(fakeChild: FakeChildProcess) { }; } +function spawnAndReadySession(session: InstanceType<typeof Session>): void { + session.spawn({ + cwd: "/tmp", + cols: 80, + rows: 24, + env: { PATH: "/usr/bin" }, + }); + sendReady(fakeChildProcess); + sendSpawned(fakeChildProcess); +} + describe("Terminal Host Session shell args", () => { beforeEach(() => { fakeChildProcess = new FakeChildProcess(); @@ -219,3 +263,229 @@ describe("Terminal Host Session shell args", () => { expect(writes.some((message) => message.includes('"hello"'))).toBe(true); }); }); + +describe("Terminal Host Session emulator backlog backpressure", () => { + beforeEach(() => { + fakeChildProcess = new FakeChildProcess(); + spawnCalls = []; + }); + + it("pauses subprocess stdout when emulator backlog exceeds the watermark without attached clients", () => { + const session = new Session({ + sessionId: "session-emulator-backpressure", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/zsh", + spawnProcess: () => fakeChildProcess as unknown as ChildProcess, + }); + + spawnAndReadySession(session); + + ( + session as unknown as { + enqueueEmulatorWrite: (data: string) => void; + } + ).enqueueEmulatorWrite("x".repeat(1_100_000)); + + expect(fakeChildProcess.stdout.pauseCalls).toBe(1); + expect(fakeChildProcess.stdout.resumeCalls).toBe(0); + }); + + it("resumes subprocess stdout once emulator backlog drains below the low watermark", () => { + const session = new Session({ + sessionId: "session-emulator-resume", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/zsh", + spawnProcess: () => fakeChildProcess as unknown as ChildProcess, + }); + + spawnAndReadySession(session); + + const internals = session as unknown as { + enqueueEmulatorWrite: (data: string) => void; + emulatorWriteQueuedBytes: number; + maybeResumeSubprocessStdoutForEmulatorBackpressure: () => void; + }; + + internals.enqueueEmulatorWrite("x".repeat(1_100_000)); + expect(fakeChildProcess.stdout.pauseCalls).toBe(1); + + internals.emulatorWriteQueuedBytes = 0; + internals.maybeResumeSubprocessStdoutForEmulatorBackpressure(); + + expect(fakeChildProcess.stdout.resumeCalls).toBe(1); + }); + + it("keeps queued byte accounting exact when chunking across a surrogate pair boundary", () => { + const session = new Session({ + sessionId: "session-surrogate-pair-backpressure", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/zsh", + spawnProcess: () => fakeChildProcess as unknown as ChildProcess, + }); + + spawnAndReadySession(session); + + const internals = session as unknown as { + enqueueEmulatorWrite: (data: string) => void; + processEmulatorWriteQueue: () => void; + emulatorWriteQueue: string[]; + emulatorWriteQueuedBytes: number; + }; + + internals.enqueueEmulatorWrite(`${"x".repeat(8191)}😀`); + internals.processEmulatorWriteQueue(); + + expect(internals.emulatorWriteQueue).toEqual([]); + expect(internals.emulatorWriteQueuedBytes).toBe(0); + }); + + it("keeps subprocess stdout paused until client drain clears too", () => { + const session = new Session({ + sessionId: "session-combined-backpressure", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/zsh", + spawnProcess: () => fakeChildProcess as unknown as ChildProcess, + }); + + spawnAndReadySession(session); + + const socket = new EventEmitter() as import("node:net").Socket; + const internals = session as unknown as { + enqueueEmulatorWrite: (data: string) => void; + emulatorWriteQueuedBytes: number; + handleClientBackpressure: (socket: import("node:net").Socket) => void; + maybeResumeSubprocessStdoutForEmulatorBackpressure: () => void; + }; + + internals.enqueueEmulatorWrite("x".repeat(1_100_000)); + expect(fakeChildProcess.stdout.pauseCalls).toBe(1); + + internals.handleClientBackpressure(socket); + internals.emulatorWriteQueuedBytes = 0; + internals.maybeResumeSubprocessStdoutForEmulatorBackpressure(); + + expect(fakeChildProcess.stdout.resumeCalls).toBe(0); + + socket.emit("drain"); + expect(fakeChildProcess.stdout.resumeCalls).toBe(1); + }); + + it("resumes subprocess stdout when a backpressured client disconnects before drain", () => { + for (const eventName of ["close", "error"] as const) { + fakeChildProcess = new FakeChildProcess(); + spawnCalls = []; + + const session = new Session({ + sessionId: `session-${eventName}-backpressure`, + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/zsh", + spawnProcess: () => fakeChildProcess as unknown as ChildProcess, + }); + + spawnAndReadySession(session); + + const socket = new EventEmitter() as import("node:net").Socket; + const internals = session as unknown as { + enqueueEmulatorWrite: (data: string) => void; + emulatorWriteQueuedBytes: number; + handleClientBackpressure: (socket: import("node:net").Socket) => void; + maybeResumeSubprocessStdoutForEmulatorBackpressure: () => void; + }; + + internals.enqueueEmulatorWrite("x".repeat(1_100_000)); + expect(fakeChildProcess.stdout.pauseCalls).toBe(1); + + internals.handleClientBackpressure(socket); + internals.emulatorWriteQueuedBytes = 0; + internals.maybeResumeSubprocessStdoutForEmulatorBackpressure(); + expect(fakeChildProcess.stdout.resumeCalls).toBe(0); + + if (eventName === "error") { + socket.emit("error", new Error("socket closed")); + } else { + socket.emit("close"); + } + + socket.emit("drain"); + socket.emit("close"); + + expect(fakeChildProcess.stdout.resumeCalls).toBe(1); + } + }); + + it("resumes subprocess stdout when the last backpressured client throws during broadcast", () => { + const session = new Session({ + sessionId: "session-dead-socket-backpressure", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/zsh", + spawnProcess: () => fakeChildProcess as unknown as ChildProcess, + }); + + spawnAndReadySession(session); + + const badSocket = new EventEmitter() as import("node:net").Socket & { + write: (_message: string) => boolean; + }; + badSocket.write = () => { + throw new Error("socket closed"); + }; + + const internals = session as unknown as { + attachedClients: Map< + import("node:net").Socket, + { + socket: import("node:net").Socket; + attachedAt: number; + attachToken: symbol; + } + >; + handleClientBackpressure: (socket: import("node:net").Socket) => void; + broadcastEvent: ( + eventType: string, + payload: { type: "data"; data: string }, + ) => void; + }; + + internals.attachedClients.set(badSocket, { + socket: badSocket, + attachedAt: Date.now(), + attachToken: Symbol("attach"), + }); + internals.handleClientBackpressure(badSocket); + expect(fakeChildProcess.stdout.pauseCalls).toBe(1); + + internals.broadcastEvent("data", { type: "data", data: "hello" }); + + expect(fakeChildProcess.stdout.resumeCalls).toBe(1); + }); +}); diff --git a/apps/desktop/src/main/terminal-host/session.ts b/apps/desktop/src/main/terminal-host/session.ts index c7da037418e..67148e927f8 100644 --- a/apps/desktop/src/main/terminal-host/session.ts +++ b/apps/desktop/src/main/terminal-host/session.ts @@ -11,6 +11,12 @@ import { type ChildProcess, spawn } from "node:child_process"; import type { Socket } from "node:net"; import * as path from "node:path"; +import { + createScanState, + SHELLS_WITH_READY_MARKER, + type ShellReadyScanState, + scanForShellReady, +} from "@superset/shared/shell-ready-scanner"; import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; import { getCommandShellArgs, @@ -34,7 +40,6 @@ import { createFrameHeader, PtySubprocessFrameDecoder, PtySubprocessIpcType, - SHELL_READY_MARKER, } from "./pty-subprocess-ipc"; // ============================================================================= @@ -54,6 +59,21 @@ const ATTACH_FLUSH_TIMEOUT_MS = 500; */ const MAX_SUBPROCESS_STDIN_QUEUE_BYTES = 2_000_000; +/** + * Emulator backlog high-water mark. + * Once crossed, pause reading PTY output until the headless emulator catches up. + * + * This keeps PTY -> daemon -> renderer backpressure end-to-end instead of + * letting Session accumulate unbounded terminal output in memory. + */ +const EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES = 1_000_000; + +/** + * Emulator backlog low-water mark for resuming PTY reads after a pause. + * Kept well below the high-water mark to avoid pause/resume thrash. + */ +const EMULATOR_WRITE_QUEUE_LOW_WATERMARK_BYTES = 250_000; + /** * How long to wait for the shell-ready marker before unblocking writes. * 15s covers heavy setups like Nix-based devenv via direnv. On timeout, @@ -61,14 +81,11 @@ const MAX_SUBPROCESS_STDIN_QUEUE_BYTES = 2_000_000; */ const SHELL_READY_TIMEOUT_MS = 15_000; -/** Shells whose wrapper files inject a {@link SHELL_READY_MARKER}. */ -const SHELLS_WITH_READY_MARKER = new Set(["zsh", "bash", "fish"]); - /** * Shell readiness lifecycle: - * - `pending` — shell is initializing; user writes are buffered, escape sequences dropped - * - `ready` — marker detected; buffered writes have been flushed - * - `timed_out` — marker never arrived within timeout; writes unblocked + * - `pending` — shell is initializing; escape sequences dropped, other writes pass through + * - `ready` — marker detected; writes pass through + * - `timed_out` — marker never arrived within timeout; writes pass through * - `unsupported` — shell has no marker (sh, ksh); writes pass through from the start */ type ShellReadyState = "pending" | "ready" | "timed_out" | "unsupported"; @@ -136,23 +153,20 @@ export class Session { private subprocessStdinQueuedBytes = 0; private subprocessStdinDrainArmed = false; private ptyPid: number | null = null; + private emulatorWriteBackpressured = false; // Promise that resolves when PTY is ready to accept writes private ptyReadyPromise: Promise<void>; private ptyReadyResolve: (() => void) | null = null; - // Shell readiness — gates write() until the shell's first prompt. + // Shell readiness — tracks the shell's init lifecycle. User input and + // preset commands pass through regardless; only stale xterm terminal-query + // responses (DA/DSR) are filtered while `pending`. // See ShellReadyState for lifecycle docs. private shellReadyState: ShellReadyState; private shellReadyTimeoutId: ReturnType<typeof setTimeout> | null = null; - private preReadyStdinQueue: string[] = []; - // Marker scanner — tracks how many characters of SHELL_READY_MARKER - // we've matched so far. Held bytes are withheld from terminal output - // until we confirm a full match (discard them) or a mismatch (flush - // them as regular output). This prevents partial OSC sequences from - // ever reaching the renderer, even when the marker spans two Data frames. - private markerMatchPos = 0; - private markerHeldBytes = ""; + // OSC 133;A scanner state — shared with v2 host-service via @superset/shared + private scanState: ShellReadyScanState = createScanState(); private emulatorWriteQueue: string[] = []; private emulatorWriteQueuedBytes = 0; @@ -208,18 +222,17 @@ export class Session { // Set initial CWD this.emulator.setCwd(options.cwd); - // The headless emulator responds to terminal queries (e.g. DA) - // when no renderer client is attached. During shell init we drop - // these — they'd land in the pre-ready stdin queue and appear as - // typed text like "?62;4;9;22c" once flushed. After a client - // attaches the renderer's xterm handles all terminal queries. + // The headless emulator responds to terminal queries (e.g. DA1, + // DSR). These responses must be forwarded to the subprocess + // regardless of whether renderer clients are attached, because + // shells like fish send DA1 at startup and wait up to 10 seconds + // for a reply before disabling optional features. + // Unlike renderer-generated responses (which go through write() + // and are correctly dropped during init to avoid appearing as + // typed text), headless emulator responses are written directly + // to the PTY and consumed by the shell as protocol data. this.emulator.onData((data) => { - if ( - this.attachedClients.size === 0 && - this.subprocess && - this.subprocessReady - ) { - if (this.shellReadyState === "pending") return; + if (this.subprocess && this.subprocessReady) { this.sendWriteToSubprocess(data); } }); @@ -349,32 +362,13 @@ export class Session { if (payload.length === 0) break; let data = payload.toString("utf8"); - // Scan for SHELL_READY_MARKER one character at a time. - // Matching bytes are held back from output; on full match - // they're discarded and readiness resolves. On mismatch - // they're flushed as regular terminal output. + // Scan for OSC 133;A (shell ready) and strip from output. if (this.shellReadyState === "pending") { - let output = ""; - for (let i = 0; i < data.length; i++) { - if (data[i] === SHELL_READY_MARKER[this.markerMatchPos]) { - this.markerHeldBytes += data[i]; - this.markerMatchPos++; - if (this.markerMatchPos === SHELL_READY_MARKER.length) { - // Full match — discard held bytes, resolve - this.markerHeldBytes = ""; - this.markerMatchPos = 0; - this.resolveShellReady("ready"); - output += data.slice(i + 1); - break; - } - } else { - // Mismatch — flush held bytes as regular output - output += this.markerHeldBytes + data[i]; - this.markerHeldBytes = ""; - this.markerMatchPos = 0; - } + const result = scanForShellReady(this.scanState, data); + data = result.output; + if (result.matched) { + this.resolveShellReady("ready"); } - data = output; } if (data.length === 0) break; @@ -586,7 +580,8 @@ export class Session { private enqueueEmulatorWrite(data: string): void { this.emulatorWriteQueue.push(data); - this.emulatorWriteQueuedBytes += data.length; + this.emulatorWriteQueuedBytes += Buffer.byteLength(data, "utf8"); + this.maybePauseSubprocessStdoutForEmulatorBackpressure(); this.scheduleEmulatorWrite(); } @@ -627,18 +622,31 @@ export class Session { let chunk = this.emulatorWriteQueue[0]; if (chunk.length > MAX_CHUNK_CHARS) { - this.emulatorWriteQueue[0] = chunk.slice(MAX_CHUNK_CHARS); - chunk = chunk.slice(0, MAX_CHUNK_CHARS); + let splitAt = MAX_CHUNK_CHARS; + const prev = chunk.charCodeAt(splitAt - 1); + const next = chunk.charCodeAt(splitAt); + if ( + prev >= 0xd800 && + prev <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff + ) { + splitAt--; + } + this.emulatorWriteQueue[0] = chunk.slice(splitAt); + chunk = chunk.slice(0, splitAt); } else { this.emulatorWriteQueue.shift(); this.emulatorWriteProcessedItems++; this.resolveReachedSnapshotBoundaryWaiters(); } - this.emulatorWriteQueuedBytes -= chunk.length; + this.emulatorWriteQueuedBytes -= Buffer.byteLength(chunk, "utf8"); this.emulator.write(chunk); } + this.maybeResumeSubprocessStdoutForEmulatorBackpressure(); + if (this.emulatorWriteQueue.length > 0) { setImmediate(() => { this.processEmulatorWriteQueue(); @@ -831,27 +839,29 @@ export class Session { } this.attachedClients.delete(socket); this.clientSocketsWaitingForDrain.delete(socket); - this.maybeResumeSubprocessStdout(); + this.updateSubprocessStdoutFlow(); } /** * Write data to the PTY's stdin. * - * While the shell is initializing (`pending` state), writes are triaged: - * - **Escape sequences** (`\x1b`-prefixed) are dropped. These are stale - * responses from the renderer's xterm to terminal queries the shell - * sent during startup (DA, DSR). If queued and flushed later they - * appear as typed text like `?62;4;9;22c`. - * - **Everything else** (preset commands, user input) is buffered and - * flushed in FIFO order once readiness resolves. + * Escape-sequence responses (`\x1b`-prefixed) are dropped while the shell + * is still initializing — these are stale DA/DSR replies from the + * renderer's xterm to terminal queries the shell sent during startup. If + * forwarded, they appear as typed text like `?62;4;9;22c` at the shell + * prompt. The headless emulator answers those queries directly (see + * constructor), so dropping the renderer's duplicate is safe. + * + * All other data — user keystrokes and preset commands alike — passes + * through immediately. Buffering here previously froze workspaces when + * shell init commands (e.g. fnm's `use-on-cd` hook) opened an interactive + * prompt before the OSC 133;A marker fired. See #3478. */ write(data: string): void { if (!this.subprocess || !this.subprocessReady) { throw new Error("PTY not spawned"); } - if (this.shellReadyState === "pending") { - if (data.startsWith("\x1b")) return; - this.preReadyStdinQueue.push(data); + if (this.shellReadyState === "pending" && data.startsWith("\x1b")) { return; } this.sendWriteToSubprocess(data); @@ -985,13 +995,12 @@ export class Session { clearTimeout(this.shellReadyTimeoutId); this.shellReadyTimeoutId = null; } - this.preReadyStdinQueue = []; - this.markerMatchPos = 0; - this.markerHeldBytes = ""; + this.scanState = createScanState(); this.subprocessStdinQueue = []; this.subprocessStdinQueuedBytes = 0; this.subprocessStdinDrainArmed = false; this.subprocessStdoutPaused = false; + this.emulatorWriteBackpressured = false; this.emulatorWriteQueue = []; this.emulatorWriteQueuedBytes = 0; @@ -1019,8 +1028,7 @@ export class Session { /** * Transition out of `pending`. Flushes any partially-matched marker - * bytes as terminal output (they weren't a real marker), then sends - * all buffered stdin writes to the PTY in order. Idempotent. + * bytes as terminal output (they weren't a real marker). Idempotent. */ private resolveShellReady(state: "ready" | "timed_out"): void { if (this.shellReadyState !== "pending") return; @@ -1030,21 +1038,15 @@ export class Session { this.shellReadyTimeoutId = null; } // Flush held marker bytes — they weren't part of a full marker - if (this.markerHeldBytes.length > 0) { - this.enqueueEmulatorWrite(this.markerHeldBytes); + if (this.scanState.heldBytes.length > 0) { + this.enqueueEmulatorWrite(this.scanState.heldBytes); this.broadcastEvent("data", { type: "data", - data: this.markerHeldBytes, + data: this.scanState.heldBytes, } satisfies TerminalDataEvent); - this.markerHeldBytes = ""; - } - this.markerMatchPos = 0; - // Flush queued writes in FIFO order - const queue = this.preReadyStdinQueue; - this.preReadyStdinQueue = []; - for (const data of queue) { - this.sendWriteToSubprocess(data); + this.scanState.heldBytes = ""; } + this.scanState.matchPos = 0; } /** @@ -1067,45 +1069,79 @@ export class Session { try { const canWrite = socket.write(message); if (!canWrite) { - // Socket buffer full - data will be queued but may cause memory pressure - // In production, could track this and pause PTY output temporarily - console.warn( - `[Session ${this.sessionId}] Client socket buffer full, output may be delayed`, - ); this.handleClientBackpressure(socket); } } catch { this.attachedClients.delete(socket); this.clientSocketsWaitingForDrain.delete(socket); + this.updateSubprocessStdoutFlow(); } } } private handleClientBackpressure(socket: Socket): void { - // If the client can’t keep up, pause reading from the subprocess stdout. - // This will backpressure the subprocess stdout pipe, which in turn pauses - // PTY reads inside the subprocess (preventing runaway buffering/CPU). - if (!this.subprocessStdoutPaused && this.subprocess?.stdout) { - this.subprocessStdoutPaused = true; - this.subprocess.stdout.pause(); - } - if (this.clientSocketsWaitingForDrain.has(socket)) return; this.clientSocketsWaitingForDrain.add(socket); + this.updateSubprocessStdoutFlow(); - socket.once("drain", () => { + const clearBackpressure = () => { + socket.off("drain", clearBackpressure); + socket.off("close", clearBackpressure); + socket.off("error", clearBackpressure); this.clientSocketsWaitingForDrain.delete(socket); - this.maybeResumeSubprocessStdout(); - }); + this.updateSubprocessStdoutFlow(); + }; + + socket.once("drain", clearBackpressure); + socket.once("close", clearBackpressure); + socket.once("error", clearBackpressure); } - private maybeResumeSubprocessStdout(): void { - if (this.clientSocketsWaitingForDrain.size > 0) return; - if (!this.subprocessStdoutPaused) return; - if (!this.subprocess?.stdout) return; + private maybePauseSubprocessStdoutForEmulatorBackpressure(): void { + if (this.emulatorWriteBackpressured) return; + if ( + this.emulatorWriteQueuedBytes < EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES + ) { + return; + } + this.emulatorWriteBackpressured = true; + console.warn( + `[Session ${this.sessionId}] Emulator backlog reached ${this.emulatorWriteQueuedBytes} bytes, pausing PTY reads`, + ); + this.updateSubprocessStdoutFlow(); + } + + private maybeResumeSubprocessStdoutForEmulatorBackpressure(): void { + if (!this.emulatorWriteBackpressured) return; + if ( + this.emulatorWriteQueuedBytes > EMULATOR_WRITE_QUEUE_LOW_WATERMARK_BYTES + ) { + return; + } + + this.emulatorWriteBackpressured = false; + this.updateSubprocessStdoutFlow(); + } + + private updateSubprocessStdoutFlow(): void { + const stdout = this.subprocess?.stdout; + if (!stdout) return; + + const shouldPause = + this.clientSocketsWaitingForDrain.size > 0 || + this.emulatorWriteBackpressured; + + if (shouldPause) { + if (this.subprocessStdoutPaused) return; + this.subprocessStdoutPaused = true; + stdout.pause(); + return; + } + + if (!this.subprocessStdoutPaused) return; this.subprocessStdoutPaused = false; - this.subprocess.stdout.resume(); + stdout.resume(); } /** diff --git a/apps/desktop/src/main/terminal-host/terminal-host.test.ts b/apps/desktop/src/main/terminal-host/terminal-host.test.ts new file mode 100644 index 00000000000..776f593e0f7 --- /dev/null +++ b/apps/desktop/src/main/terminal-host/terminal-host.test.ts @@ -0,0 +1,220 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { createFrameHeader, PtySubprocessIpcType } from "./pty-subprocess-ipc"; +import "./xterm-env-polyfill"; + +// Must import after polyfill since these transitively load @xterm/headless +const { Session } = await import("./session"); + +// ============================================================================= +// Fakes +// ============================================================================= + +class FakeStdout extends EventEmitter { + write(): boolean { + return true; + } +} + +class FakeStdin extends EventEmitter { + readonly writes: Buffer[] = []; + + write(chunk: Buffer | string): boolean { + this.writes.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"), + ); + return true; + } +} + +class FakeChildProcess extends EventEmitter { + readonly stdout = new FakeStdout(); + readonly stdin = new FakeStdin(); + pid = 4242; + kill(): boolean { + return true; + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function emitReadyAndSpawned(child: FakeChildProcess, pid = 9999): void { + // Ready frame (no payload) + child.stdout.emit("data", createFrameHeader(PtySubprocessIpcType.Ready, 0)); + + // Spawned frame with PID + const pidPayload = Buffer.allocUnsafe(4); + pidPayload.writeUInt32LE(pid, 0); + const header = createFrameHeader(PtySubprocessIpcType.Spawned, 4); + child.stdout.emit("data", Buffer.concat([header, pidPayload])); +} + +function emitReadyOnly(child: FakeChildProcess): void { + child.stdout.emit("data", createFrameHeader(PtySubprocessIpcType.Ready, 0)); +} + +function emitReadyThenError(child: FakeChildProcess, errorMsg: string): void { + // Ready frame + child.stdout.emit("data", createFrameHeader(PtySubprocessIpcType.Ready, 0)); + + // Error frame + const errorPayload = Buffer.from(errorMsg, "utf8"); + const header = createFrameHeader( + PtySubprocessIpcType.Error, + errorPayload.length, + ); + child.stdout.emit("data", Buffer.concat([header, errorPayload])); +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe("TerminalHost — PTY spawn failure handling", () => { + let fakeChild: FakeChildProcess; + + beforeEach(() => { + fakeChild = new FakeChildProcess(); + }); + + /** + * Reproduces the broken state from issue #2960: + * the subprocess reports a spawn error but stays alive, so `isAlive` + * remains true even though no PTY PID was ever assigned. + */ + it("session.isAlive is true when subprocess is alive but PTY failed to spawn (BUG)", async () => { + const session = new Session({ + sessionId: "session-spawn-fail", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/bash", + spawnProcess: () => fakeChild as unknown as ChildProcess, + }); + + session.spawn({ + cwd: "/tmp", + cols: 80, + rows: 24, + env: { PATH: "/usr/bin" }, + }); + + // Spawn fails after Ready, but the subprocess never exits. + emitReadyThenError(fakeChild, "Spawn failed: posix_spawnp failed."); + + expect(session.isAlive).toBe(true); + expect(session.pid).toBeNull(); + + const terminalHostWouldReject = !session.isAlive; + expect(terminalHostWouldReject).toBe(false); + + await session.dispose(); + }); + + it("session correctly detects spawn failure when subprocess exits after error", async () => { + const session = new Session({ + sessionId: "session-spawn-fail-fixed", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/bash", + spawnProcess: () => fakeChild as unknown as ChildProcess, + }); + + session.spawn({ + cwd: "/tmp", + cols: 80, + rows: 24, + env: { PATH: "/usr/bin" }, + }); + + // Spawn fails, then the subprocess exits. + emitReadyThenError(fakeChild, "Spawn failed: posix_spawnp failed."); + fakeChild.emit("exit", 1); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(session.isAlive).toBe(false); + expect(session.pid).toBeNull(); + + await session.dispose(); + }); + + it("TerminalHost rejects broken session when pid is null after ready timeout", async () => { + const session = new Session({ + sessionId: "session-no-pid", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/bash", + spawnProcess: () => fakeChild as unknown as ChildProcess, + }); + + session.spawn({ + cwd: "/tmp", + cols: 80, + rows: 24, + env: { PATH: "/usr/bin" }, + }); + + // Ready arrives, but PTY spawn never completes. + emitReadyOnly(fakeChild); + + const readyPromise = session.waitForReady(); + const timeoutPromise = new Promise<void>((resolve) => + setTimeout(resolve, 100), + ); + await Promise.race([readyPromise, timeoutPromise]); + + expect(session.isAlive).toBe(true); + expect(session.pid).toBeNull(); + + const shouldReject = !session.isAlive || session.pid === null; + expect(shouldReject).toBe(true); + + await session.dispose(); + }); + + it("healthy session has both isAlive=true and pid set", async () => { + const session = new Session({ + sessionId: "session-healthy", + workspaceId: "workspace-1", + paneId: "pane-1", + tabId: "tab-1", + cols: 80, + rows: 24, + cwd: "/tmp", + shell: "/bin/bash", + spawnProcess: () => fakeChild as unknown as ChildProcess, + }); + + session.spawn({ + cwd: "/tmp", + cols: 80, + rows: 24, + env: { PATH: "/usr/bin" }, + }); + + // Simulate successful spawn + emitReadyAndSpawned(fakeChild, 12345); + + await session.waitForReady(); + + expect(session.isAlive).toBe(true); + expect(session.pid).toBe(12345); + + await session.dispose(); + }); +}); diff --git a/apps/desktop/src/main/terminal-host/terminal-host.ts b/apps/desktop/src/main/terminal-host/terminal-host.ts index 82cea86b593..b84bdcd0fe8 100644 --- a/apps/desktop/src/main/terminal-host/terminal-host.ts +++ b/apps/desktop/src/main/terminal-host/terminal-host.ts @@ -1,13 +1,3 @@ -/** - * Terminal Host Manager - * - * Manages all terminal sessions in the daemon. - * Responsible for: - * - Session lifecycle (create, attach, detach, kill) - * - Session lookup and listing - * - Cleanup on shutdown - */ - import type { Socket } from "node:net"; import { TerminalAttachCanceledError } from "../lib/terminal/errors"; import type { @@ -26,11 +16,6 @@ import type { } from "../lib/terminal-host/types"; import { createSession, type Session } from "./session"; -// ============================================================================= -// TerminalHost Class -// ============================================================================= - -/** Timeout for force-disposing sessions that don't exit after kill */ const KILL_TIMEOUT_MS = 5000; const MAX_CONCURRENT_SPAWNS = 3; const SPAWN_READY_TIMEOUT_MS = 5000; @@ -90,9 +75,6 @@ export class TerminalHost { this.onUnattachedExit = onUnattachedExit; } - /** - * Create or attach to a terminal session - */ async createOrAttach( socket: Socket, request: CreateOrAttachRequest, @@ -174,7 +156,7 @@ export class TerminalHost { throwIfAborted(pendingAttach.abortController.signal); - if (!session.isAlive) { + if (!session.isAlive || session.pid === null) { void session.dispose(); throw new Error( "Session spawn failed: PTY process exited immediately", @@ -233,20 +215,12 @@ export class TerminalHost { return { success: true }; } - /** - * Write data to a terminal session. - * Throws if session is not found or is terminating. - */ write(request: WriteRequest): EmptyResponse { const session = this.getActiveSession(request.sessionId); session.write(request.data); return { success: true }; } - /** - * Resize a terminal session. - * No-op if session is not found or is terminating (prevents race condition errors). - */ resize(request: ResizeRequest): EmptyResponse { const session = this.sessions.get(request.sessionId); if (!session || !session.isAttachable) { @@ -256,9 +230,6 @@ export class TerminalHost { return { success: true }; } - /** - * Detach a client from a session - */ detach(socket: Socket, request: DetachRequest): EmptyResponse { const session = this.sessions.get(request.sessionId); if (session) { @@ -419,9 +390,6 @@ export class TerminalHost { return session; } - /** - * Handle session exit - */ private handleSessionExit( sessionId: string, exitCode: number, @@ -437,9 +405,6 @@ export class TerminalHost { this.scheduleSessionCleanup(sessionId); } - /** - * Clear the kill timeout for a session - */ private clearKillTimer(sessionId: string): void { const timer = this.killTimers.get(sessionId); if (timer) { diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 9cfb1e3fa3c..4867b91d227 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -16,7 +16,7 @@ import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; import { appState } from "../lib/app-state"; import { browserManager } from "../lib/browser/browser-manager"; -import { createApplicationMenu, registerMenuHotkeyUpdates } from "../lib/menu"; +import { createApplicationMenu } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; import { NotificationManager } from "../lib/notifications/notification-manager"; import { @@ -90,6 +90,7 @@ app.on("child-process-gone", (_event, details) => { export async function MainWindow() { const savedWindowState = loadWindowState(); const initialBounds = getInitialWindowBounds(savedWindowState); + let persistedZoomLevel = savedWindowState?.zoomLevel; const isDev = env.NODE_ENV === "development"; const workspaceName = isDev ? getEnvWorkspaceName() : undefined; @@ -126,7 +127,6 @@ export async function MainWindow() { }); createApplicationMenu(); - registerMenuHotkeyUpdates(); currentWindow = window; @@ -225,6 +225,7 @@ export async function MainWindow() { // Gated by `initialized` so the initial maximize() doesn't immediately // write isMaximized: true back to disk before the user touches the window. let initialized = false; + let hasCompletedFirstLoad = false; let saveTimeout: ReturnType<typeof setTimeout> | null = null; const debouncedSave = () => { if (!initialized || window.isDestroyed()) return; @@ -235,29 +236,43 @@ export async function MainWindow() { const bounds = isMaximized ? window.getNormalBounds() : window.getBounds(); + const zoomLevel = window.webContents.getZoomLevel(); saveWindowState({ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, isMaximized, - zoomLevel: window.webContents.getZoomLevel(), + zoomLevel, }); + persistedZoomLevel = zoomLevel; }, 500); }; window.on("move", debouncedSave); window.on("resize", debouncedSave); + window.webContents.on("zoom-changed", () => { + setTimeout(() => { + if (window.isDestroyed()) return; + persistedZoomLevel = window.webContents.getZoomLevel(); + debouncedSave(); + }, 0); + }); - window.webContents.once("did-finish-load", async () => { + window.webContents.on("did-finish-load", () => { console.log("[main-window] Renderer loaded successfully"); - if (initialBounds.isMaximized) { - window.maximize(); + + if (persistedZoomLevel !== undefined) { + window.webContents.setZoomLevel(persistedZoomLevel); } - if (savedWindowState?.zoomLevel !== undefined) { - window.webContents.setZoomLevel(savedWindowState.zoomLevel); + + if (!hasCompletedFirstLoad) { + if (initialBounds.isMaximized) { + window.maximize(); + } + window.show(); + initialized = true; + hasCompletedFirstLoad = true; } - window.show(); - initialized = true; }); window.webContents.on( @@ -295,14 +310,13 @@ export async function MainWindow() { isMaximized, zoomLevel, }); + persistedZoomLevel = zoomLevel; browserManager.unregisterAll(); server.close(); notificationManager.dispose(); notificationsEmitter.removeAllListeners(); - // Remove terminal listeners to prevent duplicates when window reopens on macOS getWorkspaceRuntimeRegistry().getDefault().terminal.detachAllListeners(); - // Detach window from IPC handler (handler stays alive for window reopen) ipcHandler?.detachWindow(window); currentWindow = null; }); diff --git a/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx b/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx index af7e0927aff..74ac677cfa6 100644 --- a/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx +++ b/apps/desktop/src/renderer/components/AgentSelect/AgentSelect.tsx @@ -1,3 +1,7 @@ +import type { + AgentDefinitionId, + ResolvedAgentConfig, +} from "@superset/shared/agent-settings"; import { Select, SelectContent, @@ -11,10 +15,6 @@ import { getPresetIcon, useIsDarkTheme, } from "renderer/assets/app-icons/preset-icons"; -import type { - AgentDefinitionId, - ResolvedAgentConfig, -} from "shared/utils/agent-settings"; const CONFIGURE_AGENTS_VALUE = "__configure_agents__"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index dce68a44cfc..532f018ce19 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -1,27 +1,27 @@ +import { chatServiceTrpc } from "@superset/chat/client"; import { PromptInput, PromptInputAttachment, PromptInputAttachments, type PromptInputMessage, - PromptInputTextarea, + usePromptInputController, } from "@superset/ui/ai-elements/prompt-input"; import type { ThinkingLevel } from "@superset/ui/ai-elements/thinking-toggle"; import type { ChatStatus, FileUIPart } from "ai"; import type React from "react"; import type { ReactNode } from "react"; -import { useCallback, useRef, useState } from "react"; -import { useHotkeyText } from "renderer/stores/hotkeys"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useFocusPromptOnPane } from "renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import type { SlashCommand } from "../../hooks/useSlashCommands"; import type { ModelOption, PermissionMode } from "../../types"; -import { IssueLinkCommand } from "../IssueLinkCommand"; -import { MentionAnchor, MentionProvider } from "../MentionPopover"; -import { SlashCommandInput } from "../SlashCommandInput"; +import { TiptapPromptEditor } from "../TiptapPromptEditor"; import { ChatComposerControls } from "./components/ChatComposerControls"; import { ChatInputDropZone } from "./components/ChatInputDropZone"; import { ChatShortcuts } from "./components/ChatShortcuts"; import { FileDropOverlay } from "./components/FileDropOverlay"; import { LinkedIssues } from "./components/LinkedIssues"; -import { SlashCommandPreview } from "./components/SlashCommandPreview"; +import { QuestionInputOverlay } from "./components/QuestionInputOverlay"; import type { LinkedIssue } from "./types"; import { getErrorMessage } from "./utils/getErrorMessage"; @@ -47,7 +47,15 @@ interface ChatInputFooterProps { onSubmitEnd?: () => void; onSend: (message: PromptInputMessage) => Promise<void> | void; onStop: (e: React.MouseEvent) => void; - onSlashCommandSend: (command: SlashCommand) => void; + pendingQuestion?: { + questionId: string; + question: string; + description?: string; + options?: { label: string; description?: string }[]; + } | null; + isQuestionSubmitting?: boolean; + onQuestionRespond?: (questionId: string, answer: string) => Promise<void>; + onQuestionCancel?: () => void; } export function ChatInputFooter({ @@ -72,29 +80,65 @@ export function ChatInputFooter({ onSubmitEnd, onSend, onStop, - onSlashCommandSend, + pendingQuestion, + isQuestionSubmitting, + onQuestionRespond, + onQuestionCancel, }: ChatInputFooterProps) { - const [issueLinkOpen, setIssueLinkOpen] = useState(false); + useFocusPromptOnPane(isFocused); + + // Focus the prompt when the question overlay dismisses (pendingQuestion → null). + // Uses rAF so the editor has time to mount, register its ref, and browser + // focus-stealing from the unmounting overlay has settled. + const { textInput } = usePromptInputController(); + const prevPendingQuestionRef = useRef(pendingQuestion); + useEffect(() => { + const prev = prevPendingQuestionRef.current; + prevPendingQuestionRef.current = pendingQuestion; + if (prev != null && pendingQuestion == null) { + const id = requestAnimationFrame(() => textInput.focus()); + return () => cancelAnimationFrame(id); + } + }, [pendingQuestion, textInput]); + const [linkedIssues, setLinkedIssues] = useState<LinkedIssue[]>([]); const inputRootRef = useRef<HTMLDivElement>(null); const errorMessage = getErrorMessage(error); - const focusShortcutText = useHotkeyText("FOCUS_CHAT_INPUT"); + const focusShortcutText = useHotkeyDisplay("FOCUS_CHAT_INPUT").text; const showFocusHint = focusShortcutText !== "Unassigned"; - const addLinkedIssue = useCallback( - (slug: string, title: string, taskId: string | undefined, url?: string) => { - setLinkedIssues((prev) => { - if (prev.some((issue) => issue.slug === slug)) return prev; - return [...prev, { slug, title, taskId, url }]; - }); - }, - [], - ); - const removeLinkedIssue = useCallback((slug: string) => { setLinkedIssues((prev) => prev.filter((issue) => issue.slug !== slug)); }, []); + const trpcUtils = chatServiceTrpc.useUtils(); + const searchFiles = useCallback( + async (query: string) => { + const results = await trpcUtils.workspace.searchFiles.fetch({ + rootPath: cwd, + query, + includeHidden: false, + limit: 20, + }); + return results.map((r) => ({ + id: r.id, + name: r.name, + relativePath: r.relativePath, + })); + }, + [trpcUtils, cwd], + ); + const previewSlashCommand = useCallback( + async (text: string) => { + const result = await trpcUtils.workspace.previewSlashCommand.fetch({ + cwd, + text, + }); + return result ?? null; + }, + [trpcUtils, cwd], + ); + const handleSend = useCallback( (message: PromptInputMessage) => { if (linkedIssues.length === 0) return onSend(message); @@ -113,7 +157,7 @@ export function ChatInputFooter({ ); return ( - <ChatInputDropZone className="bg-background px-4 py-3"> + <ChatInputDropZone className="relative bg-background px-4 pb-3 before:pointer-events-none before:absolute before:left-0 before:right-3 before:-top-8 before:h-8 before:bg-gradient-to-t before:from-background before:to-transparent"> {(dragType) => ( <div className="mx-auto w-full max-w-[680px]"> {errorMessage && ( @@ -124,82 +168,71 @@ export function ChatInputFooter({ {errorMessage} </p> )} - <SlashCommandInput - onCommandSend={onSlashCommandSend} - commands={slashCommands} - > - <MentionProvider cwd={cwd}> - <MentionAnchor> - <div - ref={inputRootRef} - className={ - dragType === "path" - ? "relative opacity-50 transition-opacity" - : "relative" + {pendingQuestion && onQuestionRespond && onQuestionCancel ? ( + <QuestionInputOverlay + question={pendingQuestion} + isSubmitting={isQuestionSubmitting ?? false} + onRespond={onQuestionRespond} + onCancel={onQuestionCancel} + /> + ) : ( + <div + ref={inputRootRef} + className={ + dragType === "path" + ? "relative opacity-50 transition-opacity" + : "relative" + } + > + <PromptInput + className="[&>[data-slot=input-group]]:rounded-[13px] [&>[data-slot=input-group]]:border-[0.5px] [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:bg-foreground/[0.02]" + onSubmitStart={onSubmitStart} + onSubmitEnd={onSubmitEnd} + onSubmit={handleSend} + multiple + maxFiles={5} + maxFileSize={10 * 1024 * 1024} + globalDrop + > + <ChatShortcuts isFocused={isFocused} /> + <FileDropOverlay visible={dragType === "files"} /> + <PromptInputAttachments> + {renderAttachment ?? + ((file) => <PromptInputAttachment data={file} />)} + </PromptInputAttachments> + <LinkedIssues + issues={linkedIssues} + onRemove={removeLinkedIssue} + /> + <TiptapPromptEditor + cwd={cwd} + searchFiles={searchFiles} + previewSlashCommand={previewSlashCommand} + slashCommands={slashCommands} + availableModels={availableModels} + placeholder="Ask to make changes, @mention files, run /commands" + focusShortcutText={ + showFocusHint ? focusShortcutText : undefined } - > - {showFocusHint && ( - <span className="pointer-events-none absolute top-3 right-3 z-10 text-xs text-muted-foreground/50 [:focus-within>&]:hidden"> - {focusShortcutText} to focus - </span> - )} - <PromptInput - className="[&>[data-slot=input-group]]:rounded-[13px] [&>[data-slot=input-group]]:border-[0.5px] [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:bg-foreground/[0.02]" - onSubmitStart={onSubmitStart} - onSubmitEnd={onSubmitEnd} - onSubmit={handleSend} - multiple - maxFiles={5} - maxFileSize={10 * 1024 * 1024} - globalDrop - > - <ChatShortcuts - isFocused={isFocused} - setIssueLinkOpen={setIssueLinkOpen} - /> - <IssueLinkCommand - open={issueLinkOpen} - onOpenChange={setIssueLinkOpen} - onSelect={addLinkedIssue} - /> - <FileDropOverlay visible={dragType === "files"} /> - <PromptInputAttachments> - {renderAttachment ?? - ((file) => <PromptInputAttachment data={file} />)} - </PromptInputAttachments> - <LinkedIssues - issues={linkedIssues} - onRemove={removeLinkedIssue} - /> - <SlashCommandPreview - cwd={cwd} - slashCommands={slashCommands} - /> - <PromptInputTextarea - placeholder="Ask to make changes, @mention files, run /commands" - className="min-h-10" - /> - <ChatComposerControls - availableModels={availableModels} - selectedModel={selectedModel} - setSelectedModel={setSelectedModel} - modelSelectorOpen={modelSelectorOpen} - setModelSelectorOpen={setModelSelectorOpen} - permissionMode={permissionMode} - setPermissionMode={setPermissionMode} - thinkingLevel={thinkingLevel} - setThinkingLevel={setThinkingLevel} - canAbort={canAbort} - submitStatus={submitStatus} - submitDisabled={submitDisabled} - onStop={onStop} - onLinkIssue={() => setIssueLinkOpen(true)} - /> - </PromptInput> - </div> - </MentionAnchor> - </MentionProvider> - </SlashCommandInput> + /> + <ChatComposerControls + availableModels={availableModels} + selectedModel={selectedModel} + setSelectedModel={setSelectedModel} + modelSelectorOpen={modelSelectorOpen} + setModelSelectorOpen={setModelSelectorOpen} + permissionMode={permissionMode} + setPermissionMode={setPermissionMode} + thinkingLevel={thinkingLevel} + setThinkingLevel={setThinkingLevel} + canAbort={canAbort} + submitStatus={submitStatus} + submitDisabled={submitDisabled} + onStop={onStop} + /> + </PromptInput> + </div> + )} <div className="py-1.5" /> </div> )} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx index d477e92e891..7039c5cb8a9 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx @@ -30,7 +30,6 @@ interface ChatComposerControlsProps { submitStatus?: ChatStatus; submitDisabled?: boolean; onStop: (event: React.MouseEvent) => void; - onLinkIssue: () => void; } export function ChatComposerControls({ @@ -47,7 +46,6 @@ export function ChatComposerControls({ submitStatus, submitDisabled, onStop, - onLinkIssue, }: ChatComposerControlsProps) { return ( <PromptInputFooter> @@ -70,7 +68,7 @@ export function ChatComposerControls({ /> </PromptInputTools> <div className="flex items-center gap-2"> - <PlusMenu onLinkIssue={onLinkIssue} /> + <PlusMenu /> <PromptInputSubmit className="size-[23px] rounded-full border border-transparent bg-foreground/10 shadow-none p-[5px] hover:bg-foreground/20" status={submitStatus} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx index c00313c1f54..e879a261b00 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx @@ -2,22 +2,17 @@ import { usePromptInputAttachments, usePromptInputController, } from "@superset/ui/ai-elements/prompt-input"; -import type React from "react"; -import { useAppHotkey } from "renderer/stores/hotkeys"; +import { useHotkey } from "renderer/hotkeys"; interface ChatShortcutsProps { isFocused: boolean; - setIssueLinkOpen: React.Dispatch<React.SetStateAction<boolean>>; } -export function ChatShortcuts({ - isFocused, - setIssueLinkOpen, -}: ChatShortcutsProps) { +export function ChatShortcuts({ isFocused }: ChatShortcutsProps) { const attachments = usePromptInputAttachments(); const { textInput } = usePromptInputController(); - useAppHotkey( + useHotkey( "CHAT_ADD_ATTACHMENT", () => { attachments.openFileDialog(); @@ -25,15 +20,7 @@ export function ChatShortcuts({ { enabled: isFocused, preventDefault: true }, ); - useAppHotkey( - "CHAT_LINK_ISSUE", - () => { - setIssueLinkOpen((prev) => !prev); - }, - { enabled: isFocused, preventDefault: true }, - ); - - useAppHotkey( + useHotkey( "FOCUS_CHAT_INPUT", () => { textInput.focus(); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx new file mode 100644 index 00000000000..4af96469d6b --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/QuestionInputOverlay.tsx @@ -0,0 +1,196 @@ +import { cn } from "@superset/ui/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { ArrowUpIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +type QuestionOption = { label: string; description?: string }; + +interface QuestionInputOverlayProps { + question: { + questionId: string; + question: string; + description?: string; + options?: QuestionOption[]; + }; + isSubmitting: boolean; + onRespond: (questionId: string, answer: string) => Promise<void>; + onCancel: () => void; +} + +export function QuestionInputOverlay({ + question, + isSubmitting, + onRespond, + onCancel, +}: QuestionInputOverlayProps) { + const [customText, setCustomText] = useState(""); + // Tracks which label was submitted: an option label, "__custom__", or "__skip__". + // null = nothing submitted yet. + const [submittedLabel, setSubmittedLabel] = useState<string | null>(null); + const inputRef = useRef<HTMLInputElement>(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: question.questionId is an intentional re-run trigger + useEffect(() => { + setSubmittedLabel(null); + setCustomText(""); + }, [question.questionId]); + + const options = question.options ?? []; + const submitted = submittedLabel !== null; + const isDisabled = isSubmitting || submitted; + const hasCustomText = customText.trim().length > 0; + // Spinner goes on the pencil icon when the answer came from the text input row. + const isInputRowSubmitted = + submitted && !options.some((o) => o.label === submittedLabel); + + const handleSubmitAnswer = (answer: string, label: string) => { + if (isDisabled) return; + setSubmittedLabel(label); + onRespond(question.questionId, answer).catch(() => { + setSubmittedLabel(null); + setCustomText(""); + }); + }; + + const handleOption = (label: string) => handleSubmitAnswer(label, label); + const handleCustom = () => { + const trimmed = customText.trim(); + if (!trimmed) return; + handleSubmitAnswer(trimmed, "__custom__"); + }; + const handleSkip = () => handleSubmitAnswer("skip", "__skip__"); + + return ( + <div className="flex max-h-[300px] flex-col overflow-hidden rounded-[13px] border-[0.5px] border-border bg-foreground/[0.02]"> + {/* Question — pinned header */} + <div className="flex shrink-0 items-start gap-2 px-3 pt-3 pb-3"> + <div className="flex-1 space-y-1"> + <p className="text-sm leading-snug text-foreground"> + {question.question} + </p> + {question.description && ( + <p className="text-xs leading-snug text-muted-foreground"> + {question.description} + </p> + )} + </div> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + className="-mr-0.5 shrink-0 rounded-md p-1 text-muted-foreground/50 transition-colors hover:bg-muted/40 hover:text-muted-foreground" + onClick={onCancel} + aria-label="Cancel" + > + <XIcon className="h-3.5 w-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent>Cancel</TooltipContent> + </Tooltip> + </div> + + {/* Options — scrollable */} + {options.length > 0 && ( + <div + className={cn( + "overflow-y-auto px-2 transition-opacity duration-200", + hasCustomText && !submitted && "opacity-25", + )} + > + {options.map((option, i) => { + const isChosen = submittedLabel === option.label; + return ( + <div key={option.label} className="border-t border-border/60"> + <button + type="button" + className={cn( + "group flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors", + isChosen ? "bg-foreground/[0.06]" : "hover:bg-muted/40", + isDisabled && !isChosen && "cursor-not-allowed opacity-40", + )} + disabled={isDisabled} + onClick={() => handleOption(option.label)} + > + <span className="flex size-6 shrink-0 items-center justify-center rounded-[3px] bg-muted/60 font-mono text-xs leading-none text-muted-foreground/70"> + {isChosen ? ( + <Loader2Icon className="size-3.5 animate-spin" /> + ) : ( + i + 1 + )} + </span> + <span + className={cn( + "text-sm transition-colors", + isChosen + ? "text-foreground" + : "text-muted-foreground group-hover:text-foreground", + )} + > + {option.label} + </span> + </button> + </div> + ); + })} + </div> + )} + + {/* Text input / skip — pinned footer */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: click-to-focus affordance */} + <form + className="mx-2 mb-2 mt-px shrink-0 flex cursor-text items-center gap-3 rounded-lg bg-black/20 px-2.5 py-2 ring-1 ring-inset ring-border/60" + onSubmit={(e) => { + e.preventDefault(); + handleCustom(); + }} + onClick={() => inputRef.current?.focus()} + > + <span className="flex size-6 shrink-0 items-center justify-center rounded-[3px] bg-muted/60"> + {isInputRowSubmitted ? ( + <Loader2Icon className="size-3.5 animate-spin text-muted-foreground/70" /> + ) : ( + <PencilIcon className="size-3.5 text-muted-foreground/70" /> + )} + </span> + <input + ref={inputRef} + value={customText} + onChange={(e) => setCustomText(e.target.value)} + placeholder={ + options.length > 0 ? "Something else" : "Type your answer..." + } + disabled={isDisabled} + className="flex-1 cursor-text bg-transparent py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground/40 disabled:cursor-not-allowed" + /> + {!isDisabled && ( + <div className="relative shrink-0"> + <button + type="button" + className={cn( + "rounded-sm border border-border px-3 py-1 text-xs font-medium text-muted-foreground transition-all duration-150 hover:border-foreground/30 hover:text-foreground", + hasCustomText ? "pointer-events-none opacity-0" : "opacity-100", + )} + onClick={(e) => { + e.stopPropagation(); + handleSkip(); + }} + > + Skip + </button> + <button + type="submit" + className={cn( + "absolute right-0 top-1/2 -translate-y-1/2 size-[23px] rounded-full bg-foreground p-[5px] transition-all duration-150 hover:bg-foreground/80", + hasCustomText ? "opacity-100" : "pointer-events-none opacity-0", + )} + aria-label="Submit" + onClick={(e) => e.stopPropagation()} + > + <ArrowUpIcon className="size-3.5 text-background" /> + </button> + </div> + )} + </form> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/index.ts new file mode 100644 index 00000000000..d7dc759eafd --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay/index.ts @@ -0,0 +1 @@ +export { QuestionInputOverlay } from "./QuestionInputOverlay"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx index ab32387af1b..4bcf7860b64 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/IssueLinkCommand/IssueLinkCommand.tsx @@ -1,18 +1,18 @@ +import { Checkbox } from "@superset/ui/checkbox"; import { Command, - CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@superset/ui/command"; -import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useLiveQuery } from "@tanstack/react-db"; import Fuse from "fuse.js"; -import type React from "react"; -import type { RefObject } from "react"; -import { useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { useId, useMemo, useState } from "react"; import { StatusIcon, type StatusType, @@ -21,23 +21,30 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect const MAX_RESULTS = 20; -type IssueLinkCommandProps = { - open: boolean; - onOpenChange: (open: boolean) => void; +function isClosedStatus(type: StatusType | undefined): boolean { + return type === "completed" || type === "canceled"; +} + +interface IssueLinkCommandProps { + children: ReactNode; + tooltipLabel: string; onSelect: ( slug: string, title: string, taskId: string | undefined, url?: string, ) => void; -} & ( - | { variant?: "dialog" } - | { variant: "popover"; anchorRef: RefObject<HTMLElement | null> } -); +} -export function IssueLinkCommand(props: IssueLinkCommandProps) { - const { open, onOpenChange, onSelect } = props; +export function IssueLinkCommand({ + children, + tooltipLabel, + onSelect, +}: IssueLinkCommandProps) { + const [open, setOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); const collections = useCollections(); const { data: allTasks } = useLiveQuery( @@ -82,21 +89,35 @@ export function IssueLinkCommand(props: IssueLinkCommandProps) { const taskFuse = useMemo( () => - new Fuse(allTasks ?? [], { - keys: [ - { name: "slug", weight: 3 }, - { name: "title", weight: 2 }, - ], - threshold: 0.4, - ignoreLocation: true, - }), - [allTasks], + new Fuse( + (allTasks ?? []).filter((task) => { + if (showClosed) return true; + const status = task.statusId + ? statusMap.get(task.statusId) + : undefined; + return !isClosedStatus(status?.type); + }), + { + keys: [ + { name: "slug", weight: 3 }, + { name: "title", weight: 2 }, + ], + threshold: 0.4, + ignoreLocation: true, + }, + ), + [allTasks, showClosed, statusMap], ); const filteredTasks = useMemo(() => { if (!allTasks?.length) return []; + const visibleTasks = allTasks.filter((task) => { + if (showClosed) return true; + const status = task.statusId ? statusMap.get(task.statusId) : undefined; + return !isClosedStatus(status?.type); + }); if (!searchQuery) { - return [...allTasks] + return visibleTasks .sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), @@ -106,12 +127,7 @@ export function IssueLinkCommand(props: IssueLinkCommandProps) { return taskFuse .search(searchQuery, { limit: MAX_RESULTS }) .map((r) => r.item); - }, [allTasks, searchQuery, taskFuse]); - - const handleClose = () => { - setSearchQuery(""); - onOpenChange(false); - }; + }, [allTasks, searchQuery, showClosed, statusMap, taskFuse]); const handleSelect = ( slug: string, @@ -120,103 +136,109 @@ export function IssueLinkCommand(props: IssueLinkCommandProps) { url?: string, ) => { onSelect(slug, title, taskId, url); - handleClose(); + setSearchQuery(""); + setOpen(false); }; - const issueListContent = ( - <> - <CommandInput - placeholder="Search issues..." - value={searchQuery} - onValueChange={setSearchQuery} - /> - <CommandList - className={props.variant === "popover" ? "max-h-[280px]" : undefined} - > - {filteredTasks.length === 0 && ( - <CommandEmpty>No issues found.</CommandEmpty> - )} - {filteredTasks.length > 0 && ( - <CommandGroup heading={searchQuery ? "Results" : "Recent issues"}> - {filteredTasks.map((task) => { - const status = task.statusId - ? statusMap.get(task.statusId) - : undefined; - return ( - <CommandItem - key={task.id} - value={task.slug} - onSelect={() => - handleSelect( - task.slug, - task.title, - task.id, - task.externalUrl ?? undefined, - ) - } - className="group" - > - {status ? ( - <StatusIcon - type={status.type} - color={status.color} - progress={status.progressPercent ?? undefined} - /> - ) : ( - <span className="size-3.5 shrink-0 rounded-full border border-muted-foreground/40" /> - )} - <span className="max-w-24 shrink-0 truncate font-mono text-xs text-muted-foreground"> - {task.slug} - </span> - <span className="min-w-0 flex-1 truncate text-xs"> - {task.title} - </span> - <span className="shrink-0 hidden text-xs text-muted-foreground group-data-[selected=true]:inline"> - Link ↵ - </span> - </CommandItem> - ); - })} - </CommandGroup> - )} - </CommandList> - </> - ); - - if (props.variant === "popover") { - return ( - <Popover open={open}> - <PopoverAnchor - virtualRef={props.anchorRef as React.RefObject<Element>} - /> - <PopoverContent - className="w-80 p-0" - align="end" - side="top" - onWheel={(event) => event.stopPropagation()} - onPointerDownOutside={handleClose} - onEscapeKeyDown={handleClose} - onFocusOutside={(e) => e.preventDefault()} - > - <Command shouldFilter={false}>{issueListContent}</Command> - </PopoverContent> - </Popover> - ); - } - return ( - <CommandDialog + <Popover open={open} - onOpenChange={(nextOpen) => { - if (!nextOpen) setSearchQuery(""); - onOpenChange(nextOpen); + onOpenChange={(next) => { + if (!next) setSearchQuery(""); + setOpen(next); }} - modal - title="Link issue" - description="Search for an issue to link" - showCloseButton={false} > - {issueListContent} - </CommandDialog> + <Tooltip> + <PopoverTrigger asChild> + <TooltipTrigger asChild>{children}</TooltipTrigger> + </PopoverTrigger> + <TooltipContent side="bottom">{tooltipLabel}</TooltipContent> + </Tooltip> + <PopoverContent + className="w-80 p-0" + align="start" + side="bottom" + onWheel={(event) => event.stopPropagation()} + > + <Command shouldFilter={false}> + <CommandInput + placeholder="Search issues..." + value={searchQuery} + onValueChange={setSearchQuery} + /> + <div className="flex items-center gap-2 border-b px-3 py-2"> + <Checkbox + id={showClosedId} + checked={showClosed} + onCheckedChange={(checked) => setShowClosed(checked === true)} + /> + <label + htmlFor={showClosedId} + className="cursor-pointer select-none text-xs text-muted-foreground" + > + Show closed + </label> + </div> + <CommandList className="max-h-[280px]"> + {filteredTasks.length === 0 && ( + <CommandEmpty> + {showClosed ? "No issues found." : "No open issues found."} + </CommandEmpty> + )} + {filteredTasks.length > 0 && ( + <CommandGroup + heading={ + searchQuery + ? "Results" + : showClosed + ? "Recent issues" + : "Open issues" + } + > + {filteredTasks.map((task) => { + const status = task.statusId + ? statusMap.get(task.statusId) + : undefined; + return ( + <CommandItem + key={task.id} + value={task.slug} + onSelect={() => + handleSelect( + task.slug, + task.title, + task.id, + task.externalUrl ?? undefined, + ) + } + className="group" + > + {status ? ( + <StatusIcon + type={status.type} + color={status.color} + progress={status.progressPercent ?? undefined} + /> + ) : ( + <span className="size-3.5 shrink-0 rounded-full border border-muted-foreground/40" /> + )} + <span className="max-w-24 shrink-0 truncate font-mono text-xs text-muted-foreground"> + {task.slug} + </span> + <span className="min-w-0 flex-1 truncate text-xs"> + {task.title} + </span> + <span className="shrink-0 hidden text-xs text-muted-foreground group-data-[selected=true]:inline"> + Link ↵ + </span> + </CommandItem> + ); + })} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx index 0250cdf6471..0859850c7a4 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessageList/MessageList.tsx @@ -7,6 +7,7 @@ import { import { Message, MessageContent } from "@superset/ui/ai-elements/message"; import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; import type { ChatStatus, UIMessage } from "ai"; +import { isToolUIPart } from "ai"; import { FileIcon, FileTextIcon, ImageIcon } from "lucide-react"; import { useCallback } from "react"; import { HiMiniChatBubbleLeftRight } from "react-icons/hi2"; @@ -19,6 +20,16 @@ import { normalizeWorkspaceFilePath } from "../../utils/file-paths"; import { MessagePartsRenderer } from "../MessagePartsRenderer"; import { MessageScrollbackRail } from "./components/MessageScrollbackRail"; +function hasRenderableParts(parts: UIMessage["parts"]): boolean { + return parts.some( + (p) => + p.type === "text" || + p.type === "reasoning" || + (p as { type: string }).type === "error" || // "error" part type exists at runtime but is not yet in the UIMessage union + isToolUIPart(p), + ); +} + interface MessageListProps { messages: UIMessage[]; interruptedMessage?: InterruptedMessagePreview | null; @@ -210,10 +221,14 @@ export function MessageList({ ); } + const showThinking = + isLastAssistant && isThinking && msg.parts.length === 0; + if (!showThinking && !hasRenderableParts(msg.parts)) return null; + return ( <Message key={msg.id} from={msg.role}> <MessageContent> - {isLastAssistant && isThinking && msg.parts.length === 0 ? ( + {showThinking ? ( <ShimmerLabel className="text-sm text-muted-foreground"> Thinking... </ShimmerLabel> @@ -239,6 +254,7 @@ export function MessageList({ parts={interruptedMessage.parts} isLastAssistant={false} isStreaming={false} + isInterrupted workspaceId={workspaceId} workspaceCwd={workspaceCwd} onAnswer={onAnswer} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx index f7380ce1a10..d3388210636 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/MessagePartsRenderer/MessagePartsRenderer.tsx @@ -29,6 +29,7 @@ interface MessagePartsRendererProps { parts: UIMessage["parts"]; isLastAssistant: boolean; isStreaming: boolean; + isInterrupted?: boolean; workspaceId?: string; workspaceCwd?: string; onAnswer?: ( @@ -41,6 +42,7 @@ export function MessagePartsRenderer({ parts, isLastAssistant, isStreaming, + isInterrupted, workspaceId, workspaceCwd, onAnswer, @@ -162,6 +164,8 @@ export function MessagePartsRenderer({ <ReadOnlyToolCall key={part.toolCallId} part={part as ToolPart} + workspaceId={workspaceId} + workspaceCwd={workspaceCwd} onOpenFileInPane={openFileInPane} />, ); @@ -190,6 +194,8 @@ export function MessagePartsRenderer({ <ReadOnlyToolCall key={groupParts[0].toolCallId} part={groupParts[0]} + workspaceId={workspaceId} + workspaceCwd={workspaceCwd} onOpenFileInPane={openFileInPane} />, ); @@ -308,6 +314,7 @@ export function MessagePartsRenderer({ <ToolCallBlock key={part.toolCallId} part={part as ToolPart} + isInterrupted={isInterrupted} workspaceId={workspaceId} workspaceCwd={workspaceCwd} onAnswer={onAnswer} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/AnthropicOAuthDialog/AnthropicOAuthDialog.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/AnthropicOAuthDialog/AnthropicOAuthDialog.tsx index 2e4c508f083..882e6a3d64f 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/AnthropicOAuthDialog/AnthropicOAuthDialog.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/AnthropicOAuthDialog/AnthropicOAuthDialog.tsx @@ -1,157 +1,24 @@ -import { Button } from "@superset/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@superset/ui/dialog"; -import { InputGroup, InputGroupInput } from "@superset/ui/input-group"; -import { Label } from "@superset/ui/label"; - -interface AnthropicOAuthDialogProps { - open: boolean; - authUrl: string | null; - code: string; - errorMessage: string | null; - isPreparing: boolean; - isPending: boolean; - canDisconnect: boolean; - onOpenChange: (open: boolean) => void; - onCodeChange: (value: string) => void; - onOpenAuthUrl: () => void; - onCopyAuthUrl: () => void; - onDisconnect: () => void; - onRetry: () => void; - onSubmit: () => void; -} - -export function AnthropicOAuthDialog({ - open, - authUrl, - code, - errorMessage, - isPreparing, - isPending, - canDisconnect, - onOpenChange, - onCodeChange, - onOpenAuthUrl, - onCopyAuthUrl, - onDisconnect, - onRetry, - onSubmit, -}: AnthropicOAuthDialogProps) { - const hasAuthUrl = Boolean(authUrl); - const showCodeInput = hasAuthUrl || isPending; - const primaryLabel = isPending - ? "Connecting..." - : hasAuthUrl - ? "Continue" - : "Try again"; - +import { OAuthDialog, type OAuthDialogProps } from "../OAuthDialog"; + +const ANTHROPIC_PROVIDER: OAuthDialogProps["provider"] = { + title: "Connect Anthropic", + description: + "Approve access in your browser, then paste the callback URL or `code#state` here.", + codeLabel: "Authorization code", + codePlaceholder: "Paste callback URL or code#state", + codeHint: + "Anthropic usually returns a full callback URL. Pasting either format works.", + preparingLabel: "Preparing Anthropic browser login...", +}; + +type AnthropicOAuthDialogProps = Omit<OAuthDialogProps, "provider">; + +export function AnthropicOAuthDialog(props: AnthropicOAuthDialogProps) { return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-lg"> - <DialogHeader> - <DialogTitle>Connect Anthropic</DialogTitle> - <DialogDescription> - Approve access in your browser, then paste the callback URL or - `code#state` here. - </DialogDescription> - </DialogHeader> - - <div className="min-w-0 space-y-4"> - {isPreparing ? ( - <div className="rounded-lg border border-border/70 bg-muted/20 px-4 py-3 text-sm text-muted-foreground"> - Preparing Anthropic browser login... - </div> - ) : null} - - {showCodeInput ? ( - <div className="min-w-0 space-y-3"> - <div className="flex flex-wrap gap-2"> - <Button - type="button" - variant="outline" - onClick={onOpenAuthUrl} - disabled={!authUrl || isPending} - > - Open browser again - </Button> - <Button - type="button" - variant="ghost" - onClick={onCopyAuthUrl} - disabled={!authUrl || isPending} - > - Copy URL - </Button> - </div> - - <div className="min-w-0 space-y-2"> - <Label htmlFor="anthropic-oauth-code">Authorization code</Label> - <InputGroup> - <InputGroupInput - id="anthropic-oauth-code" - placeholder="Paste callback URL or code#state" - value={code} - onChange={(event) => onCodeChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && code.trim()) { - onSubmit(); - } - }} - disabled={isPending} - className="h-11 font-mono" - autoFocus - /> - </InputGroup> - <p className="text-muted-foreground text-xs"> - Anthropic usually returns a full callback URL. Pasting either - format works. - </p> - </div> - </div> - ) : null} - - {errorMessage ? ( - <p className="text-destructive text-sm">{errorMessage}</p> - ) : null} - - <div className="flex flex-col gap-2 pt-2"> - <Button - type="button" - onClick={hasAuthUrl ? onSubmit : onRetry} - disabled={ - isPreparing || isPending || (hasAuthUrl && !code.trim()) - } - > - {primaryLabel} - </Button> - <div className="flex items-center justify-between gap-2"> - <Button - type="button" - variant="ghost" - onClick={() => onOpenChange(false)} - disabled={isPending} - > - Cancel - </Button> - {canDisconnect ? ( - <Button - type="button" - variant="ghost" - onClick={onDisconnect} - disabled={isPending} - > - Disconnect - </Button> - ) : null} - </div> - </div> - </div> - </DialogContent> - </Dialog> + <OAuthDialog + {...props} + provider={ANTHROPIC_PROVIDER} + requireCodeForSubmit + /> ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OAuthDialog/OAuthDialog.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OAuthDialog/OAuthDialog.tsx new file mode 100644 index 00000000000..7820f431fba --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OAuthDialog/OAuthDialog.tsx @@ -0,0 +1,180 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { InputGroup, InputGroupInput } from "@superset/ui/input-group"; +import { Label } from "@superset/ui/label"; +import { useCallback, useState } from "react"; + +export interface OAuthDialogProps { + provider: { + title: string; + description: string; + codeLabel: string; + codePlaceholder: string; + codeHint: string; + preparingLabel: string; + }; + open: boolean; + authUrl: string | null; + code: string; + errorMessage: string | null; + isPreparing?: boolean; + isPending: boolean; + canDisconnect: boolean; + requireCodeForSubmit?: boolean; + onOpenChange: (open: boolean) => void; + onCodeChange: (value: string) => void; + onOpenAuthUrl: () => void; + onCopyAuthUrl: () => void; + onDisconnect: () => void; + onRetry?: () => void; + onSubmit: () => void; +} + +export function OAuthDialog({ + provider, + open, + authUrl, + code, + errorMessage, + isPreparing, + isPending, + canDisconnect, + requireCodeForSubmit, + onOpenChange, + onCodeChange, + onOpenAuthUrl, + onCopyAuthUrl, + onDisconnect, + onRetry, + onSubmit, +}: OAuthDialogProps) { + const hasAuthUrl = Boolean(authUrl); + const showCodeInput = hasAuthUrl || isPending; + const canSubmit = + !isPreparing && + !isPending && + (!requireCodeForSubmit || code.trim().length > 0); + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(() => { + onCopyAuthUrl(); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [onCopyAuthUrl]); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-lg"> + <DialogHeader> + <DialogTitle>{provider.title}</DialogTitle> + <DialogDescription>{provider.description}</DialogDescription> + </DialogHeader> + + <div className="min-w-0 space-y-4"> + {isPreparing ? ( + <div className="rounded-lg border border-dashed border-border/70 bg-muted/10 px-4 py-3 text-sm text-muted-foreground"> + {provider.preparingLabel} + </div> + ) : null} + + {showCodeInput ? ( + <div className="min-w-0 space-y-3"> + <div className="flex flex-wrap gap-2"> + <Button + type="button" + variant="outline" + onClick={onOpenAuthUrl} + disabled={!authUrl || isPending} + > + Open browser again + </Button> + <Button + type="button" + variant="ghost" + onClick={handleCopy} + disabled={!authUrl || isPending} + > + {copied ? "Copied!" : "Copy URL"} + </Button> + </div> + + <div className="min-w-0 space-y-2"> + <Label htmlFor="oauth-code">{provider.codeLabel}</Label> + <InputGroup> + <InputGroupInput + id="oauth-code" + placeholder={provider.codePlaceholder} + value={code} + onChange={(event) => onCodeChange(event.target.value)} + onKeyDown={(event) => { + if ( + event.key === "Enter" && + !event.nativeEvent.isComposing && + canSubmit + ) { + onSubmit(); + } + }} + disabled={isPending} + className="h-11 font-mono text-sm" + autoFocus + /> + </InputGroup> + <p className="text-muted-foreground text-xs"> + {provider.codeHint} + </p> + </div> + </div> + ) : !isPreparing ? ( + <div className="rounded-lg border border-dashed border-border/70 bg-muted/10 px-4 py-3 text-sm text-muted-foreground"> + {provider.preparingLabel} + </div> + ) : null} + + {errorMessage ? ( + <p className="text-destructive text-sm">{errorMessage}</p> + ) : null} + + <div className="flex flex-col gap-2 pt-2"> + <Button + type="button" + onClick={hasAuthUrl ? onSubmit : (onRetry ?? onSubmit)} + disabled={!canSubmit} + > + {isPending + ? "Connecting..." + : hasAuthUrl + ? "Continue" + : "Try again"} + </Button> + <div className="flex items-center justify-between gap-2"> + <Button + type="button" + variant="ghost" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + Cancel + </Button> + {canDisconnect ? ( + <Button + type="button" + variant="ghost" + onClick={onDisconnect} + disabled={isPending} + > + Disconnect + </Button> + ) : null} + </div> + </div> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OAuthDialog/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OAuthDialog/index.ts new file mode 100644 index 00000000000..602028a06a5 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OAuthDialog/index.ts @@ -0,0 +1 @@ +export { OAuthDialog, type OAuthDialogProps } from "./OAuthDialog"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OpenAIOAuthDialog/OpenAIOAuthDialog.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OpenAIOAuthDialog/OpenAIOAuthDialog.tsx index 2edd4a525b6..a356da92ecc 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OpenAIOAuthDialog/OpenAIOAuthDialog.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/components/OpenAIOAuthDialog/OpenAIOAuthDialog.tsx @@ -1,152 +1,17 @@ -import { Button } from "@superset/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@superset/ui/dialog"; -import { InputGroup, InputGroupInput } from "@superset/ui/input-group"; -import { Label } from "@superset/ui/label"; - -const OPENAI_OAUTH_CALLBACK_URL = "http://localhost:1455/auth/callback"; - -interface OpenAIOAuthDialogProps { - open: boolean; - authUrl: string | null; - code: string; - errorMessage: string | null; - isPending: boolean; - canDisconnect: boolean; - onOpenChange: (open: boolean) => void; - onCodeChange: (value: string) => void; - onOpenAuthUrl: () => void; - onCopyAuthUrl: () => void; - onDisconnect: () => void; - onSubmit: () => void; -} - -export function OpenAIOAuthDialog({ - open, - authUrl, - code, - errorMessage, - isPending, - canDisconnect, - onOpenChange, - onCodeChange, - onOpenAuthUrl, - onCopyAuthUrl, - onDisconnect, - onSubmit, -}: OpenAIOAuthDialogProps) { - const hasAuthUrl = Boolean(authUrl); - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-lg"> - <DialogHeader> - <DialogTitle>Connect OpenAI</DialogTitle> - <DialogDescription> - Approve access in your browser. If the callback does not finish, - paste the redirected callback URL below. - </DialogDescription> - </DialogHeader> - - <div className="min-w-0 space-y-4"> - <div className="rounded-lg border border-border/70 bg-muted/15 px-4 py-3 text-sm text-muted-foreground"> - <span className="font-semibold text-foreground">Tip:</span> OpenAI - OAuth usually completes automatically after browser approval. If you - land on <code>{`${OPENAI_OAUTH_CALLBACK_URL}?...`}</code>, copy that - full URL and paste it below. - </div> - - <div className="flex flex-wrap gap-2"> - <Button - type="button" - variant="outline" - onClick={onOpenAuthUrl} - disabled={!authUrl || isPending} - > - Open browser again - </Button> - <Button - type="button" - variant="ghost" - onClick={onCopyAuthUrl} - disabled={!authUrl || isPending} - > - Copy URL - </Button> - </div> - - {hasAuthUrl ? ( - <div className="rounded-lg border border-border/70 bg-muted/10 px-4 py-3"> - <p className="text-xs font-medium text-foreground">OAuth URL</p> - <p className="text-muted-foreground mt-2 break-all font-mono text-xs leading-relaxed"> - {authUrl} - </p> - </div> - ) : ( - <div className="rounded-lg border border-dashed border-border/70 bg-muted/10 px-4 py-3 text-sm text-muted-foreground"> - OAuth URL not ready yet. - </div> - )} - - <div className="min-w-0 space-y-2"> - <Label htmlFor="openai-oauth-code">Callback URL (optional)</Label> - <InputGroup className="border-border/70 bg-muted/10"> - <InputGroupInput - id="openai-oauth-code" - placeholder={`Paste full ${OPENAI_OAUTH_CALLBACK_URL}?... URL`} - value={code} - onChange={(event) => onCodeChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - onSubmit(); - } - }} - disabled={isPending} - className="h-11 font-mono text-xs sm:text-sm" - autoFocus - /> - </InputGroup> - <p className="text-muted-foreground text-xs"> - Leave this empty if browser login finishes on its own. - </p> - </div> - - {errorMessage ? ( - <p className="text-destructive text-sm">{errorMessage}</p> - ) : null} - - <div className="flex flex-col gap-2 pt-2"> - <Button type="button" onClick={onSubmit} disabled={isPending}> - {isPending ? "Working..." : "Continue"} - </Button> - <div className="flex items-center justify-between gap-2"> - <Button - type="button" - variant="ghost" - onClick={() => onOpenChange(false)} - disabled={isPending} - > - Back - </Button> - {canDisconnect ? ( - <Button - type="button" - variant="ghost" - onClick={onDisconnect} - disabled={isPending} - > - Disconnect - </Button> - ) : null} - </div> - </div> - </div> - </DialogContent> - </Dialog> - ); +import { OAuthDialog, type OAuthDialogProps } from "../OAuthDialog"; + +const OPENAI_PROVIDER: OAuthDialogProps["provider"] = { + title: "Connect OpenAI", + description: + "Approve access in your browser. If the callback does not finish, paste the redirected callback URL below.", + codeLabel: "Callback URL (optional)", + codePlaceholder: "Paste callback URL", + codeHint: "Leave this empty if browser login finishes on its own.", + preparingLabel: "Preparing OpenAI browser login...", +}; + +type OpenAIOAuthDialogProps = Omit<OAuthDialogProps, "provider">; + +export function OpenAIOAuthDialog(props: OpenAIOAuthDialogProps) { + return <OAuthDialog {...props} provider={OPENAI_PROVIDER} />; } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useAnthropicOAuth/useAnthropicOAuth.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useAnthropicOAuth/useAnthropicOAuth.ts index 967c84413af..313dc58829e 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useAnthropicOAuth/useAnthropicOAuth.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useAnthropicOAuth/useAnthropicOAuth.ts @@ -1,7 +1,6 @@ import { chatServiceTrpc } from "@superset/chat/client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronTrpcClient } from "renderer/lib/trpc-client"; function getErrorMessage(error: unknown, fallback: string): string { @@ -75,7 +74,6 @@ export function useAnthropicOAuth({ const autoSubmitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( null, ); - const electronUtils = electronTrpc.useUtils(); const { data: anthropicStatus, refetch: refetchAnthropicStatus } = chatServiceTrpc.auth.getAnthropicStatus.useQuery(); @@ -177,10 +175,6 @@ export function useAnthropicOAuth({ onModelSelectorOpenChange(true); try { - await electronTrpcClient.modelProviders.clearIssue.mutate({ - providerId: "anthropic", - }); - await electronUtils.modelProviders.getStatuses.invalidate(); await refetchAnthropicStatus(); await onAuthStateChange?.(); } catch (error) { @@ -193,7 +187,6 @@ export function useAnthropicOAuth({ [ clearAutoSubmitTimeout, completeAnthropicOAuthMutation, - electronUtils.modelProviders.getStatuses.invalidate, onAuthStateChange, onModelSelectorOpenChange, refetchAnthropicStatus, @@ -223,10 +216,6 @@ export function useAnthropicOAuth({ onModelSelectorOpenChange(true); try { - await electronTrpcClient.modelProviders.clearIssue.mutate({ - providerId: "anthropic", - }); - await electronUtils.modelProviders.getStatuses.invalidate(); await refetchAnthropicStatus(); await onAuthStateChange?.(); } catch (error) { @@ -237,7 +226,6 @@ export function useAnthropicOAuth({ } }, [ disconnectAnthropicOAuthMutation, - electronUtils.modelProviders.getStatuses.invalidate, onAuthStateChange, onModelSelectorOpenChange, refetchAnthropicStatus, diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts index 59a74b5c903..2506adbbc6b 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth/useOpenAIOAuth.ts @@ -1,7 +1,6 @@ import { chatServiceTrpc } from "@superset/chat/client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { electronTrpcClient } from "renderer/lib/trpc-client"; function getErrorMessage(error: unknown, fallback: string): string { @@ -47,7 +46,6 @@ export function useOpenAIOAuth({ const [oauthCode, setOauthCode] = useState(""); const [oauthError, setOauthError] = useState<string | null>(null); const [hasPendingOAuthSession, setHasPendingOAuthSession] = useState(false); - const electronUtils = electronTrpc.useUtils(); const { data: openAIStatus, refetch: refetchOpenAIStatus } = chatServiceTrpc.auth.getOpenAIStatus.useQuery(); @@ -92,13 +90,14 @@ export function useOpenAIOAuth({ setOauthCode(""); setHasPendingOAuthSession(true); setOauthDialogOpen(true); + await openExternalUrl(result.url); } catch (error) { setOauthDialogOpen(true); setOauthError( getErrorMessage(error, "Failed to start OpenAI OAuth flow"), ); } - }, [startOpenAIOAuthMutation]); + }, [openExternalUrl, startOpenAIOAuthMutation]); const { copyToClipboard } = useCopyToClipboard(); const copyOAuthUrl = useCallback(() => { @@ -110,10 +109,6 @@ export function useOpenAIOAuth({ const syncOpenAIAuthUi = useCallback( async (action: "complete" | "disconnect") => { try { - await electronTrpcClient.modelProviders.clearIssue.mutate({ - providerId: "openai", - }); - await electronUtils.modelProviders.getStatuses.invalidate(); await refetchOpenAIStatus(); } catch (error) { console.error( @@ -122,7 +117,7 @@ export function useOpenAIOAuth({ ); } }, - [electronUtils.modelProviders.getStatuses.invalidate, refetchOpenAIStatus], + [refetchOpenAIStatus], ); const completeOpenAIOAuth = useCallback(async () => { diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/PlusMenu/PlusMenu.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/PlusMenu/PlusMenu.tsx index 0d527d44dbd..a20f6c23dac 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/PlusMenu/PlusMenu.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/PlusMenu/PlusMenu.tsx @@ -2,49 +2,25 @@ import { PromptInputButton, usePromptInputAttachments, } from "@superset/ui/ai-elements/prompt-input"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { HiMiniPaperClip } from "react-icons/hi2"; -import { LuPlus } from "react-icons/lu"; -import { SiLinear } from "react-icons/si"; import { PILL_BUTTON_CLASS } from "../../styles"; -interface PlusMenuProps { - onLinkIssue: () => void; -} - -export function PlusMenu({ onLinkIssue }: PlusMenuProps) { +export function PlusMenu() { const attachments = usePromptInputAttachments(); return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <PromptInputButton className={`${PILL_BUTTON_CLASS} w-[23px]`}> - <LuPlus className="size-3.5" /> + <Tooltip> + <TooltipTrigger asChild> + <PromptInputButton + aria-label="Add attachment" + className={`${PILL_BUTTON_CLASS} w-[23px]`} + onClick={() => attachments.openFileDialog()} + > + <HiMiniPaperClip className="size-3.5" /> </PromptInputButton> - </DropdownMenuTrigger> - <DropdownMenuContent - side="top" - align="end" - className="w-52" - onCloseAutoFocus={(e) => e.preventDefault()} - > - <DropdownMenuItem onSelect={() => attachments.openFileDialog()}> - <HiMiniPaperClip className="size-4" /> - Add attachment - <DropdownMenuShortcut>⌘U</DropdownMenuShortcut> - </DropdownMenuItem> - <DropdownMenuItem onSelect={onLinkIssue}> - <SiLinear className="size-4" /> - Link issue - <DropdownMenuShortcut>⌘I</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + </TooltipTrigger> + <TooltipContent side="top">Add attachment</TooltipContent> + </Tooltip> ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx index e7baca5a4ab..634d7894a3d 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ReadOnlyToolCall/ReadOnlyToolCall.tsx @@ -1,23 +1,21 @@ -import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; +import { ClickableFilePath } from "@superset/ui/ai-elements/clickable-file-path"; +import { ReadFileTool } from "@superset/ui/ai-elements/read-file-tool"; import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; import { getToolName } from "ai"; import { - CheckIcon, - ExternalLinkIcon, FileIcon, FileSearchIcon, FolderTreeIcon, - Loader2Icon, SearchIcon, - XIcon, } from "lucide-react"; -import { useState } from "react"; -import { getWorkspaceToolFilePath } from "../../utils/file-paths"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { detectLanguage } from "shared/detect-language"; +import type { BundledLanguage } from "shiki"; +import { + getWorkspaceToolFilePath, + normalizeWorkspaceFilePath, +} from "../../utils/file-paths"; import type { ToolPart } from "../../utils/tool-helpers"; import { getArgs, @@ -34,51 +32,19 @@ function stringify(value: unknown): string { } } -function toRecord(value: unknown): Record<string, unknown> | undefined { - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - return value as Record<string, unknown>; - } - return undefined; -} - -function toStringValue(value: unknown): string | undefined { - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return undefined; -} - -function extractReadFileContent(output: unknown): string | undefined { - const direct = toStringValue(output); - if (direct) return direct; - - const record = toRecord(output); - if (!record) return undefined; - - const nestedResult = toRecord(record.result); - - return ( - toStringValue(record.content) ?? - toStringValue(record.text) ?? - toStringValue(record.stdout) ?? - toStringValue(record.data) ?? - toStringValue(nestedResult?.content) ?? - toStringValue(nestedResult?.text) ?? - toStringValue(nestedResult?.stdout) - ); -} - interface ReadOnlyToolCallProps { part: ToolPart; + workspaceId?: string; + workspaceCwd?: string; onOpenFileInPane?: (filePath: string) => void; } export function ReadOnlyToolCall({ part, + workspaceId, + workspaceCwd, onOpenFileInPane, }: ReadOnlyToolCallProps) { - const [isOpen, setIsOpen] = useState(false); const args = getArgs(part); const toolName = normalizeToolName(getToolName(part)); const output = @@ -92,11 +58,44 @@ export function ReadOnlyToolCall({ part.state !== "output-available" && part.state !== "output-error"; const displayState = toToolDisplayState(part); const isReadFileTool = toolName === "mastra_workspace_read_file"; - const readFileContent = isReadFileTool - ? extractReadFileContent(output) - : undefined; const hasDetails = part.input != null || output != null || isError; + const rawFilePath = isReadFileTool + ? String(args.path ?? args.filePath ?? args.file_path ?? args.file ?? "") + : ""; + const absoluteFilePath = rawFilePath + ? normalizeWorkspaceFilePath({ + filePath: rawFilePath, + workspaceRoot: workspaceCwd, + }) + : null; + + const fileQuery = electronTrpc.filesystem.readFile.useQuery( + { + workspaceId: workspaceId ?? "", + absolutePath: absoluteFilePath ?? "", + encoding: "utf-8", + }, + { + enabled: + isReadFileTool && !isPending && !!absoluteFilePath && !!workspaceId, + retry: false, + refetchOnWindowFocus: false, + staleTime: Infinity, + }, + ); + + const fileContent = fileQuery.data?.content as string | undefined; + const hasFileContent = fileContent !== undefined; + + const lineRange = hasFileContent + ? (() => { + // The disk read always returns the whole file, so report 1–N + const lineCount = fileContent.trimEnd().split("\n").length; + return `1–${lineCount}`; + })() + : null; + let title = "Read file"; let subtitle = String(args.path ?? args.filePath ?? args.query ?? ""); let Icon = FileIcon; @@ -153,77 +152,76 @@ export function ReadOnlyToolCall({ const filePath = getWorkspaceToolFilePath({ toolName, args }); const canOpenFile = Boolean(filePath && onOpenFileInPane); + // Prevent a flash of raw output while the disk read is in flight + if ( + isReadFileTool && + !isError && + !isPending && + !hasFileContent && + fileQuery.isLoading + ) { + return ( + <ToolCallRow + icon={Icon} + isPending + title="Reading" + description={subtitle} + /> + ); + } + + if (isReadFileTool && !isError && hasFileContent) { + const displayPath = absoluteFilePath ?? rawFilePath; + const filename = displayPath.split("/").pop() ?? displayPath; + return ( + <ReadFileTool + filename={filename} + content={fileContent} + lineRange={lineRange ?? undefined} + language={detectLanguage(displayPath) as BundledLanguage} + isError={isError} + isPending={isPending} + onOpenInPane={ + canOpenFile && filePath + ? () => onOpenFileInPane?.(filePath) + : undefined + } + /> + ); + } + + // For file-path tools (e.g. file_stat), make the filename clickable. + // Search queries and directory listings stay as plain text. + const descriptionNode = + canOpenFile && filePath && subtitle ? ( + <ClickableFilePath + path={filePath} + display={subtitle} + onOpen={() => onOpenFileInPane?.(filePath)} + /> + ) : ( + subtitle || undefined + ); + return ( - <Collapsible - className="overflow-hidden rounded-md" - onOpenChange={(open) => hasDetails && setIsOpen(open)} - open={hasDetails ? isOpen : false} + <ToolCallRow + description={descriptionNode} + icon={Icon} + isError={isError || displayState === "output-error"} + isPending={isPending} + title={title} > - <div className="flex items-center"> - <CollapsibleTrigger asChild> - <button - className={ - hasDetails - ? "flex h-7 min-w-0 flex-1 items-center justify-between px-2.5 text-left transition-colors duration-150 hover:bg-muted/30" - : "flex h-7 min-w-0 flex-1 items-center justify-between px-2.5 text-left" - } - disabled={!hasDetails} - type="button" - > - <div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs"> - <Icon className="h-3 w-3 shrink-0 text-muted-foreground" /> - <ShimmerLabel - className="truncate text-xs text-muted-foreground" - isShimmering={isPending} - > - {subtitle ? `${title} ${subtitle}` : title} - </ShimmerLabel> - </div> - <div className="ml-2 flex h-6 w-6 items-center justify-center text-muted-foreground"> - {isPending ? ( - <Loader2Icon className="h-3 w-3 animate-spin" /> - ) : isError || displayState === "output-error" ? ( - <XIcon className="h-3 w-3" /> - ) : ( - <CheckIcon className="h-3 w-3" /> - )} - </div> - </button> - </CollapsibleTrigger> - {canOpenFile && filePath && ( - <button - type="button" - aria-label={`Open ${filePath} in file pane`} - className="mr-1 flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground" - onClick={() => onOpenFileInPane?.(filePath)} - > - <ExternalLinkIcon className="h-3 w-3" /> - </button> - )} - </div> - {hasDetails && ( - <CollapsibleContent className="data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"> - {isReadFileTool && !isError && readFileContent ? ( - <div className="mt-0.5 max-h-[300px] overflow-y-auto"> - <pre className="whitespace-pre-wrap break-words px-2.5 py-2 font-mono text-xs text-foreground"> - {readFileContent} - </pre> - </div> - ) : ( - <div className="mt-0.5"> - {part.input != null && <ToolInput input={part.input} />} - {(output != null || isError) && ( - <ToolOutput - output={!isError ? output : undefined} - errorText={ - isError ? stringify(outputError ?? output) : undefined - } - /> - )} - </div> + {hasDetails ? ( + <div className="space-y-2 pl-2"> + {part.input != null && <ToolInput input={part.input} />} + {(output != null || isError) && ( + <ToolOutput + output={!isError ? output : undefined} + errorText={isError ? stringify(outputError ?? output) : undefined} + /> )} - </CollapsibleContent> - )} - </Collapsible> + </div> + ) : undefined} + </ToolCallRow> ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx index 152348fc160..2d0b97bfae3 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/SlashCommandMenu/SlashCommandMenu.tsx @@ -28,8 +28,8 @@ export function SlashCommandMenu({ <PopoverContent side="top" align="start" - sideOffset={0} - className="w-[min(44rem,calc(100vw-2rem))] p-0 text-xs" + sideOffset={4} + className="w-[var(--radix-popover-trigger-width)] p-0 text-xs" onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/FileMentionNode.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/FileMentionNode.tsx new file mode 100644 index 00000000000..bef3b8fc3df --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/FileMentionNode.tsx @@ -0,0 +1,61 @@ +import { cn } from "@superset/ui/utils"; +import { mergeAttributes, Node } from "@tiptap/core"; +import { + type NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; + +function FileMentionChip({ node, selected }: NodeViewProps) { + const path = (node.attrs.path as string | null | undefined) ?? ""; + const name = path.split("/").pop() || path || "@"; + + return ( + <NodeViewWrapper as="span" className="inline-block align-middle"> + <span + contentEditable={false} + className={cn( + "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs text-foreground/90 select-none cursor-default transition-colors", + selected ? "bg-muted-foreground/15" : "bg-muted-foreground/10", + )} + > + <span className="text-muted-foreground">@</span> + <span>{name}</span> + </span> + </NodeViewWrapper> + ); +} + +export const FileMentionNode = Node.create({ + name: "file-mention", + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + path: { + default: null, + parseHTML: (el) => el.getAttribute("data-path"), + renderHTML: (attrs) => ({ "data-path": attrs.path }), + }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-type="file-mention"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes({ "data-type": "file-mention" }, HTMLAttributes), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(FileMentionChip); + }, +}); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandNode.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandNode.tsx new file mode 100644 index 00000000000..6390ea0e261 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandNode.tsx @@ -0,0 +1,371 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { cn } from "@superset/ui/utils"; +import { mergeAttributes, Node } from "@tiptap/core"; +import { + type NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +function SlashCommandChip({ + node, + selected, + updateAttributes, + editor, + getPos, +}: NodeViewProps) { + const name = node.attrs.name as string; + const args = (node.attrs.args as string) ?? ""; + const argumentHint = (node.attrs.argumentHint as string) ?? ""; + const argumentOptions = (node.attrs.argumentOptions as string[]) ?? []; + const hasArgs = argumentHint.trim().length > 0; + + const inputRef = useRef<HTMLInputElement>(null); + const [isEditing, setIsEditing] = useState(hasArgs); + // Which item is highlighted in the dropdown — owned here so arrow keys work + const [selectedValue, setSelectedValue] = useState<string>( + argumentOptions[0] ?? "", + ); + + const filteredOptions = argumentOptions.filter( + (opt) => !args || opt.toLowerCase().includes(args.toLowerCase()), + ); + + // Derive popover visibility directly from edit state — no separate comboOpen state + // that can drift. Popover is open iff we're editing a command that has options. + const showCombo = isEditing && filteredOptions.length > 0; + + // Keep selectedValue pointed at a valid filtered option + useEffect(() => { + if ( + !filteredOptions.includes(selectedValue) && + filteredOptions.length > 0 + ) { + setSelectedValue(filteredOptions[0] ?? ""); + } + }, [filteredOptions, selectedValue]); + + // Focus input (with rAF so Tiptap's DOM commit has settled) whenever edit mode opens + useEffect(() => { + if (!isEditing || !hasArgs) return; + const id = requestAnimationFrame(() => { + const input = inputRef.current; + if (!input) return; + input.focus(); + const len = input.value.length; + input.setSelectionRange(len, len); + }); + return () => cancelAnimationFrame(id); + }, [isEditing, hasArgs]); + + const exitEditMode = useCallback(() => { + setIsEditing(false); + const pos = getPos(); + if (pos !== undefined) { + editor + .chain() + .focus() + .setTextSelection(pos + node.nodeSize) + .run(); + } else { + editor.commands.focus("end"); + } + }, [editor, getPos, node.nodeSize]); + + const handleSelectOption = useCallback( + (value: string) => { + updateAttributes({ args: value }); + exitEditMode(); + }, + [updateAttributes, exitEditMode], + ); + + const handleChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + updateAttributes({ args: e.target.value }); + }, + [updateAttributes], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Tab") { + e.preventDefault(); + e.stopPropagation(); + if (showCombo && selectedValue) { + handleSelectOption(selectedValue); + } else { + exitEditMode(); + } + return; + } + + if (e.key === "Enter" && showCombo && selectedValue) { + e.preventDefault(); + e.stopPropagation(); + handleSelectOption(selectedValue); + return; + } + + if (e.key === "ArrowDown" && showCombo && filteredOptions.length > 0) { + e.preventDefault(); + e.stopPropagation(); + const idx = filteredOptions.indexOf(selectedValue); + setSelectedValue( + filteredOptions[(idx + 1) % filteredOptions.length] ?? "", + ); + return; + } + + if (e.key === "ArrowUp" && showCombo && filteredOptions.length > 0) { + e.preventDefault(); + e.stopPropagation(); + const idx = filteredOptions.indexOf(selectedValue); + setSelectedValue( + filteredOptions[ + (idx - 1 + filteredOptions.length) % filteredOptions.length + ] ?? "", + ); + return; + } + + if ( + e.key === "ArrowRight" && + inputRef.current?.selectionStart === args.length + ) { + e.preventDefault(); + e.stopPropagation(); + exitEditMode(); + return; + } + + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setIsEditing(false); + return; + } + + if (e.key === "Backspace" && args === "") { + e.preventDefault(); + e.stopPropagation(); + const pos = getPos(); + if (pos !== undefined) { + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + node.nodeSize }) + .run(); + } + } + }, + [ + args, + editor, + exitEditMode, + filteredOptions, + getPos, + handleSelectOption, + node.nodeSize, + selectedValue, + showCombo, + ], + ); + + const handleInputBlur = useCallback(() => { + setIsEditing(false); + }, []); + + const handleChipClick = useCallback( + (e: React.MouseEvent) => { + if (isEditing || !hasArgs) return; + e.preventDefault(); + e.stopPropagation(); + const pos = getPos(); + if (pos !== undefined) { + editor.commands.setNodeSelection(pos); + } + }, + [isEditing, hasArgs, editor, getPos], + ); + + const handleChipDoubleClick = useCallback( + (e: React.MouseEvent) => { + if (!hasArgs) return; + e.preventDefault(); + e.stopPropagation(); + setIsEditing(true); + }, + [hasArgs], + ); + + const placeholder = argumentHint || name; + // Shrink to typed content once the user starts typing; show full placeholder when empty + const displayWidth = + args.length > 0 + ? Math.max(args.length + 1, 4) + : Math.max(placeholder.length, 4); + + return ( + <NodeViewWrapper + as="span" + data-node-type="slash-command" + className="inline-block align-middle" + > + <Popover open={showCombo}> + <PopoverAnchor asChild> + {/* biome-ignore lint/a11y/useSemanticElements: cannot use <button> inside NodeViewWrapper span (invalid HTML) */} + <span + role="button" + tabIndex={-1} + contentEditable={false} + className={cn( + "inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 font-mono text-xs select-none transition-colors cursor-default", + selected ? "bg-muted-foreground/15" : "bg-muted-foreground/10", + )} + onClick={handleChipClick} + onDoubleClick={handleChipDoubleClick} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") + handleChipClick(e as unknown as React.MouseEvent); + }} + > + <span className="text-muted-foreground">/</span> + <span className="text-foreground/90">{name}</span> + {hasArgs && ( + <> + <span className="text-muted-foreground/60">:</span> + {isEditing ? ( + <input + ref={inputRef} + className="bg-transparent border-none outline-none text-foreground/90 placeholder:text-muted-foreground/40 leading-none" + style={{ width: `${displayWidth}ch` }} + value={args} + placeholder={placeholder} + onChange={handleChange} + onKeyDown={handleKeyDown} + onBlur={handleInputBlur} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <span + className={cn( + "leading-none", + args ? "text-foreground/90" : "text-muted-foreground/40", + )} + > + {args || placeholder} + </span> + )} + </> + )} + </span> + </PopoverAnchor> + {argumentOptions.length > 0 && ( + <PopoverContent + className="w-56 p-0" + side="top" + align="start" + onOpenAutoFocus={(e) => e.preventDefault()} + > + <Command + value={selectedValue} + onValueChange={setSelectedValue} + shouldFilter={false} + > + <CommandList> + <CommandEmpty>No match</CommandEmpty> + <CommandGroup> + {filteredOptions.map((opt) => ( + <CommandItem + key={opt} + value={opt} + onSelect={() => handleSelectOption(opt)} + onMouseDown={(e) => e.preventDefault()} + > + {opt} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + )} + </Popover> + </NodeViewWrapper> + ); +} + +export const SlashCommandNode = Node.create({ + name: "slash-command", + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + name: { + default: null, + parseHTML: (el) => el.getAttribute("data-name"), + renderHTML: (attrs) => ({ "data-name": attrs.name }), + }, + args: { + default: "", + parseHTML: (el) => el.getAttribute("data-args") ?? "", + renderHTML: (attrs) => (attrs.args ? { "data-args": attrs.args } : {}), + }, + argumentHint: { + default: "", + parseHTML: (el) => el.getAttribute("data-argument-hint") ?? "", + renderHTML: (attrs) => + attrs.argumentHint + ? { "data-argument-hint": attrs.argumentHint } + : {}, + }, + argumentOptions: { + default: [], + parseHTML: (el) => { + const raw = el.getAttribute("data-argument-options"); + if (!raw) return []; + try { + return JSON.parse(raw); + } catch { + return []; + } + }, + renderHTML: (attrs) => { + if (!attrs.argumentOptions?.length) return {}; + return { + "data-argument-options": JSON.stringify(attrs.argumentOptions), + }; + }, + }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-type="slash-command"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes({ "data-type": "slash-command" }, HTMLAttributes), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(SlashCommandChip); + }, +}); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandPreviewPopover.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandPreviewPopover.tsx new file mode 100644 index 00000000000..0230b8c4ae8 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/SlashCommandPreviewPopover.tsx @@ -0,0 +1,158 @@ +import { usePromptInputController } from "@superset/ui/ai-elements/prompt-input"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import type { Editor } from "@tiptap/core"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { + normalizeSlashPreviewInput, + parseSlashInput, + resolveSlashCommandDefinition, +} from "../ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model"; + +export type SlashPreviewResult = { + commandName?: string; + prompt?: string; +} | null; + +export type PreviewSlashCommandFn = ( + text: string, +) => Promise<SlashPreviewResult>; + +interface SlashCommandPreviewPopoverProps { + cwd: string; + previewSlashCommand: PreviewSlashCommandFn; + slashCommands: Array<{ + name: string; + aliases: string[]; + description: string; + argumentHint: string; + }>; + editor: Editor; + isFocused: boolean; +} + +export function SlashCommandPreviewPopover({ + cwd, + previewSlashCommand, + slashCommands, + editor, + isFocused, +}: SlashCommandPreviewPopoverProps) { + const { textInput } = usePromptInputController(); + const inputValue = textInput.value; + + const anchorRef = useRef<HTMLDivElement>(null); + + // Position the virtual anchor over the slash-command chip in the editor. + // biome-ignore lint/correctness/useExhaustiveDependencies: inputValue re-measures anchor when typing shifts the chip's position + useLayoutEffect(() => { + const el = anchorRef.current; + if (!el) return; + let foundPos: number | null = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === "slash-command") { + foundPos = pos; + return false; + } + }); + if (foundPos === null) return; + const dom = editor.view.nodeDOM(foundPos); + if (!(dom instanceof HTMLElement)) return; + const rect = dom.getBoundingClientRect(); + el.style.left = `${rect.left}px`; + el.style.top = `${rect.top}px`; + el.style.width = `${rect.width}px`; + el.style.height = `${rect.height}px`; + }, [editor, inputValue]); + + const slashPreviewInput = normalizeSlashPreviewInput(inputValue); + const parsedInput = useMemo(() => parseSlashInput(inputValue), [inputValue]); + const debouncedSlashPreviewInput = useDebouncedValue(slashPreviewInput, 120); + + const [slashPreview, setSlashPreview] = useState<SlashPreviewResult>(null); + useEffect(() => { + if (debouncedSlashPreviewInput.length <= 1 || !cwd) return; + let cancelled = false; + previewSlashCommand(debouncedSlashPreviewInput) + .then((result) => { + if (!cancelled) setSlashPreview(result); + }) + .catch(() => { + // Empty preview on error — popover degrades gracefully. + }); + return () => { + cancelled = true; + }; + }, [cwd, debouncedSlashPreviewInput, previewSlashCommand]); + + const commandDefinition = useMemo(() => { + if (!parsedInput?.commandName) return null; + return resolveSlashCommandDefinition( + slashCommands, + parsedInput.commandName, + ); + }, [parsedInput?.commandName, slashCommands]); + + const commandDescription = commandDefinition?.description?.trim() ?? ""; + const previewCommandName = slashPreview?.commandName?.toLowerCase(); + const canonicalCommandName = commandDefinition?.name.toLowerCase(); + const previewMatchesInputCommand = Boolean( + previewCommandName && + canonicalCommandName && + previewCommandName === canonicalCommandName, + ); + const previewPrompt = previewMatchesInputCommand + ? (slashPreview?.prompt ?? "") + : ""; + + // Show popover when there's an active command with a preview to display + const showPopover = Boolean( + parsedInput && + commandDefinition && + debouncedSlashPreviewInput && + previewPrompt, + ); + + return ( + <Popover open={showPopover && isFocused}> + <PopoverAnchor asChild> + <div + ref={anchorRef} + className="pointer-events-none fixed" + aria-hidden="true" + /> + </PopoverAnchor> + <PopoverContent + side="top" + align="start" + sideOffset={8} + className="w-72 p-3 text-xs" + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + onMouseDown={(e) => e.preventDefault()} + > + <div className="mb-2 flex items-center gap-1.5"> + <span className="flex size-4.5 shrink-0 items-center justify-center rounded bg-muted font-mono text-[11px]"> + / + </span> + <span className="font-mono text-foreground/90"> + {parsedInput?.commandName} + </span> + {commandDescription && ( + <span className="truncate text-muted-foreground/70"> + {commandDescription} + </span> + )} + </div> + <div className="space-y-1"> + <div className="text-[11px] uppercase tracking-wide text-muted-foreground/60"> + Prompt preview + </div> + <div className="max-h-24 overflow-y-auto whitespace-pre-wrap rounded border border-border/60 bg-muted/30 px-2 py-1.5 font-mono text-[11px] text-foreground/80"> + {previewPrompt} + </div> + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/TiptapPromptEditor.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/TiptapPromptEditor.tsx new file mode 100644 index 00000000000..bde280ae7df --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/TiptapPromptEditor.tsx @@ -0,0 +1,802 @@ +import { + usePromptInputAttachments, + usePromptInputController, +} from "@superset/ui/ai-elements/prompt-input"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import { cn } from "@superset/ui/utils"; +import { type Editor, Extension } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { HardBreak } from "@tiptap/extension-hard-break"; +import { History } from "@tiptap/extension-history"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Text } from "@tiptap/extension-text"; +import { PluginKey } from "@tiptap/pm/state"; +import { EditorContent, useEditor } from "@tiptap/react"; +import Suggestion from "@tiptap/suggestion"; + +const slashSuggestionKey = new PluginKey("slashCommandSuggestion"); +const mentionSuggestionKey = new PluginKey("fileMentionSuggestion"); + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { + getCommandMatchRank, + type SlashCommand, + shouldSuppressSlashMenuForCommittedCommand, + sortSlashCommandMatches, +} from "../../hooks/useSlashCommands"; +import type { ModelOption } from "../../types"; +import { SlashCommandMenu } from "../SlashCommandMenu"; +import { FileMentionNode } from "./FileMentionNode"; +import { parseTextToEditorContent } from "./parseTextToEditorContent"; +import { SlashCommandNode } from "./SlashCommandNode"; +import { + type PreviewSlashCommandFn, + SlashCommandPreviewPopover, +} from "./SlashCommandPreviewPopover"; +import { serializeEditorToText } from "./serializeEditorToText"; + +type FileResult = { id: string; name: string; relativePath: string }; +type SearchFilesFn = (query: string) => Promise<FileResult[]>; + +type SlashMenuState = { + commands: SlashCommand[]; + selectedIndex: number; + tiptapCommand: (props: { cmd: SlashCommand }) => void; +}; + +type MentionState = { + query: string; + selectedIndex: number; + tiptapCommand: (props: { path: string }) => void; + clientRect: (() => DOMRect | null) | null; +}; + +export interface TiptapPromptEditorProps { + cwd: string; + searchFiles: SearchFilesFn; + previewSlashCommand?: PreviewSlashCommandFn; + slashCommands: SlashCommand[]; + availableModels?: ModelOption[]; + placeholder?: string; + className?: string; + focusShortcutText?: string; +} + +function getDirectoryPath(relativePath: string): string { + const lastSlash = relativePath.lastIndexOf("/"); + if (lastSlash === -1) return ""; + return relativePath.slice(0, lastSlash); +} + +export function TiptapPromptEditor({ + cwd, + searchFiles, + previewSlashCommand, + slashCommands, + availableModels, + placeholder = "Ask to make changes, @mention files, run /commands", + className, + focusShortcutText, +}: TiptapPromptEditorProps) { + const controller = usePromptInputController(); + const attachments = usePromptInputAttachments(); + + // Stable refs to avoid stale closures in Tiptap extension callbacks + const slashCommandsRef = useRef(slashCommands); + slashCommandsRef.current = slashCommands; + const availableModelsRef = useRef(availableModels); + availableModelsRef.current = availableModels; + const attachmentsRef = useRef(attachments); + attachmentsRef.current = attachments; + const controllerRef = useRef(controller); + controllerRef.current = controller; + + // Track value last set FROM the editor → controller to break feedback loops + const lastEditorSyncedValue = useRef(""); + + // IME composition guard (prevents submit while CJK input is pending) + const isComposingRef = useRef(false); + + // Track editor focus to show/hide the keyboard shortcut hint + const [isFocused, setIsFocused] = useState(false); + + // ── Chip interaction state (drives SlashCommandPreviewPopover visibility) ── + const [chipHovered, setChipHovered] = useState(false); + const [_chipArgFocused, setChipArgFocused] = useState(false); + const [chipNodeSelected, setChipNodeSelected] = useState(false); + + // ── Slash command suggestion state ────────────────────────────────────── + const [slashMenu, setSlashMenu] = useState<SlashMenuState | null>(null); + const slashMenuRef = useRef(slashMenu); + slashMenuRef.current = slashMenu; + // True only when the menu is visible (has ≥1 matching commands) — used to + // guard the Enter key handler so zero-match "/" doesn't block form submit. + const isSlashOpenRef = useRef(false); + + // ── File mention suggestion state ──────────────────────────────────────── + const [mentionState, setMentionState] = useState<MentionState | null>(null); + const mentionStateRef = useRef(mentionState); + mentionStateRef.current = mentionState; + + // Virtual anchor div for positioning the mention popover at the @ cursor + const mentionAnchorRef = useRef<HTMLDivElement>(null); + useLayoutEffect(() => { + const el = mentionAnchorRef.current; + if (!el || !mentionState?.clientRect) return; + const rect = mentionState.clientRect(); + if (!rect) return; + el.style.left = `${rect.left}px`; + el.style.top = `${rect.top}px`; + el.style.width = `${rect.width}px`; + el.style.height = `${rect.height}px`; + }, [mentionState]); + + const debouncedMentionQuery = useDebouncedValue( + mentionState?.query ?? "", + 120, + ); + const isMentionVisible = + mentionState !== null && (mentionState?.query?.length ?? 0) > 0; + const [fileResults, setFileResults] = useState<FileResult[]>([]); + useEffect(() => { + if (!isMentionVisible || !cwd || debouncedMentionQuery.length === 0) return; + let cancelled = false; + searchFiles(debouncedMentionQuery) + .then((results) => { + if (!cancelled) setFileResults(results); + }) + .catch(() => { + // Empty results on error — mention popup degrades gracefully. + }); + return () => { + cancelled = true; + }; + }, [debouncedMentionQuery, cwd, isMentionVisible, searchFiles]); + + const mentionFiles: FileResult[] = isMentionVisible ? fileResults : []; + const mentionFilesRef = useRef(mentionFiles); + mentionFilesRef.current = mentionFiles; + + // Clamp selectedIndex when file results shrink + useEffect(() => { + if (!mentionState || mentionFiles.length === 0) return; + const max = mentionFiles.length - 1; + if (mentionState.selectedIndex > max) { + setMentionState((prev) => + prev ? { ...prev, selectedIndex: max } : null, + ); + } + }, [mentionFiles.length, mentionState]); + + // ── Build editor ───────────────────────────────────────────────────────── + const editor = useEditor({ + immediatelyRender: false, + + onFocus: () => setIsFocused(true), + onBlur: () => setIsFocused(false), + + extensions: [ + Document, + Text, + Paragraph, + HardBreak, + History, + + Placeholder.configure({ placeholder }), + + FileMentionNode, + SlashCommandNode, + + // Chat-input keyboard shortcuts + Extension.create({ + name: "chatInputKeyboard", + addKeyboardShortcuts() { + return { + Enter: () => { + // Guard: IME composition in progress + if (isComposingRef.current) return false; + // Guard: a suggestion menu is open and handling this key + if (isSlashOpenRef.current) return false; + if (mentionStateRef.current !== null) return false; + // Find the enclosing form and submit it + const dom = this.editor.view.dom; + const form = dom.closest("form"); + if (!form) return false; + const submitBtn = form.querySelector<HTMLButtonElement>( + 'button[type="submit"]', + ); + // If the submit button is disabled, consume key but don't submit + if (submitBtn?.disabled) return true; + form.requestSubmit(); + return true; + }, + + "Shift-Enter": () => { + return this.editor.commands.setHardBreak(); + }, + + Backspace: () => { + const { state } = this.editor; + // Only remove attachment when editor is completely empty + const para = state.doc.firstChild; + const docIsEmpty = + state.doc.childCount === 1 && + para !== null && + para.childCount === 0; + if (!docIsEmpty) return false; + const last = attachmentsRef.current.files.at(-1); + if (last) { + attachmentsRef.current.remove(last.id); + return true; + } + return false; + }, + }; + }, + }), + + // Slash command suggestion + Extension.create({ + name: "slashCommand", + addProseMirrorPlugins() { + return [ + Suggestion({ + pluginKey: slashSuggestionKey, + editor: this.editor, + char: "/", + allowSpaces: false, + + // Allow "/" at the start of a paragraph or after whitespace/atom + // (same logic as the @ mention) — but never mid-word. + allow: ({ state, range }) => { + const $pos = state.doc.resolve(range.from); + if ($pos.parentOffset === 0) return true; + const textBefore = $pos.parent.textBetween( + 0, + $pos.parentOffset, + "\0", + " ", + ); + const charBefore = textBefore.slice(-1); + return charBefore === " " || charBefore === "\n"; + }, + + items: ({ query }: { query: string }) => { + const commands = slashCommandsRef.current; + const q = query.toLowerCase(); + if (shouldSuppressSlashMenuForCommittedCommand(q, commands)) { + return []; + } + const matches = commands + .map((command) => { + const rank = getCommandMatchRank(command, q); + return rank === null ? null : { command, rank }; + }) + .filter( + (item): item is { command: SlashCommand; rank: number } => + item !== null, + ); + return sortSlashCommandMatches(matches); + }, + + render: () => ({ + onStart(props: { + items: SlashCommand[]; + command: (p: { cmd: SlashCommand }) => void; + }) { + setSlashMenu({ + commands: props.items, + selectedIndex: 0, + tiptapCommand: props.command, + }); + }, + onUpdate(props: { + items: SlashCommand[]; + command: (p: { cmd: SlashCommand }) => void; + }) { + setSlashMenu((prev) => + prev + ? { + ...prev, + commands: props.items, + tiptapCommand: props.command, + selectedIndex: Math.min( + prev.selectedIndex, + Math.max(0, props.items.length - 1), + ), + } + : null, + ); + }, + onKeyDown({ event }: { event: KeyboardEvent }) { + const menu = slashMenuRef.current; + if (!menu || menu.commands.length === 0) return false; + + if (event.key === "Escape") { + setSlashMenu(null); + return true; + } + if (event.key === "ArrowUp") { + setSlashMenu((prev) => + prev + ? { + ...prev, + selectedIndex: + prev.selectedIndex <= 0 + ? prev.commands.length - 1 + : prev.selectedIndex - 1, + } + : null, + ); + return true; + } + if (event.key === "ArrowDown") { + setSlashMenu((prev) => + prev + ? { + ...prev, + selectedIndex: + prev.selectedIndex >= prev.commands.length - 1 + ? 0 + : prev.selectedIndex + 1, + } + : null, + ); + return true; + } + if (event.key === "Enter") { + const cmd = menu.commands[menu.selectedIndex]; + if (cmd) menu.tiptapCommand({ cmd }); + return true; + } + return false; + }, + onExit() { + setSlashMenu(null); + }, + }), + + command({ + editor: ed, + range, + props, + }: { + editor: Editor; + range: { from: number; to: number }; + props: { cmd: SlashCommand }; + }) { + // Insert the chip; the chip's input auto-focuses so the + // user can type arguments directly inside it. + const cmd = props.cmd; + const argumentOptions = + cmd.action?.type === "set_model" + ? (availableModelsRef.current?.map((m) => m.name) ?? []) + : []; + ed.chain() + .deleteRange(range) + .insertContentAt(range.from, { + type: "slash-command", + attrs: { + name: cmd.name, + argumentHint: cmd.argumentHint, + argumentOptions, + }, + }) + .run(); + }, + }), + ]; + }, + }), + + // File mention suggestion + Extension.create({ + name: "fileMention", + addProseMirrorPlugins() { + return [ + Suggestion({ + pluginKey: mentionSuggestionKey, + editor: this.editor, + char: "@", + allowSpaces: false, + + // Only trigger @ at start of paragraph or after whitespace/atom + allow: ({ state, range }) => { + const $pos = state.doc.resolve(range.from); + if ($pos.parentOffset === 0) return true; + // textBetween with leafText=" " treats atom nodes (chips) as spaces + const textBefore = $pos.parent.textBetween( + 0, + $pos.parentOffset, + "\0", + " ", + ); + const charBefore = textBefore.slice(-1); + return charBefore === " " || charBefore === "\n"; + }, + + // Items managed in React state; return empty here + items: () => [] as FileResult[], + + render: () => ({ + onStart(props: { + query: string; + command: (p: { path: string }) => void; + clientRect?: (() => DOMRect | null) | null; + }) { + setMentionState({ + query: props.query, + selectedIndex: 0, + tiptapCommand: props.command, + clientRect: props.clientRect ?? null, + }); + }, + onUpdate(props: { + query: string; + command: (p: { path: string }) => void; + clientRect?: (() => DOMRect | null) | null; + }) { + setMentionState((prev) => + prev + ? { + ...prev, + query: props.query, + selectedIndex: 0, + tiptapCommand: props.command, + clientRect: props.clientRect ?? null, + } + : null, + ); + }, + onKeyDown({ event }: { event: KeyboardEvent }) { + const mention = mentionStateRef.current; + const files = mentionFilesRef.current; + if (!mention) return false; + + if (event.key === "Escape") { + setMentionState(null); + return true; + } + if (event.key === "ArrowUp") { + setMentionState((prev) => + prev + ? { + ...prev, + selectedIndex: + prev.selectedIndex <= 0 + ? Math.max(0, files.length - 1) + : prev.selectedIndex - 1, + } + : null, + ); + return true; + } + if (event.key === "ArrowDown") { + setMentionState((prev) => + prev + ? { + ...prev, + selectedIndex: + files.length === 0 + ? 0 + : prev.selectedIndex >= files.length - 1 + ? 0 + : prev.selectedIndex + 1, + } + : null, + ); + return true; + } + if (event.key === "Enter" || event.key === "Tab") { + const file = files[mention.selectedIndex]; + if (file) { + mention.tiptapCommand({ path: file.relativePath }); + return true; + } + // No results — close the popup and consume the event + setMentionState(null); + return true; + } + return false; + }, + onExit() { + setMentionState(null); + }, + }), + + command({ + editor: ed, + range, + props, + }: { + editor: Editor; + range: { from: number; to: number }; + props: { path: string }; + }) { + ed.chain() + .deleteRange(range) + .insertContentAt(range.from, [ + { type: "file-mention", attrs: { path: props.path } }, + { type: "text", text: " " }, + ]) + .run(); + }, + }), + ]; + }, + }), + ], + + editorProps: { + attributes: { + "data-slot": "input-group-control", + class: "tiptap-chat-input focus-visible:outline-none", + }, + + handleDOMEvents: { + compositionstart: () => { + isComposingRef.current = true; + return false; + }, + compositionend: () => { + isComposingRef.current = false; + return false; + }, + keydown: (_view, event) => { + // Stop modifier+arrow propagation so pane-navigation hotkeys don't fire + if ( + (event.key === "ArrowLeft" || event.key === "ArrowRight") && + (event.metaKey || event.ctrlKey) + ) { + event.stopPropagation(); + } + return false; + }, + }, + + handlePaste: (_view, event) => { + const clipItems = event.clipboardData?.items; + if (!clipItems) return false; + const files = Array.from(clipItems) + .filter((i) => i.kind === "file") + .map((i) => i.getAsFile()) + .filter((f): f is File => f !== null); + if (files.length > 0) { + event.preventDefault(); + attachmentsRef.current.add(files); + return true; + } + return false; + }, + }, + + onUpdate: ({ editor: e }) => { + const text = serializeEditorToText(e); + lastEditorSyncedValue.current = text; + controllerRef.current.textInput.setInput(text); + }, + }); + + // Register focus callback so controller.textInput.focus() targets the editor + useEffect(() => { + if (!editor) return; + controller.__registerFocusCallback(() => { + editor.commands.focus("end"); + }); + return () => { + controller.__registerFocusCallback(null); + }; + }, [controller, editor]); + + // Track chip node selection via ProseMirror transactions + useEffect(() => { + if (!editor) return; + const update = () => { + const { selection } = editor.state; + const node = (selection as { node?: { type: { name: string } } }).node; + setChipNodeSelected(node?.type?.name === "slash-command"); + }; + editor.on("selectionUpdate", update); + return () => { + editor.off("selectionUpdate", update); + }; + }, [editor]); + + // Sync external controller.textInput.value changes → editor + // e.g. when SlashCommandPreview.handleFieldChange sets a param value + useEffect(() => { + if (!editor) return; + const externalText = controller.textInput.value; + // Skip if the editor itself just produced this value + if (externalText === lastEditorSyncedValue.current) return; + const currentText = serializeEditorToText(editor); + if (externalText === currentText) return; + // Update editor without firing onUpdate (prevents loop) + editor.commands.setContent( + externalText + ? parseTextToEditorContent(externalText) + : { type: "doc", content: [{ type: "paragraph" }] }, + { emitUpdate: false }, + ); + lastEditorSyncedValue.current = externalText; + }, [controller.textInput.value, editor]); + + const isSlashOpen = slashMenu !== null && slashMenu.commands.length > 0; + isSlashOpenRef.current = isSlashOpen; + const isMentionOpen = mentionState !== null; + + return ( + <> + {/* Slash command params popover — anchored to the chip node. + Only rendered when the parent provides a previewSlashCommand + function; v2 ChatPane uses its own SlashCommandPreview instead. */} + {editor && previewSlashCommand && ( + <SlashCommandPreviewPopover + cwd={cwd} + previewSlashCommand={previewSlashCommand} + slashCommands={slashCommands} + editor={editor} + isFocused={chipHovered || chipNodeSelected} + /> + )} + + {/* Slash command menu popover — anchored to the full editor div */} + <Popover open={isSlashOpen && isFocused}> + <PopoverAnchor asChild> + {/* biome-ignore lint/a11y/noStaticElementInteractions: event delegation pattern for chip hover/focus detection */} + <div + role="presentation" + className={cn( + "relative w-full overflow-y-auto px-3 py-3 text-sm", + "min-h-10 max-h-48", + focusShortcutText && !isFocused && "pr-20", + className, + )} + onMouseOver={(e) => { + if ( + (e.target as Element).closest( + "[data-node-type='slash-command']", + ) + ) { + setChipHovered(true); + } + }} + onMouseOut={(e) => { + if ( + !(e.relatedTarget as Element | null)?.closest( + "[data-node-type='slash-command']", + ) + ) { + setChipHovered(false); + } + }} + onFocus={(e) => { + if ( + (e.target as Element).closest( + "[data-node-type='slash-command']", + ) + ) { + setChipArgFocused(true); + } + }} + onBlur={(e) => { + if ( + !(e.relatedTarget as Element | null)?.closest( + "[data-node-type='slash-command']", + ) + ) { + setChipArgFocused(false); + } + }} + > + {focusShortcutText && !isFocused && ( + <span className="pointer-events-none absolute top-0 right-3 flex h-full items-center text-xs text-muted-foreground/50"> + {focusShortcutText} to focus + </span> + )} + <EditorContent editor={editor} /> + </div> + </PopoverAnchor> + {isSlashOpen && slashMenu && ( + <SlashCommandMenu + commands={slashMenu.commands} + selectedIndex={slashMenu.selectedIndex} + onSelect={(cmd) => slashMenu.tiptapCommand({ cmd })} + onHover={(i) => + setSlashMenu((prev) => + prev ? { ...prev, selectedIndex: i } : null, + ) + } + /> + )} + </Popover> + + {/* File mention popover — anchored to the @ cursor via a virtual fixed div */} + <Popover open={isMentionOpen && isFocused}> + <PopoverAnchor asChild> + <div + ref={mentionAnchorRef} + className="pointer-events-none fixed" + aria-hidden="true" + /> + </PopoverAnchor> + {isMentionOpen && ( + <PopoverContent + side="top" + align="start" + sideOffset={4} + className="w-80 p-0 text-xs" + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + onMouseDown={(e) => e.preventDefault()} + > + <Command shouldFilter={false}> + <CommandInput + placeholder="Search files..." + value={mentionState?.query ?? ""} + onValueChange={(q) => + setMentionState((prev) => + prev ? { ...prev, query: q } : null, + ) + } + /> + <CommandList className="max-h-[200px] [&::-webkit-scrollbar]:hidden"> + {mentionFiles.length === 0 && ( + <CommandEmpty className="px-2 py-3 text-left text-xs text-muted-foreground"> + {!mentionState?.query + ? "Type to search files..." + : "No results found."} + </CommandEmpty> + )} + {mentionFiles.length > 0 && ( + <CommandGroup heading="Files"> + {mentionFiles.map((file, idx) => { + const dirPath = getDirectoryPath(file.relativePath); + return ( + <CommandItem + key={file.id} + value={file.relativePath} + className={cn( + idx === (mentionState?.selectedIndex ?? -1) && + "bg-accent", + )} + onSelect={() => { + mentionState?.tiptapCommand({ + path: file.relativePath, + }); + }} + > + <FileIcon + fileName={file.name} + className="size-3.5 shrink-0" + /> + <span className="truncate text-xs">{file.name}</span> + {dirPath && ( + <span className="min-w-0 truncate font-mono text-xs text-muted-foreground"> + {dirPath} + </span> + )} + </CommandItem> + ); + })} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + )} + </Popover> + </> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/index.ts new file mode 100644 index 00000000000..987067fb65b --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/index.ts @@ -0,0 +1,4 @@ +export { + TiptapPromptEditor, + type TiptapPromptEditorProps, +} from "./TiptapPromptEditor"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/parseTextToEditorContent.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/parseTextToEditorContent.ts new file mode 100644 index 00000000000..c3d06929520 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/parseTextToEditorContent.ts @@ -0,0 +1,53 @@ +import type { JSONContent } from "@tiptap/core"; + +/** + * Matches file-mention tokens produced by serializeEditorToText. + * Handles both @path/without/spaces and @"path with spaces". + * Requires @ to appear at the start of the string or after whitespace so that + * strings like "foo@bar.com" or "@decorator" mid-word are not rewritten. + */ +const MENTION_RE = /(?:^|(?<=\s))@(?:"([^"]+)"|(\S+))/g; + +/** + * Converts a plain-text string (as produced by serializeEditorToText) back + * into a Tiptap JSONContent document, restoring file-mention atoms wherever + * an @path token is found. + */ +export function parseTextToEditorContent(text: string): JSONContent { + const paragraphs = text.split("\n").map((line): JSONContent => { + if (line === "") { + return { type: "paragraph" }; + } + + const inlineNodes: JSONContent[] = []; + let lastIndex = 0; + MENTION_RE.lastIndex = 0; + + let match: RegExpExecArray | null = MENTION_RE.exec(line); + while (match !== null) { + // Text before the mention + if (match.index > lastIndex) { + inlineNodes.push({ + type: "text", + text: line.slice(lastIndex, match.index), + }); + } + // The file-mention node — group 1 = quoted path, group 2 = unquoted path + inlineNodes.push({ + type: "file-mention", + attrs: { path: match[1] ?? match[2] }, + }); + lastIndex = match.index + match[0].length; + match = MENTION_RE.exec(line); + } + + // Remaining text after the last mention + if (lastIndex < line.length) { + inlineNodes.push({ type: "text", text: line.slice(lastIndex) }); + } + + return { type: "paragraph", content: inlineNodes }; + }); + + return { type: "doc", content: paragraphs }; +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/serializeEditorToText.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/serializeEditorToText.ts new file mode 100644 index 00000000000..e5b81512267 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/TiptapPromptEditor/serializeEditorToText.ts @@ -0,0 +1,33 @@ +import type { Editor } from "@tiptap/core"; + +/** + * Serializes Tiptap editor content to plain text for submission. + * FileMentionNode atoms → "@path", text nodes → text, hardBreaks → "\n", + * block-level nodes separated by "\n". + */ +export function serializeEditorToText(editor: Editor): string { + const lines: string[] = []; + + editor.state.doc.forEach((blockNode) => { + const parts: string[] = []; + + blockNode.forEach((child) => { + if (child.type.name === "file-mention") { + const p = child.attrs.path as string; + parts.push(p.includes(" ") ? `@"${p}"` : `@${p}`); + } else if (child.type.name === "slash-command") { + const cmdName = child.attrs.name as string; + const cmdArgs = (child.attrs.args as string) ?? ""; + parts.push(cmdArgs ? `/${cmdName} ${cmdArgs}` : `/${cmdName}`); + } else if (child.type.name === "hardBreak") { + parts.push("\n"); + } else if (child.isText) { + parts.push(child.text ?? ""); + } + }); + + lines.push(parts.join("")); + }); + + return lines.join("\n"); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index 39f1a652e73..ab7498ccbe6 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -3,7 +3,7 @@ import { FileDiffTool } from "@superset/ui/ai-elements/file-diff-tool"; import { WebFetchTool } from "@superset/ui/ai-elements/web-fetch-tool"; import { WebSearchTool } from "@superset/ui/ai-elements/web-search-tool"; import { getToolName } from "ai"; -import { FileIcon, FolderIcon } from "lucide-react"; +import { FileIcon, FolderIcon, GlobeIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { posthog } from "renderer/lib/posthog"; import { useChangesStore } from "renderer/stores/changes"; @@ -35,10 +35,14 @@ import { ListProjectsToolCall } from "./components/ListProjectsToolCall"; import { ListTaskStatusesToolCall } from "./components/ListTaskStatusesToolCall"; import { ListTasksToolCall } from "./components/ListTasksToolCall"; import { ListWorkspacesToolCall } from "./components/ListWorkspacesToolCall"; +import { LspInspectToolCall } from "./components/LspInspectToolCall"; +import { RequestSandboxAccessToolCall } from "./components/RequestSandboxAccessToolCall"; +import { SkillToolCall } from "./components/SkillToolCall"; import { StartAgentSessionToolCall } from "./components/StartAgentSessionToolCall"; import { SubagentToolCall } from "./components/SubagentToolCall"; import { SupersetToolCall } from "./components/SupersetToolCall"; import { SwitchWorkspaceToolCall } from "./components/SwitchWorkspaceToolCall"; +import { TaskWriteToolCall } from "./components/TaskWriteToolCall"; import { UpdateTaskToolCall } from "./components/UpdateTaskToolCall"; import { UpdateWorkspaceToolCall } from "./components/UpdateWorkspaceToolCall"; import { getExecuteCommandViewModel } from "./utils/getExecuteCommandViewModel"; @@ -50,6 +54,8 @@ interface ToolCallBlockProps { workspaceCwd?: string; sessionId?: string | null; organizationId?: string | null; + isStreaming?: boolean; + isInterrupted?: boolean; onAnswer?: ( toolCallId: string, answers: Record<string, string>, @@ -68,6 +74,8 @@ export function ToolCallBlock({ workspaceCwd, sessionId, organizationId, + isStreaming, + isInterrupted, onAnswer, }: ToolCallBlockProps) { const args = getArgs(part); @@ -445,10 +453,20 @@ export function ToolCallBlock({ ); } - // --- Web search → WebSearchTool --- - if (toolName === "web_search") { + // --- Web search → WebSearchTool (with results) or GenericToolCall (without) --- + if (toolName === "web_search" || toolName.includes("web_search")) { const { query, results } = getWebSearchViewModel({ args, result }); - return <WebSearchTool query={query} results={results} state={state} />; + if (results.length > 0) { + return <WebSearchTool query={query} results={results} state={state} />; + } + return ( + <GenericToolCall + part={part} + toolName="Web Search" + subtitle={query || undefined} + icon={GlobeIcon} + /> + ); } // --- Web fetch → WebFetchTool --- @@ -483,6 +501,8 @@ export function ToolCallBlock({ result={result} outputObject={outputObject} nestedResultObject={nestedResultObject} + isStreaming={isStreaming} + isInterrupted={isInterrupted} onAnswer={onAnswer} /> ); @@ -568,7 +588,14 @@ export function ToolCallBlock({ // --- Read-only exploration tools --- if (READ_ONLY_TOOLS.has(toolName)) { - return <ReadOnlyToolCall part={part} onOpenFileInPane={openFileInPane} />; + return ( + <ReadOnlyToolCall + part={part} + workspaceId={workspaceId} + workspaceCwd={workspaceCwd} + onOpenFileInPane={openFileInPane} + /> + ); } // --- Destructive workspace tools --- @@ -588,12 +615,23 @@ export function ToolCallBlock({ ); } - if (toolName === "request_sandbox_access") { - return <SupersetToolCall part={part} toolName="Request sandbox access" />; + if (toolName === "request_access") { + return ( + <RequestSandboxAccessToolCall + part={part} + args={args} + result={result} + isInterrupted={isInterrupted} + /> + ); + } + + if (toolName === "lsp_inspect") { + return <LspInspectToolCall part={part} />; } if (toolName === "task_write") { - return <SupersetToolCall part={part} toolName="Write task list" />; + return <TaskWriteToolCall part={part} />; } if (toolName === "task_check") { @@ -605,7 +643,26 @@ export function ToolCallBlock({ } if (toolName === "subagent") { - return <SubagentToolCall part={part} args={args} result={result} />; + return ( + <SubagentToolCall + part={part} + args={args} + result={result} + workspaceId={workspaceId} + workspaceCwd={workspaceCwd} + onOpenFileInPane={openFileInPane} + /> + ); + } + + if (toolName === "skill" || toolName === "load_skill") { + const skillName = + typeof args.name === "string" + ? args.name + : typeof args.command === "string" + ? args.command + : toolDisplayName; + return <SkillToolCall part={part} skillName={skillName} />; } // --- Fallback: generic tool UI --- diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx index e136bd70331..245a903bcdd 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/AskUserQuestionToolCall/AskUserQuestionToolCall.tsx @@ -1,9 +1,14 @@ -import { MessageResponse } from "@superset/ui/ai-elements/message"; -import { UserQuestionTool } from "@superset/ui/ai-elements/user-question-tool"; -import { MessageCircleQuestionIcon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { + CheckIcon, + CircleXIcon, + ClockIcon, + MessageCircleQuestionIcon, + XIcon, +} from "lucide-react"; +import { useMemo } from "react"; import type { ToolPart } from "../../../../utils/tool-helpers"; -import { SupersetToolCall } from "../SupersetToolCall"; +import { ToolStatusBadge } from "../ToolStatusBadge"; interface QuestionToolOption { label: string; @@ -23,6 +28,8 @@ interface AskUserQuestionToolCallProps { result: Record<string, unknown>; outputObject?: Record<string, unknown>; nestedResultObject?: Record<string, unknown>; + isStreaming?: boolean; + isInterrupted?: boolean; onAnswer?: ( toolCallId: string, answers: Record<string, string>, @@ -61,7 +68,6 @@ function toQuestionToolQuestions(value: unknown): QuestionToolQuestion[] { typeof optionRecord.description === "string" ? optionRecord.description.trim() : ""; - return description ? { label, description } : { label }; }) .filter((option): option is QuestionToolOption => option !== null) @@ -119,37 +125,28 @@ function findAnswerForQuestion({ return undefined; } -function buildQuestionMarkdown({ - questions, - answers, -}: { - questions: QuestionToolQuestion[]; - answers: Record<string, string>; -}): string { - if (questions.length === 0) return ""; - - const lines: string[] = ["### Agent question"]; - for (const [index, question] of questions.entries()) { - lines.push(""); - if (questions.length > 1) { - lines.push(`#### ${index + 1}`); - } - if (question.header) { - lines.push(`_${question.header}_`); - } - lines.push(question.question); - - const answer = findAnswerForQuestion({ - answers, - questionText: question.question, - }); - if (answer) { - lines.push(""); - lines.push(`**Answer:** ${answer}`); - } - } - - return lines.join("\n").trim(); +function toSingleQuestion( + args: Record<string, unknown>, +): QuestionToolQuestion[] { + const question = + typeof args.question === "string" ? args.question.trim() : ""; + if (!question) return []; + + const options = Array.isArray(args.options) + ? args.options + .map((opt): QuestionToolOption | null => { + if (typeof opt !== "object" || opt === null) return null; + const o = opt as Record<string, unknown>; + const label = typeof o.label === "string" ? o.label.trim() : ""; + if (!label) return null; + const description = + typeof o.description === "string" ? o.description.trim() : ""; + return description ? { label, description } : { label }; + }) + .filter((o): o is QuestionToolOption => o !== null) + : []; + + return [{ question, options }]; } export function AskUserQuestionToolCall({ @@ -158,112 +155,126 @@ export function AskUserQuestionToolCall({ result, outputObject, nestedResultObject, - onAnswer, + isInterrupted, }: AskUserQuestionToolCallProps) { - const [optimisticAnswers, setOptimisticAnswers] = useState<Record< - string, - string - > | null>(null); - const [isSubmittingLocally, setIsSubmittingLocally] = useState(false); - - useEffect(() => { - if (part.state === "output-available" || part.state === "output-error") { - setIsSubmittingLocally(false); - } - }, [part.state]); - - const questions = useMemo(() => { - return toQuestionToolQuestions(args.questions); - }, [args.questions]); - - const serverAnswers = useMemo(() => { - return toQuestionToolAnswers( - toRecord(result.answers) ?? - toRecord(outputObject?.answers) ?? - toRecord(nestedResultObject?.answers), - ); - }, [nestedResultObject?.answers, outputObject?.answers, result.answers]); - - const answers = optimisticAnswers ?? serverAnswers; - const markdown = buildQuestionMarkdown({ questions, answers }); - const hasOutput = - part.state === "output-available" || part.state === "output-error"; - const hasQuestions = questions.length > 0; - const canRespond = Boolean(onAnswer) && !hasOutput && !isSubmittingLocally; + const questions = useMemo( + () => + Array.isArray(args.questions) + ? toQuestionToolQuestions(args.questions) + : toSingleQuestion(args), + [args], + ); - const messageBlock = markdown ? ( - <div className="rounded-lg border border-border/60 bg-muted/20 p-3"> - <MessageResponse - animated={false} - isAnimating={false} - mermaid={{ - config: { - theme: "default", - }, - }} - > - {markdown} - </MessageResponse> - </div> - ) : null; + const answers = useMemo( + () => + toQuestionToolAnswers( + toRecord(result.answers) ?? + toRecord(outputObject?.answers) ?? + toRecord(nestedResultObject?.answers), + ), + [nestedResultObject?.answers, outputObject?.answers, result.answers], + ); - const handleSubmit = (submittedAnswers: Record<string, string>): void => { - if (!onAnswer || isSubmittingLocally) return; - setOptimisticAnswers(submittedAnswers); - setIsSubmittingLocally(true); + // Mastracode sends { isError: true, content: "..." } for aborted questions + const isResultError = result.isError === true; + + // Fallback for plain-string results and mastracode's { content: "User answered: <answer>" } format + const answerFallbackText = useMemo(() => { + // Error results are not answers + if (isResultError) return undefined; + if (typeof result.text === "string" && result.text.trim()) + return result.text.trim(); + if (typeof result.answer === "string" && result.answer.trim()) + return result.answer.trim(); + // ask_user tool returns { content: "User answered: <answer>", isError: false } + if (typeof result.content === "string" && result.content.trim()) { + const raw = result.content.trim(); + const prefix = "User answered: "; + return raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw; + } + return undefined; + }, [isResultError, result.text, result.answer, result.content]); + + const isCancelledByStop = + !!isInterrupted && + part.state !== "output-available" && + part.state !== "output-error"; + const isPending = + !isCancelledByStop && + part.state !== "output-available" && + part.state !== "output-error"; + const isCancelledByError = part.state === "output-error" || isResultError; + const hasAnswers = + Object.keys(answers).length > 0 || answerFallbackText !== undefined; + + const answeredQAs = useMemo( + () => + questions + .map((q) => ({ + question: q.question, + answer: findAnswerForQuestion({ answers, questionText: q.question }), + })) + .filter( + (qa): qa is { question: string; answer: string } => + qa.answer !== undefined, + ), + [questions, answers], + ); - void Promise.resolve(onAnswer(part.toolCallId, submittedAnswers)).catch( - () => { - setOptimisticAnswers(null); - setIsSubmittingLocally(false); - }, - ); - }; + // No args available (tool_result-only path with input: {}) — nothing useful to show + if (questions.length === 0 && !isCancelledByError && !isCancelledByStop) + return null; - if (!hasQuestions) { - return ( - <SupersetToolCall - part={part} - toolName="Question" - icon={MessageCircleQuestionIcon} - /> - ); - } + const isAnswered = + !isPending && !isCancelledByError && !isCancelledByStop && hasAnswers; + const isCancelled = + !isPending && !isCancelledByError && !isCancelledByStop && !hasAnswers; - if (hasOutput || !onAnswer || optimisticAnswers) { - return ( - <div className="space-y-2"> - {messageBlock} - <SupersetToolCall - part={part} - toolName="Question" - icon={MessageCircleQuestionIcon} - /> - </div> - ); - } + // Fallback for plain-string result when questions array has one entry + const fallbackQA = + answeredQAs.length === 0 && answerFallbackText && questions[0] + ? { question: questions[0].question, answer: answerFallbackText } + : null; - if (!canRespond) { - return ( - <div className="space-y-2"> - {messageBlock} - <SupersetToolCall - part={part} - toolName="Question" - icon={MessageCircleQuestionIcon} - /> - </div> - ); - } + const qasToShow = + answeredQAs.length > 0 ? answeredQAs : fallbackQA ? [fallbackQA] : []; return ( - <div className="space-y-2"> - {messageBlock} - <UserQuestionTool - questions={questions} - onAnswer={handleSubmit} - onSkip={() => handleSubmit({})} - /> - </div> + <ToolCallRow + icon={MessageCircleQuestionIcon} + isPending={false} + isError={false} + title="Question" + description={ + isPending ? ( + <ToolStatusBadge icon={ClockIcon} label="Awaiting Response" /> + ) : isAnswered ? ( + <ToolStatusBadge icon={CheckIcon} label="Answered" /> + ) : isCancelled || isCancelledByError || isCancelledByStop ? ( + <ToolStatusBadge icon={XIcon} label="Cancelled" /> + ) : undefined + } + > + {isAnswered && qasToShow.length > 0 + ? qasToShow.map((qa) => ( + <div key={qa.question} className="space-y-1 px-3 py-2"> + <div className="text-xs text-muted-foreground">{qa.question}</div> + <div className="text-sm text-foreground">{qa.answer}</div> + </div> + )) + : (isCancelledByError || isCancelledByStop) && questions.length > 0 + ? questions.map((q) => ( + <div key={q.question} className="space-y-1 px-3 py-2"> + <div className="text-xs text-muted-foreground"> + {q.question} + </div> + <div className="flex items-center gap-1 text-sm text-destructive"> + <CircleXIcon className="h-3 w-3 shrink-0" /> + Aborted by the user + </div> + </div> + )) + : undefined} + </ToolCallRow> ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx index 037f9e76077..25d4fdd1c4a 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/GenericToolCall.tsx @@ -1,83 +1,68 @@ -import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; -import { CheckIcon, Loader2Icon, WrenchIcon, XIcon } from "lucide-react"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { WrenchIcon } from "lucide-react"; import type { ComponentType } from "react"; -import { useState } from "react"; import type { ToolPart } from "../../../../utils/tool-helpers"; import { getGenericToolCallState } from "./getGenericToolCallState"; type GenericToolCallProps = { part: ToolPart; toolName: string; + subtitle?: string; icon?: ComponentType<{ className?: string }>; }; +function getQueryFromInput(input: unknown): string | undefined { + if (input != null && typeof input === "object" && !Array.isArray(input)) { + const query = (input as Record<string, unknown>).query; + if (typeof query === "string" && query.trim().length > 0) return query; + } + return undefined; +} + export function GenericToolCall({ part, toolName, + subtitle, icon: Icon = WrenchIcon, }: GenericToolCallProps) { - const [isOpen, setIsOpen] = useState(false); - const { output, isError, displayState, errorText } = + const { output, isError, isNotConfigured, displayState, errorText } = getGenericToolCallState(part); const isPending = part.state !== "output-available" && part.state !== "output-error"; const hasDetails = part.input != null || output != null || isError; + const query = getQueryFromInput(part.input); return ( - <Collapsible - className="overflow-hidden rounded-md" - onOpenChange={(open) => hasDetails && setIsOpen(open)} - open={hasDetails ? isOpen : false} + <ToolCallRow + description={subtitle} + icon={Icon} + isError={isError || displayState === "output-error"} + isNotConfigured={isNotConfigured} + isPending={isPending} + title={toolName} > - <CollapsibleTrigger asChild> - <button - className={ - hasDetails - ? "flex h-7 w-full items-center justify-between px-2.5 text-left transition-colors duration-150 hover:bg-muted/30" - : "flex h-7 w-full items-center justify-between px-2.5 text-left" - } - disabled={!hasDetails} - type="button" - > - <div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs"> - <Icon className="h-3 w-3 shrink-0 text-muted-foreground" /> - <ShimmerLabel - className="truncate text-xs text-muted-foreground" - isShimmering={isPending} - > - {toolName} - </ShimmerLabel> - </div> - <div className="ml-2 flex h-6 w-6 items-center justify-center text-muted-foreground"> - {isPending ? ( - <Loader2Icon className="h-3 w-3 animate-spin" /> - ) : isError || displayState === "output-error" ? ( - <XIcon className="h-3 w-3" /> - ) : ( - <CheckIcon className="h-3 w-3" /> - )} - </div> - </button> - </CollapsibleTrigger> - {hasDetails && ( - <CollapsibleContent className="data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"> - <div className="mt-0.5"> - {part.input != null && <ToolInput input={part.input} />} - {(output != null || isError) && ( - <ToolOutput - output={!isError ? output : undefined} - errorText={isError ? errorText : undefined} - /> - )} - </div> - </CollapsibleContent> - )} - </Collapsible> + {hasDetails ? ( + <div className="space-y-3 py-1 pl-3"> + {query != null ? ( + <div className="space-y-1"> + <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide"> + Query + </h4> + <p className="text-xs text-foreground">{query}</p> + </div> + ) : ( + part.input != null && <ToolInput input={part.input} /> + )} + {(output != null || isError) && ( + <ToolOutput + output={!isError ? output : undefined} + errorText={isError ? errorText : undefined} + label={query != null ? "Response" : undefined} + /> + )} + </div> + ) : undefined} + </ToolCallRow> ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/getGenericToolCallState.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/getGenericToolCallState.ts index 8ae55c160d8..b299fbc8cba 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/getGenericToolCallState.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/GenericToolCall/getGenericToolCallState.ts @@ -5,6 +5,7 @@ import { toToolDisplayState } from "../../../../utils/tool-helpers"; export type GenericToolCallState = { output: unknown; isError: boolean; + isNotConfigured: boolean; displayState: ToolDisplayState; errorText?: string; }; @@ -48,9 +49,15 @@ export function getGenericToolCallState(part: ToolPart): GenericToolCallState { } } + const isNotConfigured = + isError && + typeof errorText === "string" && + errorText.toLowerCase().includes("not configured"); + return { output, isError, + isNotConfigured, displayState, errorText, }; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx new file mode 100644 index 00000000000..07bdbb60c43 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/LspInspectToolCall.tsx @@ -0,0 +1,50 @@ +import { ToolInput, ToolOutput } from "@superset/ui/ai-elements/tool"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { SearchCheckIcon } from "lucide-react"; +import type { ToolPart } from "../../../../utils/tool-helpers"; +import { getArgs } from "../../../../utils/tool-helpers"; +import { getGenericToolCallState } from "../GenericToolCall/getGenericToolCallState"; + +interface LspInspectToolCallProps { + part: ToolPart; +} + +export function LspInspectToolCall({ part }: LspInspectToolCallProps) { + const args = getArgs(part); + const { output, isError, isNotConfigured, errorText } = + getGenericToolCallState(part); + const isPending = + part.state !== "output-available" && part.state !== "output-error"; + + const rawPath = String( + args.file_path ?? args.filePath ?? args.path ?? args.file ?? "", + ); + const fileName = rawPath.includes("/") + ? rawPath.split("/").pop() + : rawPath || undefined; + + const hasDetails = part.input != null || output != null || isError; + + return ( + <ToolCallRow + icon={SearchCheckIcon} + isError={isError} + isNotConfigured={isNotConfigured} + isPending={isPending} + title="LSP Inspect" + description={fileName} + > + {hasDetails ? ( + <div className="space-y-3 py-1 pl-3"> + {part.input != null && <ToolInput input={part.input} />} + {(output != null || isError) && ( + <ToolOutput + output={!isError ? output : undefined} + errorText={isError ? errorText : undefined} + /> + )} + </div> + ) : undefined} + </ToolCallRow> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/index.ts new file mode 100644 index 00000000000..a8c95496e1c --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/LspInspectToolCall/index.ts @@ -0,0 +1 @@ +export { LspInspectToolCall } from "./LspInspectToolCall"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/RequestSandboxAccessToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/RequestSandboxAccessToolCall.tsx new file mode 100644 index 00000000000..f583d6a874f --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/RequestSandboxAccessToolCall.tsx @@ -0,0 +1,123 @@ +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { + CheckIcon, + CircleXIcon, + ClockIcon, + FolderLockIcon, + XIcon, +} from "lucide-react"; +import type { ComponentType } from "react"; +import type { ToolPart } from "../../../../utils/tool-helpers"; +import type { ToolStatusBadgeVariant } from "../ToolStatusBadge"; +import { ToolStatusBadge } from "../ToolStatusBadge"; + +interface RequestSandboxAccessToolCallProps { + part: ToolPart; + args: Record<string, unknown>; + result: Record<string, unknown>; + isInterrupted?: boolean; +} + +type AccessStatus = "pending" | "granted" | "denied" | "cancelled" | "error"; + +const ACCESS_STATUS_CONFIG: Record< + AccessStatus, + { + icon: ComponentType<{ className?: string }>; + label: string; + variant?: ToolStatusBadgeVariant; + } +> = { + pending: { icon: ClockIcon, label: "Awaiting Response" }, + granted: { icon: CheckIcon, label: "Access Granted" }, + denied: { icon: XIcon, label: "Access Denied" }, + cancelled: { icon: XIcon, label: "Cancelled" }, + error: { icon: CircleXIcon, label: "Error", variant: "danger" }, +}; + +function toAccessDecision(content: string): "granted" | "denied" | null { + if (content.startsWith("Access already granted")) return "granted"; + if (content.startsWith("Access granted")) return "granted"; + if (content.startsWith("Access denied")) return "denied"; + return null; +} + +function toAccessStatus( + part: ToolPart, + result: Record<string, unknown>, + isInterrupted: boolean, +): AccessStatus { + if ( + isInterrupted && + part.state !== "output-available" && + part.state !== "output-error" + ) { + return "cancelled"; + } + if (part.state !== "output-available" && part.state !== "output-error") { + return "pending"; + } + if (part.state === "output-error" || result.isError === true) { + return "error"; + } + const content = + (typeof result.content === "string" && result.content.trim()) || + (typeof result.text === "string" && result.text.trim()) || + ""; + return toAccessDecision(content) ?? "error"; +} + +export function RequestSandboxAccessToolCall({ + part, + args, + result, + isInterrupted = false, +}: RequestSandboxAccessToolCallProps) { + const requestedPath = typeof args.path === "string" ? args.path.trim() : null; + const reason = typeof args.reason === "string" ? args.reason.trim() : null; + + const status = toAccessStatus(part, result, isInterrupted); + const { icon, label, variant } = ACCESS_STATUS_CONFIG[status]; + const statusBadge = ( + <ToolStatusBadge icon={icon} label={label} variant={variant} /> + ); + + const isPending = status === "pending"; + const isCancelledOrError = status === "cancelled" || status === "error"; + const hasContext = Boolean(requestedPath || reason); + + return ( + <ToolCallRow + icon={FolderLockIcon} + isPending={false} + isError={false} + title="Request Access" + description={statusBadge} + > + {!isPending && hasContext ? ( + <div className="space-y-1 px-3 py-2"> + {requestedPath ? ( + <div className="text-xs text-muted-foreground"> + Path: {requestedPath} + </div> + ) : null} + {reason ? ( + <div className="text-xs text-muted-foreground"> + Reason: {reason} + </div> + ) : null} + {!isCancelledOrError ? ( + <div className="text-sm text-foreground"> + {status === "granted" ? "Access granted" : "Access denied"} + </div> + ) : ( + <div className="flex items-center gap-1 text-sm text-destructive"> + <CircleXIcon className="h-3 w-3 shrink-0" /> + Aborted + </div> + )} + </div> + ) : undefined} + </ToolCallRow> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/index.ts new file mode 100644 index 00000000000..be4eeb9f3d8 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/RequestSandboxAccessToolCall/index.ts @@ -0,0 +1 @@ +export { RequestSandboxAccessToolCall } from "./RequestSandboxAccessToolCall"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SkillToolCall/SkillToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SkillToolCall/SkillToolCall.tsx new file mode 100644 index 00000000000..96aad45a47b --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SkillToolCall/SkillToolCall.tsx @@ -0,0 +1,35 @@ +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { ZapIcon } from "lucide-react"; +import type { ToolPart } from "../../../../utils/tool-helpers"; + +type SkillToolCallProps = { + part: ToolPart; + skillName: string; +}; + +export function SkillToolCall({ part, skillName }: SkillToolCallProps) { + const isError = part.state === "output-error"; + const isPending = + part.state !== "output-available" && part.state !== "output-error"; + + return ( + <ToolCallRow + icon={ZapIcon} + isError={isError} + isPending={isPending} + title={`Skill(${skillName})`} + > + {!isPending ? ( + <div className="py-1 pl-3"> + {isError ? ( + <p className="text-xs text-destructive">Failed to load skill</p> + ) : ( + <p className="text-xs text-muted-foreground"> + Successfully loaded skill + </p> + )} + </div> + ) : undefined} + </ToolCallRow> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SkillToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SkillToolCall/index.ts new file mode 100644 index 00000000000..b338dfa9f15 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SkillToolCall/index.ts @@ -0,0 +1 @@ +export { SkillToolCall } from "./SkillToolCall"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx index 6470cf5d12c..d593b96bb2f 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/SubagentToolCall.tsx @@ -1,13 +1,11 @@ -import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; -import { cn } from "@superset/ui/lib/utils"; -import { BotIcon, CheckIcon, Loader2Icon, XIcon } from "lucide-react"; -import { useId, useMemo, useState } from "react"; -import { MarkdownToggleContent } from "renderer/components/Chat/components/MarkdownToggleContent"; + MessageResponse, + TOOL_CALL_MD_CLASSNAME, +} from "@superset/ui/ai-elements/message"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { BotIcon } from "lucide-react"; +import { useMemo } from "react"; +import { SubagentInnerToolCall } from "renderer/components/Chat/components/SubagentInnerToolCall"; import type { ToolPart } from "../../../../utils/tool-helpers"; import { parseSubagentToolResult } from "./utils/parseSubagentToolResult"; @@ -15,6 +13,9 @@ interface SubagentToolCallProps { part: ToolPart; args: Record<string, unknown>; result: Record<string, unknown>; + workspaceId?: string; + workspaceCwd?: string; + onOpenFileInPane?: (filePath: string) => void; } function asString(value: unknown): string | null { @@ -27,10 +28,10 @@ export function SubagentToolCall({ part, args, result, + workspaceId, + workspaceCwd, + onOpenFileInPane, }: SubagentToolCallProps) { - const [isOpen, setIsOpen] = useState(false); - const [renderMarkdown, setRenderMarkdown] = useState(true); - const markdownToggleId = useId(); const isPending = part.state !== "output-available" && part.state !== "output-error"; const isError = @@ -42,89 +43,61 @@ export function SubagentToolCall({ const parsed = useMemo(() => parseSubagentToolResult(result), [result]); const hasDetails = - task.length > 0 || - parsed.text.length > 0 || - parsed.tools.length > 0 || - Boolean(parsed.modelId) || - parsed.durationMs !== undefined; + task.length > 0 || parsed.text.length > 0 || parsed.tools.length > 0; + + // Title: "Agent" (foreground) — agentType goes in description (muted) + const titleNode = ( + <span className="shrink-0 font-medium text-xs"> + <span className="text-foreground">Agent</span>{" "} + <span className="text-muted-foreground">{agentType}</span> + </span> + ); return ( - <Collapsible - className="overflow-hidden rounded-md" - onOpenChange={(open) => hasDetails && setIsOpen(open)} - open={hasDetails ? isOpen : false} + <ToolCallRow + icon={BotIcon} + isError={isError} + isPending={isPending} + title={titleNode} > - <CollapsibleTrigger asChild> - <button - className={ - hasDetails - ? "flex h-7 w-full items-center justify-between px-2.5 text-left transition-colors duration-150 hover:bg-muted/30" - : "flex h-7 w-full items-center justify-between px-2.5 text-left" - } - disabled={!hasDetails} - type="button" - > - <div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs"> - <BotIcon className="h-3 w-3 shrink-0 text-muted-foreground" /> - <ShimmerLabel - className="truncate text-xs text-muted-foreground" - isShimmering={isPending} - > - {`Subagent (${agentType})`} - </ShimmerLabel> - </div> - <div className="ml-2 flex h-6 w-6 items-center justify-center text-muted-foreground"> - {isPending ? ( - <Loader2Icon className="h-3 w-3 animate-spin" /> - ) : isError ? ( - <XIcon className="h-3 w-3" /> - ) : ( - <CheckIcon className="h-3 w-3" /> - )} - </div> - </button> - </CollapsibleTrigger> - {hasDetails && ( - <CollapsibleContent className="data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"> - <div className="mt-0.5 space-y-2 rounded border bg-muted/20 p-2.5 text-xs"> - <div className="font-medium text-foreground">{task}</div> - <div className="text-muted-foreground"> - {agentType} - {parsed.modelId ? ` • ${parsed.modelId}` : ""} - {parsed.durationMs !== undefined - ? ` • ${Math.round(parsed.durationMs)} ms` - : ""} + {hasDetails ? ( + <div className="space-y-2 pl-2 text-xs"> + <MessageResponse + animated={false} + className={`font-medium ${TOOL_CALL_MD_CLASSNAME}`} + isAnimating={false} + mermaid={{ config: { theme: "default" } }} + > + {task} + </MessageResponse> + {parsed.tools.length > 0 ? ( + <div className="space-y-1"> + {parsed.tools.map((tool, index) => ( + <SubagentInnerToolCall + key={`${tool.name}-${index}`} + name={tool.name} + isError={tool.isError} + args={tool.args} + result={tool.result} + workspaceId={workspaceId} + workspaceCwd={workspaceCwd} + onOpenFileInPane={onOpenFileInPane} + /> + ))} </div> - {parsed.tools.length > 0 ? ( - <div className="flex flex-wrap gap-1.5"> - {parsed.tools.map((tool, index) => ( - <span - key={`${tool.name}-${index}`} - className={cn( - "rounded-full border px-2 py-0.5", - tool.isError - ? "border-destructive/40 bg-destructive/10 text-destructive" - : "border-muted-foreground/30 bg-background/80 text-muted-foreground", - )} - > - {tool.name} - </span> - ))} - </div> - ) : null} - {parsed.text ? ( - <MarkdownToggleContent - toggleId={markdownToggleId} - checked={renderMarkdown} - onCheckedChange={setRenderMarkdown} - content={parsed.text} - markdownContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2" - plainContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2 text-xs whitespace-pre-wrap break-words" - /> - ) : null} - </div> - </CollapsibleContent> - )} - </Collapsible> + ) : null} + {parsed.text ? ( + <MessageResponse + animated={false} + className={`${TOOL_CALL_MD_CLASSNAME} [&_[data-streamdown=table-header-cell]]:px-2.5 [&_[data-streamdown=table-header-cell]]:py-1.5 [&_[data-streamdown=table-header-cell]]:text-xs [&_[data-streamdown=table-cell]]:px-2.5 [&_[data-streamdown=table-cell]]:py-1.5 [&_[data-streamdown=table-cell]]:text-xs`} + isAnimating={false} + mermaid={{ config: { theme: "default" } }} + > + {parsed.text} + </MessageResponse> + ) : null} + </div> + ) : undefined} + </ToolCallRow> ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/utils/parseSubagentToolResult/parseSubagentToolResult.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/utils/parseSubagentToolResult/parseSubagentToolResult.ts index 8d829331b38..eb6e9d98fa5 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/utils/parseSubagentToolResult/parseSubagentToolResult.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SubagentToolCall/utils/parseSubagentToolResult/parseSubagentToolResult.ts @@ -1,6 +1,8 @@ -interface SubagentToolExecution { +export interface SubagentToolExecution { name: string; isError: boolean; + args: Record<string, unknown> | null; + result: string | null; } export interface SubagentToolResultSummary { @@ -26,7 +28,45 @@ function firstString(...values: unknown[]): string | null { return null; } -function parseTools(value: string | undefined): SubagentToolExecution[] { +function parseDetailedToolCalls( + content: string, +): { tools: SubagentToolExecution[]; stripped: string } | null { + const match = content.match( + /\n<subagent-tool-calls>([\s\S]*?)<\/subagent-tool-calls>/, + ); + if (!match) return null; + try { + const parsed = JSON.parse(match[1]); + if (!Array.isArray(parsed)) return null; + const tools = parsed + .filter( + (item): item is Record<string, unknown> => + typeof item === "object" && item !== null, + ) + .map((item) => ({ + name: typeof item.name === "string" ? item.name : "tool", + isError: item.isError === true, + args: + typeof item.args === "object" && item.args !== null + ? (item.args as Record<string, unknown>) + : null, + result: + typeof item.result === "string" + ? item.result + : item.result !== null && item.result !== undefined + ? String(item.result) + : null, + })); + const stripped = + content.slice(0, match.index) + + content.slice((match.index ?? 0) + match[0].length); + return { tools, stripped }; + } catch { + return null; + } +} + +function parseLegacyTools(value: string | undefined): SubagentToolExecution[] { if (!value) return []; return value .split(",") @@ -38,7 +78,9 @@ function parseTools(value: string | undefined): SubagentToolExecution[] { const status = statusPart?.trim().toLowerCase() || "ok"; return { name, - isError: status === "error" || status === "failed", + isError: status === "error" || status === "failed" || status === "err", + args: null, + result: null, }; }); } @@ -49,12 +91,17 @@ export function parseSubagentToolResult( const record = asRecord(value); const textContent = firstString(record?.content, record?.result, record?.text) ?? ""; - const metaTagRegex = /<subagent-meta\s+([^>]+?)\s*\/>/i; - const match = textContent.match(metaTagRegex); + + // Try to parse the detailed tool-calls block first + const detailed = parseDetailedToolCalls(textContent); + const workingContent = detailed ? detailed.stripped : textContent; + + const metaTagRegex = /\n?<subagent-meta\s+([^>]+?)\s*\/>/i; + const match = workingContent.match(metaTagRegex); if (!match) { return { - text: textContent, - tools: [], + text: workingContent.trim(), + tools: detailed?.tools ?? [], }; } @@ -67,10 +114,10 @@ export function parseSubagentToolResult( const durationRaw = attrs.get("durationMs"); const durationMs = durationRaw ? Number(durationRaw) : Number.NaN; return { - text: textContent.replace(metaTagRegex, "").trim(), + text: workingContent.replace(metaTagRegex, "").trim(), modelId: attrs.get("modelId"), durationMs: Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : undefined, - tools: parseTools(attrs.get("tools")), + tools: detailed?.tools ?? parseLegacyTools(attrs.get("tools")), }; } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx index cd5e20ecc03..c0788a38a6e 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/SupersetToolCall/SupersetToolCall.tsx @@ -1,12 +1,11 @@ -import { ShimmerLabel } from "@superset/ui/ai-elements/shimmer-label"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; -import { CheckIcon, Loader2Icon, WrenchIcon, XIcon } from "lucide-react"; + MessageResponse, + TOOL_CALL_MD_CLASSNAME, +} from "@superset/ui/ai-elements/message"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { WrenchIcon } from "lucide-react"; import type { ComponentType, ReactNode } from "react"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import type { ToolPart } from "../../../../utils/tool-helpers"; type SupersetToolCallProps = { @@ -14,6 +13,7 @@ type SupersetToolCallProps = { toolName: string; icon?: ComponentType<{ className?: string }>; details?: ReactNode; + subtitle?: string; }; function stringifyValue(value: unknown): string { @@ -30,8 +30,8 @@ export function SupersetToolCall({ toolName, icon: Icon = WrenchIcon, details, + subtitle, }: SupersetToolCallProps) { - const [isOpen, setIsOpen] = useState(false); const output = "output" in part ? (part as { output?: unknown }).output : undefined; const outputObject = @@ -52,60 +52,48 @@ export function SupersetToolCall({ return "Tool failed"; }, [isError, output, outputError, outputObject?.message]); - const hasDetails = Boolean(details) || isError; + const contentText = (() => { + if (isPending || isError) return null; + if (typeof output === "string" && output.trim()) return output.trim(); + if (outputObject) { + const c = outputObject.content ?? outputObject.text; + if (typeof c === "string" && c.trim()) return c.trim(); + } + return null; + })(); + + const hasDetails = Boolean(details) || isError || contentText != null; return ( - <Collapsible - className="overflow-hidden rounded-md" - onOpenChange={(open) => hasDetails && setIsOpen(open)} - open={hasDetails ? isOpen : false} + <ToolCallRow + icon={Icon} + isError={isError} + isPending={isPending} + title={toolName} + description={subtitle} > - <CollapsibleTrigger asChild> - <button - className={ - hasDetails - ? "flex h-7 w-full items-center justify-between px-2.5 text-left transition-colors duration-150 hover:bg-muted/30" - : "flex h-7 w-full items-center justify-between px-2.5 text-left" - } - disabled={!hasDetails} - type="button" - > - <div className="flex min-w-0 flex-1 items-center gap-1.5 text-xs"> - <Icon className="h-3 w-3 shrink-0 text-muted-foreground" /> - <ShimmerLabel - className="truncate text-xs text-muted-foreground" - isShimmering={isPending} - > - {toolName} - </ShimmerLabel> - </div> - <div className="ml-2 flex h-6 w-6 items-center justify-center text-muted-foreground"> - {isPending ? ( - <Loader2Icon className="h-3 w-3 animate-spin" /> - ) : isError ? ( - <XIcon className="h-3 w-3" /> - ) : ( - <CheckIcon className="h-3 w-3" /> - )} - </div> - </button> - </CollapsibleTrigger> {hasDetails ? ( - <CollapsibleContent className="data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"> - <div className="mt-0.5 space-y-1"> - {details ? ( - <div className="rounded border bg-muted/20 p-2.5 text-xs"> - {details} - </div> - ) : null} - {isError && errorText ? ( - <div className="rounded border border-destructive/40 bg-destructive/10 p-2.5 text-xs text-destructive"> - {errorText} - </div> - ) : null} - </div> - </CollapsibleContent> - ) : null} - </Collapsible> + <div className="space-y-1 pl-2"> + {details ? ( + <div className="rounded border bg-muted/20 ps-2 text-xs"> + {details} + </div> + ) : null} + {isError && errorText ? ( + <div className="rounded border border-destructive/40 bg-destructive/10 ps-2 text-xs text-destructive"> + {errorText} + </div> + ) : contentText != null ? ( + <MessageResponse + animated={false} + className={TOOL_CALL_MD_CLASSNAME} + isAnimating={false} + > + {contentText} + </MessageResponse> + ) : null} + </div> + ) : undefined} + </ToolCallRow> ); } diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/TaskWriteToolCall.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/TaskWriteToolCall.tsx new file mode 100644 index 00000000000..9d4370eed53 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/TaskWriteToolCall.tsx @@ -0,0 +1,59 @@ +import { ListTodoIcon } from "lucide-react"; +import type { ToolPart } from "../../../../utils/tool-helpers"; +import { getArgs } from "../../../../utils/tool-helpers"; +import { SupersetToolCall } from "../SupersetToolCall"; + +interface TodoItem { + id: string; + content: string; + status: "pending" | "in_progress" | "completed"; + priority?: string; +} + +function toTodoItems(value: unknown): TodoItem[] { + if (!Array.isArray(value)) return []; + return value.filter( + (item): item is TodoItem => + typeof item === "object" && + item !== null && + typeof (item as TodoItem).content === "string", + ); +} + +function buildDescription(todos: TodoItem[]): string | undefined { + if (todos.length === 0) return undefined; + + const inProgress = todos.filter((t) => t.status === "in_progress").length; + const completed = todos.filter((t) => t.status === "completed").length; + const pending = todos.filter((t) => t.status === "pending").length; + + const parts: string[] = [ + `${todos.length} task${todos.length === 1 ? "" : "s"}`, + ]; + const statusParts: string[] = []; + if (inProgress > 0) statusParts.push(`${inProgress} in progress`); + if (completed > 0) statusParts.push(`${completed} completed`); + if (pending > 0) statusParts.push(`${pending} pending`); + if (statusParts.length > 0) parts.push(statusParts.join(" · ")); + + return parts.join(" · "); +} + +interface TaskWriteToolCallProps { + part: ToolPart; +} + +export function TaskWriteToolCall({ part }: TaskWriteToolCallProps) { + const args = getArgs(part); + const todos = toTodoItems(args.todos); + const description = buildDescription(todos); + + return ( + <SupersetToolCall + part={part} + toolName="Update Tasks" + icon={ListTodoIcon} + subtitle={description} + /> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/index.ts new file mode 100644 index 00000000000..fcf501bd5a4 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/TaskWriteToolCall/index.ts @@ -0,0 +1 @@ +export { TaskWriteToolCall } from "./TaskWriteToolCall"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/ToolStatusBadge.tsx b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/ToolStatusBadge.tsx new file mode 100644 index 00000000000..c9368456f15 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/ToolStatusBadge.tsx @@ -0,0 +1,34 @@ +import { cn } from "@superset/ui/lib/utils"; +import type { ComponentType } from "react"; + +const VARIANT_CLASSES = { + default: "", + success: "text-emerald-500", + danger: "text-destructive", +} as const; + +export type ToolStatusBadgeVariant = keyof typeof VARIANT_CLASSES; + +interface ToolStatusBadgeProps { + icon: ComponentType<{ className?: string }>; + label: string; + variant?: ToolStatusBadgeVariant; +} + +export function ToolStatusBadge({ + icon: Icon, + label, + variant = "default", +}: ToolStatusBadgeProps) { + return ( + <span + className={cn( + "ml-2 flex items-center gap-1 font-medium uppercase tracking-wide", + VARIANT_CLASSES[variant], + )} + > + <Icon className="h-3 w-3 shrink-0" /> + {label} + </span> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/index.ts new file mode 100644 index 00000000000..0a5773c27ea --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/components/ToolCallBlock/components/ToolStatusBadge/index.ts @@ -0,0 +1,2 @@ +export type { ToolStatusBadgeVariant } from "./ToolStatusBadge"; +export { ToolStatusBadge } from "./ToolStatusBadge"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane.ts new file mode 100644 index 00000000000..f5444622569 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane.ts @@ -0,0 +1,13 @@ +import { usePromptInputController } from "@superset/ui/ai-elements/prompt-input"; +import { useEffect } from "react"; + +export function useFocusPromptOnPane(isFocused: boolean) { + const { textInput } = usePromptInputController(); + const { focus } = textInput; + + useEffect(() => { + if (isFocused) { + focus(); + } + }, [isFocused, focus]); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useSlashCommands/index.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useSlashCommands/index.ts index 55788e4020a..bb418444f11 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useSlashCommands/index.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useSlashCommands/index.ts @@ -1,5 +1,8 @@ export { + getCommandMatchRank, resolveCommandAction, type SlashCommand, + shouldSuppressSlashMenuForCommittedCommand, + sortSlashCommandMatches, useSlashCommands, } from "./useSlashCommands"; diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useSlashCommands/useSlashCommands.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useSlashCommands/useSlashCommands.ts index 7026c9b0356..0e3a55fb72b 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useSlashCommands/useSlashCommands.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/hooks/useSlashCommands/useSlashCommands.ts @@ -22,7 +22,7 @@ function getMatchRank(commandName: string, query: string): number | null { return null; } -function getCommandMatchRank( +export function getCommandMatchRank( command: SlashCommand, query: string, ): number | null { diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/messageHelpers.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/messageHelpers.ts new file mode 100644 index 00000000000..7fc77f41791 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/messageHelpers.ts @@ -0,0 +1,60 @@ +/** + * Returns true if an assistant message contains an ask_user_question/ask_user + * tool call that has already been answered (i.e. has a matching tool_result). + * + * Works with any message type that carries a `content: unknown[]` array — both + * the historical HistoryMessage and the display-layer ChatMessage. + */ +/** + * Returns true if an assistant message contains an ask_user_question/ask_user + * tool call that has NOT yet been answered (i.e. no matching tool_result). + */ +export function hasPendingQuestionToolCall(message: { + content: unknown[]; +}): boolean { + const questionCallIds = new Set<string>(); + const resultIds = new Set<string>(); + for (const part of message.content) { + const p = part as Record<string, unknown>; + if (p.type === "tool_call") { + const name = typeof p.name === "string" ? p.name : ""; + if (name === "ask_user_question" || name === "ask_user") { + const id = typeof p.id === "string" ? p.id : ""; + if (id) questionCallIds.add(id); + } + } + if (p.type === "tool_result") { + const id = typeof p.id === "string" ? p.id : ""; + if (id) resultIds.add(id); + } + } + return ( + questionCallIds.size > 0 && + [...questionCallIds].every((id) => !resultIds.has(id)) + ); +} + +export function hasAnsweredQuestionToolCall(message: { + content: unknown[]; +}): boolean { + const questionCallIds = new Set<string>(); + const resultIds = new Set<string>(); + for (const part of message.content) { + const p = part as Record<string, unknown>; + if (p.type === "tool_call") { + const name = typeof p.name === "string" ? p.name : ""; + if (name === "ask_user_question" || name === "ask_user") { + const id = typeof p.id === "string" ? p.id : ""; + if (id) questionCallIds.add(id); + } + } + if (p.type === "tool_result") { + const id = typeof p.id === "string" ? p.id : ""; + if (id) resultIds.add(id); + } + } + return ( + questionCallIds.size > 0 && + [...questionCallIds].some((id) => resultIds.has(id)) + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.test.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.test.ts index 502c3d31a31..dcc55d947e4 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.test.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.test.ts @@ -17,9 +17,7 @@ describe("normalizeToolName", () => { expect(normalizeToolName("web_extract")).toBe("web_fetch"); expect(normalizeToolName("ask_user")).toBe("ask_user_question"); expect(normalizeToolName("ast_smart_edit")).toBe("ast_smart_edit"); - expect(normalizeToolName("request_sandbox_access")).toBe( - "request_sandbox_access", - ); + expect(normalizeToolName("request_sandbox_access")).toBe("request_access"); expect(normalizeToolName("task_write")).toBe("task_write"); expect(normalizeToolName("task_check")).toBe("task_check"); expect(normalizeToolName("submit_plan")).toBe("submit_plan"); diff --git a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts index b08aade3641..6d493864ab6 100644 --- a/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts +++ b/apps/desktop/src/renderer/components/Chat/ChatInterface/utils/tool-helpers.ts @@ -29,10 +29,13 @@ const TOOL_NAME_ALIASES: Record<string, string> = { // Keep explicit passthroughs for newer Mastra tool names ast_smart_edit: "ast_smart_edit", - request_sandbox_access: "request_sandbox_access", + request_access: "request_access", + request_sandbox_access: "request_access", task_write: "task_write", task_check: "task_check", submit_plan: "submit_plan", + lsp_inspect: "lsp_inspect", + mastra_workspace_lsp_inspect: "lsp_inspect", // Legacy Superset MCP names create_worktree: "create_workspace", diff --git a/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/MarkdownToggleContent.tsx b/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/MarkdownToggleContent.tsx deleted file mode 100644 index 4411123790f..00000000000 --- a/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/MarkdownToggleContent.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { MessageResponse } from "@superset/ui/ai-elements/message"; -import { Switch } from "@superset/ui/switch"; - -interface MarkdownToggleContentProps { - toggleId: string; - checked: boolean; - onCheckedChange: (checked: boolean) => void; - content: string; - labelClassName?: string; - markdownContainerClassName?: string; - plainContainerClassName?: string; -} - -export function MarkdownToggleContent({ - toggleId, - checked, - onCheckedChange, - content, - labelClassName = "flex cursor-pointer items-center gap-2 text-muted-foreground", - markdownContainerClassName = "max-h-64 overflow-auto rounded border bg-background/80 p-2", - plainContainerClassName = "max-h-64 overflow-auto rounded border bg-background/80 p-2 text-xs whitespace-pre-wrap break-words", -}: MarkdownToggleContentProps) { - return ( - <> - <label htmlFor={toggleId} className={labelClassName}> - <Switch - id={toggleId} - checked={checked} - onCheckedChange={onCheckedChange} - /> - Render markdown - </label> - {checked ? ( - <div className={markdownContainerClassName}> - <MessageResponse - animated={false} - isAnimating={false} - mermaid={{ - config: { - theme: "default", - }, - }} - > - {content} - </MessageResponse> - </div> - ) : ( - <pre className={plainContainerClassName}>{content}</pre> - )} - </> - ); -} diff --git a/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/index.ts b/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/index.ts deleted file mode 100644 index fb38d303ed2..00000000000 --- a/apps/desktop/src/renderer/components/Chat/components/MarkdownToggleContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MarkdownToggleContent } from "./MarkdownToggleContent"; diff --git a/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx new file mode 100644 index 00000000000..7b3a93c847f --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/SubagentInnerToolCall.tsx @@ -0,0 +1,322 @@ +import { BashTool } from "@superset/ui/ai-elements/bash-tool"; +import { ClickableFilePath } from "@superset/ui/ai-elements/clickable-file-path"; +import { ReadFileTool } from "@superset/ui/ai-elements/read-file-tool"; +import { ToolCallRow } from "@superset/ui/ai-elements/tool-call-row"; +import { + CodeIcon, + FileIcon, + FileSearchIcon, + FileTextIcon, + FolderIcon, + GlobeIcon, + SearchIcon, + TerminalIcon, + WrenchIcon, +} from "lucide-react"; +import { type ComponentType, useMemo } from "react"; +import { getExecuteCommandViewModel } from "renderer/components/Chat/ChatInterface/components/ToolCallBlock/utils/getExecuteCommandViewModel"; +import { normalizeWorkspaceFilePath } from "renderer/components/Chat/ChatInterface/utils/file-paths"; +import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; +import { detectLanguage } from "shared/detect-language"; +import type { BundledLanguage } from "shiki"; + +interface SubagentInnerToolCallProps { + name: string; + isError: boolean; + isPending?: boolean; + args: Record<string, unknown> | null; + result: string | null; + workspaceId?: string; + workspaceCwd?: string; + onOpenFileInPane?: (filePath: string) => void; +} + +interface ToolMeta { + label: string; + icon: ComponentType<{ className?: string }>; +} + +const TOOL_META: Record<string, ToolMeta> = { + mastra_workspace_execute_command: { + label: "Bash", + icon: TerminalIcon, + }, + mastra_workspace_write_file: { label: "Write", icon: FileIcon }, + mastra_workspace_edit_file: { label: "Edit", icon: FileTextIcon }, + mastra_workspace_read_file: { label: "Read", icon: FileIcon }, + mastra_workspace_list_files: { label: "List Files", icon: FolderIcon }, + mastra_workspace_file_stat: { label: "Check file", icon: FileSearchIcon }, + mastra_workspace_search: { label: "Search", icon: SearchIcon }, + mastra_workspace_mkdir: { label: "Create Directory", icon: FolderIcon }, + mastra_workspace_delete: { label: "Delete", icon: FileIcon }, + ast_smart_edit: { label: "Smart Edit", icon: CodeIcon }, + web_fetch: { label: "Web Fetch", icon: GlobeIcon }, + web_search: { label: "Web Search", icon: GlobeIcon }, +}; + +function getToolMeta(toolName: string): ToolMeta { + return ( + TOOL_META[toolName] ?? { + label: toolName.replaceAll("_", " "), + icon: WrenchIcon, + } + ); +} + +/** Tools where the description is a file path (not a search query or URL). */ +const FILE_PATH_TOOLS = new Set([ + "mastra_workspace_write_file", + "mastra_workspace_edit_file", + "mastra_workspace_file_stat", + "mastra_workspace_delete", + "ast_smart_edit", +]); + +function getRawFilePath( + toolName: string, + args: Record<string, unknown>, +): string | null { + if (FILE_PATH_TOOLS.has(toolName)) { + const raw = String( + args.path ?? args.filePath ?? args.file_path ?? args.file ?? "", + ); + return raw || null; + } + return null; +} + +function getDescription( + toolName: string, + args: Record<string, unknown> | null, +): string | undefined { + if (!args) return undefined; + + let raw: string | undefined; + + switch (toolName) { + case "mastra_workspace_read_file": + case "mastra_workspace_write_file": + case "mastra_workspace_edit_file": + case "mastra_workspace_file_stat": + case "mastra_workspace_delete": + case "ast_smart_edit": + raw = + String( + args.path ?? args.filePath ?? args.file_path ?? args.file ?? "", + ) || undefined; + break; + case "mastra_workspace_list_files": + case "mastra_workspace_mkdir": + raw = + String( + args.path ?? + args.directory ?? + args.directoryPath ?? + args.directory_path ?? + args.root ?? + args.cwd ?? + "", + ) || undefined; + break; + case "mastra_workspace_search": + raw = + String( + args.query ?? + args.pattern ?? + args.regex ?? + args.substring_pattern ?? + args.text ?? + "", + ) || undefined; + break; + case "web_fetch": + raw = String(args.url ?? args.uri ?? "") || undefined; + break; + case "web_search": + raw = String(args.query ?? args.q ?? "") || undefined; + break; + default: + return undefined; + } + + if (!raw) return undefined; + + // For paths, show only the filename + if ( + raw.includes("/") && + toolName !== "mastra_workspace_search" && + toolName !== "web_fetch" && + toolName !== "web_search" + ) { + return raw.split("/").pop() ?? raw; + } + + return raw; +} + +/** + * The Mastra workspace read_file tool returns content in this format: + * /path/to/file (N bytes) + * 1\tline one + * 2\tline two + * + * Strip the header and line-number prefixes to get clean file content. + */ +function parseReadFileResult(result: string): { + filename: string; + content: string; + lineCount: number; +} | null { + const lines = result.split("\n"); + if (lines.length < 2) return null; + + // First line: "path/to/file (N bytes)" or just content + const headerMatch = lines[0].match(/^(.+?)\s*\(\d+\s*bytes?\)\s*$/i); + if (!headerMatch) return null; + + const filename = headerMatch[1].trim(); + const contentLines = lines.slice(1); + + // Strip line-number prefix: " N\t" or " N→" + const stripped = contentLines.map((line) => { + const tabMatch = line.match(/^\s*\d+\t(.*)$/); + if (tabMatch) return tabMatch[1]; + const arrowMatch = line.match(/^\s*\d+\u2192(.*)$/); + if (arrowMatch) return arrowMatch[1]; + return line; + }); + + // Trim trailing blank lines + while (stripped.length > 0 && stripped[stripped.length - 1].trim() === "") { + stripped.pop(); + } + + return { + filename, + content: stripped.join("\n"), + lineCount: stripped.length, + }; +} + +export function SubagentInnerToolCall({ + name, + isError, + isPending = false, + args, + result, + workspaceCwd, + onOpenFileInPane, +}: SubagentInnerToolCallProps) { + const normalized = normalizeToolName(name); + const state = isPending + ? ("input-available" as const) + : isError + ? ("output-error" as const) + : ("output-available" as const); + + const { label, icon } = getToolMeta(normalized); + const description = getDescription(normalized, args); + const hasResult = result !== null && result.trim().length > 0; + + // Read file: parse and display using the shared ReadFileTool component + const parsedReadFile = useMemo(() => { + if (normalized !== "mastra_workspace_read_file") return null; + if (result === null || result.trim().length === 0) return null; + return parseReadFileResult(result); + }, [normalized, result]); + + if (normalized === "mastra_workspace_execute_command") { + const argsRecord = args ?? {}; + const resultRecord = result !== null ? { content: result } : {}; + const { command, stdout, stderr, exitCode } = getExecuteCommandViewModel({ + args: argsRecord, + result: resultRecord, + }); + return ( + <BashTool + command={command} + stdout={stdout} + stderr={stderr} + exitCode={exitCode} + state={state} + /> + ); + } + if ( + normalized === "mastra_workspace_read_file" && + hasResult && + parsedReadFile + ) { + const parsed = parsedReadFile; + if (parsed) { + const filename = parsed.filename.split("/").pop() ?? parsed.filename; + const lineRange = `1–${parsed.lineCount}`; + const openInPane = onOpenFileInPane + ? () => { + const rawPath = String( + args?.path ?? + args?.filePath ?? + args?.file_path ?? + args?.file ?? + parsed.filename, + ); + const resolvedPath = + normalizeWorkspaceFilePath({ + filePath: rawPath, + workspaceRoot: workspaceCwd, + }) ?? rawPath; + onOpenFileInPane(resolvedPath); + } + : undefined; + return ( + <ReadFileTool + filename={filename} + content={parsed.content} + lineRange={lineRange} + language={detectLanguage(parsed.filename) as BundledLanguage} + isError={isError} + isPending={isPending} + onOpenInPane={openInPane} + /> + ); + } + } + + // For file-path tools, make the filename in the description clickable. + const rawFilePath = getRawFilePath(normalized, args ?? {}); + const resolvedFilePath = rawFilePath + ? (normalizeWorkspaceFilePath({ + filePath: rawFilePath, + workspaceRoot: workspaceCwd, + }) ?? rawFilePath) + : null; + + const descriptionNode = + resolvedFilePath && onOpenFileInPane && description ? ( + <ClickableFilePath + path={resolvedFilePath} + display={description} + onOpen={() => onOpenFileInPane(resolvedFilePath)} + /> + ) : ( + description + ); + + return ( + <ToolCallRow + icon={icon} + isError={isError} + isPending={isPending} + title={label} + description={descriptionNode} + > + {hasResult ? ( + <div className="pl-2 py-1.5"> + <div className="whitespace-pre-wrap break-all font-mono text-xs text-muted-foreground"> + {result} + </div> + </div> + ) : undefined} + </ToolCallRow> + ); +} diff --git a/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/index.ts b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/index.ts new file mode 100644 index 00000000000..e2ec02722a2 --- /dev/null +++ b/apps/desktop/src/renderer/components/Chat/components/SubagentInnerToolCall/index.ts @@ -0,0 +1 @@ +export { SubagentInnerToolCall } from "./SubagentInnerToolCall"; diff --git a/apps/desktop/src/renderer/components/EmojiTextInput/EmojiTextInput.tsx b/apps/desktop/src/renderer/components/EmojiTextInput/EmojiTextInput.tsx new file mode 100644 index 00000000000..93f66588c05 --- /dev/null +++ b/apps/desktop/src/renderer/components/EmojiTextInput/EmojiTextInput.tsx @@ -0,0 +1,107 @@ +import { cn } from "@superset/ui/utils"; +import { Extension } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { EmojiSuggestionPluginKey } from "@tiptap/extension-emoji"; +import { History } from "@tiptap/extension-history"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Text } from "@tiptap/extension-text"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { useEffect } from "react"; +import { EmojiSuggestion } from "renderer/components/MarkdownEditor/components/EmojiSuggestion"; + +/** Doc that allows exactly one paragraph — no block splitting. */ +const SingleLineDocument = Document.extend({ + content: "paragraph", +}); + +/** Blocks Enter / Shift-Enter so the editor stays on one line. */ +const NoLineBreaks = Extension.create<{ onEnter?: () => void }>({ + name: "noLineBreaks", + addOptions() { + return { onEnter: undefined }; + }, + addKeyboardShortcuts() { + const guarded = ({ editor }: { editor: { state: unknown } }) => { + // If the emoji suggestion popup is open, let it handle Enter (select). + const emojiState = EmojiSuggestionPluginKey.getState( + editor.state as Parameters<typeof EmojiSuggestionPluginKey.getState>[0], + ) as { active?: boolean } | undefined; + if (emojiState?.active) return false; + this.options.onEnter?.(); + return true; + }; + return { + Enter: guarded, + "Shift-Enter": guarded, + }; + }, +}); + +interface EmojiTextInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + onEnter?: () => void; + onBlur?: (value: string) => void; +} + +function getPlainText( + editor: { + state: { doc: { textContent: string } }; + } | null, +): string { + return editor?.state.doc.textContent ?? ""; +} + +export function EmojiTextInput({ + value, + onChange, + placeholder, + className, + onEnter, + onBlur, +}: EmojiTextInputProps) { + const editor = useEditor({ + immediatelyRender: false, + extensions: [ + SingleLineDocument, + Text, + Paragraph, + History, + Placeholder.configure({ + placeholder: placeholder ?? "", + emptyNodeClass: + "first:before:text-muted-foreground first:before:float-left first:before:h-0 first:before:pointer-events-none first:before:content-[attr(data-placeholder)]", + }), + EmojiSuggestion, + NoLineBreaks.configure({ onEnter }), + ], + content: value, + editorProps: { + attributes: { + class: cn( + "focus:outline-none whitespace-nowrap overflow-hidden text-ellipsis", + className, + ), + }, + }, + onUpdate: ({ editor: e }) => { + onChange(getPlainText(e)); + }, + onBlur: ({ editor: e }) => { + onBlur?.(getPlainText(e)); + }, + }); + + useEffect(() => { + if (!editor) return; + if (editor.isFocused) return; + const current = getPlainText(editor); + if (current === value) return; + editor.commands.setContent(value, { emitUpdate: false }); + }, [value, editor]); + + return <EditorContent editor={editor} className="w-full" />; +} diff --git a/apps/desktop/src/renderer/components/EmojiTextInput/index.ts b/apps/desktop/src/renderer/components/EmojiTextInput/index.ts new file mode 100644 index 00000000000..216b8d44ddc --- /dev/null +++ b/apps/desktop/src/renderer/components/EmojiTextInput/index.ts @@ -0,0 +1 @@ +export { EmojiTextInput } from "./EmojiTextInput"; diff --git a/apps/desktop/src/renderer/components/HotkeyMenuShortcut/HotkeyMenuShortcut.tsx b/apps/desktop/src/renderer/components/HotkeyMenuShortcut/HotkeyMenuShortcut.tsx index 3c8d918f986..183d3c17d35 100644 --- a/apps/desktop/src/renderer/components/HotkeyMenuShortcut/HotkeyMenuShortcut.tsx +++ b/apps/desktop/src/renderer/components/HotkeyMenuShortcut/HotkeyMenuShortcut.tsx @@ -1,15 +1,15 @@ import { DropdownMenuShortcut } from "@superset/ui/dropdown-menu"; -import { useHotkeyText } from "renderer/stores/hotkeys"; -import type { HotkeyId } from "shared/hotkeys"; +import type { HotkeyId } from "renderer/hotkeys"; +import { useHotkeyDisplay } from "renderer/hotkeys"; interface HotkeyMenuShortcutProps { hotkeyId: HotkeyId; } export function HotkeyMenuShortcut({ hotkeyId }: HotkeyMenuShortcutProps) { - const hotkeyText = useHotkeyText(hotkeyId); - if (hotkeyText === "Unassigned") { + const { text } = useHotkeyDisplay(hotkeyId); + if (text === "Unassigned") { return null; } - return <DropdownMenuShortcut>{hotkeyText}</DropdownMenuShortcut>; + return <DropdownMenuShortcut>{text}</DropdownMenuShortcut>; } diff --git a/apps/desktop/src/renderer/components/HotkeyTooltipContent/HotkeyTooltipContent.tsx b/apps/desktop/src/renderer/components/HotkeyTooltipContent/HotkeyTooltipContent.tsx deleted file mode 100644 index b015a0f3060..00000000000 --- a/apps/desktop/src/renderer/components/HotkeyTooltipContent/HotkeyTooltipContent.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Kbd, KbdGroup } from "@superset/ui/kbd"; -import type { ReactNode } from "react"; -import { - useEffectiveHotkeysMap, - useHotkeysStore, -} from "renderer/stores/hotkeys"; -import { formatHotkeyDisplay, type HotkeyId } from "shared/hotkeys"; - -export interface HotkeyTooltipContentItem { - label: string; - id: HotkeyId; -} - -interface HotkeyTooltipContentProps { - label: string; - hotkeyId?: HotkeyId; - items?: HotkeyTooltipContentItem[]; - showUnassigned?: boolean; - unassignedPlaceholder?: ReactNode; -} - -function isUnassigned(display: string[]): boolean { - return display.length === 1 && display[0] === "Unassigned"; -} - -export function HotkeyTooltipContent({ - label, - hotkeyId, - items, - showUnassigned = false, - unassignedPlaceholder = null, -}: HotkeyTooltipContentProps) { - const platform = useHotkeysStore((state) => state.platform); - const effective = useEffectiveHotkeysMap(); - - const getDisplay = (id: HotkeyId): string[] => { - const keys = effective[id] ?? null; - return formatHotkeyDisplay(keys, platform); - }; - - const renderShortcut = (id?: HotkeyId): ReactNode => { - if (!id) return null; - const display = getDisplay(id); - if (isUnassigned(display)) { - return showUnassigned ? unassignedPlaceholder : null; - } - - return ( - <KbdGroup> - {display.map((key) => ( - <Kbd key={key}>{key}</Kbd> - ))} - </KbdGroup> - ); - }; - - if (items?.length) { - const visibleItems = showUnassigned - ? items - : items.filter((item) => !isUnassigned(getDisplay(item.id))); - - return ( - <div className="flex flex-col gap-1"> - <span>{label}</span> - {visibleItems.length > 0 && ( - <div className="flex flex-col gap-0.5 text-xs text-muted-foreground"> - {visibleItems.map((item) => ( - <span - key={item.id} - className="flex items-center justify-between gap-2" - > - <span>{item.label}</span> - {renderShortcut(item.id)} - </span> - ))} - </div> - )} - </div> - ); - } - - return ( - <span className="flex items-center gap-2"> - {label} - {renderShortcut(hotkeyId)} - </span> - ); -} diff --git a/apps/desktop/src/renderer/components/HotkeyTooltipContent/index.ts b/apps/desktop/src/renderer/components/HotkeyTooltipContent/index.ts deleted file mode 100644 index 17db513479f..00000000000 --- a/apps/desktop/src/renderer/components/HotkeyTooltipContent/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { HotkeyTooltipContentItem } from "./HotkeyTooltipContent"; -export { HotkeyTooltipContent } from "./HotkeyTooltipContent"; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/MarkdownEditor.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/MarkdownEditor.tsx new file mode 100644 index 00000000000..ee5ea3d6cbf --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/MarkdownEditor.tsx @@ -0,0 +1,392 @@ +import "highlight.js/styles/github-dark.css"; +import "./markdown-editor.css"; + +import { cn } from "@superset/ui/utils"; +import { Extension } from "@tiptap/core"; +import { Blockquote } from "@tiptap/extension-blockquote"; +import { Bold } from "@tiptap/extension-bold"; +import { BulletList } from "@tiptap/extension-bullet-list"; +import { Code } from "@tiptap/extension-code"; +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; +import { Document } from "@tiptap/extension-document"; +import { HardBreak } from "@tiptap/extension-hard-break"; +import { Heading } from "@tiptap/extension-heading"; +import { History } from "@tiptap/extension-history"; +import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; +import Image from "@tiptap/extension-image"; +import { Italic } from "@tiptap/extension-italic"; +import Link from "@tiptap/extension-link"; +import { ListItem } from "@tiptap/extension-list-item"; +import { OrderedList } from "@tiptap/extension-ordered-list"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Strike } from "@tiptap/extension-strike"; +import { TableKit } from "@tiptap/extension-table"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import { Text } from "@tiptap/extension-text"; +import { Underline } from "@tiptap/extension-underline"; +import { + type Editor, + EditorContent, + ReactNodeViewRenderer, + useEditor, +} from "@tiptap/react"; +import { BubbleMenu } from "@tiptap/react/menus"; +import { common, createLowlight } from "lowlight"; +import { useEffect, useRef } from "react"; +import { BubbleMenuToolbar } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar"; +import { env } from "renderer/env.renderer"; +import { useInlineLinkActions } from "renderer/hooks/useV2UserPreferences"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { Markdown } from "tiptap-markdown"; +import { CodeBlockView } from "./components/CodeBlockView"; +import { EmojiSuggestion } from "./components/EmojiSuggestion"; +import { + FileMentionNode, + type FileMentionSearchFn, + FileMentionSuggestion, +} from "./components/FileMention"; +import { SlashCommand } from "./components/SlashCommand"; + +const lowlight = createLowlight(common); + +const LINEAR_IMAGE_HOST = "uploads.linear.app"; + +function isLinearImageUrl(src: string): boolean { + try { + const url = new URL(src); + return url.host === LINEAR_IMAGE_HOST; + } catch { + return false; + } +} + +function getLinearProxyUrl(linearUrl: string): string { + const proxyUrl = new URL(`${env.NEXT_PUBLIC_API_URL}/api/proxy/linear-image`); + proxyUrl.searchParams.set("url", linearUrl); + return proxyUrl.toString(); +} + +const LinearImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + src: { + default: null, + parseHTML: (element) => element.getAttribute("src"), + renderHTML: (attributes) => { + const src = attributes.src; + if (!src) return { src: null }; + const proxiedSrc = isLinearImageUrl(src) + ? getLinearProxyUrl(src) + : src; + return { + src: proxiedSrc, + crossorigin: isLinearImageUrl(src) ? "use-credentials" : undefined, + }; + }, + }, + }; + }, +}); + +const HEADING_CLASSES: Record<number, string> = { + 1: "text-3xl font-bold leading-tight mt-0 mb-3", + 2: "text-2xl font-semibold leading-snug mt-6 mb-2", + 3: "text-xl font-semibold leading-snug mt-5 mb-2", + 4: "text-base font-semibold leading-normal mt-4 mb-2", + 5: "text-base font-semibold leading-normal mt-4 mb-2", + 6: "text-base font-semibold leading-normal mt-4 mb-2", +}; + +const StyledHeading = Heading.extend({ + renderHTML({ node, HTMLAttributes }) { + const level = node.attrs.level as number; + const classes = HEADING_CLASSES[level] || HEADING_CLASSES[1]; + return [`h${level}`, { ...HTMLAttributes, class: classes }, 0]; + }, +}); + +const KeyboardHandler = Extension.create({ + name: "keyboardHandler", + addKeyboardShortcuts() { + return { + Tab: ({ editor }) => { + if (editor.commands.sinkListItem("listItem")) return true; + if (editor.commands.sinkListItem("taskItem")) return true; + // Not in a list - consume event to prevent browser focus navigation + return true; + }, + "Shift-Tab": ({ editor }) => { + if (editor.commands.liftListItem("listItem")) return true; + if (editor.commands.liftListItem("taskItem")) return true; + return true; + }, + Escape: ({ editor }) => { + editor.commands.blur(); + return true; + }, + }; + }, +}); + +interface MarkdownEditorProps { + content: string; + onSave?: (markdown: string) => void; + onChange?: (markdown: string) => void; + placeholder?: string; + autoFocus?: boolean; + className?: string; + editorClassName?: string; + onModEnter?: () => void; + /** If provided, enables @-mention file search for the editor. */ + searchFiles?: FileMentionSearchFn; +} + +function getMarkdown(editor: Editor | null): string { + const storage = editor?.storage as + | Record<string, { getMarkdown?: () => string }> + | undefined; + return storage?.markdown?.getMarkdown?.() ?? ""; +} + +function isMarkdownTable(text: string): boolean { + const lines = text + .trim() + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length < 2 || !lines[0]?.includes("|")) { + return false; + } + + return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(lines[1]); +} + +export function MarkdownEditor({ + content, + onSave, + onChange, + placeholder = "Add description...", + autoFocus = false, + className, + editorClassName, + onModEnter, + searchFiles, +}: MarkdownEditorProps) { + // useEditor captures extensions on first render, so searchFiles gets frozen + // at its initial (likely stale, since projectId resolves in an effect) value. + // Thread through a ref so the extension reads the live callback each fire. + const searchFilesRef = useRef(searchFiles); + searchFilesRef.current = searchFiles; + const editorRef = useRef<Editor | null>(null); + + const { getUrlAction } = useInlineLinkActions(); + + const editor = useEditor({ + autofocus: autoFocus ? "end" : false, + extensions: [ + Document, + Text, + Paragraph.configure({ + HTMLAttributes: { class: "mt-0 mb-3 leading-relaxed" }, + }), + StyledHeading.configure({ levels: [1, 2, 3, 4, 5, 6] }), + Bold.configure({ + HTMLAttributes: { class: "font-semibold" }, + }), + Italic.configure({ + HTMLAttributes: { class: "italic" }, + }), + Strike.configure({ + HTMLAttributes: { class: "line-through" }, + }), + Underline.configure({ + HTMLAttributes: { class: "underline" }, + }), + Code.configure({ + HTMLAttributes: { + class: "font-mono text-sm px-1 py-0.5 rounded bg-muted", + }, + }), + CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView); + }, + }).configure({ + lowlight, + HTMLAttributes: { + class: + "my-3 p-3 rounded-md bg-muted overflow-x-auto font-mono text-sm", + }, + }), + BulletList.configure({ + HTMLAttributes: { + class: "task-markdown-list mt-0 pl-6", + }, + }), + OrderedList.configure({ + HTMLAttributes: { class: "mt-0 mb-3 pl-6 list-decimal" }, + }), + ListItem.configure({ + HTMLAttributes: {}, + }), + TaskList.configure({ + HTMLAttributes: { class: "mt-0 mb-3 pl-0 list-none" }, + }), + TaskItem.configure({ + HTMLAttributes: { class: "flex items-start gap-2 mb-1" }, + nested: true, + }), + Blockquote.configure({ + HTMLAttributes: { + class: "my-3 pl-4 border-l-2 border-border text-muted-foreground", + }, + }), + HorizontalRule.configure({ + HTMLAttributes: { class: "my-6 border-none border-t border-border" }, + }), + HardBreak, + History, + Link.configure({ + openOnClick: false, + HTMLAttributes: { class: "text-primary underline" }, + }), + LinearImage.configure({ + HTMLAttributes: { class: "max-w-full h-auto rounded-md my-3" }, + }), + TableKit.configure({ + table: { + resizable: false, + cellMinWidth: 192, + HTMLAttributes: { + class: "markdown-table my-4 min-w-full border-collapse", + }, + }, + tableHeader: { + HTMLAttributes: { + class: + "bg-muted px-4 py-2 text-left text-sm font-semibold align-top", + }, + }, + tableCell: { + HTMLAttributes: { + class: "border-t border-border px-4 py-2 text-sm align-top", + }, + }, + }), + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "paragraph") { + return placeholder; + } + return ""; + }, + showOnlyCurrent: false, + emptyNodeClass: + "first:before:text-muted-foreground first:before:float-left first:before:h-0 first:before:pointer-events-none first:before:content-[attr(data-placeholder)]", + }), + Markdown.configure({ + html: true, + transformPastedText: true, + transformCopiedText: true, + }), + SlashCommand, + EmojiSuggestion, + FileMentionNode, + FileMentionSuggestion.configure({ + searchFiles: (query) => + searchFilesRef.current?.(query) ?? Promise.resolve([]), + }), + KeyboardHandler, + ], + content, + editorProps: { + attributes: { + class: cn("focus:outline-none min-h-[100px]", editorClassName), + }, + handleKeyDown: (_, event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + onModEnter?.(); + return true; + } + return false; + }, + handlePaste: (_, event) => { + const text = event.clipboardData?.getData("text/plain") ?? ""; + const currentEditor = editorRef.current; + if (!currentEditor || !isMarkdownTable(text)) { + return false; + } + + event.preventDefault(); + return currentEditor.commands.insertContentAt( + { + from: currentEditor.state.selection.from, + to: currentEditor.state.selection.to, + }, + text, + ); + }, + handleClickOn: (_view, _pos, _node, _nodePos, event) => { + const target = event.target as HTMLElement | null; + const anchor = target?.closest?.("a") as HTMLAnchorElement | null; + if (!anchor) return false; + const href = anchor.getAttribute("href"); + if (!href) return false; + // No pane context here, so "pane" and "external" both route to the + // system browser. Null means do nothing — fall through to ProseMirror + // so the user can still click into the link to place a cursor. + if (getUrlAction(event) === null) return false; + event.preventDefault(); + electronTrpcClient.external.openUrl.mutate(href).catch((error) => { + console.error("[MarkdownEditor] Failed to open URL:", href, error); + }); + return true; + }, + }, + onUpdate: ({ editor }) => { + onChange?.(getMarkdown(editor)); + }, + onBlur: ({ editor }) => { + onSave?.(getMarkdown(editor)); + }, + }); + editorRef.current = editor; + + useEffect(() => { + if (!editor || editor.isFocused) return; + + const currentMarkdown = getMarkdown(editor); + if (currentMarkdown === content) return; + + editor.commands.setContent(content, { emitUpdate: false }); + }, [content, editor]); + + return ( + <div className={cn("w-full", className)}> + {editor && ( + <BubbleMenu + editor={editor} + options={{ + placement: "top", + offset: { mainAxis: 8 }, + }} + shouldShow={({ editor: e, from, to }) => { + if (from === to) return false; + if (e.isActive("codeBlock")) return false; + return true; + }} + > + <BubbleMenuToolbar editor={editor} /> + </BubbleMenu> + )} + <EditorContent + editor={editor} + className="w-full flex-1 min-h-0 flex flex-col [&>.ProseMirror]:flex-1 [&>.ProseMirror]:min-h-0" + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx rename to apps/desktop/src/renderer/components/MarkdownEditor/components/BubbleMenuToolbar/BubbleMenuToolbar.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/BubbleMenuToolbar/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/BubbleMenuToolbar/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/BubbleMenuToolbar/index.ts rename to apps/desktop/src/renderer/components/MarkdownEditor/components/BubbleMenuToolbar/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/CodeBlockView/CodeBlockView.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/CodeBlockView/CodeBlockView.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/CodeBlockView/CodeBlockView.tsx rename to apps/desktop/src/renderer/components/MarkdownEditor/components/CodeBlockView/CodeBlockView.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/CodeBlockView/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/CodeBlockView/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/CodeBlockView/index.ts rename to apps/desktop/src/renderer/components/MarkdownEditor/components/CodeBlockView/index.ts diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/EmojiSuggestion.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/EmojiSuggestion.ts new file mode 100644 index 00000000000..bce6ab07eb7 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/EmojiSuggestion.ts @@ -0,0 +1,101 @@ +import { Emoji, type EmojiItem, emojis } from "@tiptap/extension-emoji"; +import { ReactRenderer } from "@tiptap/react"; +import type { + SuggestionKeyDownProps, + SuggestionProps, +} from "@tiptap/suggestion"; +import tippy, { type Instance as TippyInstance } from "tippy.js"; +import { + EmojiSuggestionList, + type EmojiSuggestionListRef, +} from "./components/EmojiSuggestionList"; + +const MAX_RESULTS = 10; + +function matchEmoji(item: EmojiItem, query: string): boolean { + const q = query.toLowerCase(); + return ( + item.shortcodes.some((s) => s.toLowerCase().includes(q)) || + item.tags.some((t) => t.toLowerCase().includes(q)) || + item.name.toLowerCase().includes(q) + ); +} + +export const EmojiSuggestion = Emoji.configure({ + enableEmoticons: true, + emojis, + suggestion: { + items: ({ query }) => { + // Require at least 1 character — otherwise the first items in the + // emojibase list are regional indicators (A–Z flag-building chars), + // which aren't useful defaults. + if (!query) return []; + return emojis + .filter((item) => matchEmoji(item, query)) + .slice(0, MAX_RESULTS); + }, + // Override the default command, which appends an extra " " after the emoji. + command: ({ editor, range, props }) => { + editor + .chain() + .focus() + .insertContentAt(range, { + type: "emoji", + attrs: props, + }) + .run(); + }, + render: () => { + let component: ReactRenderer< + EmojiSuggestionListRef, + SuggestionProps<EmojiItem> + > | null = null; + let popup: TippyInstance[] | null = null; + + return { + onStart: (props: SuggestionProps<EmojiItem>) => { + component = new ReactRenderer(EmojiSuggestionList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) return; + + const clientRect = props.clientRect; + popup = tippy("body", { + getReferenceClientRect: () => clientRect?.() ?? new DOMRect(), + appendTo: () => document.body, + content: component.element, + showOnCreate: !!props.query, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: SuggestionProps<EmojiItem>) => { + component?.updateProps(props); + if (!props.clientRect) return; + const getClientRect = props.clientRect; + popup?.[0]?.setProps({ + getReferenceClientRect: () => getClientRect() ?? new DOMRect(), + }); + if (props.query) popup?.[0]?.show(); + else popup?.[0]?.hide(); + }, + onKeyDown: (props: SuggestionKeyDownProps) => { + if (props.event.key === "Escape") { + props.event.preventDefault(); + props.event.stopPropagation(); + popup?.[0]?.hide(); + return true; + } + return component?.ref?.onKeyDown(props) ?? false; + }, + onExit: () => { + popup?.[0]?.destroy(); + component?.destroy(); + }, + }; + }, + }, +}); diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/components/EmojiSuggestionList/EmojiSuggestionList.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/components/EmojiSuggestionList/EmojiSuggestionList.tsx new file mode 100644 index 00000000000..7860938ec8a --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/components/EmojiSuggestionList/EmojiSuggestionList.tsx @@ -0,0 +1,97 @@ +import type { EmojiItem } from "@tiptap/extension-emoji"; +import type { + SuggestionKeyDownProps, + SuggestionProps, +} from "@tiptap/suggestion"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; + +export interface EmojiSuggestionListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean; +} + +export const EmojiSuggestionList = forwardRef< + EmojiSuggestionListRef, + SuggestionProps<EmojiItem> +>(({ items, command }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const containerRef = useRef<HTMLDivElement>(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset selection when items change + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + useEffect(() => { + containerRef.current + ?.querySelector(`[data-index="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: SuggestionKeyDownProps) => { + if (items.length === 0) return false; + + if (event.key === "ArrowUp") { + setSelectedIndex((prev) => (prev - 1 + items.length) % items.length); + return true; + } + if (event.key === "ArrowDown") { + setSelectedIndex((prev) => (prev + 1) % items.length); + return true; + } + if (event.key === "Enter") { + const item = items[selectedIndex]; + if (item) command(item); + return true; + } + return false; + }, + })); + + if (items.length === 0) { + return ( + <div className="bg-popover text-popover-foreground rounded-md border p-1 shadow-md"> + <div className="px-2 py-1.5 text-sm text-muted-foreground"> + No emoji found + </div> + </div> + ); + } + + return ( + <div + ref={containerRef} + className="bg-popover text-popover-foreground rounded-md border p-1 shadow-md overflow-hidden max-h-72 overflow-y-auto w-64" + > + {items.map((item, index) => { + const shortcode = item.shortcodes[0] ?? item.name; + return ( + <button + type="button" + key={item.name} + data-index={index} + onClick={() => command(item)} + className={`relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none w-full ${ + index === selectedIndex ? "bg-accent text-accent-foreground" : "" + }`} + > + <span className="w-5 shrink-0 text-base leading-none"> + {item.emoji ?? "·"} + </span> + <span className="flex-1 truncate text-left text-xs text-muted-foreground"> + :{shortcode}: + </span> + </button> + ); + })} + </div> + ); +}); + +EmojiSuggestionList.displayName = "EmojiSuggestionList"; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/components/EmojiSuggestionList/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/components/EmojiSuggestionList/index.ts new file mode 100644 index 00000000000..3ba9868f461 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/components/EmojiSuggestionList/index.ts @@ -0,0 +1,2 @@ +export type { EmojiSuggestionListRef } from "./EmojiSuggestionList"; +export { EmojiSuggestionList } from "./EmojiSuggestionList"; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/index.ts new file mode 100644 index 00000000000..389176a087b --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/EmojiSuggestion/index.ts @@ -0,0 +1 @@ +export { EmojiSuggestion } from "./EmojiSuggestion"; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionNode.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionNode.tsx new file mode 100644 index 00000000000..b7cb5fa50dd --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionNode.tsx @@ -0,0 +1,103 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { mergeAttributes, Node } from "@tiptap/core"; +import { + type NodeViewProps, + NodeViewWrapper, + ReactNodeViewRenderer, +} from "@tiptap/react"; +import { LuX } from "react-icons/lu"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; + +function FileMentionChip({ node, selected, deleteNode }: NodeViewProps) { + const path = (node.attrs.path as string | null | undefined) ?? ""; + const broken = node.attrs.broken === true; + const fileName = path.split("/").pop() || path; + + return ( + <NodeViewWrapper as="span" className="inline-block align-middle"> + <span + contentEditable={false} + title={path} + className={cn( + "mx-0.5 inline-flex items-center gap-1 rounded-sm border px-1 py-[1px] font-mono text-xs transition-colors", + broken + ? "border-destructive/30 bg-destructive/10 text-destructive/80 line-through" + : "border-border bg-muted text-foreground/90 hover:bg-muted/70", + !broken && selected && "ring-1 ring-primary/40", + )} + > + <FileIcon fileName={fileName} className="size-3 shrink-0" /> + <span + className="inline-block max-w-[16rem] truncate align-bottom" + style={{ direction: "rtl", textAlign: "left" }} + > + <bdi>{path}</bdi> + </span> + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <button + type="button" + aria-label={`Remove mention ${path}`} + onMouseDown={(event) => { + event.preventDefault(); + }} + onClick={(event) => { + event.stopPropagation(); + deleteNode(); + }} + className="ml-0.5 inline-flex size-3.5 items-center justify-center rounded-sm text-muted-foreground hover:bg-foreground/10 hover:text-foreground" + > + <LuX className="size-3" /> + </button> + </TooltipTrigger> + <TooltipContent side="top">Remove mention</TooltipContent> + </Tooltip> + </span> + </NodeViewWrapper> + ); +} + +export const FileMentionNode = Node.create({ + name: "file-mention", + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { + path: { + default: null, + parseHTML: (el) => el.getAttribute("data-path"), + renderHTML: (attrs) => ({ "data-path": attrs.path }), + }, + broken: { + default: false, + parseHTML: (el) => el.getAttribute("data-broken") === "true", + renderHTML: (attrs) => (attrs.broken ? { "data-broken": "true" } : {}), + }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-type="file-mention"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes({ "data-type": "file-mention" }, HTMLAttributes), + ]; + }, + + renderText({ node }) { + const path = (node.attrs.path as string | null | undefined) ?? ""; + return path.includes(" ") ? `@"${path}"` : `@${path}`; + }, + + addNodeView() { + return ReactNodeViewRenderer(FileMentionChip); + }, +}); diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionSuggestion.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionSuggestion.ts new file mode 100644 index 00000000000..19536ed2df4 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/FileMentionSuggestion.ts @@ -0,0 +1,142 @@ +import { Extension } from "@tiptap/core"; +import { PluginKey } from "@tiptap/pm/state"; +import { type Editor, ReactRenderer } from "@tiptap/react"; +import Suggestion, { + type SuggestionKeyDownProps, + type SuggestionProps, +} from "@tiptap/suggestion"; +import tippy, { type Instance as TippyInstance } from "tippy.js"; +import { + FileMentionList, + type FileMentionListRef, +} from "./components/FileMentionList"; +import type { FileMentionResult, FileMentionSearchFn } from "./types"; + +const fileMentionSuggestionKey = new PluginKey("markdownEditorFileMention"); + +export interface FileMentionSuggestionOptions { + searchFiles: FileMentionSearchFn | null; +} + +export const FileMentionSuggestion = + Extension.create<FileMentionSuggestionOptions>({ + name: "fileMentionSuggestion", + + addOptions() { + return { + searchFiles: null, + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + pluginKey: fileMentionSuggestionKey, + editor: this.editor, + char: "@", + allowSpaces: false, + // Only trigger at start of block or after whitespace/atom + allow: ({ state, range }) => { + const $pos = state.doc.resolve(range.from); + if ($pos.parentOffset === 0) return true; + const before = $pos.parent.textBetween( + 0, + $pos.parentOffset, + "\0", + " ", + ); + const charBefore = before.slice(-1); + return charBefore === " " || charBefore === "\n"; + }, + + items: async ({ query }): Promise<FileMentionResult[]> => { + const search = this.options.searchFiles; + if (!search) return []; + if (query.length === 0) return []; + try { + return await search(query); + } catch { + return []; + } + }, + + command: ({ + editor, + range, + props, + }: { + editor: Editor; + range: { from: number; to: number }; + props: FileMentionResult; + }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContentAt(range.from, [ + { + type: "file-mention", + attrs: { path: props.relativePath, broken: false }, + }, + { type: "text", text: " " }, + ]) + .run(); + }, + + render: () => { + let component: ReactRenderer< + FileMentionListRef, + SuggestionProps<FileMentionResult> + > | null = null; + let popup: TippyInstance[] | null = null; + + return { + onStart: (props: SuggestionProps<FileMentionResult>) => { + component = new ReactRenderer(FileMentionList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) return; + + const clientRect = props.clientRect; + popup = tippy("body", { + getReferenceClientRect: () => clientRect?.() ?? new DOMRect(), + appendTo: () => document.body, + content: component.element, + showOnCreate: !!props.query, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: SuggestionProps<FileMentionResult>) => { + component?.updateProps(props); + if (!props.clientRect) return; + const getClientRect = props.clientRect; + popup?.[0]?.setProps({ + getReferenceClientRect: () => + getClientRect() ?? new DOMRect(), + }); + if (props.query) popup?.[0]?.show(); + else popup?.[0]?.hide(); + }, + onKeyDown: (props: SuggestionKeyDownProps) => { + if (props.event.key === "Escape") { + props.event.preventDefault(); + props.event.stopPropagation(); + popup?.[0]?.hide(); + return true; + } + return component?.ref?.onKeyDown(props) ?? false; + }, + onExit: () => { + popup?.[0]?.destroy(); + component?.destroy(); + }, + }; + }, + }), + ]; + }, + }); diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/FileMentionList.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/FileMentionList.tsx new file mode 100644 index 00000000000..af88946416b --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/FileMentionList.tsx @@ -0,0 +1,107 @@ +import type { + SuggestionKeyDownProps, + SuggestionProps, +} from "@tiptap/suggestion"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import type { FileMentionResult } from "../../types"; + +function getDirectory(relativePath: string): string { + const lastSlash = relativePath.lastIndexOf("/"); + return lastSlash === -1 ? "" : relativePath.slice(0, lastSlash); +} + +export interface FileMentionListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean; +} + +export const FileMentionList = forwardRef< + FileMentionListRef, + SuggestionProps<FileMentionResult> +>(({ items, command }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const containerRef = useRef<HTMLDivElement>(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: reset on new items + useEffect(() => { + setSelectedIndex(0); + }, [items]); + + useEffect(() => { + containerRef.current + ?.querySelector(`[data-index="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: SuggestionKeyDownProps) => { + if (items.length === 0) return false; + if (event.key === "ArrowUp") { + setSelectedIndex((prev) => (prev - 1 + items.length) % items.length); + return true; + } + if (event.key === "ArrowDown") { + setSelectedIndex((prev) => (prev + 1) % items.length); + return true; + } + if (event.key === "Enter" || event.key === "Tab") { + const item = items[selectedIndex]; + if (item) command(item); + return true; + } + return false; + }, + })); + + if (items.length === 0) { + return ( + <div className="bg-popover text-popover-foreground rounded-md border p-1 shadow-md"> + <div className="px-2 py-1.5 text-xs text-muted-foreground"> + No files found + </div> + </div> + ); + } + + return ( + <div + ref={containerRef} + className="bg-popover text-popover-foreground rounded-md border p-1 shadow-md max-h-72 overflow-y-auto w-[28rem]" + > + {items.map((item, index) => { + const directory = getDirectory(item.relativePath); + return ( + <button + type="button" + key={item.id} + data-index={index} + onClick={() => command(item)} + className={`relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-xs outline-hidden select-none w-full ${ + index === selectedIndex ? "bg-accent text-accent-foreground" : "" + }`} + > + <FileIcon + fileName={item.name} + isDirectory={item.isDirectory} + className="size-3.5 shrink-0" + /> + <span className="max-w-[14rem] truncate">{item.name}</span> + {directory && ( + <span className="min-w-0 truncate font-mono text-muted-foreground"> + {directory} + </span> + )} + </button> + ); + })} + </div> + ); +}); + +FileMentionList.displayName = "FileMentionList"; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/index.ts new file mode 100644 index 00000000000..90455ce344e --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/components/FileMentionList/index.ts @@ -0,0 +1,2 @@ +export type { FileMentionListRef } from "./FileMentionList"; +export { FileMentionList } from "./FileMentionList"; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/index.ts new file mode 100644 index 00000000000..ae211cad34f --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/index.ts @@ -0,0 +1,3 @@ +export { FileMentionNode } from "./FileMentionNode"; +export { FileMentionSuggestion } from "./FileMentionSuggestion"; +export type { FileMentionResult, FileMentionSearchFn } from "./types"; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/types.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/types.ts new file mode 100644 index 00000000000..82f77a737a8 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/FileMention/types.ts @@ -0,0 +1,10 @@ +export interface FileMentionResult { + id: string; + name: string; + relativePath: string; + isDirectory: boolean; +} + +export type FileMentionSearchFn = ( + query: string, +) => Promise<FileMentionResult[]>; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/LinearImage/LinearImage.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/LinearImage/LinearImage.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/LinearImage/LinearImage.tsx rename to apps/desktop/src/renderer/components/MarkdownEditor/components/LinearImage/LinearImage.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/LinearImage/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/LinearImage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/LinearImage/index.ts rename to apps/desktop/src/renderer/components/MarkdownEditor/components/LinearImage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/SlashCommand/SlashCommand.tsx b/apps/desktop/src/renderer/components/MarkdownEditor/components/SlashCommand/SlashCommand.tsx similarity index 96% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/SlashCommand/SlashCommand.tsx rename to apps/desktop/src/renderer/components/MarkdownEditor/components/SlashCommand/SlashCommand.tsx index 95292e0379d..669804d684c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/SlashCommand/SlashCommand.tsx +++ b/apps/desktop/src/renderer/components/MarkdownEditor/components/SlashCommand/SlashCommand.tsx @@ -1,9 +1,13 @@ import { Extension } from "@tiptap/core"; +import { PluginKey } from "@tiptap/pm/state"; import { type Editor, ReactRenderer } from "@tiptap/react"; import Suggestion, { type SuggestionKeyDownProps, type SuggestionProps, } from "@tiptap/suggestion"; + +const slashCommandSuggestionKey = new PluginKey("markdownEditorSlashCommand"); + import { forwardRef, useEffect, @@ -229,6 +233,7 @@ export const SlashCommand = Extension.create({ addProseMirrorPlugins() { return [ Suggestion({ + pluginKey: slashCommandSuggestionKey, editor: this.editor, ...this.options.suggestion, allow: ({ editor: e }) => { @@ -279,6 +284,8 @@ export const SlashCommand = Extension.create({ }, onKeyDown: (props: SuggestionKeyDownProps) => { if (props.event.key === "Escape") { + props.event.preventDefault(); + props.event.stopPropagation(); popup?.[0]?.hide(); return true; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/SlashCommand/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/components/SlashCommand/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/components/SlashCommand/index.ts rename to apps/desktop/src/renderer/components/MarkdownEditor/components/SlashCommand/index.ts diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/index.ts b/apps/desktop/src/renderer/components/MarkdownEditor/index.ts new file mode 100644 index 00000000000..fc00fe1d885 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/index.ts @@ -0,0 +1 @@ +export { MarkdownEditor } from "./MarkdownEditor"; diff --git a/apps/desktop/src/renderer/components/MarkdownEditor/markdown-editor.css b/apps/desktop/src/renderer/components/MarkdownEditor/markdown-editor.css new file mode 100644 index 00000000000..d22e8d50486 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownEditor/markdown-editor.css @@ -0,0 +1,111 @@ +/* Alternating bullet styles - works at any nesting depth */ +.task-markdown-list { + list-style-type: disc; +} + +.task-markdown-list ul { + list-style-type: circle; + margin: 0; + padding-left: 1.5rem; +} + +.task-markdown-list ul ul { + list-style-type: disc; +} + +.task-markdown-list ul ul ul { + list-style-type: circle; +} + +.task-markdown-list ul ul ul ul { + list-style-type: disc; +} + +.task-markdown-list ul ul ul ul ul { + list-style-type: circle; +} + +/* Tighter spacing for list items */ +.task-markdown-list li { + margin: 0; +} + +/* Task list checkbox styling */ +ul[data-type="taskList"] li[data-type="taskItem"] { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +ul[data-type="taskList"] li[data-type="taskItem"] > label { + display: flex; + align-items: center; + margin-top: 0.125rem; +} + +ul[data-type="taskList"] + li[data-type="taskItem"] + > label + > input[type="checkbox"] { + appearance: none; + width: 1rem; + height: 1rem; + border: 1.5px solid hsl(var(--border)); + border-radius: 0.25rem; + background-color: transparent; + cursor: pointer; + transition: all 0.15s ease; +} + +ul[data-type="taskList"] + li[data-type="taskItem"] + > label + > input[type="checkbox"]:hover { + border-color: hsl(var(--primary)); +} + +ul[data-type="taskList"] + li[data-type="taskItem"] + > label + > input[type="checkbox"]:checked { + background-color: hsl(var(--primary)); + border-color: hsl(var(--primary)); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); + background-size: 0.75rem; + background-position: center; + background-repeat: no-repeat; +} + +ul[data-type="taskList"] + li[data-type="taskItem"] + > label + > input[type="checkbox"]:focus { + outline: none; + box-shadow: + 0 0 0 2px hsl(var(--background)), + 0 0 0 4px hsl(var(--ring)); +} + +.markdown-table { + width: max-content; + min-width: 100%; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; +} + +.markdown-table th, +.markdown-table td { + border: 1px solid hsl(var(--border)); + min-width: 12rem; +} + +.markdown-table th > *, +.markdown-table td > * { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-table .selectedCell { + background-color: hsl(var(--accent) / 0.6); +} diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/CodeBlock/CodeBlock.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/CodeBlock/CodeBlock.tsx index 91cfe28d606..9fd23c6e5fe 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/CodeBlock/CodeBlock.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/CodeBlock/CodeBlock.tsx @@ -1,10 +1,6 @@ import { mermaid } from "@streamdown/mermaid"; +import { ShowCode } from "@superset/ui/ai-elements/show-code"; import type { ReactNode } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { - oneDark, - oneLight, -} from "react-syntax-highlighter/dist/esm/styles/prism"; import { useTheme } from "renderer/stores"; import { Streamdown } from "streamdown"; @@ -26,7 +22,6 @@ interface CodeBlockProps { export function CodeBlock({ children, className, node }: CodeBlockProps) { const theme = useTheme(); const isDark = theme?.type !== "light"; - const syntaxStyle = isDark ? oneDark : oneLight; const match = /language-(\w+)/.exec(className || ""); const language = match ? match[1] : undefined; @@ -56,13 +51,12 @@ export function CodeBlock({ children, className, node }: CodeBlockProps) { } return ( - <SyntaxHighlighter - style={syntaxStyle as Record<string, React.CSSProperties>} - language={language ?? "text"} - PreTag="div" - className="rounded-md text-sm" - > - {codeString} - </SyntaxHighlighter> + <ShowCode + className="my-4" + // biome-ignore lint/suspicious/noExplicitAny: ShowCode accepts BundledLanguage; language is an untyped string here + language={language as any} + code={codeString} + showLineNumbers + /> ); } diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/EditableCodeBlockView/EditableCodeBlockView.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/EditableCodeBlockView/EditableCodeBlockView.tsx index 2e0a3e78a09..43bd13de0b5 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/EditableCodeBlockView/EditableCodeBlockView.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/EditableCodeBlockView/EditableCodeBlockView.tsx @@ -1,3 +1,4 @@ +import { mermaid } from "@streamdown/mermaid"; import { DropdownMenu, DropdownMenuContent, @@ -7,12 +8,22 @@ import { import type { NodeViewProps } from "@tiptap/react"; import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; import { useState } from "react"; -import { HiCheck, HiChevronDown, HiOutlineClipboard } from "react-icons/hi2"; +import { + HiCheck, + HiChevronDown, + HiOutlineClipboard, + HiOutlineCodeBracket, + HiOutlineEye, +} from "react-icons/hi2"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { FILE_VIEW_CODE_BLOCK_LANGUAGES, getCodeBlockLanguageLabel, } from "renderer/lib/tiptap/code-block-languages"; +import { useTheme } from "renderer/stores"; +import { Streamdown } from "streamdown"; + +const mermaidPlugins = { mermaid }; export function EditableCodeBlockView({ node, @@ -20,6 +31,8 @@ export function EditableCodeBlockView({ extension, }: NodeViewProps) { const [menuOpen, setMenuOpen] = useState(false); + const theme = useTheme(); + const isDark = theme?.type !== "light"; const attrs = node.attrs as { language?: string }; const htmlAttrs = extension.options.HTMLAttributes as { class?: string }; @@ -30,6 +43,16 @@ export function EditableCodeBlockView({ currentLanguage, ); + const isMermaid = currentLanguage === "mermaid"; + const mermaidSource = node.textContent; + const mermaidHasContent = mermaidSource.trim().length > 0; + const [mermaidMode, setMermaidMode] = useState<"preview" | "source">(() => + mermaidHasContent ? "preview" : "source", + ); + const showMermaidPreview = + isMermaid && mermaidMode === "preview" && mermaidHasContent; + const showMermaidToggle = isMermaid && mermaidHasContent; + const { copyToClipboard, copied } = useCopyToClipboard(); const handleCopy = () => { copyToClipboard(node.textContent); @@ -41,15 +64,43 @@ export function EditableCodeBlockView({ }; return ( - <NodeViewWrapper as="pre" className={`${htmlAttrs.class} relative group`}> + <NodeViewWrapper + as="pre" + className={`${htmlAttrs.class} relative group ${showMermaidPreview ? "!bg-transparent !p-0" : ""}`} + > <div - className={`absolute top-2 right-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 ${menuOpen ? "opacity-100" : ""}`} + className={`absolute top-2 z-10 flex items-center gap-1 rounded-md border border-border bg-background/80 p-1 opacity-0 backdrop-blur-sm transition-opacity supports-[backdrop-filter]:bg-background/70 group-hover:opacity-100 group-focus-within:opacity-100 ${menuOpen ? "opacity-100" : ""} ${showMermaidPreview ? "left-2" : "right-2"}`} > + {showMermaidToggle && ( + <button + type="button" + onClick={() => + setMermaidMode(mermaidMode === "preview" ? "source" : "preview") + } + aria-label={ + mermaidMode === "preview" + ? "Edit mermaid source" + : "View mermaid diagram" + } + title={ + mermaidMode === "preview" + ? "Edit mermaid source" + : "View mermaid diagram" + } + className="flex items-center justify-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + {mermaidMode === "preview" ? ( + <HiOutlineCodeBracket className="h-3.5 w-3.5" /> + ) : ( + <HiOutlineEye className="h-3.5 w-3.5" /> + )} + </button> + )} <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenuTrigger asChild> <button type="button" - className="flex h-6 items-center gap-1 rounded border border-border bg-background/80 px-2 text-xs backdrop-blur transition-colors hover:bg-accent" + className="flex items-center gap-1 rounded px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground" > {currentLabel} <HiChevronDown className="h-3 w-3" /> @@ -79,7 +130,7 @@ export function EditableCodeBlockView({ onClick={handleCopy} aria-label={copied ? "Copied code block" : "Copy code block"} title={copied ? "Copied code block" : "Copy code block"} - className="flex h-6 w-6 items-center justify-center rounded border border-border bg-background/80 backdrop-blur transition-colors hover:bg-accent" + className="flex items-center justify-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > {copied ? ( <HiCheck className="h-3.5 w-3.5 text-green-500" /> @@ -89,7 +140,22 @@ export function EditableCodeBlockView({ </button> </div> - <code className="hljs block !bg-transparent"> + {showMermaidPreview && ( + <div contentEditable={false} className="w-full [&_.min-h-28]:min-h-80"> + <Streamdown + mode="static" + plugins={mermaidPlugins} + mermaid={{ config: { theme: isDark ? "dark" : "default" } }} + > + {`\`\`\`\`mermaid\n${mermaidSource}\n\`\`\`\``} + </Streamdown> + </div> + )} + + <code + className="hljs block !bg-transparent" + style={showMermaidPreview ? { display: "none" } : undefined} + > <NodeViewContent /> </code> </NodeViewWrapper> diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/createMarkdownExtensions.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/createMarkdownExtensions.ts index 0f04ccd4735..71212c6228e 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/createMarkdownExtensions.ts +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/createMarkdownExtensions.ts @@ -16,10 +16,7 @@ import { ListItem } from "@tiptap/extension-list-item"; import { OrderedList } from "@tiptap/extension-ordered-list"; import { Paragraph } from "@tiptap/extension-paragraph"; import { Strike } from "@tiptap/extension-strike"; -import { Table } from "@tiptap/extension-table"; -import TableCell from "@tiptap/extension-table-cell"; -import TableHeader from "@tiptap/extension-table-header"; -import TableRow from "@tiptap/extension-table-row"; +import { TableKit } from "@tiptap/extension-table"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import { Text } from "@tiptap/extension-text"; @@ -158,21 +155,23 @@ export function createMarkdownExtensions({ }, }), SafeImage, - Table.configure({ - resizable: false, - HTMLAttributes: { - class: "markdown-table my-4 min-w-full border-collapse", + TableKit.configure({ + table: { + resizable: false, + cellMinWidth: 192, + HTMLAttributes: { + class: "markdown-table my-4 min-w-full border-collapse", + }, }, - }), - TableRow, - TableHeader.configure({ - HTMLAttributes: { - class: "bg-muted px-4 py-2 text-left text-sm font-semibold align-top", + tableHeader: { + HTMLAttributes: { + class: "bg-muted px-4 py-2 text-left text-sm font-semibold align-top", + }, }, - }), - TableCell.configure({ - HTMLAttributes: { - class: "border-t border-border px-4 py-2 text-sm align-top", + tableCell: { + HTMLAttributes: { + class: "border-t border-border px-4 py-2 text-sm align-top", + }, }, }), Markdown.configure({ diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/default.css b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/default.css index b24a3cde29a..8085dcc3dfe 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/default.css +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/default.css @@ -48,7 +48,7 @@ .default-markdown p { margin-top: 0; margin-bottom: 1rem; - line-height: 1.7; + line-height: 1.5; } .default-markdown ul, @@ -60,7 +60,7 @@ .default-markdown li { margin-bottom: 0.25rem; - line-height: 1.6; + line-height: 1.5; } .default-markdown ul { diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/tufte.css b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/tufte.css index 5d21f9938a7..edff21d4fcd 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/tufte.css +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/tufte.css @@ -2,7 +2,7 @@ .tufte-markdown { font-family: Georgia, "Times New Roman", serif; font-size: 1.1rem; - line-height: 1.8; + line-height: 1.6; color: var(--foreground); } @@ -84,7 +84,7 @@ /* Body text */ .tufte-markdown p { margin-top: 0; - margin-bottom: 1.4rem; + margin-bottom: 1.1rem; text-align: justify; -webkit-hyphens: auto; hyphens: auto; @@ -101,7 +101,7 @@ /* Lists */ .tufte-markdown ul, .tufte-markdown ol { - margin: 1.4rem 0; + margin: 1.1rem 0; padding-left: 1.5rem; list-style-position: outside; } @@ -115,7 +115,8 @@ } .tufte-markdown li { - margin-bottom: 0.5rem; + margin-bottom: 0.35rem; + line-height: 1.5; } /* Links */ diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 3734601c58e..2ee758447f5 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -73,7 +73,11 @@ export function NewWorkspaceModal() { <NewWorkspaceModalDraftProvider onClose={closeModal}> <PromptInputProvider> <PromptInputResetSync /> - <Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}> + <Dialog + modal + open={isOpen} + onOpenChange={(open) => !open && closeModal()} + > <DialogHeader className="sr-only"> <DialogTitle>New Workspace</DialogTitle> <DialogDescription>Create a new workspace</DialogDescription> diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx index 419e682a25b..60a4770ec46 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx @@ -1,4 +1,11 @@ import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; +import { buildPromptAgentLaunchRequest } from "@superset/shared/agent-launch-request"; +import { + type AgentDefinitionId, + getEnabledAgentConfigs, + indexResolvedAgentConfigs, +} from "@superset/shared/agent-settings"; +import { sanitizeBranchNameWithMaxLength } from "@superset/shared/workspace-launch"; import { PromptInput, PromptInputAttachment, @@ -21,15 +28,11 @@ import { CommandList, CommandSeparator, } from "@superset/ui/command"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; import { Input } from "@superset/ui/input"; +import { isEnterSubmit } from "@superset/ui/lib/keyboard"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; @@ -39,14 +42,7 @@ import { PaperclipIcon, PlusIcon, } from "lucide-react"; -import { - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GoArrowUpRight, GoGitBranch, @@ -55,30 +51,21 @@ import { } from "react-icons/go"; import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; import { LuFolderGit, LuFolderOpen, LuGitPullRequest } from "react-icons/lu"; -import { SiLinear } from "react-icons/si"; import { AgentSelect } from "renderer/components/AgentSelect"; import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; -import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; import { useAgentLaunchPreferences } from "renderer/hooks/useAgentLaunchPreferences"; +import { PLATFORM } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; import { resolveEffectiveWorkspaceBaseBranch } from "renderer/lib/workspaceBaseBranch"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { ProjectThumbnail } from "renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail"; -import { useHotkeysStore } from "renderer/stores/hotkeys/store"; import { useClearPendingWorkspace, useNewWorkspaceModalOpen, useSetPendingWorkspace, useSetPendingWorkspaceStatus, } from "renderer/stores/new-workspace-modal"; -import { buildPromptAgentLaunchRequest } from "shared/utils/agent-launch-request"; -import { - type AgentDefinitionId, - getEnabledAgentConfigs, - indexResolvedAgentConfigs, -} from "shared/utils/agent-settings"; -import { sanitizeBranchNameWithMaxLength } from "shared/utils/branch"; import type { LinkedPR } from "../../NewWorkspaceModalDraftContext"; import { useNewWorkspaceModalDraft } from "../../NewWorkspaceModalDraftContext"; import { GitHubIssueLinkCommand } from "./components/GitHubIssueLinkCommand"; @@ -123,46 +110,55 @@ export function PromptGroup(props: PromptGroupProps) { return <PromptGroupInner {...props} />; } -const PlusMenu = forwardRef< - HTMLDivElement, - { - onOpenIssueLink: () => void; - onOpenGitHubIssue: () => void; - onOpenPRLink: () => void; - } ->(function PlusMenu({ onOpenIssueLink, onOpenGitHubIssue, onOpenPRLink }, ref) { +function AttachmentButtons({ + anchorRef, + onOpenGitHubIssue, + onOpenPRLink, +}: { + anchorRef: React.RefObject<HTMLDivElement | null>; + onOpenGitHubIssue: () => void; + onOpenPRLink: () => void; +}) { const attachments = usePromptInputAttachments(); return ( - <div ref={ref}> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <PromptInputButton className={`${PILL_BUTTON_CLASS} w-[22px]`}> - <PlusIcon className="size-3.5" /> + <div ref={anchorRef} className="flex items-center gap-1"> + <Tooltip> + <TooltipTrigger asChild> + <PromptInputButton + className={`${PILL_BUTTON_CLASS} w-[22px]`} + onClick={() => attachments.openFileDialog()} + > + <PaperclipIcon className="size-3.5" /> + </PromptInputButton> + </TooltipTrigger> + <TooltipContent side="bottom">Add attachment</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <PromptInputButton + className={`${PILL_BUTTON_CLASS} w-[22px]`} + onClick={onOpenGitHubIssue} + > + <GoIssueOpened className="size-3.5" /> </PromptInputButton> - </DropdownMenuTrigger> - <DropdownMenuContent side="bottom" align="start" className="w-52"> - <DropdownMenuItem onSelect={() => attachments.openFileDialog()}> - <PaperclipIcon className="size-4" /> - Add attachment - </DropdownMenuItem> - <DropdownMenuItem onSelect={onOpenIssueLink}> - <SiLinear className="size-4" /> - Link issue - </DropdownMenuItem> - <DropdownMenuItem onSelect={onOpenGitHubIssue}> - <GoIssueOpened className="size-4" /> - Link GitHub issue - </DropdownMenuItem> - <DropdownMenuItem onSelect={onOpenPRLink}> - <LuGitPullRequest className="size-4" /> - Link pull request - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + </TooltipTrigger> + <TooltipContent side="bottom">Link GitHub issue</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <PromptInputButton + className={`${PILL_BUTTON_CLASS} w-[22px]`} + onClick={onOpenPRLink} + > + <LuGitPullRequest className="size-3.5" /> + </PromptInputButton> + </TooltipTrigger> + <TooltipContent side="bottom">Link pull request</TooltipContent> + </Tooltip> </div> ); -}); +} function ProjectPickerPill({ selectedProject, @@ -528,8 +524,7 @@ function PromptGroupInner({ onNewProject, }: PromptGroupProps) { const navigate = useNavigate(); - const platform = useHotkeysStore((state) => state.platform); - const modKey = platform === "darwin" ? "⌘" : "Ctrl"; + const modKey = PLATFORM === "mac" ? "⌘" : "Ctrl"; const isNewWorkspaceModalOpen = useNewWorkspaceModalOpen(); const utils = electronTrpc.useUtils(); const { @@ -580,7 +575,6 @@ function PromptGroupInner({ validAgents: ["none", ...selectableAgentIds], agentsReady: agentPresetsQuery.isFetched, }); - const [issueLinkOpen, setIssueLinkOpen] = useState(false); const [gitHubIssueLinkOpen, setGitHubIssueLinkOpen] = useState(false); const [prLinkOpen, setPRLinkOpen] = useState(false); const plusMenuRef = useRef<HTMLDivElement>(null); @@ -717,174 +711,177 @@ function PromptGroupInner({ [], ); - const handleCreate = useCallback(async () => { - if (!projectId) { - toast.error("Select a project first"); - return; - } + const handleCreate = useCallback( + async (preConvertedFiles?: ConvertedFile[]) => { + if (!projectId) { + toast.error("Select a project first"); + return; + } - if (submitStartedRef.current) { - return; - } - submitStartedRef.current = true; - - const displayName = - workspaceNameEdited && workspaceName.trim() - ? workspaceName.trim() - : trimmedPrompt || "New workspace"; - const willGenerateAIName = - !branchNameEdited && !!trimmedPrompt && !linkedPR; - const pendingWorkspaceId = crypto.randomUUID(); - const detachedFiles = attachments.takeFiles(); - - setPendingWorkspace({ - id: pendingWorkspaceId, - projectId, - name: displayName, - status: willGenerateAIName ? "generating-branch" : "preparing", - }); - closeAndResetDraft(); + if (submitStartedRef.current) { + return; + } + submitStartedRef.current = true; + + const displayName = + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : trimmedPrompt || "New workspace"; + const willGenerateAIName = + !branchNameEdited && !!trimmedPrompt && !linkedPR; + const pendingWorkspaceId = crypto.randomUUID(); + const detachedFiles = preConvertedFiles ? [] : attachments.takeFiles(); + + setPendingWorkspace({ + id: pendingWorkspaceId, + projectId, + name: displayName, + status: willGenerateAIName ? "generating-branch" : "preparing", + }); + closeAndResetDraft(); - try { - let aiBranchName: string | null = null; - if (willGenerateAIName) { - let timeoutId: NodeJS.Timeout | null = null; - try { - const AI_GENERATION_TIMEOUT_MS = 30000; - const timeoutPromise = new Promise<never>((_, reject) => { - timeoutId = setTimeout( - () => reject(new Error("AI generation timeout")), - AI_GENERATION_TIMEOUT_MS, - ); - }); + try { + let aiBranchName: string | null = null; + if (willGenerateAIName) { + let timeoutId: NodeJS.Timeout | null = null; + try { + const AI_GENERATION_TIMEOUT_MS = 30000; + const timeoutPromise = new Promise<never>((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("AI generation timeout")), + AI_GENERATION_TIMEOUT_MS, + ); + }); + + const result = await Promise.race([ + generateBranchNameMutation.mutateAsync({ + prompt: trimmedPrompt, + projectId, + }), + timeoutPromise, + ]); - const result = await Promise.race([ - generateBranchNameMutation.mutateAsync({ - prompt: trimmedPrompt, - projectId, - }), - timeoutPromise, - ]); + if (timeoutId) clearTimeout(timeoutId); + aiBranchName = result.branchName; + } catch (error) { + if (timeoutId) clearTimeout(timeoutId); + + const errorMessage = + error instanceof Error ? error.message : String(error); + if (errorMessage.includes("timeout")) { + console.warn("[PromptGroup] AI generation timeout"); + toast.info("Using random branch name (AI generation timed out)"); + } else if ( + errorMessage.toLowerCase().includes("auth") || + errorMessage.includes("401") || + errorMessage.includes("403") + ) { + console.error("[PromptGroup] AI auth error:", error); + toast.error( + "AI authentication failed. Please check your AI settings.", + ); + clearPendingWorkspace(pendingWorkspaceId); + return; + } else { + console.warn("[PromptGroup] AI generation failed:", error); + toast.info( + "Using random branch name (AI generation unavailable)", + ); + } + } finally { + setPendingWorkspaceStatus(pendingWorkspaceId, "preparing"); + } + } - if (timeoutId) clearTimeout(timeoutId); - aiBranchName = result.branchName; - } catch (error) { - if (timeoutId) clearTimeout(timeoutId); - - const errorMessage = - error instanceof Error ? error.message : String(error); - if (errorMessage.includes("timeout")) { - console.warn("[PromptGroup] AI generation timeout"); - toast.info("Using random branch name (AI generation timed out)"); - } else if ( - errorMessage.toLowerCase().includes("auth") || - errorMessage.includes("401") || - errorMessage.includes("403") - ) { - console.error("[PromptGroup] AI auth error:", error); - toast.error( - "AI authentication failed. Please check your AI settings.", + let convertedFiles: ConvertedFile[] = preConvertedFiles ?? []; + if (!preConvertedFiles && detachedFiles.length > 0) { + try { + convertedFiles = await Promise.all( + detachedFiles.map(async (file) => ({ + data: await convertBlobUrlToDataUrl(file.url), + mediaType: file.mediaType, + filename: file.filename, + })), ); + } catch (err) { clearPendingWorkspace(pendingWorkspaceId); + toast.error( + err instanceof Error + ? err.message + : "Failed to process attachments", + ); return; - } else { - console.warn("[PromptGroup] AI generation failed:", error); - toast.info("Using random branch name (AI generation unavailable)"); } - } finally { - setPendingWorkspaceStatus(pendingWorkspaceId, "preparing"); } - } - let convertedFiles: ConvertedFile[] = []; - if (detachedFiles.length > 0) { - try { - convertedFiles = await Promise.all( - detachedFiles.map(async (file) => ({ - data: await convertBlobUrlToDataUrl(file.url), - mediaType: file.mediaType, - filename: file.filename, - })), - ); - } catch (err) { - clearPendingWorkspace(pendingWorkspaceId); - toast.error( - err instanceof Error - ? err.message - : "Failed to process attachments", - ); - return; - } - } - - // Fetch and attach GitHub issue content - const githubIssues = linkedIssues.filter( - (issue): issue is typeof issue & { number: number } => - issue.source === "github" && typeof issue.number === "number", - ); - if (githubIssues.length > 0 && projectId) { - try { - // Helper to add timeout to promises - const fetchWithTimeout = <T,>( - promise: Promise<T>, - timeoutMs: number, - ): Promise<T> => { - return Promise.race([ - promise, - new Promise<T>((_, reject) => - setTimeout( - () => reject(new Error("Request timeout")), - timeoutMs, + // Fetch and attach GitHub issue content + const githubIssues = linkedIssues.filter( + (issue): issue is typeof issue & { number: number } => + issue.source === "github" && typeof issue.number === "number", + ); + if (githubIssues.length > 0 && projectId) { + try { + // Helper to add timeout to promises + const fetchWithTimeout = <T,>( + promise: Promise<T>, + timeoutMs: number, + ): Promise<T> => { + return Promise.race([ + promise, + new Promise<T>((_, reject) => + setTimeout( + () => reject(new Error("Request timeout")), + timeoutMs, + ), ), - ), - ]); - }; - - const issueContents = await Promise.all( - githubIssues.map(async (issue) => { - try { - const content = await fetchWithTimeout( - utils.client.projects.getIssueContent.query({ - projectId, - issueNumber: issue.number, - }), - 10000, // 10 second timeout per issue - ); - - // Sanitize user-generated content to prevent injection - const sanitizeText = (str: string) => - str.replace(/[&<>"']/g, (char) => { - const entities: Record<string, string> = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }; - return entities[char] || char; - }); - - const sanitizeUrl = (url: string) => { - try { - const parsed = new URL(url); - // Only allow http/https protocols - if (!["http:", "https:"].includes(parsed.protocol)) { + ]); + }; + + const issueContents = await Promise.all( + githubIssues.map(async (issue) => { + try { + const content = await fetchWithTimeout( + utils.client.projects.getIssueContent.query({ + projectId, + issueNumber: issue.number, + }), + 10000, // 10 second timeout per issue + ); + + // Sanitize user-generated content to prevent injection + const sanitizeText = (str: string) => + str.replace(/[&<>"']/g, (char) => { + const entities: Record<string, string> = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[char] || char; + }); + + const sanitizeUrl = (url: string) => { + try { + const parsed = new URL(url); + // Only allow http/https protocols + if (!["http:", "https:"].includes(parsed.protocol)) { + return "#invalid-url"; + } + return url; + } catch { return "#invalid-url"; } - return url; - } catch { - return "#invalid-url"; - } - }; + }; - // Limit body size to prevent memory issues - const MAX_BODY_LENGTH = 50000; // 50KB - const truncatedBody = - content.body.length > MAX_BODY_LENGTH - ? `${content.body.slice(0, MAX_BODY_LENGTH)}\n\n[... content truncated due to length ...]` - : content.body; + // Limit body size to prevent memory issues + const MAX_BODY_LENGTH = 50000; // 50KB + const truncatedBody = + content.body.length > MAX_BODY_LENGTH + ? `${content.body.slice(0, MAX_BODY_LENGTH)}\n\n[... content truncated due to length ...]` + : content.body; - const markdown = `# GitHub Issue #${content.number}: ${sanitizeText(content.title)} + const markdown = `# GitHub Issue #${content.number}: ${sanitizeText(content.title)} **URL:** ${sanitizeUrl(content.url)} **State:** ${content.state} @@ -896,152 +893,177 @@ function PromptGroupInner({ ${sanitizeText(truncatedBody)}`; - // Convert markdown to base64 data URL - const base64 = btoa( - encodeURIComponent(markdown).replace( - /%([0-9A-F]{2})/g, - (_, p1) => String.fromCharCode(Number.parseInt(p1, 16)), - ), - ); - - return { - data: `data:text/markdown;base64,${base64}`, - mediaType: "text/markdown", - filename: `github-issue-${content.number}.md`, - }; - } catch (err) { - console.warn( - `Failed to fetch GitHub issue #${issue.number}:`, - err, - ); - return null; - } - }), - ); + // Convert markdown to base64 data URL + const base64 = btoa( + encodeURIComponent(markdown).replace( + /%([0-9A-F]{2})/g, + (_, p1) => String.fromCharCode(Number.parseInt(p1, 16)), + ), + ); + + return { + data: `data:text/markdown;base64,${base64}`, + mediaType: "text/markdown", + filename: `github-issue-${content.number}.md`, + }; + } catch (err) { + console.warn( + `Failed to fetch GitHub issue #${issue.number}:`, + err, + ); + return null; + } + }), + ); - // Add successfully fetched issues to convertedFiles - const validIssueFiles = issueContents.filter( - (file) => file !== null, - ) as ConvertedFile[]; - convertedFiles = [...convertedFiles, ...validIssueFiles]; - } catch (err) { - console.warn("Failed to fetch GitHub issue contents:", err); - // Don't block workspace creation if issue fetching fails + // Add successfully fetched issues to convertedFiles + const validIssueFiles = issueContents.filter( + (file) => file !== null, + ) as ConvertedFile[]; + convertedFiles = [...convertedFiles, ...validIssueFiles]; + } catch (err) { + console.warn("Failed to fetch GitHub issue contents:", err); + // Don't block workspace creation if issue fetching fails + } } - } - let launchRequest: AgentLaunchRequest | null = null; - try { - launchRequest = buildLaunchRequest( - trimmedPrompt, - convertedFiles.length > 0 ? convertedFiles : undefined, - ); - } catch (error) { - clearPendingWorkspace(pendingWorkspaceId); - toast.error( - error instanceof Error - ? error.message - : "Failed to prepare agent launch", - ); - return; - } + let launchRequest: AgentLaunchRequest | null = null; + try { + launchRequest = buildLaunchRequest( + trimmedPrompt, + convertedFiles.length > 0 ? convertedFiles : undefined, + ); + } catch (error) { + clearPendingWorkspace(pendingWorkspaceId); + toast.error( + error instanceof Error + ? error.message + : "Failed to prepare agent launch", + ); + return; + } - setPendingWorkspaceStatus(pendingWorkspaceId, "creating"); + setPendingWorkspaceStatus(pendingWorkspaceId, "creating"); + + if (linkedPR) { + void runAsyncAction( + createFromPr.mutateAsyncWithSetup( + { projectId, prUrl: linkedPR.url }, + launchRequest ?? undefined, + ), + { + loading: `Creating workspace from PR #${linkedPR.prNumber}...`, + success: "Workspace created from PR", + error: (err) => + err instanceof Error + ? err.message + : "Failed to create workspace from PR", + }, + { closeAndReset: false }, + ).finally(() => { + clearPendingWorkspace(pendingWorkspaceId); + }); + return; + } - if (linkedPR) { void runAsyncAction( - createFromPr.mutateAsyncWithSetup( - { projectId, prUrl: linkedPR.url }, - launchRequest ?? undefined, + createWorkspace.mutateAsyncWithPendingSetup( + { + projectId, + name: + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : undefined, + prompt: trimmedPrompt || undefined, + branchName: + (branchNameEdited && branchName.trim() + ? sanitizeBranchNameWithMaxLength( + branchName.trim(), + undefined, + { + preserveCase: true, + }, + ) + : aiBranchName) || undefined, + compareBaseBranch: compareBaseBranch || undefined, + }, + { + agentLaunchRequest: launchRequest ?? undefined, + resolveInitialCommands: runSetupScript + ? (commands) => commands + : () => null, + }, ), { - loading: `Creating workspace from PR #${linkedPR.prNumber}...`, - success: "Workspace created from PR", + loading: "Creating workspace...", + success: "Workspace created", error: (err) => - err instanceof Error - ? err.message - : "Failed to create workspace from PR", + err instanceof Error ? err.message : "Failed to create workspace", }, { closeAndReset: false }, ).finally(() => { clearPendingWorkspace(pendingWorkspaceId); }); - return; - } - - void runAsyncAction( - createWorkspace.mutateAsyncWithPendingSetup( - { - projectId, - name: - workspaceNameEdited && workspaceName.trim() - ? workspaceName.trim() - : undefined, - prompt: trimmedPrompt || undefined, - branchName: - (branchNameEdited && branchName.trim() - ? sanitizeBranchNameWithMaxLength( - branchName.trim(), - undefined, - { - preserveCase: true, - }, - ) - : aiBranchName) || undefined, - compareBaseBranch: compareBaseBranch || undefined, - }, - { - agentLaunchRequest: launchRequest ?? undefined, - resolveInitialCommands: runSetupScript - ? (commands) => commands - : () => null, - }, - ), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - { closeAndReset: false }, - ).finally(() => { - clearPendingWorkspace(pendingWorkspaceId); - }); - } finally { - for (const file of detachedFiles) { - if (file.url?.startsWith("blob:")) { - URL.revokeObjectURL(file.url); + } finally { + for (const file of detachedFiles) { + if (file.url?.startsWith("blob:")) { + URL.revokeObjectURL(file.url); + } } } - } - }, [ - attachments, - compareBaseBranch, - branchName, - branchNameEdited, - buildLaunchRequest, - closeAndResetDraft, - clearPendingWorkspace, - convertBlobUrlToDataUrl, - createFromPr, - createWorkspace, - generateBranchNameMutation, - linkedIssues, - linkedPR, - projectId, - runAsyncAction, - runSetupScript, - setPendingWorkspace, - setPendingWorkspaceStatus, - trimmedPrompt, - utils, - workspaceName, - workspaceNameEdited, - ]); + }, + [ + attachments, + compareBaseBranch, + branchName, + branchNameEdited, + buildLaunchRequest, + closeAndResetDraft, + clearPendingWorkspace, + convertBlobUrlToDataUrl, + createFromPr, + createWorkspace, + generateBranchNameMutation, + linkedIssues, + linkedPR, + projectId, + runAsyncAction, + runSetupScript, + setPendingWorkspace, + setPendingWorkspaceStatus, + trimmedPrompt, + utils, + workspaceName, + workspaceNameEdited, + ], + ); - const handlePromptSubmit = useCallback(() => { - void handleCreate(); - }, [handleCreate]); + const handlePromptSubmit = useCallback( + (message: { + files: Array<{ url: string; mediaType: string; filename?: string }>; + }) => { + const converted: ConvertedFile[] = message.files + .filter((f) => f.url) + .map((f) => ({ + data: f.url, + mediaType: f.mediaType, + filename: f.filename, + })); + void handleCreate(converted.length > 0 ? converted : undefined); + }, + [handleCreate], + ); + + useEffect(() => { + if (!isNewWorkspaceModalOpen) return; + const handler = (e: KeyboardEvent) => { + if (!isEnterSubmit(e, { requireMod: true })) return; + e.preventDefault(); + void handleCreate(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [isNewWorkspaceModalOpen, handleCreate]); const handleCompareBaseBranchSelect = (selectedBaseBranch: string) => { updateDraft({ compareBaseBranch: selectedBaseBranch }); @@ -1095,21 +1117,6 @@ ${sanitizeText(truncatedBody)}`; [closeModal, navigate], ); - const addLinkedIssue = ( - slug: string, - title: string, - taskId: string | undefined, - url?: string, - ) => { - if (linkedIssues.some((issue) => issue.slug === slug)) return; - updateDraft({ - linkedIssues: [ - ...linkedIssues, - { slug, title, source: "internal", taskId, url }, - ], - }); - }; - const addLinkedGitHubIssue = ( issueNumber: number, title: string, @@ -1165,12 +1172,6 @@ ${sanitizeText(truncatedBody)}`; updateDraft({ workspaceName: "", workspaceNameEdited: false }); } }} - onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - void handleCreate(); - } - }} /> <div className="shrink min-w-0 ml-auto max-w-[50%]"> <Input @@ -1197,12 +1198,6 @@ ${sanitizeText(truncatedBody)}`; updateDraft({ branchName: sanitized }); } }} - onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - void handleCreate(); - } - }} /> </div> </div> @@ -1273,12 +1268,6 @@ ${sanitizeText(truncatedBody)}`; className="min-h-10" value={prompt} onChange={(e) => updateDraft({ prompt: e.target.value })} - onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - void handleCreate(); - } - }} /> <PromptInputFooter> <PromptInputTools className="gap-1.5"> @@ -1296,11 +1285,8 @@ ${sanitizeText(truncatedBody)}`; /> </PromptInputTools> <div className="flex items-center gap-2"> - <PlusMenu - ref={plusMenuRef} - onOpenIssueLink={() => - requestAnimationFrame(() => setIssueLinkOpen(true)) - } + <AttachmentButtons + anchorRef={plusMenuRef} onOpenGitHubIssue={() => requestAnimationFrame(() => setGitHubIssueLinkOpen(true)) } @@ -1308,13 +1294,6 @@ ${sanitizeText(truncatedBody)}`; requestAnimationFrame(() => setPRLinkOpen(true)) } /> - <IssueLinkCommand - variant="popover" - anchorRef={plusMenuRef} - open={issueLinkOpen} - onOpenChange={setIssueLinkOpen} - onSelect={addLinkedIssue} - /> <GitHubIssueLinkCommand open={gitHubIssueLinkOpen} onOpenChange={setGitHubIssueLinkOpen} @@ -1334,6 +1313,8 @@ ${sanitizeText(truncatedBody)}`; onOpenChange={setPRLinkOpen} onSelect={setLinkedPR} projectId={projectId} + githubOwner={project?.githubOwner ?? null} + repoName={project?.mainRepoPath.split("/").pop() ?? null} anchorRef={plusMenuRef} /> <PromptInputSubmit @@ -1400,7 +1381,7 @@ ${sanitizeText(truncatedBody)}`; </AnimatePresence> </div> <span className="text-[11px] text-muted-foreground/50"> - {modKey}+↵ to create + {modKey}↵ to create </span> </div> </div> diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx index 09692d696f3..6b53c461541 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -1,3 +1,4 @@ +import { Checkbox } from "@superset/ui/checkbox"; import { Command, CommandEmpty, @@ -10,7 +11,7 @@ import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; import Fuse from "fuse.js"; import type React from "react"; import type { RefObject } from "react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { IssueIcon, @@ -46,9 +47,11 @@ export function GitHubIssueLinkCommand({ anchorRef, }: GitHubIssueLinkCommandProps) { const [searchQuery, setSearchQuery] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); const { data: issues, isLoading } = electronTrpc.projects.listIssues.useQuery( - { projectId: projectId ?? "" }, + { projectId: projectId ?? "", includeClosed: showClosed }, { enabled: !!projectId && open }, ); @@ -109,8 +112,8 @@ export function GitHubIssueLinkCommand({ <PopoverAnchor virtualRef={anchorRef as React.RefObject<Element>} /> <PopoverContent className="w-80 p-0" - align="end" - side="top" + align="start" + side="bottom" onWheel={(event) => event.stopPropagation()} onPointerDownOutside={handleClose} onEscapeKeyDown={handleClose} @@ -122,14 +125,39 @@ export function GitHubIssueLinkCommand({ value={searchQuery} onValueChange={setSearchQuery} /> + <div className="flex items-center gap-2 border-b px-3 py-2"> + <Checkbox + id={showClosedId} + checked={showClosed} + onCheckedChange={(checked) => setShowClosed(checked === true)} + /> + <label + htmlFor={showClosedId} + className="cursor-pointer select-none text-xs text-muted-foreground" + > + Show closed + </label> + </div> <CommandList className="max-h-[280px]"> {searchResults.length === 0 && ( <CommandEmpty> - {isLoading ? "Loading issues..." : "No open issues found."} + {isLoading + ? "Loading issues..." + : showClosed + ? "No issues found." + : "No open issues found."} </CommandEmpty> )} {searchResults.length > 0 && ( - <CommandGroup heading={searchQuery ? "Results" : "Open issues"}> + <CommandGroup + heading={ + searchQuery + ? "Results" + : showClosed + ? "Recent issues" + : "Open issues" + } + > {searchResults.map((issue) => ( <CommandItem key={issue.issueNumber} diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx index b47e2a1f424..062f920aab4 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx @@ -1,3 +1,4 @@ +import { Checkbox } from "@superset/ui/checkbox"; import { Command, CommandEmpty, @@ -7,18 +8,16 @@ import { CommandList, } from "@superset/ui/command"; import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; -import Fuse from "fuse.js"; import type React from "react"; import type { RefObject } from "react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { PRIcon, type PRState, } from "renderer/screens/main/components/PRIcon/PRIcon"; -const MAX_RESULTS = 20; - export interface SelectedPR { prNumber: number; title: string; @@ -31,64 +30,120 @@ interface PRLinkCommandProps { onOpenChange: (open: boolean) => void; onSelect: (pr: SelectedPR) => void; projectId: string | null; + githubOwner: string | null; + repoName: string | null; anchorRef: RefObject<HTMLElement | null>; } +function parseGitHubPullRequestUrl(query: string): { + owner: string; + repo: string; + prNumber: string; +} | null { + const match = query.match( + /^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i, + ); + + if (!match) return null; + + return { + owner: match[1], + repo: match[2], + prNumber: match[3], + }; +} + export function PRLinkCommand({ open, onOpenChange, onSelect, projectId, + githubOwner, + repoName, anchorRef, }: PRLinkCommandProps) { const [searchQuery, setSearchQuery] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const trimmedQuery = searchQuery.trim(); // Immediate trim for UI decisions + const debouncedTrimmed = debouncedQuery.trim(); // Debounced trim for RPC calls + + // Detect if we're in the pending debounce state + const isPendingDebounce = trimmedQuery !== debouncedTrimmed; + + const parsedPullRequestUrl = useMemo(() => { + return parseGitHubPullRequestUrl(debouncedTrimmed); + }, [debouncedTrimmed]); + + const selectedRepositoryLabel = useMemo(() => { + if (!githubOwner || !repoName) return null; + return `${githubOwner}/${repoName}`; + }, [githubOwner, repoName]); - const { data: pullRequests, isLoading } = + const pastedRepository = useMemo(() => { + if (!parsedPullRequestUrl) return null; + return `${parsedPullRequestUrl.owner}/${parsedPullRequestUrl.repo}`.toLowerCase(); + }, [parsedPullRequestUrl]); + + const isCrossRepositoryUrl = Boolean( + selectedRepositoryLabel && + pastedRepository && + pastedRepository !== selectedRepositoryLabel.toLowerCase(), + ); + + // Search by PR number when the pasted URL matches the selected repository. + const effectiveQuery = parsedPullRequestUrl + ? isCrossRepositoryUrl + ? "" + : parsedPullRequestUrl.prNumber + : debouncedTrimmed; + + // Fetch recent PRs for browsing (only when no search query) + const { data: recentPRs, isLoading: isLoadingRecent } = electronTrpc.projects.listPullRequests.useQuery( - { projectId: projectId ?? "" }, - { enabled: !!projectId && open }, + { projectId: projectId ?? "", includeClosed: showClosed }, + { enabled: !!projectId && open && !debouncedTrimmed }, ); - const prsWithSearchField = useMemo( - () => - (pullRequests ?? []).map((pr) => ({ - ...pr, - prNumberStr: String(pr.prNumber), - })), - [pullRequests], - ); + // Server-side search when user types (use debounced for RPC) + const { data: searchResults, isLoading: isSearching } = + electronTrpc.projects.searchPullRequests.useQuery( + { + projectId: projectId ?? "", + query: effectiveQuery, + includeClosed: showClosed, + }, + { + enabled: + !!projectId && open && !!effectiveQuery && !isCrossRepositoryUrl, + }, + ); - const prFuse = useMemo( - () => - new Fuse(prsWithSearchField, { - keys: [ - { name: "prNumberStr", weight: 3 }, - { name: "title", weight: 2 }, - ], - threshold: 0.4, - ignoreLocation: true, - }), - [prsWithSearchField], - ); + const pullRequests = useMemo(() => { + if (isCrossRepositoryUrl) { + return []; + } - const searchResults = useMemo(() => { - if (!prsWithSearchField.length) return []; - if (!searchQuery) { - return prsWithSearchField.slice(0, MAX_RESULTS); + // Use debounced value for mode decision to avoid empty gap + if (debouncedTrimmed) { + return searchResults ?? []; } - const urlMatch = prsWithSearchField.find((pr) => pr.url === searchQuery); - if (urlMatch) return [urlMatch]; - return prFuse - .search(searchQuery, { limit: MAX_RESULTS }) - .map((r) => r.item); - }, [prsWithSearchField, searchQuery, prFuse]); + return recentPRs ?? []; + }, [debouncedTrimmed, isCrossRepositoryUrl, searchResults, recentPRs]); + + const isLoading = isCrossRepositoryUrl + ? false + : debouncedTrimmed + ? isSearching || isPendingDebounce + : isLoadingRecent; const handleClose = () => { setSearchQuery(""); onOpenChange(false); }; - const handleSelect = (pr: (typeof searchResults)[number]) => { + const handleSelect = (pr: (typeof pullRequests)[number]) => { onSelect({ prNumber: pr.prNumber, title: pr.title, @@ -103,8 +158,8 @@ export function PRLinkCommand({ <PopoverAnchor virtualRef={anchorRef as React.RefObject<Element>} /> <PopoverContent className="w-80 p-0" - align="end" - side="top" + align="start" + side="bottom" onWheel={(event) => event.stopPropagation()} onPointerDownOutside={handleClose} onEscapeKeyDown={handleClose} @@ -116,19 +171,46 @@ export function PRLinkCommand({ value={searchQuery} onValueChange={setSearchQuery} /> + <div className="flex items-center gap-2 border-b px-3 py-2"> + <Checkbox + id={showClosedId} + checked={showClosed} + onCheckedChange={(checked) => setShowClosed(checked === true)} + /> + <label + htmlFor={showClosedId} + className="cursor-pointer select-none text-xs text-muted-foreground" + > + Show closed + </label> + </div> <CommandList className="max-h-[280px]"> - {searchResults.length === 0 && ( + {pullRequests.length === 0 && ( <CommandEmpty> {isLoading - ? "Loading pull requests..." - : "No open pull requests found."} + ? debouncedTrimmed + ? "Searching..." + : "Loading pull requests..." + : isCrossRepositoryUrl + ? `PR URL must match ${selectedRepositoryLabel}.` + : debouncedTrimmed + ? "No pull requests found." + : showClosed + ? "No pull requests found." + : "No open pull requests."} </CommandEmpty> )} - {searchResults.length > 0 && ( + {pullRequests.length > 0 && ( <CommandGroup - heading={searchQuery ? "Results" : "Open pull requests"} + heading={ + debouncedTrimmed + ? `${pullRequests.length} result${pullRequests.length === 1 ? "" : "s"}` + : showClosed + ? "Recent pull requests" + : "Open pull requests" + } > - {searchResults.map((pr) => ( + {pullRequests.map((pr) => ( <CommandItem key={pr.prNumber} value={`${pr.prNumber}-${pr.title}`} diff --git a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx index 1fa42170cc7..9297f34cb22 100644 --- a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx +++ b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx @@ -14,9 +14,9 @@ import { OpenInExternalDropdownItems, } from "renderer/components/OpenInExternalDropdown"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useThemeStore } from "renderer/stores"; -import { useHotkeyText } from "renderer/stores/hotkeys"; export interface OpenInButtonProps { path: string | undefined; @@ -37,8 +37,8 @@ export function OpenInButton({ const activeTheme = useThemeStore((state) => state.activeTheme); const [isOpen, setIsOpen] = useState(false); const utils = electronTrpc.useUtils(); - const openInShortcut = useHotkeyText("OPEN_IN_APP"); - const copyPathShortcut = useHotkeyText("COPY_PATH"); + const openInShortcut = useHotkeyDisplay("OPEN_IN_APP").text; + const copyPathShortcut = useHotkeyDisplay("COPY_PATH").text; const showOpenInShortcut = showShortcuts && openInShortcut !== "Unassigned"; const showCopyPathShortcut = diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/FeaturePreview.tsx b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/FeaturePreview.tsx index 5ba497acbdc..a843131ac3d 100644 --- a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/FeaturePreview.tsx +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/FeaturePreview.tsx @@ -1,20 +1,20 @@ import { Badge } from "@superset/ui/badge"; -import { MeshGradient } from "@superset/ui/mesh-gradient"; import { cn } from "@superset/ui/utils"; import type { ComponentType } from "react"; import type { ProFeature } from "../../constants"; import { PRO_FEATURES } from "../../constants"; -import { CloudWorkspacesDemo } from "./components/CloudWorkspacesDemo"; -import { IntegrationsDemo } from "./components/IntegrationsDemo"; +import { AutomationsDemo } from "./components/AutomationsDemo"; +import { DitheredBackground } from "./components/DitheredBackground"; import { MobileAppDemo } from "./components/MobileAppDemo"; +import { RemoteWorkspacesDemo } from "./components/RemoteWorkspacesDemo"; import { TasksDemo } from "./components/TasksDemo"; import { TeamCollaborationDemo } from "./components/TeamCollaborationDemo"; const DEMO_COMPONENTS: Record<string, ComponentType> = { "team-collaboration": TeamCollaborationDemo, - integrations: IntegrationsDemo, tasks: TasksDemo, - "cloud-workspaces": CloudWorkspacesDemo, + automations: AutomationsDemo, + "remote-workspaces": RemoteWorkspacesDemo, "mobile-app": MobileAppDemo, }; @@ -27,7 +27,7 @@ export function FeaturePreview({ selectedFeature }: FeaturePreviewProps) { return ( <div className="flex w-[495px] flex-col"> - <div className="relative h-[346px] overflow-hidden"> + <div className="relative h-[346px] overflow-hidden bg-[#0a0a0f]"> {PRO_FEATURES.map((proFeature) => ( <div key={`gradient-${proFeature.id}`} @@ -38,7 +38,7 @@ export function FeaturePreview({ selectedFeature }: FeaturePreviewProps) { : "opacity-0", )} > - <MeshGradient + <DitheredBackground colors={proFeature.gradientColors} className="absolute inset-0 w-full h-full" /> diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/AutomationsDemo.tsx b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/AutomationsDemo.tsx new file mode 100644 index 00000000000..69acd9040f0 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/AutomationsDemo.tsx @@ -0,0 +1,49 @@ +import { LuClock, LuPlay } from "react-icons/lu"; + +const SCHEDULED = [ + { id: "1", name: "Triage new issues", schedule: "Every weekday at 9am" }, + { id: "2", name: "Bump dependencies", schedule: "Every Monday" }, + { id: "3", name: "Sweep stale PRs", schedule: "Every 6 hours" }, +]; + +export function AutomationsDemo() { + return ( + <div className="w-full h-full flex items-center justify-center"> + <div className="w-[300px] bg-card/90 backdrop-blur-sm rounded-lg border border-border shadow-2xl overflow-hidden"> + <div className="flex items-center justify-between px-4 py-3 bg-muted/80 border-b border-border/50"> + <div className="flex items-center gap-2"> + <div className="flex gap-1.5"> + <div className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" /> + <div className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" /> + <div className="w-2.5 h-2.5 rounded-full bg-[#28c840]" /> + </div> + <span className="text-xs text-muted-foreground ml-1"> + Automations + </span> + </div> + </div> + + <div className="p-4 space-y-1.5"> + {SCHEDULED.map((item) => ( + <div + key={item.id} + className="flex items-center gap-2 px-2 py-2 rounded bg-foreground/5" + > + <div className="size-2 rounded-full bg-emerald-500 shrink-0" /> + <div className="flex-1 min-w-0"> + <div className="text-xs text-foreground/90 truncate"> + {item.name} + </div> + <div className="flex items-center gap-1 text-[10px] text-muted-foreground/80"> + <LuClock className="size-3 shrink-0" /> + <span className="truncate">{item.schedule}</span> + </div> + </div> + <LuPlay className="size-3 text-muted-foreground/60 shrink-0" /> + </div> + ))} + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/index.ts b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/index.ts new file mode 100644 index 00000000000..75023464ec3 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/AutomationsDemo/index.ts @@ -0,0 +1 @@ +export { AutomationsDemo } from "./AutomationsDemo"; diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/CloudWorkspacesDemo.tsx b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/CloudWorkspacesDemo.tsx deleted file mode 100644 index 90ac5411816..00000000000 --- a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/CloudWorkspacesDemo.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - HiArrowPath, - HiCloud, - HiComputerDesktop, - HiDeviceTablet, -} from "react-icons/hi2"; - -const WORKSPACES = [ - { id: "1", name: "superset-app", branch: "main", synced: true }, - { id: "2", name: "api-server", branch: "feature/auth", synced: true }, - { id: "3", name: "mobile-app", branch: "dev", synced: false }, -]; - -export function CloudWorkspacesDemo() { - return ( - <div className="w-full h-full flex items-center justify-center"> - <div className="w-[300px] bg-card/90 backdrop-blur-sm rounded-lg border border-border shadow-2xl overflow-hidden"> - {/* Header */} - <div className="flex items-center justify-between px-4 py-3 bg-muted/80 border-b border-border/50"> - <div className="flex items-center gap-2"> - <div className="flex gap-1.5"> - <div className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" /> - <div className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" /> - <div className="w-2.5 h-2.5 rounded-full bg-[#28c840]" /> - </div> - <span className="text-xs text-muted-foreground ml-1">Cloud</span> - </div> - </div> - - {/* Cloud sync visual */} - <div className="p-4"> - <div className="flex items-center justify-center gap-3 mb-4 py-3"> - <div className="flex flex-col items-center gap-1"> - <HiComputerDesktop className="w-6 h-6 text-muted-foreground" /> - <span className="text-[9px] text-muted-foreground/70"> - Desktop - </span> - </div> - <div className="flex flex-col items-center"> - <div className="flex items-center gap-1"> - <div className="w-4 h-px bg-foreground/20" /> - <HiArrowPath className="w-3 h-3 text-muted-foreground/50" /> - <div className="w-4 h-px bg-foreground/20" /> - </div> - </div> - <div className="flex flex-col items-center gap-1"> - <div className="w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center"> - <HiCloud className="w-5 h-5 text-amber-400" /> - </div> - <span className="text-[9px] text-muted-foreground/70">Cloud</span> - </div> - <div className="flex flex-col items-center"> - <div className="flex items-center gap-1"> - <div className="w-4 h-px bg-foreground/20" /> - <HiArrowPath className="w-3 h-3 text-muted-foreground/50" /> - <div className="w-4 h-px bg-foreground/20" /> - </div> - </div> - <div className="flex flex-col items-center gap-1"> - <HiDeviceTablet className="w-6 h-6 text-muted-foreground" /> - <span className="text-[9px] text-muted-foreground/70"> - Tablet - </span> - </div> - </div> - - {/* Synced workspaces */} - <div className="text-[10px] uppercase text-muted-foreground/70 font-medium tracking-wider mb-2"> - Synced Workspaces - </div> - <div className="space-y-1.5"> - {WORKSPACES.map((ws) => ( - <div - key={ws.id} - className="flex items-center gap-2 px-2 py-1.5 rounded bg-foreground/5 text-xs" - > - <div - className={`w-1.5 h-1.5 rounded-full ${ws.synced ? "bg-emerald-400" : "bg-amber-400"}`} - /> - <span className="text-foreground/80 truncate flex-1"> - {ws.name} - </span> - <span className="text-muted-foreground/50 text-[10px]"> - {ws.branch} - </span> - </div> - ))} - </div> - </div> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/index.ts b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/index.ts deleted file mode 100644 index a9c31d55875..00000000000 --- a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/CloudWorkspacesDemo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CloudWorkspacesDemo } from "./CloudWorkspacesDemo"; diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/DitheredBackground.tsx b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/DitheredBackground.tsx new file mode 100644 index 00000000000..aae29a615e1 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/DitheredBackground.tsx @@ -0,0 +1,33 @@ +import { lazy, Suspense } from "react"; + +const Dithering = lazy(() => + import("@paper-design/shaders-react").then((mod) => ({ + default: mod.Dithering, + })), +); + +interface DitheredBackgroundProps { + colors: readonly [string, string, string, string]; + className?: string; +} + +export function DitheredBackground({ + colors, + className = "", +}: DitheredBackgroundProps) { + return ( + <div className={`${className} pointer-events-none mix-blend-screen`}> + <Suspense fallback={null}> + <Dithering + colorBack="#00000000" + colorFront={colors[0]} + shape="warp" + type="4x4" + speed={0.15} + className="size-full" + minPixelRatio={1} + /> + </Suspense> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/index.ts b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/index.ts new file mode 100644 index 00000000000..cfaafce3c76 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/DitheredBackground/index.ts @@ -0,0 +1 @@ +export { DitheredBackground } from "./DitheredBackground"; diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/IntegrationsDemo.tsx b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/IntegrationsDemo.tsx deleted file mode 100644 index 1a11b48fd32..00000000000 --- a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/IntegrationsDemo.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { HiArrowPath, HiCheck } from "react-icons/hi2"; -import { SiGithub, SiLinear } from "react-icons/si"; - -const SYNCED_ITEMS = [ - { id: "1", type: "issue", name: "SUP-142: Fix auth flow", status: "synced" }, - { id: "2", type: "pr", name: "PR #89: Add workspace sync", status: "synced" }, - { - id: "3", - type: "issue", - name: "SUP-156: Mobile responsive", - status: "syncing", - }, -]; - -export function IntegrationsDemo() { - return ( - <div className="w-full h-full flex items-center justify-center"> - <div className="w-[300px] bg-card/90 backdrop-blur-sm rounded-lg border border-border shadow-2xl overflow-hidden"> - {/* Header */} - <div className="flex items-center justify-between px-4 py-3 bg-muted/80 border-b border-border/50"> - <div className="flex items-center gap-2"> - <div className="flex gap-1.5"> - <div className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" /> - <div className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" /> - <div className="w-2.5 h-2.5 rounded-full bg-[#28c840]" /> - </div> - <span className="text-xs text-muted-foreground ml-1"> - Integrations - </span> - </div> - </div> - - {/* Connected services */} - <div className="p-4"> - <div className="flex items-center justify-center gap-6 mb-4 py-2"> - <div className="flex flex-col items-center gap-1.5"> - <div className="w-10 h-10 rounded-lg bg-[#5E6AD2] flex items-center justify-center"> - <SiLinear className="w-5 h-5 text-white" /> - </div> - <span className="text-[10px] text-muted-foreground">Linear</span> - </div> - <div className="flex items-center gap-1"> - <div className="w-6 h-px bg-foreground/20" /> - <HiArrowPath className="w-4 h-4 text-muted-foreground/70 animate-spin-slow" /> - <div className="w-6 h-px bg-foreground/20" /> - </div> - <div className="flex flex-col items-center gap-1.5"> - <div className="w-10 h-10 rounded-lg bg-[#24292e] flex items-center justify-center"> - <SiGithub className="w-5 h-5 text-white" /> - </div> - <span className="text-[10px] text-muted-foreground">GitHub</span> - </div> - </div> - - {/* Synced items */} - <div className="text-[10px] uppercase text-muted-foreground/70 font-medium tracking-wider mb-2"> - Synced Items - </div> - <div className="space-y-1.5"> - {SYNCED_ITEMS.map((item) => ( - <div - key={item.id} - className="flex items-center gap-2 px-2 py-1.5 rounded bg-foreground/5 text-xs" - > - {item.status === "synced" ? ( - <HiCheck className="w-3 h-3 text-emerald-400 shrink-0" /> - ) : ( - <HiArrowPath className="w-3 h-3 text-amber-400 shrink-0 animate-spin" /> - )} - <span className="text-foreground/80 truncate">{item.name}</span> - </div> - ))} - </div> - </div> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/index.ts b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/index.ts deleted file mode 100644 index 1dd7a1aff1b..00000000000 --- a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/IntegrationsDemo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IntegrationsDemo } from "./IntegrationsDemo"; diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/RemoteWorkspacesDemo.tsx b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/RemoteWorkspacesDemo.tsx new file mode 100644 index 00000000000..bd08cda18e0 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/RemoteWorkspacesDemo.tsx @@ -0,0 +1,57 @@ +import { HiOutlineComputerDesktop, HiOutlineSignal } from "react-icons/hi2"; + +export function RemoteWorkspacesDemo() { + return ( + <div className="w-full h-full flex items-center justify-center"> + <div className="w-[300px] bg-card/90 backdrop-blur-sm rounded-lg border border-border shadow-2xl overflow-hidden"> + <div className="flex items-center justify-between px-4 py-3 bg-muted/80 border-b border-border/50"> + <div className="flex items-center gap-2"> + <div className="flex gap-1.5"> + <div className="w-2.5 h-2.5 rounded-full bg-[#ff5f57]" /> + <div className="w-2.5 h-2.5 rounded-full bg-[#febc2e]" /> + <div className="w-2.5 h-2.5 rounded-full bg-[#28c840]" /> + </div> + <span className="text-xs text-muted-foreground ml-1"> + Remote Workspaces + </span> + </div> + </div> + + <div className="p-4"> + <div className="flex items-center justify-center gap-3 py-3"> + <div className="flex flex-col items-center gap-1.5"> + <div className="w-10 h-10 rounded-lg bg-foreground/10 flex items-center justify-center"> + <HiOutlineComputerDesktop className="size-5 text-foreground/80" /> + </div> + <span className="text-[10px] text-muted-foreground"> + This Mac + </span> + </div> + <div className="flex items-center gap-1"> + <div className="w-6 h-px bg-foreground/20" /> + <HiOutlineSignal className="size-4 text-pink-400 animate-pulse" /> + <div className="w-6 h-px bg-foreground/20" /> + </div> + <div className="flex flex-col items-center gap-1.5"> + <div className="w-10 h-10 rounded-lg bg-foreground/10 flex items-center justify-center"> + <HiOutlineComputerDesktop className="size-5 text-foreground/80" /> + </div> + <span className="text-[10px] text-muted-foreground">Remote</span> + </div> + </div> + + <div className="mt-2 space-y-1.5"> + <div className="flex items-center justify-between px-2 py-1.5 rounded bg-foreground/5 text-xs"> + <span className="text-foreground/80">Tunnel established</span> + <span className="text-emerald-400 text-[10px]">live</span> + </div> + <div className="flex items-center justify-between px-2 py-1.5 rounded bg-foreground/5 text-xs"> + <span className="text-foreground/80">Latency</span> + <span className="text-foreground/60 text-[10px]">42ms</span> + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/index.ts b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/index.ts new file mode 100644 index 00000000000..48c6b977109 --- /dev/null +++ b/apps/desktop/src/renderer/components/Paywall/components/FeaturePreview/components/RemoteWorkspacesDemo/index.ts @@ -0,0 +1 @@ +export { RemoteWorkspacesDemo } from "./RemoteWorkspacesDemo"; diff --git a/apps/desktop/src/renderer/components/Paywall/constants.ts b/apps/desktop/src/renderer/components/Paywall/constants.ts index 2dac4cebb40..4dd37aa66a2 100644 --- a/apps/desktop/src/renderer/components/Paywall/constants.ts +++ b/apps/desktop/src/renderer/components/Paywall/constants.ts @@ -1,17 +1,17 @@ import type { IconType } from "react-icons"; import { - HiCloud, HiDevicePhoneMobile, HiOutlineClipboardDocumentList, - HiOutlinePuzzlePiece, + HiOutlineSignal, HiUsers, } from "react-icons/hi2"; +import { LuClock } from "react-icons/lu"; export const GATED_FEATURES = { INVITE_MEMBERS: "invite-members", - INTEGRATIONS: "integrations", TASKS: "tasks", - CLOUD_WORKSPACES: "cloud-workspaces", + AUTOMATIONS: "automations", + REMOTE_WORKSPACES: "remote-workspaces", MOBILE_APP: "mobile-app", } as const; @@ -28,6 +28,24 @@ export interface ProFeature { } export const PRO_FEATURES: ProFeature[] = [ + { + id: "remote-workspaces", + title: "Remote Workspaces", + description: + "Reach this Mac from anywhere via the Superset relay, or spin up cloud workspaces. Connect from any client.", + icon: HiOutlineSignal, + iconColor: "text-pink-500", + gradientColors: ["#be185d", "#9d174d", "#831843", "#1a1a2e"], + }, + { + id: "automations", + title: "Automations", + description: + "Run agents on a schedule. Kick off PR reviews, refactors, and recurring chores without lifting a finger.", + icon: LuClock, + iconColor: "text-cyan-500", + gradientColors: ["#0e7490", "#155e75", "#164e63", "#1a1a2e"], + }, { id: "team-collaboration", title: "Team Collaboration", @@ -37,15 +55,6 @@ export const PRO_FEATURES: ProFeature[] = [ iconColor: "text-blue-500", gradientColors: ["#1e40af", "#1e3a8a", "#172554", "#1a1a2e"], }, - { - id: "integrations", - title: "Integrations", - description: - "Connect Linear, GitHub, and more to sync issues and PRs directly with your workspaces.", - icon: HiOutlinePuzzlePiece, - iconColor: "text-purple-500", - gradientColors: ["#7c3aed", "#6d28d9", "#4c1d95", "#1a1a2e"], - }, { id: "tasks", title: "Tasks", @@ -55,16 +64,6 @@ export const PRO_FEATURES: ProFeature[] = [ iconColor: "text-emerald-500", gradientColors: ["#047857", "#065f46", "#064e3b", "#1a1a2e"], }, - { - id: "cloud-workspaces", - title: "Cloud Workspaces", - description: - "Access your workspaces from anywhere with cloud-hosted environments.", - icon: HiCloud, - iconColor: "text-amber-500", - gradientColors: ["#b45309", "#92400e", "#78350f", "#1a1a2e"], - comingSoon: true, - }, { id: "mobile-app", title: "Mobile App", @@ -80,8 +79,8 @@ export const PRO_FEATURES: ProFeature[] = [ // Map gated feature IDs to the feature to highlight in the paywall dialog export const FEATURE_ID_MAP: Record<GatedFeature, string> = { [GATED_FEATURES.INVITE_MEMBERS]: "team-collaboration", - [GATED_FEATURES.INTEGRATIONS]: "integrations", [GATED_FEATURES.TASKS]: "tasks", - [GATED_FEATURES.CLOUD_WORKSPACES]: "cloud-workspaces", + [GATED_FEATURES.AUTOMATIONS]: "automations", + [GATED_FEATURES.REMOTE_WORKSPACES]: "remote-workspaces", [GATED_FEATURES.MOBILE_APP]: "mobile-app", }; diff --git a/apps/desktop/src/renderer/components/PickerTrigger/PickerTrigger.tsx b/apps/desktop/src/renderer/components/PickerTrigger/PickerTrigger.tsx new file mode 100644 index 00000000000..93e6c7aa1be --- /dev/null +++ b/apps/desktop/src/renderer/components/PickerTrigger/PickerTrigger.tsx @@ -0,0 +1,38 @@ +import { Button } from "@superset/ui/button"; +import { cn } from "@superset/ui/utils"; +import type * as React from "react"; +import { HiChevronUpDown } from "react-icons/hi2"; + +type PickerTriggerProps = Omit< + React.ComponentProps<typeof Button>, + "children" +> & { + icon?: React.ReactNode; + label: React.ReactNode; + /** Rendered after the label and before the chevron (e.g. status dot). */ + endAdornment?: React.ReactNode; +}; + +export function PickerTrigger({ + icon, + label, + endAdornment, + className, + variant = "ghost", + ...props +}: PickerTriggerProps) { + return ( + <Button + variant={variant} + {...props} + className={cn("justify-between gap-1 px-2 text-xs", className)} + > + <span className="flex min-w-0 flex-1 items-center gap-1.5"> + {icon} + <span className="truncate text-left">{label}</span> + {endAdornment} + </span> + <HiChevronUpDown className="size-3 shrink-0" /> + </Button> + ); +} diff --git a/apps/desktop/src/renderer/components/PickerTrigger/index.ts b/apps/desktop/src/renderer/components/PickerTrigger/index.ts new file mode 100644 index 00000000000..90d627c057a --- /dev/null +++ b/apps/desktop/src/renderer/components/PickerTrigger/index.ts @@ -0,0 +1 @@ +export { PickerTrigger } from "./PickerTrigger"; diff --git a/apps/desktop/src/renderer/components/PostHogSurfaceTagger/PostHogSurfaceTagger.tsx b/apps/desktop/src/renderer/components/PostHogSurfaceTagger/PostHogSurfaceTagger.tsx new file mode 100644 index 00000000000..fefef77ea13 --- /dev/null +++ b/apps/desktop/src/renderer/components/PostHogSurfaceTagger/PostHogSurfaceTagger.tsx @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; +import { posthog } from "renderer/lib/posthog"; +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; + +export function PostHogSurfaceTagger() { + const { isV2CloudEnabled, isRemoteV2Enabled } = useIsV2CloudEnabled(); + const optInV2 = useV2LocalOverrideStore((s) => s.optInV2); + + useEffect(() => { + const surface = isV2CloudEnabled ? "v2" : "v1"; + const surface_source = !isRemoteV2Enabled + ? "v2-flag-off" + : optInV2 + ? "opted-in" + : "opted-out"; + + posthog.register({ surface, surface_source }); + + posthog.people.set({ surface }); + if (isV2CloudEnabled) { + posthog.people.set_once({ + surface_first_v2_at: new Date().toISOString(), + surface_ever_v2: true, + }); + } + }, [isV2CloudEnabled, isRemoteV2Enabled, optInV2]); + + return null; +} diff --git a/apps/desktop/src/renderer/components/PostHogSurfaceTagger/index.ts b/apps/desktop/src/renderer/components/PostHogSurfaceTagger/index.ts new file mode 100644 index 00000000000..79e096e00bd --- /dev/null +++ b/apps/desktop/src/renderer/components/PostHogSurfaceTagger/index.ts @@ -0,0 +1 @@ +export { PostHogSurfaceTagger } from "./PostHogSurfaceTagger"; diff --git a/apps/desktop/src/renderer/components/UpdateRequiredPage/UpdateRequiredPage.tsx b/apps/desktop/src/renderer/components/UpdateRequiredPage/UpdateRequiredPage.tsx index 968020c7ced..c6c4a763306 100644 --- a/apps/desktop/src/renderer/components/UpdateRequiredPage/UpdateRequiredPage.tsx +++ b/apps/desktop/src/renderer/components/UpdateRequiredPage/UpdateRequiredPage.tsx @@ -80,7 +80,7 @@ export function UpdateRequiredPage({ </p> {isError && ( - <p className="text-sm text-destructive"> + <p className="text-sm text-destructive select-text cursor-text break-words"> {updateStatus.error || "Update check failed. Please try again."} </p> )} @@ -90,7 +90,11 @@ export function UpdateRequiredPage({ <Button onClick={handleInstall} disabled={installMutation.isPending} + className="gap-2" > + {installMutation.isPending && ( + <HiArrowPath className="h-4 w-4 animate-spin" /> + )} {installMutation.isPending ? "Installing..." : "Install & Restart"} diff --git a/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx b/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx index 85f54a6d631..9eeed7d2af2 100644 --- a/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx +++ b/apps/desktop/src/renderer/components/UpdateToast/UpdateToast.tsx @@ -1,7 +1,7 @@ import { COMPANY } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; -import { HiMiniXMark } from "react-icons/hi2"; +import { HiArrowPath, HiMiniXMark } from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { AUTO_UPDATE_STATUS } from "shared/auto-update"; @@ -93,6 +93,9 @@ export function UpdateToast({ onClick={handleInstall} disabled={installMutation.isPending} > + {installMutation.isPending && ( + <HiArrowPath className="size-3.5 animate-spin" /> + )} {installMutation.isPending ? "Installing..." : "Install"} </Button> </div> diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts index 4c41ac232e1..445b0be9d46 100644 --- a/apps/desktop/src/renderer/env.renderer.ts +++ b/apps/desktop/src/renderer/env.renderer.ts @@ -17,12 +17,14 @@ const envSchema = z.object({ .default("development"), NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), + NEXT_PUBLIC_MARKETING_URL: z.url().default("https://superset.sh"), NEXT_PUBLIC_ELECTRIC_URL: z .url() .default("https://electric-proxy.avi-6ac.workers.dev"), NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), SENTRY_DSN_DESKTOP: z.string().optional(), + RELAY_URL: z.url().default("https://relay.superset.sh"), }); /** @@ -36,6 +38,7 @@ const rawEnv = { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, + NEXT_PUBLIC_MARKETING_URL: process.env.NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_ELECTRIC_URL: process.env.NEXT_PUBLIC_ELECTRIC_URL, NEXT_PUBLIC_POSTHOG_KEY: import.meta.env.NEXT_PUBLIC_POSTHOG_KEY as | string @@ -44,6 +47,7 @@ const rawEnv = { | string | undefined, SENTRY_DSN_DESKTOP: import.meta.env.SENTRY_DSN_DESKTOP as string | undefined, + RELAY_URL: process.env.RELAY_URL, }; // Only allow skipping validation in development (never in production) diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index a21a41cd21f..bdea523634a 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -280,4 +280,19 @@ [data-sonner-toast]:has(.update-toast) { transform: translateY(0); } + + /* Tiptap chat input placeholder */ + .tiptap-chat-input p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--muted-foreground); + pointer-events: none; + height: 0; + opacity: 0.5; + } + + /* Ensure Tiptap chat input paragraphs have no default margin */ + .tiptap-chat-input p { + margin: 0; + } } diff --git a/apps/desktop/src/renderer/hooks/host-service/useDestroyWorkspace/index.ts b/apps/desktop/src/renderer/hooks/host-service/useDestroyWorkspace/index.ts new file mode 100644 index 00000000000..18470f70f61 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useDestroyWorkspace/index.ts @@ -0,0 +1,7 @@ +export { + type DestroyWorkspaceError, + type DestroyWorkspaceInput, + type DestroyWorkspaceSuccess, + type UseDestroyWorkspace, + useDestroyWorkspace, +} from "./useDestroyWorkspace"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useDestroyWorkspace/useDestroyWorkspace.ts b/apps/desktop/src/renderer/hooks/host-service/useDestroyWorkspace/useDestroyWorkspace.ts new file mode 100644 index 00000000000..83e2681d7e8 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useDestroyWorkspace/useDestroyWorkspace.ts @@ -0,0 +1,87 @@ +import type { TeardownFailureCause } from "@superset/host-service"; +import { TRPCClientError } from "@trpc/client"; +import { useCallback } from "react"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl"; + +export interface DestroyWorkspaceInput { + deleteBranch?: boolean; + force?: boolean; +} + +export interface DestroyWorkspaceSuccess { + success: boolean; + worktreeRemoved: boolean; + branchDeleted: boolean; + cloudDeleted: boolean; + warnings: string[]; +} + +export type DestroyWorkspaceError = + | { kind: "conflict"; message: string } + | { kind: "teardown-failed"; cause: TeardownFailureCause } + | { kind: "unknown"; message: string }; + +export interface UseDestroyWorkspace { + destroy: (input?: DestroyWorkspaceInput) => Promise<DestroyWorkspaceSuccess>; +} + +/** + * Calls `workspaceCleanup.destroy` on the workspace's owning host-service. + * Translates TRPC errors into a typed discriminated union so callers can + * prompt for `force: true` on conflict or teardown failure. + * + * Throws a DestroyWorkspaceError (not a TRPCClientError) for easier handling. + */ +export function useDestroyWorkspace(workspaceId: string): UseDestroyWorkspace { + const hostUrl = useWorkspaceHostUrl(workspaceId); + + const destroy = useCallback( + async ( + input: DestroyWorkspaceInput = {}, + ): Promise<DestroyWorkspaceSuccess> => { + if (!hostUrl) { + throw { + kind: "unknown", + message: "Host unavailable", + } satisfies DestroyWorkspaceError; + } + + const client = getHostServiceClientByUrl(hostUrl); + try { + const result = await client.workspaceCleanup.destroy.mutate({ + workspaceId, + deleteBranch: input.deleteBranch ?? false, + force: input.force ?? false, + }); + return result; + } catch (err) { + throw normalizeError(err); + } + }, + [hostUrl, workspaceId], + ); + + return { destroy }; +} + +function normalizeError(err: unknown): DestroyWorkspaceError { + if (err instanceof TRPCClientError) { + const code = err.data?.code as string | undefined; + const teardownFailure = ( + err.data as { teardownFailure?: TeardownFailureCause } + )?.teardownFailure; + + if (teardownFailure) { + return { kind: "teardown-failed", cause: teardownFailure }; + } + if (code === "CONFLICT") { + return { kind: "conflict", message: err.message }; + } + return { kind: "unknown", message: err.message }; + } + return { + kind: "unknown", + message: err instanceof Error ? err.message : String(err), + }; +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/index.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/index.ts new file mode 100644 index 00000000000..5dc048a8ea2 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/index.ts @@ -0,0 +1 @@ +export { type DiffStats, useDiffStats } from "./useDiffStats"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts new file mode 100644 index 00000000000..13f27f2e853 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from "react"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useWorkspaceEvent } from "../useWorkspaceEvent"; +import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl"; + +export interface DiffStats { + additions: number; + deletions: number; +} + +/** + * Fetches diff stats for a single workspace, auto-updates on git changes. + * Just pass the workspaceId — host resolution is handled internally. + */ +export function useDiffStats(workspaceId: string): DiffStats | null { + const [stats, setStats] = useState<DiffStats | null>(null); + const hostUrl = useWorkspaceHostUrl(workspaceId); + + const fetchStats = useCallback(async () => { + if (!hostUrl) return; + try { + const client = getHostServiceClientByUrl(hostUrl); + const status = await client.git.getStatus.query({ workspaceId }); + + // Deduplicate by path — a file can appear in multiple categories + const byPath = new Map< + string, + { additions: number; deletions: number } + >(); + for (const file of status.againstBase) { + byPath.set(file.path, file); + } + for (const file of status.staged) { + byPath.set(file.path, file); + } + for (const file of status.unstaged) { + byPath.set(file.path, file); + } + + let additions = 0; + let deletions = 0; + for (const file of byPath.values()) { + additions += file.additions; + deletions += file.deletions; + } + + setStats({ additions, deletions }); + } catch { + // Host unavailable or workspace deleted + } + }, [hostUrl, workspaceId]); + + useEffect(() => { + void fetchStats(); + }, [fetchStats]); + + useWorkspaceEvent("git:changed", workspaceId, () => { + void fetchStats(); + }); + + return stats; +} diff --git a/packages/workspace-client/src/hooks/useFileTree/index.ts b/apps/desktop/src/renderer/hooks/host-service/useFileTree/index.ts similarity index 100% rename from packages/workspace-client/src/hooks/useFileTree/index.ts rename to apps/desktop/src/renderer/hooks/host-service/useFileTree/index.ts diff --git a/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts b/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts new file mode 100644 index 00000000000..9032188bf20 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts @@ -0,0 +1,534 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +import type { FsEntry, FsEntryKind } from "@superset/workspace-fs/client"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useWorkspaceEvent } from "../useWorkspaceEvent"; + +export interface FileTreeNode { + absolutePath: string; + kind: FsEntryKind; + name: string; + relativePath: string; + isExpanded: boolean; + isLoading: boolean; + children: FileTreeNode[]; +} + +export interface UseFileTreeParams { + workspaceId: string; + rootPath: string; + persistKey?: string; +} + +export interface UseFileTreeResult { + isLoadingRoot: boolean; + collapseAll: () => void; + rootEntries: FileTreeNode[]; + expand: (path: string) => Promise<void>; + collapse: (path: string) => void; + toggle: (path: string) => Promise<void>; + refreshAll: () => Promise<void>; + refreshPath: (path: string) => Promise<void>; + reveal: (path: string, options?: { isDirectory?: boolean }) => Promise<void>; +} + +interface FileTreeState { + childPathsByDirectory: Map<string, string[]>; + entriesByPath: Map<string, FsEntry>; + expandedDirectories: Set<string>; + invalidatedDirectories: Set<string>; + loadedDirectories: Set<string>; + loadingDirectories: Set<string>; +} + +interface LoadDirectoryOptions { + force?: boolean; +} + +function applyDirectoryEntries( + current: FileTreeState, + absolutePath: string, + entries: FsEntry[], +): FileTreeState { + const nextEntries = new Map(current.entriesByPath); + const nextChildren = new Map(current.childPathsByDirectory); + const nextLoaded = new Set(current.loadedDirectories); + const nextInvalidated = new Set(current.invalidatedDirectories); + const nextLoading = new Set(current.loadingDirectories); + nextLoading.delete(absolutePath); + nextLoaded.add(absolutePath); + nextInvalidated.delete(absolutePath); + + for (const entry of entries) { + nextEntries.set(entry.absolutePath, entry); + } + + nextChildren.set( + absolutePath, + entries.map((entry) => entry.absolutePath), + ); + + return { + ...current, + childPathsByDirectory: nextChildren, + entriesByPath: nextEntries, + invalidatedDirectories: nextInvalidated, + loadedDirectories: nextLoaded, + loadingDirectories: nextLoading, + }; +} + +function createInitialState(): FileTreeState { + return { + childPathsByDirectory: new Map<string, string[]>(), + entriesByPath: new Map<string, FsEntry>(), + expandedDirectories: new Set<string>(), + invalidatedDirectories: new Set<string>(), + loadedDirectories: new Set<string>(), + loadingDirectories: new Set<string>(), + }; +} + +function getParentPath(absolutePath: string): string { + const trimmedPath = absolutePath.replace(/[\\/]+$/, ""); + const lastSeparatorIndex = Math.max( + trimmedPath.lastIndexOf("/"), + trimmedPath.lastIndexOf("\\"), + ); + + if (lastSeparatorIndex <= 0) { + return trimmedPath; + } + + if (/^[A-Za-z]:$/.test(trimmedPath.slice(0, lastSeparatorIndex))) { + return `${trimmedPath.slice(0, lastSeparatorIndex)}\\`; + } + + return trimmedPath.slice(0, lastSeparatorIndex); +} + +function getRelativePath(rootPath: string, absolutePath: string): string { + if (absolutePath === rootPath) { + return ""; + } + + if (absolutePath.startsWith(`${rootPath}/`)) { + return absolutePath.slice(rootPath.length + 1); + } + + if (absolutePath.startsWith(`${rootPath}\\`)) { + return absolutePath.slice(rootPath.length + 1); + } + + return absolutePath; +} + +function isWithinPath(rootPath: string, absolutePath: string): boolean { + return ( + absolutePath === rootPath || + absolutePath.startsWith(`${rootPath}/`) || + absolutePath.startsWith(`${rootPath}\\`) + ); +} + +function deleteSubtree( + state: FileTreeState, + absolutePath: string, +): FileTreeState { + const nextEntries = new Map(state.entriesByPath); + const nextChildren = new Map(state.childPathsByDirectory); + const nextExpanded = new Set(state.expandedDirectories); + const nextLoaded = new Set(state.loadedDirectories); + const nextInvalidated = new Set(state.invalidatedDirectories); + const nextLoading = new Set(state.loadingDirectories); + + for (const path of nextEntries.keys()) { + if (isWithinPath(absolutePath, path)) { + nextEntries.delete(path); + } + } + + for (const path of nextChildren.keys()) { + if (isWithinPath(absolutePath, path)) { + nextChildren.delete(path); + } + } + + for (const path of Array.from(nextExpanded)) { + if (isWithinPath(absolutePath, path)) { + nextExpanded.delete(path); + } + } + + for (const path of Array.from(nextLoaded)) { + if (isWithinPath(absolutePath, path)) { + nextLoaded.delete(path); + } + } + + for (const path of Array.from(nextInvalidated)) { + if (isWithinPath(absolutePath, path)) { + nextInvalidated.delete(path); + } + } + + for (const path of Array.from(nextLoading)) { + if (isWithinPath(absolutePath, path)) { + nextLoading.delete(path); + } + } + + return { + childPathsByDirectory: nextChildren, + entriesByPath: nextEntries, + expandedDirectories: nextExpanded, + invalidatedDirectories: nextInvalidated, + loadedDirectories: nextLoaded, + loadingDirectories: nextLoading, + }; +} + +function retargetPath(path: string, fromPath: string, toPath: string): string { + if (path === fromPath) { + return toPath; + } + + if (path.startsWith(`${fromPath}/`)) { + return `${toPath}${path.slice(fromPath.length)}`; + } + + if (path.startsWith(`${fromPath}\\`)) { + return `${toPath}${path.slice(fromPath.length)}`; + } + + return path; +} + +export function useFileTree({ + workspaceId, + rootPath, +}: UseFileTreeParams): UseFileTreeResult { + const utils = workspaceTrpc.useUtils(); + const [state, setState] = useState<FileTreeState>(() => createInitialState()); + const stateRef = useRef(state); + stateRef.current = state; + + const updateState = useCallback( + (updater: (current: FileTreeState) => FileTreeState) => { + setState((current) => { + const next = updater(current); + stateRef.current = next; + return next; + }); + }, + [], + ); + + const loadDirectory = useCallback( + async ( + absolutePath: string, + options: LoadDirectoryOptions = {}, + ): Promise<void> => { + const { force = false } = options; + if (!workspaceId || !absolutePath) return; + + const currentState = stateRef.current; + if (currentState.loadingDirectories.has(absolutePath)) return; + if ( + !force && + currentState.loadedDirectories.has(absolutePath) && + !currentState.invalidatedDirectories.has(absolutePath) + ) { + return; + } + + const input = { workspaceId, absolutePath }; + const cachedResult = utils.filesystem.listDirectory.getData(input); + if (cachedResult) { + updateState((current) => + applyDirectoryEntries(current, absolutePath, cachedResult.entries), + ); + if (!force) return; + } + + updateState((current) => ({ + ...current, + loadingDirectories: new Set(current.loadingDirectories).add( + absolutePath, + ), + })); + + try { + // Server-side timeout + React Query's TIMEOUT-aware retry handle + // hung host-service IPC; we just await the fetch and apply results. + const result = await utils.filesystem.listDirectory.fetch(input); + updateState((current) => + applyDirectoryEntries(current, absolutePath, result.entries), + ); + } catch (error) { + console.error( + "[workspace-client/useFileTree] Failed to load directory:", + { absolutePath, error }, + ); + updateState((current) => { + const nextLoading = new Set(current.loadingDirectories); + nextLoading.delete(absolutePath); + return { ...current, loadingDirectories: nextLoading }; + }); + } + }, + [updateState, utils.filesystem.listDirectory, workspaceId], + ); + + const refreshPath = useCallback( + async (absolutePath: string): Promise<void> => { + await loadDirectory(absolutePath, { force: true }); + }, + [loadDirectory], + ); + + const refreshAll = useCallback(async (): Promise<void> => { + if (!rootPath) { + return; + } + + const expandedDirectories = Array.from( + stateRef.current.expandedDirectories, + ).sort( + (left, right) => left.split(/[/\\]/).length - right.split(/[/\\]/).length, + ); + + await loadDirectory(rootPath, { force: true }); + for (const absolutePath of expandedDirectories) { + if (absolutePath !== rootPath) { + await loadDirectory(absolutePath, { force: true }); + } + } + }, [loadDirectory, rootPath]); + + const expand = useCallback( + async (absolutePath: string): Promise<void> => { + updateState((current) => { + const nextExpanded = new Set(current.expandedDirectories); + nextExpanded.add(absolutePath); + return { + ...current, + expandedDirectories: nextExpanded, + }; + }); + + await loadDirectory(absolutePath); + }, + [loadDirectory, updateState], + ); + + const collapse = useCallback( + (absolutePath: string): void => { + updateState((current) => { + const nextExpanded = new Set(current.expandedDirectories); + nextExpanded.delete(absolutePath); + return { + ...current, + expandedDirectories: nextExpanded, + }; + }); + }, + [updateState], + ); + + const toggle = useCallback( + async (absolutePath: string): Promise<void> => { + if (stateRef.current.expandedDirectories.has(absolutePath)) { + collapse(absolutePath); + return; + } + + await expand(absolutePath); + }, + [collapse, expand], + ); + + const collapseAll = useCallback((): void => { + updateState((current) => ({ + ...current, + expandedDirectories: new Set<string>(), + })); + }, [updateState]); + + useEffect(() => { + updateState(() => createInitialState()); + if (!rootPath) return; + void loadDirectory(rootPath, { force: true }); + }, [loadDirectory, rootPath, updateState]); + + useWorkspaceEvent( + "fs:events", + workspaceId, + (event) => { + if (!rootPath) { + return; + } + + const relevantPaths = [event.absolutePath, event.oldAbsolutePath].filter( + (path): path is string => Boolean(path), + ); + if (!relevantPaths.some((path) => isWithinPath(rootPath, path))) { + return; + } + + if (event.kind === "overflow") { + void refreshAll(); + return; + } + + if (event.kind === "rename" && event.oldAbsolutePath) { + const oldAbsolutePath = event.oldAbsolutePath; + const oldParentPath = getParentPath(oldAbsolutePath); + const newParentPath = getParentPath(event.absolutePath); + + updateState((current) => { + let nextState = deleteSubtree(current, oldAbsolutePath); + if (event.isDirectory) { + const nextExpanded = new Set<string>(); + for (const path of current.expandedDirectories) { + nextExpanded.add( + retargetPath(path, oldAbsolutePath, event.absolutePath), + ); + } + nextState = { + ...nextState, + expandedDirectories: nextExpanded, + }; + } + + const nextInvalidated = new Set(nextState.invalidatedDirectories); + nextInvalidated.add(oldParentPath); + nextInvalidated.add(newParentPath); + return { + ...nextState, + invalidatedDirectories: nextInvalidated, + }; + }); + + if (stateRef.current.loadedDirectories.has(oldParentPath)) { + void loadDirectory(oldParentPath, { force: true }); + } + if (stateRef.current.loadedDirectories.has(newParentPath)) { + void loadDirectory(newParentPath, { force: true }); + } + if ( + event.isDirectory && + stateRef.current.expandedDirectories.has(event.absolutePath) + ) { + void loadDirectory(event.absolutePath, { force: true }); + } + return; + } + + const parentPath = + event.kind === "update" && event.isDirectory + ? event.absolutePath + : getParentPath(event.absolutePath); + + updateState((current) => { + let nextState = current; + if (event.kind === "delete" && event.isDirectory) { + nextState = deleteSubtree(current, event.absolutePath); + } + + const nextInvalidated = new Set(nextState.invalidatedDirectories); + nextInvalidated.add(parentPath); + return { + ...nextState, + invalidatedDirectories: nextInvalidated, + }; + }); + + if (stateRef.current.loadedDirectories.has(parentPath)) { + void loadDirectory(parentPath, { force: true }); + } + }, + Boolean(workspaceId && rootPath), + ); + + const rootEntries = useMemo(() => { + const buildChildren = (directoryPath: string): FileTreeNode[] => { + const childPaths = state.childPathsByDirectory.get(directoryPath) ?? []; + return childPaths + .map((childPath) => { + const entry = state.entriesByPath.get(childPath); + if (!entry) { + return null; + } + + const isExpanded = state.expandedDirectories.has(childPath); + const isLoading = state.loadingDirectories.has(childPath); + return { + absolutePath: entry.absolutePath, + kind: entry.kind, + name: entry.name, + relativePath: getRelativePath(rootPath, entry.absolutePath), + isExpanded, + isLoading, + children: + entry.kind === "directory" && isExpanded + ? buildChildren(childPath) + : [], + } satisfies FileTreeNode; + }) + .filter((entry): entry is FileTreeNode => Boolean(entry)); + }; + + return rootPath ? buildChildren(rootPath) : []; + }, [ + rootPath, + state.childPathsByDirectory, + state.entriesByPath, + state.expandedDirectories, + state.loadingDirectories, + ]); + + const reveal = useCallback( + async ( + absolutePath: string, + options?: { isDirectory?: boolean }, + ): Promise<void> => { + if (!rootPath || !absolutePath.startsWith(rootPath)) return; + + const ancestors: string[] = []; + let current = getParentPath(absolutePath); + while (current.length >= rootPath.length && current !== absolutePath) { + ancestors.unshift(current); + if (current === rootPath) break; + current = getParentPath(current); + } + + for (const dir of ancestors) { + await expand(dir); + } + + // Trust a caller's explicit isDirectory hint (e.g. terminal link-click + // that already stat'd the path) — the loaded `entriesByPath` may not + // contain the target if its path form (case, symlink resolution) differs + // from what `listDirectory` produced for the parent. + const entry = stateRef.current.entriesByPath.get(absolutePath); + const isDirectory = + options?.isDirectory === true || entry?.kind === "directory"; + if (isDirectory) { + await expand(absolutePath); + } + }, + [expand, rootPath], + ); + + return { + isLoadingRoot: state.loadingDirectories.has(rootPath), + collapseAll, + rootEntries, + expand, + collapse, + toggle, + refreshAll, + refreshPath, + reveal, + }; +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/index.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/index.ts new file mode 100644 index 00000000000..e57f7d0e00a --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/index.ts @@ -0,0 +1 @@ +export { useGitStatus } from "./useGitStatus"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts new file mode 100644 index 00000000000..0596ef443b4 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatus/useGitStatus.ts @@ -0,0 +1,41 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +import { useCallback } from "react"; +import { useWorkspaceEvent } from "../useWorkspaceEvent"; + +/** + * Fetches workspace git status and keeps it live against server events. + * + * Single owner of the `git.getStatus` query + `git:changed` subscription for + * a workspace. Consumers (Changes tab UI, file tree decoration, anything + * else) receive the query result as data and do not re-fetch. + * + * `git:changed` is already debounced server-side in `GitWatcher` and covers + * both `.git/` metadata writes and worktree file edits — no client-side + * debounce needed. + */ +export function useGitStatus(workspaceId: string) { + const utils = workspaceTrpc.useUtils(); + + const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( + { workspaceId }, + { staleTime: Number.POSITIVE_INFINITY, enabled: Boolean(workspaceId) }, + ); + const baseBranch = baseBranchQuery.data?.baseBranch ?? null; + + const query = workspaceTrpc.git.getStatus.useQuery( + { workspaceId, baseBranch: baseBranch ?? undefined }, + { refetchOnWindowFocus: true, enabled: Boolean(workspaceId) }, + ); + + const invalidate = useCallback(() => { + void utils.git.getStatus.invalidate({ workspaceId }); + // Current branch may have changed (external checkout), and + // branch.<name>.base is per-branch — drop the cache so the next read + // picks up the new branch's base. + void utils.git.getBaseBranch.invalidate({ workspaceId }); + }, [utils, workspaceId]); + + useWorkspaceEvent("git:changed", workspaceId, invalidate); + + return query; +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/index.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/index.ts new file mode 100644 index 00000000000..b37377bddb3 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/index.ts @@ -0,0 +1,5 @@ +export { + type FileStatus, + type UseGitStatusMapResult, + useGitStatusMap, +} from "./useGitStatusMap"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/useGitStatusMap.ts b/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/useGitStatusMap.ts new file mode 100644 index 00000000000..c4f0bee8819 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useGitStatusMap/useGitStatusMap.ts @@ -0,0 +1,103 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterOutputs } from "@trpc/server"; +import { useMemo } from "react"; + +type GitStatusData = inferRouterOutputs<AppRouter>["git"]["getStatus"]; +type ChangedFile = GitStatusData["againstBase"][number]; +export type FileStatus = ChangedFile["status"]; + +export interface UseGitStatusMapResult { + /** Changed files keyed by repo-relative POSIX path. */ + fileStatusByPath: Map<string, FileStatus>; + /** + * Folder decoration status keyed by repo-relative POSIX path. For each + * folder that (transitively) contains a changed file, the value is the + * highest-severity status among its descendants — used to color the + * roll-up dot in the file tree. + */ + folderStatusByPath: Map<string, FileStatus>; + /** Repo-relative POSIX paths reported as gitignored, normalized. */ + ignoredPaths: Set<string>; +} + +/** + * Status severity used when rolling up a folder's decoration from its + * descendants — the folder takes the "worst" status under it. + */ +const STATUS_SEVERITY: Record<FileStatus, number> = { + deleted: 5, + modified: 4, + changed: 4, + added: 3, + untracked: 2, + renamed: 1, + copied: 0, +}; + +function emptyResult(): UseGitStatusMapResult { + return { + fileStatusByPath: new Map(), + folderStatusByPath: new Map(), + ignoredPaths: new Set(), + }; +} + +/** + * Pure derivation over `git.getStatus` data. Returns lookup maps for + * decorating the file tree with git status + gitignored muting. + */ +export function useGitStatusMap( + status: GitStatusData | undefined, +): UseGitStatusMapResult { + return useMemo(() => { + if (!status) return emptyResult(); + + // Union of all changes — later writes win so uncommitted state + // overrides committed state. Same pattern as useChangesTab's "all" filter. + const fileStatusByPath = new Map<string, FileStatus>(); + for (const file of status.againstBase) { + fileStatusByPath.set(normalizePath(file.path), file.status); + } + for (const file of status.staged) { + fileStatusByPath.set(normalizePath(file.path), file.status); + } + for (const file of status.unstaged) { + fileStatusByPath.set(normalizePath(file.path), file.status); + } + + const folderStatusByPath = new Map<string, FileStatus>(); + for (const [path, fileStatus] of fileStatusByPath) { + // Deleted files don't appear in the tree, so propagating a dot to + // ancestor folders is misleading — users expand the folder expecting + // to find something but there's nothing there. + if (fileStatus === "deleted") continue; + + const segments = path.split("/"); + for (let i = 1; i < segments.length; i++) { + const ancestor = segments.slice(0, i).join("/"); + const existing = folderStatusByPath.get(ancestor); + if ( + !existing || + STATUS_SEVERITY[fileStatus] > STATUS_SEVERITY[existing] + ) { + folderStatusByPath.set(ancestor, fileStatus); + } + } + } + + const ignoredPaths = new Set<string>(); + for (const entry of status.ignoredPaths) { + ignoredPaths.add(normalizePath(entry).replace(/\/$/, "")); + } + + return { + fileStatusByPath, + folderStatusByPath, + ignoredPaths, + }; + }, [status]); +} + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/"); +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/index.ts b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/index.ts new file mode 100644 index 00000000000..c6e517c7d34 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/index.ts @@ -0,0 +1 @@ +export { useHostTargetUrl } from "./useHostTargetUrl"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/useHostTargetUrl.ts b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/useHostTargetUrl.ts new file mode 100644 index 00000000000..a3edd0ee1a0 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useHostTargetUrl/useHostTargetUrl.ts @@ -0,0 +1,25 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +export function useHostTargetUrl( + hostTarget: WorkspaceHostTarget | null | undefined, +): string | null { + const { activeHostUrl } = useLocalHostService(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = session?.session?.activeOrganizationId ?? null; + + return useMemo(() => { + if (!hostTarget) return null; + if (hostTarget.kind === "local") return activeHostUrl; + if (!activeOrganizationId) return null; + const routingKey = buildHostRoutingKey( + activeOrganizationId, + hostTarget.hostId, + ); + return `${env.RELAY_URL}/hosts/${routingKey}`; + }, [hostTarget, activeOrganizationId, activeHostUrl]); +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/index.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/index.ts new file mode 100644 index 00000000000..35bb950c735 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/index.ts @@ -0,0 +1 @@ +export { useWorkspaceEvent } from "./useWorkspaceEvent"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts new file mode 100644 index 00000000000..cdc7521a73a --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceEvent/useWorkspaceEvent.ts @@ -0,0 +1,130 @@ +import { + type AgentLifecyclePayload, + type GitChangedPayload, + getEventBus, + type PortChangedPayload, + type TerminalLifecyclePayload, +} from "@superset/workspace-client"; +import type { FsWatchEvent } from "@superset/workspace-fs/client"; +import { useEffect, useEffectEvent } from "react"; +import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl"; + +/** + * Subscribe to an event bus event for a workspace. + * Resolves the workspace's host and connects to the correct event bus automatically. + */ +export function useWorkspaceEvent( + type: "git:changed", + workspaceId: string, + callback: (payload: GitChangedPayload) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: "fs:events", + workspaceId: string, + callback: (event: FsWatchEvent) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: "agent:lifecycle", + workspaceId: string, + callback: (payload: AgentLifecyclePayload) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: "terminal:lifecycle", + workspaceId: string, + callback: (payload: TerminalLifecyclePayload) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: "port:changed", + workspaceId: string, + callback: (payload: PortChangedPayload) => void, + enabled?: boolean, +): void; +export function useWorkspaceEvent( + type: + | "git:changed" + | "fs:events" + | "agent:lifecycle" + | "terminal:lifecycle" + | "port:changed", + workspaceId: string, + callback: + | ((event: FsWatchEvent) => void) + | ((payload: GitChangedPayload) => void) + | ((payload: AgentLifecyclePayload) => void) + | ((payload: TerminalLifecyclePayload) => void) + | ((payload: PortChangedPayload) => void), + enabled = true, +): void { + const hostUrl = useWorkspaceHostUrl(workspaceId); + const handler = useEffectEvent(callback); + + useEffect(() => { + if (!enabled || !hostUrl) return; + + const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); + const cleanups: Array<() => void> = []; + + if (type === "fs:events") { + bus.watchFs(workspaceId); + const removeListener = bus.on( + "fs:events", + workspaceId, + (_wid, payload) => { + for (const event of payload.events) { + (handler as (event: FsWatchEvent) => void)(event); + } + }, + ); + cleanups.push(removeListener, () => bus.unwatchFs(workspaceId)); + } else if (type === "agent:lifecycle") { + const removeListener = bus.on( + "agent:lifecycle", + workspaceId, + (_wid, payload) => { + (handler as (payload: AgentLifecyclePayload) => void)(payload); + }, + ); + cleanups.push(removeListener); + } else if (type === "terminal:lifecycle") { + const removeListener = bus.on( + "terminal:lifecycle", + workspaceId, + (_wid, payload) => { + (handler as (payload: TerminalLifecyclePayload) => void)(payload); + }, + ); + cleanups.push(removeListener); + } else if (type === "port:changed") { + const removeListener = bus.on( + "port:changed", + workspaceId, + (_wid, payload) => { + (handler as (payload: PortChangedPayload) => void)(payload); + }, + ); + cleanups.push(removeListener); + } else { + const removeListener = bus.on( + "git:changed", + workspaceId, + (_wid, payload) => { + (handler as (payload: GitChangedPayload) => void)(payload); + }, + ); + cleanups.push(removeListener); + } + + cleanups.push(bus.retain()); + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [enabled, hostUrl, type, workspaceId]); +} diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/index.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/index.ts new file mode 100644 index 00000000000..4b07976d824 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/index.ts @@ -0,0 +1 @@ +export { useWorkspaceHostUrl } from "./useWorkspaceHostUrl"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts new file mode 100644 index 00000000000..b6d435675f9 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useWorkspaceHostUrl/useWorkspaceHostUrl.ts @@ -0,0 +1,37 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +/** + * Resolves a workspace ID to its host-service URL. + * Local host → localhost port. Remote host → relay proxy URL. + */ +export function useWorkspaceHostUrl(workspaceId: string | null): string | null { + const collections = useCollections(); + const { machineId, activeHostUrl } = useLocalHostService(); + + const { data: workspaceRows = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .where(({ workspaces }) => eq(workspaces.id, workspaceId ?? "")) + .select(({ workspaces }) => ({ + organizationId: workspaces.organizationId, + hostId: workspaces.hostId, + })), + [collections, workspaceId], + ); + + const match = workspaceId ? (workspaceRows[0] ?? null) : null; + + return useMemo(() => { + if (!match) return null; + if (match.hostId === machineId) return activeHostUrl; + const routingKey = buildHostRoutingKey(match.organizationId, match.hostId); + return `${env.RELAY_URL}/hosts/${routingKey}`; + }, [match, machineId, activeHostUrl]); +} diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/index.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/index.ts new file mode 100644 index 00000000000..a5558ffb77c --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/index.ts @@ -0,0 +1,7 @@ +export { + killPortTarget, + type LocalPortKill, + type PortKillResult, + type PortKillTarget, +} from "./killPortTarget"; +export { usePortKillActions } from "./usePortKillActions"; diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts new file mode 100644 index 00000000000..3db93786e19 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const remoteKillMock = mock(async () => ({ success: true })); + +mock.module("renderer/lib/host-service-client", () => ({ + getHostServiceClientByUrl: () => ({ + ports: { + kill: { + mutate: remoteKillMock, + }, + }, + }), +})); + +const { killPortTarget } = await import("./killPortTarget"); + +describe("killPortTarget", () => { + beforeEach(() => { + remoteKillMock.mockClear(); + remoteKillMock.mockResolvedValue({ success: true }); + }); + + it("routes host-owned ports through the host-service client", async () => { + const result = await killPortTarget({ + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 5173, + hostUrl: "http://host-service", + }); + + expect(result).toEqual({ success: true }); + expect(remoteKillMock).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 5173, + }); + }); + + it("routes local ports through the provided local kill function", async () => { + const localKill = mock(async () => ({ success: true })); + + const result = await killPortTarget( + { + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 3000, + hostUrl: null, + }, + localKill, + ); + + expect(result).toEqual({ success: true }); + expect(localKill).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 3000, + }); + }); + + it("normalizes thrown kill errors into failed results", async () => { + const result = await killPortTarget( + { + workspaceId: "workspace-1", + terminalId: "terminal-1", + port: 3000, + }, + async () => { + throw new Error("network down"); + }, + ); + + expect(result).toEqual({ success: false, error: "network down" }); + }); +}); diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts new file mode 100644 index 00000000000..6a93f3bd34f --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/killPortTarget.ts @@ -0,0 +1,51 @@ +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; + +export type PortKillResult = { success: boolean; error?: string }; + +export interface PortKillTarget { + workspaceId: string; + terminalId: string; + port: number; + hostUrl?: string | null; +} + +export type LocalPortKill = (input: { + workspaceId: string; + terminalId: string; + port: number; +}) => Promise<PortKillResult>; + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +export async function killPortTarget( + target: PortKillTarget, + localKill?: LocalPortKill, +): Promise<PortKillResult> { + const payload = { + workspaceId: target.workspaceId, + terminalId: target.terminalId, + port: target.port, + }; + + try { + if (target.hostUrl) { + return await getHostServiceClientByUrl(target.hostUrl).ports.kill.mutate( + payload, + ); + } + + if (!localKill) { + return { + success: false, + error: "No host is available for this port", + }; + } + + return await localKill(payload); + } catch (error) { + return { success: false, error: toErrorMessage(error) }; + } +} diff --git a/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts new file mode 100644 index 00000000000..8877610c53b --- /dev/null +++ b/apps/desktop/src/renderer/hooks/ports/usePortKillActions/usePortKillActions.ts @@ -0,0 +1,90 @@ +import { toast } from "@superset/ui/sonner"; +import { type QueryKey, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { + killPortTarget, + type LocalPortKill, + type PortKillResult, + type PortKillTarget, +} from "./killPortTarget"; + +interface UsePortKillActionsOptions { + localKill?: LocalPortKill; + refreshQueryKey?: QueryKey; + externalPending?: boolean; +} + +function getFailureDescription(result: PortKillResult): string | undefined { + return result.error && result.error.length > 0 ? result.error : undefined; +} + +export function usePortKillActions<TPort extends PortKillTarget>({ + localKill, + refreshQueryKey, + externalPending = false, +}: UsePortKillActionsOptions = {}) { + const queryClient = useQueryClient(); + const [pendingCount, setPendingCount] = useState(0); + + const refreshPorts = useCallback(async () => { + if (!refreshQueryKey) return; + try { + await queryClient.invalidateQueries({ queryKey: refreshQueryKey }); + } catch (error) { + console.error("[ports] Failed to refresh ports after kill:", error); + } + }, [queryClient, refreshQueryKey]); + + const killPort = useCallback( + async (port: TPort): Promise<PortKillResult> => { + setPendingCount((count) => count + 1); + try { + const result = await killPortTarget(port, localKill); + if (!result.success) { + toast.error(`Failed to close port ${port.port}`, { + description: getFailureDescription(result), + }); + } + return result; + } finally { + await refreshPorts(); + setPendingCount((count) => Math.max(0, count - 1)); + } + }, + [localKill, refreshPorts], + ); + + const killPorts = useCallback( + async (ports: TPort[]): Promise<PortKillResult[]> => { + if (ports.length === 0) return []; + + setPendingCount((count) => count + 1); + try { + const results = await Promise.all( + ports.map((port) => killPortTarget(port, localKill)), + ); + const failed = results.filter((result) => !result.success); + if (failed.length === 1) { + toast.error("Failed to close 1 port", { + description: getFailureDescription(failed[0] ?? { success: false }), + }); + } else if (failed.length > 1) { + toast.error(`Failed to close ${failed.length} ports`, { + description: getFailureDescription(failed[0] ?? { success: false }), + }); + } + return results; + } finally { + await refreshPorts(); + setPendingCount((count) => Math.max(0, count - 1)); + } + }, + [localKill, refreshPorts], + ); + + return { + killPort, + killPorts, + isPending: pendingCount > 0 || externalPending, + }; +} diff --git a/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts b/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts index f8329d908be..86a5ff8263c 100644 --- a/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts +++ b/apps/desktop/src/renderer/hooks/useCopyToClipboard.ts @@ -1,26 +1,55 @@ import { useCallback, useState } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; - -/** - * Copy text to clipboard via Electron's native clipboard API (IPC). - * - * Unlike `navigator.clipboard.writeText`, this works regardless of - * document focus — no DOMException when a terminal or webview has focus. - * - * Returns `{ copyToClipboard, copied }` where `copied` is true for - * `timeout` ms after a successful write. - */ + +async function writeTextToClipboard(text: string): Promise<void> { + try { + await navigator.clipboard.writeText(text); + return; + } catch {} + + const textarea = window.document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.width = "1px"; + textarea.style.height = "1px"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + + const body = window.document.body; + body.appendChild(textarea); + + const previousSelection = window.document.getSelection(); + const previousRange = + previousSelection && previousSelection.rangeCount > 0 + ? previousSelection.getRangeAt(0) + : null; + + try { + textarea.select(); + textarea.setSelectionRange(0, text.length); + const ok = window.document.execCommand("copy"); + if (!ok) throw new Error("Copy to clipboard failed"); + } finally { + body.removeChild(textarea); + if (previousRange && previousSelection) { + previousSelection.removeAllRanges(); + previousSelection.addRange(previousRange); + } + } +} + export function useCopyToClipboard(timeout = 2000) { - const { mutateAsync } = electronTrpc.external.copyPath.useMutation(); const [copied, setCopied] = useState(false); const copyToClipboard = useCallback( async (text: string) => { - await mutateAsync(text); + await writeTextToClipboard(text); setCopied(true); setTimeout(() => setCopied(false), timeout); }, - [mutateAsync, timeout], + [timeout], ); return { copyToClipboard, copied }; diff --git a/apps/desktop/src/renderer/hooks/useCurrentPlan.ts b/apps/desktop/src/renderer/hooks/useCurrentPlan.ts index 7bda542c6b4..0ea0e1199d4 100644 --- a/apps/desktop/src/renderer/hooks/useCurrentPlan.ts +++ b/apps/desktop/src/renderer/hooks/useCurrentPlan.ts @@ -1,16 +1,18 @@ +import { + isActiveSubscriptionStatus, + type PlanTier, +} from "@superset/shared/billing"; import { useLiveQuery } from "@tanstack/react-db"; import { authClient } from "renderer/lib/auth-client"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -export type UserPlan = "free" | "pro" | "enterprise"; - interface ResolveCurrentPlanArgs { subscriptionPlan?: string | null; sessionPlan?: string | null; subscriptionsLoaded: boolean; } -function isPaidPlan( +function isPaidPlanTier( plan: string | null | undefined, ): plan is "pro" | "enterprise" { return plan === "pro" || plan === "enterprise"; @@ -20,8 +22,8 @@ export function resolveCurrentPlan({ subscriptionPlan, sessionPlan, subscriptionsLoaded, -}: ResolveCurrentPlanArgs): UserPlan { - if (isPaidPlan(subscriptionPlan)) { +}: ResolveCurrentPlanArgs): PlanTier { + if (isPaidPlanTier(subscriptionPlan)) { return subscriptionPlan; } @@ -29,14 +31,14 @@ export function resolveCurrentPlan({ return "free"; } - if (isPaidPlan(sessionPlan)) { + if (isPaidPlanTier(sessionPlan)) { return sessionPlan; } return "free"; } -export function useCurrentPlan(): UserPlan { +export function useCurrentPlan(): PlanTier { const { data: session } = authClient.useSession(); const collections = useCollections(); @@ -45,8 +47,8 @@ export function useCurrentPlan(): UserPlan { [collections], ); - const activeSubscription = subscriptionsData?.find( - (subscription) => subscription.status === "active", + const activeSubscription = subscriptionsData?.find((subscription) => + isActiveSubscriptionStatus(subscription.status), ); return resolveCurrentPlan({ diff --git a/apps/desktop/src/renderer/hooks/useEnabledAgents/index.ts b/apps/desktop/src/renderer/hooks/useEnabledAgents/index.ts new file mode 100644 index 00000000000..bc812c4b70e --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useEnabledAgents/index.ts @@ -0,0 +1 @@ +export { useEnabledAgents } from "./useEnabledAgents"; diff --git a/apps/desktop/src/renderer/hooks/useEnabledAgents/useEnabledAgents.ts b/apps/desktop/src/renderer/hooks/useEnabledAgents/useEnabledAgents.ts new file mode 100644 index 00000000000..b707db988a1 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useEnabledAgents/useEnabledAgents.ts @@ -0,0 +1,25 @@ +import { + getEnabledAgentConfigs, + type ResolvedAgentConfig, +} from "@superset/shared/agent-settings"; +import { useMemo } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +interface UseEnabledAgentsResult { + agents: ResolvedAgentConfig[]; + isPending: boolean; + isFetched: boolean; +} + +/** Fetches agent presets from the desktop settings IPC and returns only the + * enabled ones. Shared across the automations and new-workspace flows. */ +export function useEnabledAgents(): UseEnabledAgentsResult { + const query = electronTrpc.settings.getAgentPresets.useQuery(); + + const agents = useMemo( + () => getEnabledAgentConfigs(query.data ?? []), + [query.data], + ); + + return { agents, isPending: query.isPending, isFetched: query.isFetched }; +} diff --git a/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts new file mode 100644 index 00000000000..19742f6c014 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts @@ -0,0 +1,29 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { useFeatureFlagEnabled } from "posthog-js/react"; +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; + +const IS_DEV = process.env.NODE_ENV === "development"; + +/** + * Returns effective v2 state: remote PostHog flag AND local opt-in. + * Also returns the raw remote flag so the toggle can be shown conditionally. + */ +export function useIsV2CloudEnabled() { + const remoteV2Enabled = + useFeatureFlagEnabled(FEATURE_FLAGS.V2_CLOUD) ?? false; + const optInV2 = useV2LocalOverrideStore((s) => s.optInV2); + + if (IS_DEV) { + return { + isV2CloudEnabled: true, + isRemoteV2Enabled: true, + }; + } + + return { + /** The effective value — use this wherever you previously checked the flag directly. */ + isV2CloudEnabled: remoteV2Enabled && optInV2, + /** Whether the remote PostHog flag is on (for showing the toggle). */ + isRemoteV2Enabled: remoteV2Enabled, + }; +} diff --git a/apps/desktop/src/renderer/hooks/useNow/index.ts b/apps/desktop/src/renderer/hooks/useNow/index.ts new file mode 100644 index 00000000000..49bab4a75f7 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useNow/index.ts @@ -0,0 +1 @@ +export { useNow } from "./useNow"; diff --git a/apps/desktop/src/renderer/hooks/useNow/useNow.ts b/apps/desktop/src/renderer/hooks/useNow/useNow.ts new file mode 100644 index 00000000000..87139ffe5ae --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useNow/useNow.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +/** + * A Date that re-renders on a cadence so `formatDistance`-style displays + * keep ticking. Defaults to 1s so fresh rows never show a stale delta. + */ +export function useNow(intervalMs = 1000): Date { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return now; +} diff --git a/apps/desktop/src/renderer/hooks/useV2UserPreferences/index.ts b/apps/desktop/src/renderer/hooks/useV2UserPreferences/index.ts new file mode 100644 index 00000000000..84cec9bb060 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useV2UserPreferences/index.ts @@ -0,0 +1,13 @@ +export { + actionFor, + inlineTierFor, + type MouseEventLike, + terminalTierFor, +} from "./tiers"; +export { + type LinkActionsApi, + useInlineLinkActions, + useTerminalLinkActions, +} from "./useLinkActions"; +export type { V2UserPreferencesApi } from "./useV2UserPreferences"; +export { useV2UserPreferences } from "./useV2UserPreferences"; diff --git a/apps/desktop/src/renderer/hooks/useV2UserPreferences/tiers.ts b/apps/desktop/src/renderer/hooks/useV2UserPreferences/tiers.ts new file mode 100644 index 00000000000..48efd7a72f7 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useV2UserPreferences/tiers.ts @@ -0,0 +1,30 @@ +import type { + LinkAction, + LinkTier, + LinkTierMap, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; + +export interface MouseEventLike { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; +} + +export function terminalTierFor(event: MouseEventLike): LinkTier { + if (event.metaKey || event.ctrlKey) { + return event.shiftKey ? "metaShift" : "meta"; + } + return "plain"; +} + +export function inlineTierFor(event: MouseEventLike): LinkTier { + if (event.metaKey || event.ctrlKey) return "meta"; + return "plain"; +} + +export function actionFor( + tierMap: LinkTierMap, + tier: LinkTier, +): LinkAction | null { + return tierMap[tier]; +} diff --git a/apps/desktop/src/renderer/hooks/useV2UserPreferences/useLinkActions.ts b/apps/desktop/src/renderer/hooks/useV2UserPreferences/useLinkActions.ts new file mode 100644 index 00000000000..595e7756d37 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useV2UserPreferences/useLinkActions.ts @@ -0,0 +1,37 @@ +import { useCallback } from "react"; +import type { LinkAction } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { inlineTierFor, type MouseEventLike, terminalTierFor } from "./tiers"; +import { useV2UserPreferences } from "./useV2UserPreferences"; + +export interface LinkActionsApi { + getFileAction: (event: MouseEventLike) => LinkAction | null; + getUrlAction: (event: MouseEventLike) => LinkAction | null; +} + +/** 3-tier dispatcher: plain / meta / metaShift. Use inside the terminal. */ +export function useTerminalLinkActions(): LinkActionsApi { + const { preferences } = useV2UserPreferences(); + const getFileAction = useCallback( + (event: MouseEventLike) => preferences.fileLinks[terminalTierFor(event)], + [preferences.fileLinks], + ); + const getUrlAction = useCallback( + (event: MouseEventLike) => preferences.urlLinks[terminalTierFor(event)], + [preferences.urlLinks], + ); + return { getFileAction, getUrlAction }; +} + +/** 2-tier dispatcher: plain / meta (shift collapses into meta). Use in chat, markdown. */ +export function useInlineLinkActions(): LinkActionsApi { + const { preferences } = useV2UserPreferences(); + const getFileAction = useCallback( + (event: MouseEventLike) => preferences.fileLinks[inlineTierFor(event)], + [preferences.fileLinks], + ); + const getUrlAction = useCallback( + (event: MouseEventLike) => preferences.urlLinks[inlineTierFor(event)], + [preferences.urlLinks], + ); + return { getFileAction, getUrlAction }; +} diff --git a/apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts b/apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts new file mode 100644 index 00000000000..eabd986be95 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts @@ -0,0 +1,155 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + DEFAULT_V2_USER_PREFERENCES, + type LinkTierMap, + V2_USER_PREFERENCES_ID, + type V2UserPreferencesRow, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; + +export type RightSidebarTab = V2UserPreferencesRow["rightSidebarTab"]; + +export interface V2UserPreferencesApi { + preferences: V2UserPreferencesRow; + setFileLinks: (next: LinkTierMap) => void; + setUrlLinks: (next: LinkTierMap) => void; + setRightSidebarOpen: (next: boolean | ((prev: boolean) => boolean)) => void; + setRightSidebarTab: (next: RightSidebarTab) => void; + setRightSidebarWidth: (next: number) => void; + setDeleteLocalBranch: (next: boolean) => void; +} + +export function useV2UserPreferences(): V2UserPreferencesApi { + const collections = useCollections(); + + const { data: rows = [] } = useLiveQuery( + (query) => + query + .from({ prefs: collections.v2UserPreferences }) + .where(({ prefs }) => eq(prefs.id, V2_USER_PREFERENCES_ID)), + [collections], + ); + + const preferences = rows[0] ?? DEFAULT_V2_USER_PREFERENCES; + + const upsertTierMap = useCallback( + (key: "fileLinks" | "urlLinks", next: LinkTierMap) => { + const existing = collections.v2UserPreferences.get( + V2_USER_PREFERENCES_ID, + ); + if (!existing) { + collections.v2UserPreferences.insert({ + ...DEFAULT_V2_USER_PREFERENCES, + [key]: next, + }); + return; + } + collections.v2UserPreferences.update(V2_USER_PREFERENCES_ID, (draft) => { + draft[key] = next; + }); + }, + [collections], + ); + + const setFileLinks = useCallback( + (next: LinkTierMap) => upsertTierMap("fileLinks", next), + [upsertTierMap], + ); + + const setUrlLinks = useCallback( + (next: LinkTierMap) => upsertTierMap("urlLinks", next), + [upsertTierMap], + ); + + const setRightSidebarOpen = useCallback( + (next: boolean | ((prev: boolean) => boolean)) => { + const existing = collections.v2UserPreferences.get( + V2_USER_PREFERENCES_ID, + ); + const prev = + existing?.rightSidebarOpen ?? + DEFAULT_V2_USER_PREFERENCES.rightSidebarOpen; + const value = typeof next === "function" ? next(prev) : next; + if (!existing) { + collections.v2UserPreferences.insert({ + ...DEFAULT_V2_USER_PREFERENCES, + rightSidebarOpen: value, + }); + return; + } + collections.v2UserPreferences.update(V2_USER_PREFERENCES_ID, (draft) => { + draft.rightSidebarOpen = value; + }); + }, + [collections], + ); + + const setRightSidebarTab = useCallback( + (next: RightSidebarTab) => { + const existing = collections.v2UserPreferences.get( + V2_USER_PREFERENCES_ID, + ); + if (!existing) { + collections.v2UserPreferences.insert({ + ...DEFAULT_V2_USER_PREFERENCES, + rightSidebarTab: next, + }); + return; + } + collections.v2UserPreferences.update(V2_USER_PREFERENCES_ID, (draft) => { + draft.rightSidebarTab = next; + }); + }, + [collections], + ); + + const setRightSidebarWidth = useCallback( + (next: number) => { + const existing = collections.v2UserPreferences.get( + V2_USER_PREFERENCES_ID, + ); + if (!existing) { + collections.v2UserPreferences.insert({ + ...DEFAULT_V2_USER_PREFERENCES, + rightSidebarWidth: next, + }); + return; + } + collections.v2UserPreferences.update(V2_USER_PREFERENCES_ID, (draft) => { + draft.rightSidebarWidth = next; + }); + }, + [collections], + ); + + const setDeleteLocalBranch = useCallback( + (next: boolean) => { + const existing = collections.v2UserPreferences.get( + V2_USER_PREFERENCES_ID, + ); + if (!existing) { + collections.v2UserPreferences.insert({ + ...DEFAULT_V2_USER_PREFERENCES, + deleteLocalBranch: next, + }); + return; + } + collections.v2UserPreferences.update(V2_USER_PREFERENCES_ID, (draft) => { + draft.deleteLocalBranch = next; + }); + }, + [collections], + ); + + return { + preferences, + setFileLinks, + setUrlLinks, + setRightSidebarOpen, + setRightSidebarTab, + setRightSidebarWidth, + setDeleteLocalBranch, + }; +} diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index 76d7ffbd86c..17a7b1ef048 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -1,8 +1,8 @@ import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; +import { useHotkey } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useAppHotkey } from "renderer/stores/hotkeys"; /** * Shared hook for workspace keyboard shortcuts. @@ -43,33 +43,15 @@ export function useWorkspaceShortcuts() { [allWorkspaces, navigate], ); - useAppHotkey("JUMP_TO_WORKSPACE_1", () => switchToWorkspace(0), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_2", () => switchToWorkspace(1), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_3", () => switchToWorkspace(2), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_4", () => switchToWorkspace(3), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_5", () => switchToWorkspace(4), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_6", () => switchToWorkspace(5), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_7", () => switchToWorkspace(6), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_8", () => switchToWorkspace(7), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_9", () => switchToWorkspace(8), undefined, [ - switchToWorkspace, - ]); + useHotkey("JUMP_TO_WORKSPACE_1", () => switchToWorkspace(0)); + useHotkey("JUMP_TO_WORKSPACE_2", () => switchToWorkspace(1)); + useHotkey("JUMP_TO_WORKSPACE_3", () => switchToWorkspace(2)); + useHotkey("JUMP_TO_WORKSPACE_4", () => switchToWorkspace(3)); + useHotkey("JUMP_TO_WORKSPACE_5", () => switchToWorkspace(4)); + useHotkey("JUMP_TO_WORKSPACE_6", () => switchToWorkspace(5)); + useHotkey("JUMP_TO_WORKSPACE_7", () => switchToWorkspace(6)); + useHotkey("JUMP_TO_WORKSPACE_8", () => switchToWorkspace(7)); + useHotkey("JUMP_TO_WORKSPACE_9", () => switchToWorkspace(8)); return { groups, diff --git a/apps/desktop/src/renderer/hotkeys/components/HotkeyLabel/HotkeyLabel.tsx b/apps/desktop/src/renderer/hotkeys/components/HotkeyLabel/HotkeyLabel.tsx new file mode 100644 index 00000000000..48b81820fcc --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/components/HotkeyLabel/HotkeyLabel.tsx @@ -0,0 +1,18 @@ +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { useHotkeyDisplay } from "../../hooks/useHotkeyDisplay"; +import type { HotkeyId } from "../../registry"; + +export function HotkeyLabel({ label, id }: { label: string; id?: HotkeyId }) { + const { keys } = useHotkeyDisplay(id ?? ("" as HotkeyId)); + if (!id || keys[0] === "Unassigned") return <span>{label}</span>; + return ( + <span className="flex items-center gap-2"> + {label} + <KbdGroup> + {keys.map((k) => ( + <Kbd key={k}>{k}</Kbd> + ))} + </KbdGroup> + </span> + ); +} diff --git a/apps/desktop/src/renderer/hotkeys/components/HotkeyLabel/index.ts b/apps/desktop/src/renderer/hotkeys/components/HotkeyLabel/index.ts new file mode 100644 index 00000000000..51694411394 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/components/HotkeyLabel/index.ts @@ -0,0 +1 @@ +export { HotkeyLabel } from "./HotkeyLabel"; diff --git a/apps/desktop/src/renderer/hotkeys/display.test.ts b/apps/desktop/src/renderer/hotkeys/display.test.ts new file mode 100644 index 00000000000..1afb504335a --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/display.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "bun:test"; +import { formatHotkeyDisplay, glyphForCode } from "./display"; + +describe("formatHotkeyDisplay", () => { + it("formats a mac chord with modifier glyphs and no separator", () => { + const result = formatHotkeyDisplay("meta+shift+n", "mac"); + expect(result.text).toBe("⌘⇧N"); + expect(result.keys).toEqual(["⌘", "⇧", "N"]); + }); + + it("formats a windows chord with named modifiers and `+` separators", () => { + const result = formatHotkeyDisplay("ctrl+shift+n", "windows"); + expect(result.text).toBe("Ctrl+Shift+N"); + }); + + it("renders short arrow aliases and canonical arrow names identically", () => { + const short = formatHotkeyDisplay("meta+alt+up", "mac"); + const canonical = formatHotkeyDisplay("alt+meta+arrowup", "mac"); + expect(short.text).toBe("⌘⌥↑"); + expect(canonical.text).toBe("⌘⌥↑"); + }); + + it("renders punctuation tokens with their character", () => { + expect(formatHotkeyDisplay("meta+bracketleft", "mac").text).toBe("⌘["); + expect(formatHotkeyDisplay("meta+comma", "mac").text).toBe("⌘,"); + expect(formatHotkeyDisplay("ctrl+backslash", "linux").text).toBe("Ctrl+\\"); + expect(formatHotkeyDisplay("ctrl+slash", "linux").text).toBe("Ctrl+/"); + }); + + it("treats `control` as `ctrl`", () => { + const result = formatHotkeyDisplay("control+k", "windows"); + expect(result.text).toBe("Ctrl+K"); + }); + + it("returns Unassigned for null or chords with no key token", () => { + expect(formatHotkeyDisplay(null, "mac")).toEqual({ + keys: ["Unassigned"], + text: "Unassigned", + }); + expect(formatHotkeyDisplay("meta", "mac")).toEqual({ + keys: ["Unassigned"], + text: "Unassigned", + }); + }); +}); + +describe("glyphForCode", () => { + const usMap = new Map<string, string>([ + ["KeyA", "a"], + ["KeyZ", "z"], + ["Slash", "/"], + ["Quote", "'"], + ["Digit5", "5"], + ]); + const qwertzMap = new Map<string, string>([ + ["KeyA", "a"], + ["KeyZ", "y"], // QWERTZ — Y/Z swapped + ["Slash", "-"], + ["Quote", "ä"], + ]); + + it("returns null when no layout map provided", () => { + expect(glyphForCode("z", null)).toBeNull(); + }); + + it("returns the printed glyph for a letter on the current layout", () => { + expect(glyphForCode("a", usMap)).toBe("A"); + expect(glyphForCode("z", usMap)).toBe("Z"); + expect(glyphForCode("z", qwertzMap)).toBe("Y"); + }); + + it("returns the printed glyph for digits", () => { + expect(glyphForCode("5", usMap)).toBe("5"); + }); + + it("returns the printed glyph for punctuation tokens", () => { + expect(glyphForCode("slash", usMap)).toBe("/"); + expect(glyphForCode("slash", qwertzMap)).toBe("-"); + }); + + it("returns null for special keys that don't have a printable glyph", () => { + expect(glyphForCode("enter", usMap)).toBeNull(); + expect(glyphForCode("arrowup", usMap)).toBeNull(); + expect(glyphForCode("escape", usMap)).toBeNull(); + expect(glyphForCode("f5", usMap)).toBeNull(); + }); + + it("returns null when the layout map has no entry for the code", () => { + expect(glyphForCode("z", new Map())).toBeNull(); + }); + + it("returns null for multi-character (composing) glyphs", () => { + const composing = new Map<string, string>([["KeyA", "ʼa"]]); + expect(glyphForCode("a", composing)).toBeNull(); + }); + + it("preserves non-ASCII glyphs that would expand on uppercase (ß, ı)", () => { + // "ß".toUpperCase() === "SS" in JS — would break single-keycap display + const german = new Map<string, string>([["KeyS", "ß"]]); + expect(glyphForCode("s", german)).toBe("ß"); + const turkish = new Map<string, string>([["KeyI", "ı"]]); + expect(glyphForCode("i", turkish)).toBe("ı"); + }); +}); + +describe("formatHotkeyDisplay — layout-aware", () => { + const usMap = new Map<string, string>([ + ["KeyZ", "z"], + ["Slash", "/"], + ["BracketLeft", "["], + ]); + const qwertzMap = new Map<string, string>([ + ["KeyZ", "y"], + ["Slash", "-"], + ]); + + it("uses the layout glyph for printable keys when a map is provided", () => { + expect(formatHotkeyDisplay("meta+z", "mac", qwertzMap).text).toBe("⌘Y"); + expect(formatHotkeyDisplay("ctrl+slash", "linux", qwertzMap).text).toBe( + "Ctrl+-", + ); + }); + + it("falls back to KEY_DISPLAY when layoutMap is null (regression — current behavior)", () => { + expect(formatHotkeyDisplay("meta+z", "mac", null).text).toBe("⌘Z"); + expect(formatHotkeyDisplay("ctrl+slash", "linux", null).text).toBe( + "Ctrl+/", + ); + }); + + it("matches today's output for a US-equivalent map (no visible change)", () => { + expect(formatHotkeyDisplay("meta+z", "mac").text).toBe( + formatHotkeyDisplay("meta+z", "mac", usMap).text, + ); + expect(formatHotkeyDisplay("ctrl+slash", "linux").text).toBe( + formatHotkeyDisplay("ctrl+slash", "linux", usMap).text, + ); + }); + + it("special keys ignore layoutMap and keep their symbol", () => { + // Even if a malicious map tried to remap "Enter", we ignore it for + // special keys since glyphForCode returns null for them. + const weird = new Map<string, string>([["Enter", "X"]]); + expect(formatHotkeyDisplay("meta+enter", "mac", weird).text).toBe("⌘↵"); + }); + + it("falls back to KEY_DISPLAY when layoutMap is missing the code (e.g. Numpad)", () => { + expect(formatHotkeyDisplay("meta+bracketleft", "mac", qwertzMap).text).toBe( + "⌘[", + ); + }); +}); diff --git a/apps/desktop/src/renderer/hotkeys/display.ts b/apps/desktop/src/renderer/hotkeys/display.ts new file mode 100644 index 00000000000..ba9a08cf14d --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/display.ts @@ -0,0 +1,129 @@ +/** + * Display formatting for hotkey bindings. + * Converts key strings like "meta+shift+n" into platform-specific symbols. + */ + +import type { HotkeyDisplay, Platform } from "./types"; +import { normalizeToken } from "./utils/resolveHotkeyFromEvent"; + +const MODIFIER_DISPLAY: Record<Platform, Record<string, string>> = { + mac: { meta: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧" }, + windows: { meta: "Win", ctrl: "Ctrl", alt: "Alt", shift: "Shift" }, + linux: { meta: "Super", ctrl: "Ctrl", alt: "Alt", shift: "Shift" }, +}; + +// Keyed by canonical (event.code-normalized) tokens. normalizeToken aliases +// the short forms (`up` → `arrowup`, `esc` → `escape`) so only canonical +// names need entries here. +const KEY_DISPLAY: Record<string, string> = { + enter: "↵", + backspace: "⌫", + delete: "⌦", + escape: "⎋", + tab: "⇥", + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + space: "␣", + slash: "/", + backslash: "\\", + comma: ",", + period: ".", + semicolon: ";", + quote: "'", + backquote: "`", + minus: "-", + equal: "=", + bracketleft: "[", + bracketright: "]", +}; + +// canonical token (e.g. "z", "slash") → event.code (e.g. "KeyZ", "Slash") +// for keymap lookup against the layout data sourced from native-keymap. +// Only includes printable keys whose glyph varies by layout. Special keys +// (Enter, arrows, etc.) deliberately stay on KEY_DISPLAY — their +// event.code isn't a printable character. +const PRINTABLE_TO_SCAN_CODE: Record<string, string> = { + slash: "Slash", + backslash: "Backslash", + comma: "Comma", + period: "Period", + semicolon: "Semicolon", + quote: "Quote", + backquote: "Backquote", + minus: "Minus", + equal: "Equal", + bracketleft: "BracketLeft", + bracketright: "BracketRight", +}; + +function canonicalToScanCode(canonical: string): string | null { + if (/^[a-z]$/.test(canonical)) return `Key${canonical.toUpperCase()}`; + if (/^[0-9]$/.test(canonical)) return `Digit${canonical}`; + return PRINTABLE_TO_SCAN_CODE[canonical] ?? null; +} + +/** Glyph printed at this physical key on the user's current layout, or null. */ +export function glyphForCode( + canonical: string, + layoutMap: ReadonlyMap<string, string> | null, +): string | null { + if (!layoutMap) return null; + const scan = canonicalToScanCode(canonical); + if (!scan) return null; + const v = layoutMap.get(scan); + if (!v || v.length !== 1) return null; + // Uppercase only ASCII letters. Some layout glyphs expand to multiple + // characters when uppercased (`ß` → `SS`, Turkish `ı` → `I`/`İ`) which + // would break single-glyph keycap rendering — keep those as-is. + return /^[a-z]$/.test(v) ? v.toUpperCase() : v; +} + +const MODIFIER_ORDER = ["meta", "ctrl", "alt", "shift"] as const; +type Modifier = (typeof MODIFIER_ORDER)[number]; + +const isModifier = (p: string): p is Modifier => + (MODIFIER_ORDER as readonly string[]).includes(p); + +/** + * Format a chord string into display symbols. + * e.g. `"meta+shift+n"` on mac → `{ keys: ["⌘", "⇧", "N"], text: "⌘⇧N" }` + * + * `layoutMap` (optional) is `Map<event.code, unshifted glyph>` derived from + * the OS keyboard layout (sourced from native-keymap via the main process). + * When provided, printable keys (letters/digits/punctuation) are looked up + * so the displayed glyph matches what the user sees on their physical key + * — e.g. `meta+z` shows `⌘Y` on a German QWERTZ keyboard. When null, falls + * back to the US-ANSI glyph table. + */ +export function formatHotkeyDisplay( + keys: string | null, + platform: Platform, + layoutMap: ReadonlyMap<string, string> | null = null, +): HotkeyDisplay { + if (!keys) return { keys: ["Unassigned"], text: "Unassigned" }; + + const parts = keys + .toLowerCase() + .split("+") + .map(normalizeToken) + .map((p) => (p === "control" ? "ctrl" : p)); + + const modifiers = parts.filter(isModifier); + const key = parts.find((p) => !isModifier(p)); + if (!key) return { keys: ["Unassigned"], text: "Unassigned" }; + + const modSymbols = MODIFIER_ORDER.filter((m) => modifiers.includes(m)).map( + (m) => MODIFIER_DISPLAY[platform][m], + ); + // Order matters: layoutMap wins for printable keys (so QWERTZ shows the + // user's printed glyph for `KeyZ`), KEY_DISPLAY wins for special keys + // (Enter, arrows, etc. — glyphForCode returns null for these because + // PRINTABLE_TO_SCAN_CODE doesn't include them). + const keyDisplay = + glyphForCode(key, layoutMap) ?? KEY_DISPLAY[key] ?? key.toUpperCase(); + const displayKeys = [...modSymbols, keyDisplay]; + const separator = platform === "mac" ? "" : "+"; + return { keys: displayKeys, text: displayKeys.join(separator) }; +} diff --git a/apps/desktop/src/renderer/hotkeys/hooks/index.ts b/apps/desktop/src/renderer/hotkeys/hooks/index.ts new file mode 100644 index 00000000000..681c17d9108 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/index.ts @@ -0,0 +1,4 @@ +export { getBinding, getDispatchChord, useBinding } from "./useBinding"; +export { useHotkey } from "./useHotkey"; +export { useFormatBinding, useHotkeyDisplay } from "./useHotkeyDisplay"; +export { useRecordHotkeys } from "./useRecordHotkeys"; diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useBinding/index.ts b/apps/desktop/src/renderer/hotkeys/hooks/useBinding/index.ts new file mode 100644 index 00000000000..5c4b30a8835 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useBinding/index.ts @@ -0,0 +1 @@ +export { getBinding, getDispatchChord, useBinding } from "./useBinding"; diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts b/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts new file mode 100644 index 00000000000..f3ba672b9ce --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useBinding/useBinding.ts @@ -0,0 +1,40 @@ +import { HOTKEYS, type HotkeyId } from "../../registry"; +import { useHotkeyOverridesStore } from "../../stores/hotkeyOverridesStore"; +import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; +import type { ShortcutBinding } from "../../types"; +import { bindingToDispatchChord } from "../../utils/binding"; + +/** + * Reactive: get the effective binding for a hotkey (override ?? default). + * Returns the raw stored shape — bare chord string (legacy / shipped + * defaults, treated as physical mode) or v2 object. Use `parseBinding` to + * normalize. + */ +export function useBinding(id: HotkeyId): ShortcutBinding | null { + return useHotkeyOverridesStore((state) => { + if (!id) return null; + if (id in state.overrides) return state.overrides[id] ?? null; + return HOTKEYS[id]?.key ?? null; + }); +} + +/** Imperative version of {@link useBinding} for non-React contexts. */ +export function getBinding(id: HotkeyId): ShortcutBinding | null { + const state = useHotkeyOverridesStore.getState(); + if (!id) return null; + if (id in state.overrides) return state.overrides[id] ?? null; + return HOTKEYS[id]?.key ?? null; +} + +/** + * Imperative dispatch-form chord (event.code-based, layout-translated for + * logical bindings). Use when synthesizing KeyboardEvents that should match + * the same registration `useHotkey` makes — otherwise the event won't fire + * the bound handler on non-US layouts. + */ +export function getDispatchChord(id: HotkeyId): string | null { + return bindingToDispatchChord( + getBinding(id), + useKeyboardLayoutStore.getState().map, + ); +} diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/index.ts b/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/index.ts new file mode 100644 index 00000000000..420b7f54de3 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/index.ts @@ -0,0 +1 @@ +export { useHotkey } from "./useHotkey"; diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts b/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts new file mode 100644 index 00000000000..a36cbd8657d --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useHotkey/useHotkey.ts @@ -0,0 +1,51 @@ +import { useRef } from "react"; +import { type Options, useHotkeys } from "react-hotkeys-hook"; +import { formatHotkeyDisplay } from "../../display"; +import type { HotkeyId } from "../../registry"; +import { PLATFORM } from "../../registry"; +import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; +import type { HotkeyDisplay } from "../../types"; +import { bindingToDispatchChord } from "../../utils/binding"; +import { useBinding } from "../useBinding"; + +// react-hotkeys-hook doesn't check AltGraph or IME composition. Use its +// `ignoreEventWhen` option (runs after match, before preventDefault) to +// suppress those events so AltGr-typed printables and IME keystrokes pass +// through to the focused element. +function shouldIgnoreEvent(e: KeyboardEvent): boolean { + if (e.isComposing || e.keyCode === 229) return true; + if (e.getModifierState?.("AltGraph") === true) return true; + return false; +} + +export function useHotkey( + id: HotkeyId, + callback: (e: KeyboardEvent) => void, + options?: Options, +): HotkeyDisplay { + const binding = useBinding(id); + const layoutMap = useKeyboardLayoutStore((s) => s.map); + const chord = bindingToDispatchChord(binding, layoutMap); + const callbackRef = useRef(callback); + callbackRef.current = callback; + const callerIgnore = options?.ignoreEventWhen; + useHotkeys( + chord ?? "", + (e, _h) => { + if (options?.preventDefault !== false) { + e.preventDefault(); + } + callbackRef.current(e); + }, + { + enableOnFormTags: true, + enableOnContentEditable: true, + ...options, + ignoreEventWhen: callerIgnore + ? (e) => shouldIgnoreEvent(e) || callerIgnore(e) + : shouldIgnoreEvent, + }, + [chord], + ); + return formatHotkeyDisplay(chord, PLATFORM, layoutMap); +} diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/index.ts b/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/index.ts new file mode 100644 index 00000000000..6ffafab8271 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/index.ts @@ -0,0 +1 @@ +export { useFormatBinding, useHotkeyDisplay } from "./useHotkeyDisplay"; diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts b/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts new file mode 100644 index 00000000000..9df55b5c205 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useHotkeyDisplay/useHotkeyDisplay.ts @@ -0,0 +1,34 @@ +import { useMemo } from "react"; +import { formatHotkeyDisplay } from "../../display"; +import { PLATFORM } from "../../registry"; +import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; +import type { HotkeyDisplay, ShortcutBinding } from "../../types"; +import { bindingToDispatchChord } from "../../utils/binding"; +import { useBinding } from "../useBinding"; + +export function useHotkeyDisplay(id: string): HotkeyDisplay { + const binding = useBinding(id as Parameters<typeof useBinding>[0]); + const layoutMap = useKeyboardLayoutStore((s) => s.map); + const chord = bindingToDispatchChord(binding, layoutMap); + return useMemo( + () => formatHotkeyDisplay(chord, PLATFORM, layoutMap), + [chord, layoutMap], + ); +} + +/** + * Format an arbitrary binding (e.g. one captured during recording, before + * it's saved) with layout-aware glyphs. Use this when you have a + * ShortcutBinding but no registered hotkey id — most callers should use + * {@link useHotkeyDisplay} via the hotkey id. + */ +export function useFormatBinding( + binding: ShortcutBinding | null, +): HotkeyDisplay { + const layoutMap = useKeyboardLayoutStore((s) => s.map); + const chord = bindingToDispatchChord(binding, layoutMap); + return useMemo( + () => formatHotkeyDisplay(chord, PLATFORM, layoutMap), + [chord, layoutMap], + ); +} diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/index.ts b/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/index.ts new file mode 100644 index 00000000000..17acbaa5b5d --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/index.ts @@ -0,0 +1 @@ +export { useRecordHotkeys } from "./useRecordHotkeys"; diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.test.ts b/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.test.ts new file mode 100644 index 00000000000..3329c66f6bb --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "bun:test"; +import { + captureHotkeyFromEvent, + resolveCapturedBinding, +} from "./useRecordHotkeys"; + +/** + * Covers the regressions fixed in + * apps/desktop/plans/20260412-keyboard-recorder-ctrl-binding-fix.md plus + * Phase 2 additions (logical/named classification, dual-form capture). + * + * Note: `captureHotkeyFromEvent` reads `PLATFORM` via registry.ts, which in a + * Bun test runtime without a DOM navigator resolves to "mac". The meta-on- + * non-Mac branch is exercised indirectly via review, not here. + */ + +interface StubInit { + code?: string | undefined; + key?: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} +function ev(init: StubInit): KeyboardEvent { + return { + type: "keydown", + ...("code" in init ? { code: init.code } : { code: "" }), + key: init.key ?? "", + ctrlKey: !!init.ctrlKey, + metaKey: !!init.metaKey, + altKey: !!init.altKey, + shiftKey: !!init.shiftKey, + preventDefault() {}, + stopPropagation() {}, + } as unknown as KeyboardEvent; +} + +describe("captureHotkeyFromEvent — Bug 1: lone Ctrl must not auto-commit", () => { + it("returns null when only Control is pressed", () => { + expect( + captureHotkeyFromEvent(ev({ code: "ControlLeft", ctrlKey: true })), + ).toBeNull(); + expect( + captureHotkeyFromEvent(ev({ code: "ControlRight", ctrlKey: true })), + ).toBeNull(); + }); + + it("returns null for every other lone modifier", () => { + expect( + captureHotkeyFromEvent(ev({ code: "ShiftLeft", shiftKey: true })), + ).toBeNull(); + expect( + captureHotkeyFromEvent(ev({ code: "AltLeft", altKey: true })), + ).toBeNull(); + expect( + captureHotkeyFromEvent(ev({ code: "MetaLeft", metaKey: true })), + ).toBeNull(); + }); + + it("ignores lock keys even if Ctrl is also held", () => { + expect( + captureHotkeyFromEvent(ev({ code: "CapsLock", ctrlKey: true })), + ).toBeNull(); + expect( + captureHotkeyFromEvent(ev({ code: "NumLock", ctrlKey: true })), + ).toBeNull(); + }); +}); + +describe("captureHotkeyFromEvent — codeChord uses event.code, not event.key", () => { + it("Ctrl+Shift+2 codeChord is ctrl+shift+2 (not ctrl+shift+@)", () => { + const captured = captureHotkeyFromEvent( + ev({ code: "Digit2", key: "@", ctrlKey: true, shiftKey: true }), + ); + expect(captured?.codeChord).toBe("ctrl+shift+2"); + }); + + it("Alt+L on Mac (event.key=`¬`) codeChord is ctrl+alt+l via event.code", () => { + const captured = captureHotkeyFromEvent( + ev({ code: "KeyL", key: "¬", ctrlKey: true, altKey: true }), + ); + expect(captured?.codeChord).toBe("ctrl+alt+l"); + }); + + it("Ctrl+[ codeChord is ctrl+bracketleft (registry form)", () => { + const captured = captureHotkeyFromEvent( + ev({ code: "BracketLeft", key: "[", ctrlKey: true }), + ); + expect(captured?.codeChord).toBe("ctrl+bracketleft"); + }); + + it("Ctrl+/ codeChord is ctrl+slash", () => { + const captured = captureHotkeyFromEvent( + ev({ code: "Slash", key: "/", ctrlKey: true }), + ); + expect(captured?.codeChord).toBe("ctrl+slash"); + }); + + it("Meta+Alt+ArrowUp codeChord is meta+alt+arrowup", () => { + const captured = captureHotkeyFromEvent( + ev({ code: "ArrowUp", key: "ArrowUp", metaKey: true, altKey: true }), + ); + expect(captured?.codeChord).toBe("meta+alt+arrowup"); + }); + + it("F-keys are accepted without a modifier", () => { + expect( + captureHotkeyFromEvent(ev({ code: "F1", key: "F1" }))?.codeChord, + ).toBe("f1"); + expect( + captureHotkeyFromEvent(ev({ code: "F12", key: "F12" }))?.codeChord, + ).toBe("f12"); + }); + + it("returns null when event.code is undefined", () => { + expect( + captureHotkeyFromEvent(ev({ code: undefined, ctrlKey: true })), + ).toBeNull(); + }); +}); + +describe("captureHotkeyFromEvent — modifier ordering", () => { + it("emits modifiers in MODIFIER_ORDER (meta, ctrl, alt, shift)", () => { + const captured = captureHotkeyFromEvent( + ev({ + code: "KeyK", + key: "k", + metaKey: true, + ctrlKey: true, + altKey: true, + shiftKey: true, + }), + ); + expect(captured?.codeChord).toBe("meta+ctrl+alt+shift+k"); + }); +}); + +describe("captureHotkeyFromEvent — classification & dual-form capture", () => { + it("classifies F-keys", () => { + const captured = captureHotkeyFromEvent(ev({ code: "F5", key: "F5" })); + expect(captured?.classification).toBe("fkey"); + }); + + it("classifies named keys (Enter, ArrowUp, Backspace, ...)", () => { + expect( + captureHotkeyFromEvent(ev({ code: "Enter", key: "Enter", metaKey: true })) + ?.classification, + ).toBe("named"); + expect( + captureHotkeyFromEvent( + ev({ code: "ArrowUp", key: "ArrowUp", metaKey: true }), + )?.classification, + ).toBe("named"); + }); + + it("classifies letters/digits/punctuation as printable", () => { + expect( + captureHotkeyFromEvent(ev({ code: "KeyP", key: "p", metaKey: true })) + ?.classification, + ).toBe("printable"); + expect( + captureHotkeyFromEvent(ev({ code: "Slash", key: "/", ctrlKey: true })) + ?.classification, + ).toBe("printable"); + }); + + it("named/fkey: keyChord matches codeChord", () => { + const named = captureHotkeyFromEvent( + ev({ code: "Enter", key: "Enter", metaKey: true }), + ); + expect(named?.keyChord).toBe(named?.codeChord); + const fkey = captureHotkeyFromEvent(ev({ code: "F1", key: "F1" })); + expect(fkey?.keyChord).toBe(fkey?.codeChord); + }); + + it("printable: keyChord uses event.key (Dvorak: KeyR + key='p' → meta+p)", () => { + const captured = captureHotkeyFromEvent( + ev({ code: "KeyR", key: "p", metaKey: true }), + ); + expect(captured?.codeChord).toBe("meta+r"); + expect(captured?.keyChord).toBe("meta+p"); + }); + + it("printable: keyChord lowercases shifted glyphs (Shift+P → 'P' → 'p')", () => { + const captured = captureHotkeyFromEvent( + ev({ code: "KeyP", key: "P", metaKey: true, shiftKey: true }), + ); + expect(captured?.keyChord).toBe("meta+shift+p"); + }); + + it("printable with multi-char event.key falls back to codeChord", () => { + // Dead-key composition or "Process" can produce non-single-char event.key + const captured = captureHotkeyFromEvent( + ev({ code: "KeyA", key: "Dead", metaKey: true }), + ); + expect(captured?.keyChord).toBe(captured?.codeChord); + }); + + it("printable '+' falls back to codeChord (would collide with chord separator)", () => { + // Shift+= on US produces event.key "+" — accepting it as a logical + // token would build "meta+shift++" which can't be parsed back. + const captured = captureHotkeyFromEvent( + ev({ code: "Equal", key: "+", metaKey: true, shiftKey: true }), + ); + expect(captured?.codeChord).toBe("meta+shift+equal"); + expect(captured?.keyChord).toBe(captured?.codeChord); + }); +}); + +describe("resolveCapturedBinding", () => { + function capture(init: StubInit) { + const captured = captureHotkeyFromEvent(ev(init)); + if (!captured) { + throw new Error("expected captureHotkeyFromEvent to succeed"); + } + return captured; + } + + it("F-keys force named regardless of preferredMode", () => { + const captured = capture({ code: "F5", key: "F5" }); + expect(resolveCapturedBinding(captured, "logical").mode).toBe("named"); + expect(resolveCapturedBinding(captured, "physical").mode).toBe("named"); + }); + + it("Named keys force named regardless of preferredMode", () => { + const captured = capture({ code: "Enter", key: "Enter", metaKey: true }); + expect(resolveCapturedBinding(captured, "logical").mode).toBe("named"); + expect(resolveCapturedBinding(captured, "physical").mode).toBe("named"); + }); + + it("Printable + logical → keyChord", () => { + const captured = capture({ code: "KeyR", key: "p", metaKey: true }); + const resolved = resolveCapturedBinding(captured, "logical"); + expect(resolved.mode).toBe("logical"); + expect(resolved.chord).toBe("meta+p"); + }); + + it("Printable + physical → codeChord", () => { + const captured = capture({ code: "KeyR", key: "p", metaKey: true }); + const resolved = resolveCapturedBinding(captured, "physical"); + expect(resolved.mode).toBe("physical"); + expect(resolved.chord).toBe("meta+r"); + }); +}); diff --git a/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.ts b/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.ts new file mode 100644 index 00000000000..cc8ac66e0f1 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/hooks/useRecordHotkeys/useRecordHotkeys.ts @@ -0,0 +1,247 @@ +import { useEffect, useRef } from "react"; +import { HOTKEYS, type HotkeyId, PLATFORM } from "../../registry"; +import { useHotkeyOverridesStore } from "../../stores/hotkeyOverridesStore"; +import { useKeyboardLayoutStore } from "../../stores/keyboardLayoutStore"; +import type { + BindingMode, + ParsedBinding, + Platform, + ShortcutBinding, +} from "../../types"; +import { + bindingsEqual, + bindingToDispatchChord, + isFunctionKey, + NAMED_KEYS, + serializeBinding, +} from "../../utils/binding"; +import { + canonicalizeChord, + isIgnorableKey, + normalizeToken, + TERMINAL_RESERVED_CHORDS, +} from "../../utils/resolveHotkeyFromEvent"; + +// Matches the registry's written modifier order (`meta+alt+up`) so recorded +// strings stay visually aligned with defaults. Canonicalization handles +// reordering at compare time. +const MODIFIER_ORDER = ["meta", "ctrl", "alt", "shift"] as const; + +export interface CapturedHotkey { + /** Modifiers + canonical(event.code). Always meaningful. */ + codeChord: string; + /** Modifiers + lowercased event.key for printable letters/digits/punctuation; + * identical to codeChord for named keys / F-keys. */ + keyChord: string; + classification: "named" | "fkey" | "printable"; +} + +export function captureHotkeyFromEvent( + event: KeyboardEvent, +): CapturedHotkey | null { + if (event.code === undefined) return null; + const codeKey = normalizeToken(event.code); + if (isIgnorableKey(codeKey)) return null; + + const isFKey = isFunctionKey(codeKey); + const isNamed = NAMED_KEYS.has(codeKey); + // Mac Option is a legitimate shortcut modifier (⌥⌫ = delete-word). On + // other platforms Alt is the menu key and AltGr masquerades as ctrl+alt, + // so we still require ctrl/meta. + const altIsAppModifier = PLATFORM === "mac" && event.altKey; + if (!isFKey && !event.ctrlKey && !event.metaKey && !altIsAppModifier) { + return null; + } + + const modifiers = new Set<string>(); + if (event.metaKey) modifiers.add("meta"); + if (event.ctrlKey) modifiers.add("ctrl"); + if (event.altKey) modifiers.add("alt"); + if (event.shiftKey) modifiers.add("shift"); + const ordered = MODIFIER_ORDER.filter((m) => modifiers.has(m)); + + const codeChord = [...ordered, codeKey].join("+"); + + let classification: "named" | "fkey" | "printable" = "printable"; + if (isFKey) classification = "fkey"; + else if (isNamed) classification = "named"; + + let keyChord = codeChord; + if (classification === "printable") { + const produced = (event.key ?? "").toLowerCase(); + // Single printable char only — strings like "Dead", "Process" or + // multi-char IME output stay on codeChord. "+" would collide with + // the chord separator and break round-tripping (`meta+shift++`). + if (produced.length === 1 && /\S/.test(produced) && produced !== "+") { + keyChord = [...ordered, produced].join("+"); + } + } + return { codeChord, keyChord, classification }; +} + +/** + * Pick the right chord + mode for a captured event, given a user mode + * preference. F-keys and named keys force `named` regardless of preference. + */ +export function resolveCapturedBinding( + captured: CapturedHotkey, + preferredMode: "physical" | "logical", +): ParsedBinding { + if (captured.classification === "fkey" || captured.classification === "named") + return { mode: "named", chord: captured.codeChord }; + const mode: BindingMode = preferredMode; + const chord = mode === "logical" ? captured.keyChord : captured.codeChord; + return { mode, chord }; +} + +// Chords the OS / shell is likely to intercept. Binding is allowed (Linux +// WM configs vary), but the recorder emits a warning so the user knows why +// a chord they just bound might not fire. Canonicalized at build time so +// multi-modifier entries (e.g. `ctrl+alt+delete` → `alt+ctrl+delete`) match. +const OS_RESERVED: Record<Platform, Set<string>> = { + mac: new Set(["meta+q", "meta+space", "meta+tab"].map(canonicalizeChord)), + windows: new Set( + [ + "alt+f4", + "alt+tab", + "ctrl+alt+delete", + "meta+d", // Show desktop + "meta+e", // Explorer + "meta+l", // Lock + "meta+r", // Run + "meta+tab", // Task view + ].map(canonicalizeChord), + ), + linux: new Set(["alt+f4", "alt+tab"].map(canonicalizeChord)), +}; + +function isMacAltOnlyChord(canonical: string): boolean { + const mods = new Set(canonical.split("+").slice(0, -1)); + return mods.has("alt") && !mods.has("meta") && !mods.has("ctrl"); +} + +function checkReserved( + keys: string, +): { reason: string; severity: "error" | "warning" } | null { + const canonical = canonicalizeChord(keys); + if (TERMINAL_RESERVED_CHORDS.has(canonical)) + return { reason: "Reserved by terminal", severity: "error" }; + if (OS_RESERVED[PLATFORM].has(canonical)) + return { reason: "Reserved by OS", severity: "warning" }; + if (PLATFORM === "mac" && isMacAltOnlyChord(canonical)) + return { + reason: "Option shortcuts may prevent typing special characters", + severity: "warning", + }; + return null; +} + +function getHotkeyConflict( + candidate: ShortcutBinding, + excludeId: HotkeyId, +): HotkeyId | null { + const { overrides } = useHotkeyOverridesStore.getState(); + const layoutMap = useKeyboardLayoutStore.getState().map; + const candidateDispatch = bindingToDispatchChord(candidate, layoutMap); + if (!candidateDispatch) return null; + const target = canonicalizeChord(candidateDispatch); + for (const id of Object.keys(HOTKEYS) as HotkeyId[]) { + if (id === excludeId) continue; + const effective = id in overrides ? overrides[id] : HOTKEYS[id].key; + if (!effective) continue; + const otherDispatch = bindingToDispatchChord(effective, layoutMap); + if (otherDispatch && canonicalizeChord(otherDispatch) === target) return id; + } + return null; +} + +interface UseRecordHotkeysOptions { + /** User's mode preference for new printable bindings. Default `"logical"` + * — the recorded chord follows the printed character (Dvorak user + * pressing the P-labeled key gets a binding for the P character, which + * works on any layout). F-keys and named keys ignore this and use + * `"named"` mode regardless. */ + preferredMode?: "physical" | "logical"; + onSave?: (id: HotkeyId, binding: ShortcutBinding) => void; + onCancel?: () => void; + onUnassign?: (id: HotkeyId) => void; + onConflict?: ( + targetId: HotkeyId, + binding: ShortcutBinding, + conflictId: HotkeyId, + ) => void; + onReserved?: ( + binding: ShortcutBinding, + info: { reason: string; severity: "error" | "warning" }, + ) => void; +} + +export function useRecordHotkeys( + recordingId: HotkeyId | null, + options?: UseRecordHotkeysOptions, +) { + const optionsRef = useRef(options); + optionsRef.current = options; + + const setOverride = useHotkeyOverridesStore((s) => s.setOverride); + const resetOverride = useHotkeyOverridesStore((s) => s.resetOverride); + + useEffect(() => { + if (!recordingId) return; + + const handler = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + optionsRef.current?.onCancel?.(); + return; + } + + if (event.key === "Backspace" || event.key === "Delete") { + setOverride(recordingId, null); + optionsRef.current?.onUnassign?.(recordingId); + return; + } + + const captured = captureHotkeyFromEvent(event); + if (!captured) return; + + const preferredMode = optionsRef.current?.preferredMode ?? "logical"; + const parsed = resolveCapturedBinding(captured, preferredMode); + const binding = serializeBinding(parsed); + + // Reserved chords gate on the dispatch chord (event.code form), since + // that's what the OS / terminal sees when the user presses the key. + const reserved = checkReserved(captured.codeChord); + if (reserved?.severity === "error") { + optionsRef.current?.onReserved?.(binding, reserved); + return; + } + + const conflictId = getHotkeyConflict(binding, recordingId); + if (conflictId) { + optionsRef.current?.onConflict?.(recordingId, binding, conflictId); + return; + } + + if (reserved?.severity === "warning") { + optionsRef.current?.onReserved?.(binding, reserved); + } + + const defaultBinding = HOTKEYS[recordingId].key; + if (defaultBinding && bindingsEqual(binding, defaultBinding)) { + resetOverride(recordingId); + } else { + setOverride(recordingId, binding); + } + optionsRef.current?.onSave?.(recordingId, binding); + }; + + window.addEventListener("keydown", handler, { capture: true }); + return () => + window.removeEventListener("keydown", handler, { capture: true }); + }, [recordingId, setOverride, resetOverride]); + + return { isRecording: !!recordingId }; +} diff --git a/apps/desktop/src/renderer/hotkeys/index.ts b/apps/desktop/src/renderer/hotkeys/index.ts new file mode 100644 index 00000000000..0cdc8a0ef37 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/index.ts @@ -0,0 +1,31 @@ +export { HotkeyLabel } from "./components/HotkeyLabel"; +export { formatHotkeyDisplay } from "./display"; +export { + getBinding, + getDispatchChord, + useBinding, + useFormatBinding, + useHotkey, + useHotkeyDisplay, + useRecordHotkeys, +} from "./hooks"; +export { HOTKEYS, type HotkeyId, PLATFORM } from "./registry"; +export { useHotkeyOverridesStore } from "./stores"; +export type { + BindingMode, + HotkeyCategory, + HotkeyDefinition, + HotkeyDisplay, + ParsedBinding, + Platform, + ShortcutBinding, +} from "./types"; +export { + bindingsEqual, + defaultModeForChord, + isTerminalReservedEvent, + matchesChord, + parseBinding, + resolveHotkeyFromEvent, + serializeBinding, +} from "./utils"; diff --git a/apps/desktop/src/renderer/hotkeys/registry.ts b/apps/desktop/src/renderer/hotkeys/registry.ts new file mode 100644 index 00000000000..67053c0881d --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/registry.ts @@ -0,0 +1,581 @@ +import type { + HotkeyCategory, + HotkeyDefinition, + Platform, + PlatformKey, +} from "./types"; + +interface HotkeyRegistryDefinition { + key: PlatformKey; + label: string; + category: HotkeyCategory; + description?: string; +} + +function detectPlatform(): Platform { + if (typeof navigator === "undefined") return "mac"; + const p = navigator.platform.toLowerCase(); + if (p.includes("mac")) return "mac"; + if (p.includes("win")) return "windows"; + return "linux"; +} + +export const PLATFORM: Platform = detectPlatform(); + +// --------------------------------------------------------------------------- +// Hotkey definitions +// --------------------------------------------------------------------------- + +export const HOTKEYS_REGISTRY = { + // Navigation + NAVIGATE_BACK: { + key: { + mac: "meta+bracketleft", + windows: "ctrl+shift+bracketleft", + linux: "ctrl+shift+bracketleft", + }, + label: "Navigate Back", + category: "Navigation", + description: "Go back to the previous page in history", + }, + NAVIGATE_FORWARD: { + key: { + mac: "meta+bracketright", + windows: "ctrl+shift+bracketright", + linux: "ctrl+shift+bracketright", + }, + label: "Navigate Forward", + category: "Navigation", + description: "Go forward to the next page in history", + }, + QUICK_OPEN: { + key: { mac: "meta+p", windows: "ctrl+shift+p", linux: "ctrl+shift+p" }, + label: "Quick Open File", + category: "Navigation", + description: "Search and open files in the current workspace", + }, + + // Workspace switching + JUMP_TO_WORKSPACE_1: { + key: { mac: "meta+1", windows: "ctrl+shift+1", linux: "ctrl+shift+1" }, + label: "Switch to Workspace 1", + category: "Workspace", + }, + JUMP_TO_WORKSPACE_2: { + key: { mac: "meta+2", windows: "ctrl+shift+2", linux: "ctrl+shift+2" }, + label: "Switch to Workspace 2", + category: "Workspace", + }, + JUMP_TO_WORKSPACE_3: { + key: { mac: "meta+3", windows: "ctrl+shift+3", linux: "ctrl+shift+3" }, + label: "Switch to Workspace 3", + category: "Workspace", + }, + JUMP_TO_WORKSPACE_4: { + key: { mac: "meta+4", windows: "ctrl+shift+4", linux: "ctrl+shift+4" }, + label: "Switch to Workspace 4", + category: "Workspace", + }, + JUMP_TO_WORKSPACE_5: { + key: { mac: "meta+5", windows: "ctrl+shift+5", linux: "ctrl+shift+5" }, + label: "Switch to Workspace 5", + category: "Workspace", + }, + JUMP_TO_WORKSPACE_6: { + key: { mac: "meta+6", windows: "ctrl+shift+6", linux: "ctrl+shift+6" }, + label: "Switch to Workspace 6", + category: "Workspace", + }, + JUMP_TO_WORKSPACE_7: { + key: { mac: "meta+7", windows: "ctrl+shift+7", linux: "ctrl+shift+7" }, + label: "Switch to Workspace 7", + category: "Workspace", + }, + JUMP_TO_WORKSPACE_8: { + key: { mac: "meta+8", windows: "ctrl+shift+8", linux: "ctrl+shift+8" }, + label: "Switch to Workspace 8", + category: "Workspace", + }, + JUMP_TO_WORKSPACE_9: { + key: { mac: "meta+9", windows: "ctrl+shift+9", linux: "ctrl+shift+9" }, + label: "Switch to Workspace 9", + category: "Workspace", + }, + PREV_WORKSPACE: { + key: { + mac: "meta+alt+up", + windows: "ctrl+shift+alt+up", + linux: "ctrl+shift+alt+up", + }, + label: "Previous Workspace", + category: "Workspace", + description: "Navigate to the previous workspace in the sidebar", + }, + NEXT_WORKSPACE: { + key: { + mac: "meta+alt+down", + windows: "ctrl+shift+alt+down", + linux: "ctrl+shift+alt+down", + }, + label: "Next Workspace", + category: "Workspace", + description: "Navigate to the next workspace in the sidebar", + }, + CLOSE_WORKSPACE: { + key: { + mac: "meta+shift+backspace", + windows: "ctrl+shift+backspace", + linux: "ctrl+shift+backspace", + }, + label: "Close Workspace", + category: "Workspace", + description: "Close or delete the current workspace", + }, + NEW_WORKSPACE: { + key: { mac: "meta+n", windows: "ctrl+shift+n", linux: "ctrl+shift+n" }, + label: "New Workspace", + category: "Workspace", + description: "Open the new workspace modal", + }, + QUICK_CREATE_WORKSPACE: { + key: { + mac: "meta+shift+n", + windows: "ctrl+shift+alt+n", + linux: "ctrl+shift+alt+n", + }, + label: "Quick Create Workspace", + category: "Workspace", + description: "Quickly create a workspace in the current project", + }, + RUN_WORKSPACE_COMMAND: { + key: { mac: "meta+g", windows: "ctrl+shift+g", linux: "ctrl+shift+g" }, + label: "Run Workspace Command", + category: "Workspace", + description: "Start or stop the workspace run command", + }, + FOCUS_TASK_SEARCH: { + key: { mac: "meta+f", windows: "ctrl+shift+f", linux: "ctrl+shift+f" }, + label: "Focus Task Search", + category: "Workspace", + description: "Focus the search input in the tasks view", + }, + OPEN_PROJECT: { + key: { + mac: "meta+shift+o", + windows: "ctrl+shift+alt+o", + linux: "ctrl+shift+alt+o", + }, + label: "Open Project", + category: "Workspace", + description: "Open an existing project folder", + }, + OPEN_PR: { + key: { + mac: "meta+shift+p", + windows: "ctrl+shift+alt+p", + linux: "ctrl+shift+alt+p", + }, + label: "Open Pull Request", + category: "Workspace", + description: "Open existing PR or create a new one on GitHub", + }, + + // Layout + TOGGLE_SIDEBAR: { + key: { mac: "meta+l", windows: "ctrl+shift+l", linux: "ctrl+shift+l" }, + label: "Toggle Changes Tab", + category: "Layout", + }, + OPEN_DIFF_VIEWER: { + key: { + mac: "meta+shift+l", + windows: "ctrl+shift+alt+l", + linux: "ctrl+shift+alt+l", + }, + label: "Open Diff Viewer", + category: "Layout", + description: + "Open the diff viewer in a new tab, or focus the existing diff viewer", + }, + TOGGLE_WORKSPACE_SIDEBAR: { + key: { mac: "meta+b", windows: "ctrl+shift+b", linux: "ctrl+shift+b" }, + label: "Toggle Workspaces Sidebar", + category: "Layout", + }, + SPLIT_RIGHT: { + key: { mac: "meta+d", windows: "ctrl+shift+d", linux: "ctrl+shift+d" }, + label: "Split Right", + category: "Layout", + description: "Split the current pane to the right", + }, + SPLIT_DOWN: { + key: { + mac: "meta+shift+d", + windows: "ctrl+shift+alt+d", + linux: "ctrl+shift+alt+d", + }, + label: "Split Down", + category: "Layout", + description: "Split the current pane downward", + }, + SPLIT_AUTO: { + key: { mac: "meta+e", windows: "ctrl+shift+e", linux: "ctrl+shift+e" }, + label: "Split Pane Auto", + category: "Layout", + description: "Split the current pane along its longer side", + }, + SPLIT_WITH_CHAT: { + key: { mac: "meta+shift+e", windows: "ctrl+alt+e", linux: "ctrl+alt+e" }, + label: "Split with New Chat", + category: "Layout", + description: "Split the current pane and open a new chat pane", + }, + SPLIT_WITH_BROWSER: { + key: { + mac: "meta+shift+s", + windows: "ctrl+shift+alt+s", + linux: "ctrl+shift+alt+s", + }, + label: "Split with New Browser", + category: "Layout", + description: "Split the current pane and open a new browser pane", + }, + EQUALIZE_PANE_SPLITS: { + key: { + mac: "meta+shift+0", + windows: "ctrl+shift+0", + linux: "ctrl+shift+0", + }, + label: "Equalize Pane Splits", + category: "Layout", + description: "Make all panes equal size", + }, + CLOSE_PANE: { + key: { mac: "meta+w", windows: "ctrl+shift+w", linux: "ctrl+shift+w" }, + label: "Close Pane", + category: "Layout", + description: "Close the current pane", + }, + + // Terminal + FIND_IN_TERMINAL: { + key: { mac: "meta+f", windows: "ctrl+shift+f", linux: "ctrl+shift+f" }, + label: "Find in Terminal", + category: "Terminal", + description: "Search text in the active terminal", + }, + FIND_IN_FILE_VIEWER: { + key: { mac: "meta+f", windows: "ctrl+shift+f", linux: "ctrl+shift+f" }, + label: "Find in File Viewer", + category: "Terminal", + description: "Search text in the rendered file viewer", + }, + FIND_IN_CHAT: { + key: { mac: "meta+f", windows: "ctrl+shift+f", linux: "ctrl+shift+f" }, + label: "Find in Chat", + category: "Terminal", + description: "Search text in the active chat", + }, + NEW_GROUP: { + key: { mac: "meta+t", windows: "ctrl+shift+t", linux: "ctrl+shift+t" }, + label: "New Terminal", + category: "Terminal", + }, + NEW_CHAT: { + key: { + mac: "meta+shift+t", + windows: "ctrl+shift+alt+t", + linux: "ctrl+shift+alt+t", + }, + label: "New Chat", + category: "Terminal", + }, + REOPEN_TAB: { + key: { + mac: "meta+shift+r", + windows: "ctrl+shift+alt+r", + linux: "ctrl+shift+alt+r", + }, + label: "Reopen Closed Tab", + category: "Terminal", + }, + NEW_BROWSER: { + key: { + mac: "meta+shift+b", + windows: "ctrl+shift+alt+b", + linux: "ctrl+shift+alt+b", + }, + label: "New Browser", + category: "Terminal", + }, + CLOSE_TERMINAL: { + key: { mac: "meta+w", windows: "ctrl+shift+w", linux: "ctrl+shift+w" }, + label: "Close Terminal", + category: "Terminal", + }, + CLOSE_TAB: { + key: { + mac: "meta+shift+w", + windows: "ctrl+shift+alt+w", + linux: "ctrl+shift+alt+w", + }, + label: "Close Tab", + category: "Terminal", + description: "Close the current tab", + }, + CLEAR_TERMINAL: { + key: { mac: "meta+k", windows: "ctrl+shift+k", linux: "ctrl+shift+k" }, + label: "Clear Terminal", + category: "Terminal", + }, + SCROLL_TO_BOTTOM: { + key: { + mac: "meta+shift+down", + windows: "ctrl+end", + linux: "ctrl+end", + }, + label: "Scroll to Bottom", + category: "Terminal", + description: "Scroll the active terminal to the bottom", + }, + PREV_TAB_ALT: { + key: { + mac: "ctrl+shift+tab", + windows: "ctrl+shift+tab", + linux: "ctrl+shift+tab", + }, + label: "Previous Tab (Alt)", + category: "Terminal", + }, + NEXT_TAB_ALT: { + key: { mac: "ctrl+tab", windows: "ctrl+tab", linux: "ctrl+tab" }, + label: "Next Tab (Alt)", + category: "Terminal", + }, + PREV_TAB: { + key: { + mac: "meta+alt+left", + windows: "ctrl+shift+alt+left", + linux: "ctrl+shift+alt+left", + }, + label: "Previous Tab", + category: "Terminal", + description: "Focus the previous tab in the active workspace", + }, + NEXT_TAB: { + key: { + mac: "meta+alt+right", + windows: "ctrl+shift+alt+right", + linux: "ctrl+shift+alt+right", + }, + label: "Next Tab", + category: "Terminal", + description: "Focus the next tab in the active workspace", + }, + FOCUS_PANE_LEFT: { + key: { mac: null, windows: null, linux: null }, + label: "Focus Pane Left", + category: "Terminal", + description: "Focus the pane to the left of the active pane", + }, + FOCUS_PANE_RIGHT: { + key: { mac: null, windows: null, linux: null }, + label: "Focus Pane Right", + category: "Terminal", + description: "Focus the pane to the right of the active pane", + }, + FOCUS_PANE_UP: { + key: { mac: null, windows: null, linux: null }, + label: "Focus Pane Up", + category: "Terminal", + description: "Focus the pane above the active pane", + }, + FOCUS_PANE_DOWN: { + key: { mac: null, windows: null, linux: null }, + label: "Focus Pane Down", + category: "Terminal", + description: "Focus the pane below the active pane", + }, + JUMP_TO_TAB_1: { + key: { + mac: "meta+alt+1", + windows: "ctrl+shift+alt+1", + linux: "ctrl+shift+alt+1", + }, + label: "Switch to Tab 1", + category: "Terminal", + }, + JUMP_TO_TAB_2: { + key: { + mac: "meta+alt+2", + windows: "ctrl+shift+alt+2", + linux: "ctrl+shift+alt+2", + }, + label: "Switch to Tab 2", + category: "Terminal", + }, + JUMP_TO_TAB_3: { + key: { + mac: "meta+alt+3", + windows: "ctrl+shift+alt+3", + linux: "ctrl+shift+alt+3", + }, + label: "Switch to Tab 3", + category: "Terminal", + }, + JUMP_TO_TAB_4: { + key: { + mac: "meta+alt+4", + windows: "ctrl+shift+alt+4", + linux: "ctrl+shift+alt+4", + }, + label: "Switch to Tab 4", + category: "Terminal", + }, + JUMP_TO_TAB_5: { + key: { + mac: "meta+alt+5", + windows: "ctrl+shift+alt+5", + linux: "ctrl+shift+alt+5", + }, + label: "Switch to Tab 5", + category: "Terminal", + }, + JUMP_TO_TAB_6: { + key: { + mac: "meta+alt+6", + windows: "ctrl+shift+alt+6", + linux: "ctrl+shift+alt+6", + }, + label: "Switch to Tab 6", + category: "Terminal", + }, + JUMP_TO_TAB_7: { + key: { + mac: "meta+alt+7", + windows: "ctrl+shift+alt+7", + linux: "ctrl+shift+alt+7", + }, + label: "Switch to Tab 7", + category: "Terminal", + }, + JUMP_TO_TAB_8: { + key: { + mac: "meta+alt+8", + windows: "ctrl+shift+alt+8", + linux: "ctrl+shift+alt+8", + }, + label: "Switch to Tab 8", + category: "Terminal", + }, + JUMP_TO_TAB_9: { + key: { + mac: "meta+alt+9", + windows: "ctrl+shift+alt+9", + linux: "ctrl+shift+alt+9", + }, + label: "Switch to Tab 9", + category: "Terminal", + }, + OPEN_PRESET_1: { + key: { mac: "ctrl+1", windows: "ctrl+1", linux: "ctrl+1" }, + label: "Open Preset 1", + category: "Terminal", + }, + OPEN_PRESET_2: { + key: { mac: "ctrl+2", windows: "ctrl+2", linux: "ctrl+2" }, + label: "Open Preset 2", + category: "Terminal", + }, + OPEN_PRESET_3: { + key: { mac: "ctrl+3", windows: "ctrl+3", linux: "ctrl+3" }, + label: "Open Preset 3", + category: "Terminal", + }, + OPEN_PRESET_4: { + key: { mac: "ctrl+4", windows: "ctrl+4", linux: "ctrl+4" }, + label: "Open Preset 4", + category: "Terminal", + }, + OPEN_PRESET_5: { + key: { mac: "ctrl+5", windows: "ctrl+5", linux: "ctrl+5" }, + label: "Open Preset 5", + category: "Terminal", + }, + OPEN_PRESET_6: { + key: { mac: "ctrl+6", windows: "ctrl+6", linux: "ctrl+6" }, + label: "Open Preset 6", + category: "Terminal", + }, + OPEN_PRESET_7: { + key: { mac: "ctrl+7", windows: "ctrl+7", linux: "ctrl+7" }, + label: "Open Preset 7", + category: "Terminal", + }, + OPEN_PRESET_8: { + key: { mac: "ctrl+8", windows: "ctrl+8", linux: "ctrl+8" }, + label: "Open Preset 8", + category: "Terminal", + }, + OPEN_PRESET_9: { + key: { mac: "ctrl+9", windows: "ctrl+9", linux: "ctrl+9" }, + label: "Open Preset 9", + category: "Terminal", + }, + + // Chat + FOCUS_CHAT_INPUT: { + key: { mac: "meta+j", windows: "ctrl+shift+j", linux: "ctrl+shift+j" }, + label: "Focus Chat Input", + category: "Terminal", + }, + CHAT_ADD_ATTACHMENT: { + key: { mac: "meta+u", windows: "ctrl+shift+u", linux: "ctrl+shift+u" }, + label: "Add Attachment", + category: "Terminal", + }, + + // Window + OPEN_IN_APP: { + key: { mac: "meta+o", windows: "ctrl+shift+o", linux: "ctrl+shift+o" }, + label: "Open in App", + category: "Window", + description: "Open workspace in external app (Cursor, VS Code, etc.)", + }, + COPY_PATH: { + key: { + mac: "meta+shift+c", + windows: "ctrl+shift+alt+c", + linux: "ctrl+shift+alt+c", + }, + label: "Copy Path", + category: "Window", + description: "Copy the workspace path to the clipboard", + }, + + // Help + OPEN_SETTINGS: { + key: { mac: "meta+comma", windows: "ctrl+comma", linux: "ctrl+comma" }, + label: "Open Settings", + category: "Help", + }, + SHOW_HOTKEYS: { + key: { + mac: "meta+shift+slash", + windows: "ctrl+shift+slash", + linux: "ctrl+shift+slash", + }, + label: "Show Keyboard Shortcuts", + category: "Help", + }, +} as const satisfies Record<string, HotkeyRegistryDefinition>; + +export type HotkeyId = keyof typeof HOTKEYS_REGISTRY; + +/** Hotkey definitions resolved for the current platform (computed once at import time) */ +export const HOTKEYS = Object.fromEntries( + Object.entries(HOTKEYS_REGISTRY).map(([id, def]) => [ + id, + { ...def, key: def.key[PLATFORM] }, + ]), +) as Record<HotkeyId, HotkeyDefinition>; diff --git a/apps/desktop/src/renderer/hotkeys/stores/hotkeyOverridesStore.ts b/apps/desktop/src/renderer/hotkeys/stores/hotkeyOverridesStore.ts new file mode 100644 index 00000000000..2cb2acedfe1 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/stores/hotkeyOverridesStore.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import type { ShortcutBinding } from "../types"; + +interface HotkeyOverridesState { + /** Per-hotkey-id override. `null` = explicit unassignment. Stored as the + * ShortcutBinding shape: bare string for physical-mode bindings (legacy + * + shipped defaults), v2 object for logical / named modes. */ + overrides: Record<string, ShortcutBinding | null>; + setOverride: (id: string, binding: ShortcutBinding | null) => void; + resetOverride: (id: string) => void; + resetAll: () => void; +} + +export const useHotkeyOverridesStore = create<HotkeyOverridesState>()( + persist( + (set) => ({ + overrides: {}, + setOverride: (id, keys) => + set((state) => ({ + overrides: { ...state.overrides, [id]: keys }, + })), + resetOverride: (id) => + set((state) => { + const next = { ...state.overrides }; + delete next[id]; + return { overrides: next }; + }), + resetAll: () => set({ overrides: {} }), + }), + { + name: "hotkey-overrides", + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ overrides: state.overrides }), + }, + ), +); diff --git a/apps/desktop/src/renderer/hotkeys/stores/index.ts b/apps/desktop/src/renderer/hotkeys/stores/index.ts new file mode 100644 index 00000000000..8c5b63a8a45 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/stores/index.ts @@ -0,0 +1 @@ +export { useHotkeyOverridesStore } from "./hotkeyOverridesStore"; diff --git a/apps/desktop/src/renderer/hotkeys/stores/keyboardLayoutStore.ts b/apps/desktop/src/renderer/hotkeys/stores/keyboardLayoutStore.ts new file mode 100644 index 00000000000..9b602cceca4 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/stores/keyboardLayoutStore.ts @@ -0,0 +1,55 @@ +import type { KeyboardLayoutData } from "main/lib/keyboardLayout"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { create } from "zustand"; + +// Mirror of the main-process layout service for synchronous reads from +// React. Lives in main because macOS input-source switches (menu-bar +// picker, Cmd+Space) don't fire navigator.keyboard's `layoutchange` — +// native-keymap hooks the OS-level +// kTISNotifySelectedKeyboardInputSourceChanged distributed notification, +// which fires for every input-source change. + +interface State { + /** Map<event.code, unshifted glyph>. Null until the first tRPC payload + * arrives (~10ms after window load); display falls back to US-ANSI + * glyphs while null. */ + map: ReadonlyMap<string, string> | null; + /** OS-specific layout id, e.g. "com.apple.keylayout.German". */ + layoutId: string; +} + +export const useKeyboardLayoutStore = create<State>(() => ({ + map: null, + layoutId: "", +})); + +function applySnapshot(data: KeyboardLayoutData): void { + useKeyboardLayoutStore.setState({ + map: new Map(Object.entries(data.unshifted)), + layoutId: data.layoutId, + }); +} + +// Process-lifetime subscription. If it errors, retry with backoff — +// otherwise `map` would stay null until window reload and every hotkey +// label would silently fall back to US-ANSI glyphs. +const RETRY_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000]; +let retryAttempt = 0; + +function startKeyboardLayoutSync(): void { + electronTrpcClient.keyboardLayout.changes.subscribe(undefined, { + onData: (data) => { + retryAttempt = 0; + applySnapshot(data); + }, + onError: (err) => { + console.error("[keyboardLayoutStore] subscription error:", err); + const idx = Math.min(retryAttempt, RETRY_BACKOFF_MS.length - 1); + const delay = RETRY_BACKOFF_MS[idx] ?? 10_000; + retryAttempt++; + setTimeout(startKeyboardLayoutSync, delay); + }, + }); +} + +startKeyboardLayoutSync(); diff --git a/apps/desktop/src/renderer/hotkeys/types.ts b/apps/desktop/src/renderer/hotkeys/types.ts new file mode 100644 index 00000000000..96d6982c73e --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/types.ts @@ -0,0 +1,61 @@ +export type Platform = "mac" | "windows" | "linux"; + +export type PlatformKey = { + mac: string | null; + windows: string | null; + linux: string | null; +}; + +export type HotkeyCategory = + | "Navigation" + | "Workspace" + | "Layout" + | "Terminal" + | "Window" + | "Help"; + +export interface HotkeyDisplay { + /** Individual symbols for <Kbd> components: ["⌘", "⇧", "N"] */ + keys: string[]; + /** Joined string for tooltip text: "⌘⇧N" (mac) or "Ctrl+Shift+N" (windows/linux) */ + text: string; +} + +export interface HotkeyDefinition { + key: string | null; + label: string; + category: HotkeyCategory; + description?: string; +} + +/** + * How a binding identifies a key: + * - `physical`: matches `event.code` — same physical key on every layout. + * Default for shipped registry entries (preserves QWERTY muscle memory). + * - `logical`: matches the produced character (`event.key`) — same printed + * letter on every layout, even when it lives on different physical keys. + * Default for new user-recorded printable bindings. + * - `named`: stable named keys (Enter, ArrowUp, F1-F12, ...). Used + * automatically for non-printable keys regardless of preference. + */ +export type BindingMode = "physical" | "logical" | "named"; + +/** + * Stored as a bare chord string for legacy / shipped defaults (implicitly + * physical) or a v2 object for explicit modes. The legacy string form is + * preserved indefinitely so default registry entries stay terse. + */ +export type ShortcutBinding = + | string + | { + version: 2; + mode: BindingMode; + /** Canonical form, e.g. "meta+shift+p", "ctrl+slash". */ + chord: string; + }; + +/** Normalized view of a binding, regardless of stored form. */ +export interface ParsedBinding { + mode: BindingMode; + chord: string; +} diff --git a/apps/desktop/src/renderer/hotkeys/utils/binding.test.ts b/apps/desktop/src/renderer/hotkeys/utils/binding.test.ts new file mode 100644 index 00000000000..6282ee88ad2 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/utils/binding.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "bun:test"; +import { + bindingsEqual, + bindingToDispatchChord, + defaultModeForChord, + parseBinding, + serializeBinding, + translateLogicalChord, +} from "./binding"; + +describe("defaultModeForChord", () => { + it("classifies named keys as 'named'", () => { + expect(defaultModeForChord("meta+enter")).toBe("named"); + expect(defaultModeForChord("ctrl+arrowup")).toBe("named"); + expect(defaultModeForChord("alt+up")).toBe("named"); + expect(defaultModeForChord("escape")).toBe("named"); + expect(defaultModeForChord("backspace")).toBe("named"); + }); + + it("classifies F-keys as 'named'", () => { + expect(defaultModeForChord("f1")).toBe("named"); + expect(defaultModeForChord("meta+f10")).toBe("named"); + expect(defaultModeForChord("f12")).toBe("named"); + }); + + it("classifies letters/digits/punctuation as 'physical'", () => { + expect(defaultModeForChord("meta+p")).toBe("physical"); + expect(defaultModeForChord("ctrl+shift+1")).toBe("physical"); + expect(defaultModeForChord("meta+slash")).toBe("physical"); + expect(defaultModeForChord("ctrl+bracketleft")).toBe("physical"); + }); +}); + +describe("parseBinding", () => { + it("treats legacy string as physical for printable keys", () => { + expect(parseBinding("meta+p")).toEqual({ + mode: "physical", + chord: "meta+p", + }); + }); + + it("treats legacy string as named for special keys", () => { + expect(parseBinding("meta+enter")).toEqual({ + mode: "named", + chord: "meta+enter", + }); + expect(parseBinding("f5")).toEqual({ mode: "named", chord: "f5" }); + }); + + it("preserves explicit v2 object form", () => { + expect( + parseBinding({ version: 2, mode: "logical", chord: "meta+p" }), + ).toEqual({ mode: "logical", chord: "meta+p" }); + expect( + parseBinding({ version: 2, mode: "physical", chord: "meta+p" }), + ).toEqual({ mode: "physical", chord: "meta+p" }); + }); +}); + +describe("serializeBinding", () => { + it("compacts physical mode to bare string (matches legacy storage)", () => { + expect(serializeBinding({ mode: "physical", chord: "meta+p" })).toBe( + "meta+p", + ); + }); + + it("encodes logical mode as v2 object", () => { + expect(serializeBinding({ mode: "logical", chord: "meta+p" })).toEqual({ + version: 2, + mode: "logical", + chord: "meta+p", + }); + }); + + it("encodes named mode as v2 object", () => { + expect(serializeBinding({ mode: "named", chord: "meta+enter" })).toEqual({ + version: 2, + mode: "named", + chord: "meta+enter", + }); + }); + + it("canonicalizes the chord on serialize", () => { + expect(serializeBinding({ mode: "physical", chord: "shift+ctrl+k" })).toBe( + "ctrl+shift+k", + ); + }); + + it("round-trips legacy physical bindings unchanged", () => { + const legacy: string = "meta+shift+p"; + const round = serializeBinding(parseBinding(legacy)); + expect(round).toBe(legacy); + }); + + it("round-trips logical bindings as v2 objects", () => { + const v2 = { + version: 2 as const, + mode: "logical" as const, + chord: "meta+p", + }; + const round = serializeBinding(parseBinding(v2)); + expect(round).toEqual(v2); + }); +}); + +describe("translateLogicalChord", () => { + const usMap = new Map<string, string>([ + ["KeyA", "a"], + ["KeyP", "p"], + ["KeyR", "r"], + ["KeyZ", "z"], + ["Slash", "/"], + ["Quote", "'"], + ["Semicolon", ";"], + ]); + // Dvorak: physical KeyR position prints "p", physical KeyP prints "l" + const dvorakMap = new Map<string, string>([ + ["KeyA", "a"], + ["KeyP", "l"], + ["KeyR", "p"], + ["KeyZ", ";"], + ["Quote", "q"], + ]); + // QWERTZ: KeyY/KeyZ swapped, Slash → "-" + const qwertzMap = new Map<string, string>([ + ["KeyY", "z"], + ["KeyZ", "y"], + ["Slash", "-"], + ]); + + it("returns null when layout map is unavailable", () => { + expect(translateLogicalChord("meta+p", null)).toBeNull(); + }); + + it("US layout: chord round-trips unchanged", () => { + expect(translateLogicalChord("meta+p", usMap)).toBe("meta+p"); + expect(translateLogicalChord("ctrl+shift+a", usMap)).toBe("ctrl+shift+a"); + }); + + it("Dvorak: meta+p (logical) translates to meta+r (physical R prints 'p')", () => { + expect(translateLogicalChord("meta+p", dvorakMap)).toBe("meta+r"); + }); + + it("QWERTZ: meta+y (logical) translates to meta+z (physical Z prints 'y')", () => { + expect(translateLogicalChord("meta+y", qwertzMap)).toBe("meta+z"); + }); + + it("translates punctuation aliases via their US glyph", () => { + // US: Slash prints "/" → no change + expect(translateLogicalChord("ctrl+slash", usMap)).toBe("ctrl+slash"); + // QWERTZ: Slash prints "-", but the binding wants the "/" character. + // On QWERTZ "/" is at Shift+7, not on a single key — so no scan code + // has unshifted glyph "/", returns null (caller falls back). + expect(translateLogicalChord("ctrl+slash", qwertzMap)).toBeNull(); + }); + + it("named keys (Enter, ArrowUp, F-keys) pass through unchanged", () => { + expect(translateLogicalChord("meta+enter", dvorakMap)).toBe("meta+enter"); + expect(translateLogicalChord("ctrl+arrowup", dvorakMap)).toBe( + "ctrl+arrowup", + ); + expect(translateLogicalChord("f5", dvorakMap)).toBe("f5"); + }); + + it("returns null when the produced character isn't on the keyboard", () => { + // Logical "meta+ñ" — no scan code in usMap has "ñ" as unshifted glyph + expect(translateLogicalChord("meta+ñ", usMap)).toBeNull(); + }); + + it("preserves modifier order from the input chord", () => { + // Verify modifiers stay in their input order; canonicalizeChord sorts them + const result = translateLogicalChord("alt+meta+shift+p", dvorakMap); + expect(result).toBe("alt+meta+shift+r"); + }); +}); + +describe("bindingToDispatchChord", () => { + const usMap = new Map<string, string>([ + ["KeyP", "p"], + ["KeyR", "r"], + ["KeyY", "y"], + ["KeyZ", "z"], + ]); + // QWERTZ: Y/Z swapped + const qwertzMap = new Map<string, string>([ + ["KeyY", "z"], + ["KeyZ", "y"], + ]); + + it("returns null for null binding", () => { + expect(bindingToDispatchChord(null, usMap)).toBeNull(); + }); + + it("legacy string (physical) returns chord unchanged", () => { + expect(bindingToDispatchChord("meta+p", usMap)).toBe("meta+p"); + expect(bindingToDispatchChord("meta+p", qwertzMap)).toBe("meta+p"); + }); + + it("explicit physical mode returns chord unchanged", () => { + expect( + bindingToDispatchChord( + { version: 2, mode: "physical", chord: "meta+p" }, + qwertzMap, + ), + ).toBe("meta+p"); + }); + + it("named mode returns chord unchanged", () => { + expect( + bindingToDispatchChord( + { version: 2, mode: "named", chord: "meta+enter" }, + qwertzMap, + ), + ).toBe("meta+enter"); + }); + + // The bug we just fixed: logical bindings recorded on QWERTZ were displayed + // as if their key were a scan-code token. Verify the translation flips the + // chord to the equivalent event.code form before display/dispatch. + it("logical binding on QWERTZ: meta+z translates to meta+y (KeyY prints 'z')", () => { + expect( + bindingToDispatchChord( + { version: 2, mode: "logical", chord: "meta+z" }, + qwertzMap, + ), + ).toBe("meta+y"); + }); + + it("logical binding on QWERTZ: meta+y translates to meta+z (KeyZ prints 'y')", () => { + expect( + bindingToDispatchChord( + { version: 2, mode: "logical", chord: "meta+y" }, + qwertzMap, + ), + ).toBe("meta+z"); + }); + + it("logical binding on US: identity (printed char == scan-code token)", () => { + expect( + bindingToDispatchChord( + { version: 2, mode: "logical", chord: "meta+z" }, + usMap, + ), + ).toBe("meta+z"); + }); + + it("logical binding falls back to literal chord when layoutMap missing", () => { + expect( + bindingToDispatchChord( + { version: 2, mode: "logical", chord: "meta+z" }, + null, + ), + ).toBe("meta+z"); + }); +}); + +describe("bindingsEqual", () => { + it("nulls match nulls", () => { + expect(bindingsEqual(null, null)).toBe(true); + expect(bindingsEqual(null, "meta+p")).toBe(false); + expect(bindingsEqual("meta+p", null)).toBe(false); + }); + + it("legacy string matches itself across modifier reorderings", () => { + expect(bindingsEqual("meta+shift+p", "shift+meta+p")).toBe(true); + }); + + it("legacy physical does NOT equal explicit logical with same chord", () => { + expect( + bindingsEqual("meta+p", { version: 2, mode: "logical", chord: "meta+p" }), + ).toBe(false); + }); + + it("legacy physical equals explicit physical with same chord", () => { + expect( + bindingsEqual("meta+p", { + version: 2, + mode: "physical", + chord: "meta+p", + }), + ).toBe(true); + }); + + it("two logical bindings with equivalent chords match", () => { + expect( + bindingsEqual( + { version: 2, mode: "logical", chord: "shift+meta+p" }, + { version: 2, mode: "logical", chord: "meta+shift+p" }, + ), + ).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/hotkeys/utils/binding.ts b/apps/desktop/src/renderer/hotkeys/utils/binding.ts new file mode 100644 index 00000000000..f83ad421f81 --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/utils/binding.ts @@ -0,0 +1,133 @@ +import type { ParsedBinding, ShortcutBinding } from "../types"; +import { canonicalizeChord, normalizeToken } from "./resolveHotkeyFromEvent"; + +/** + * Keys whose `event.code` is stable across keyboard layouts (Enter, arrows, + * Backspace, ...). Tokens listed here are post-`normalizeToken` form — + * aliases like `esc` / `up` / `return` resolve to their canonical names + * (`escape`, `arrowup`, `enter`) before lookup, so this set must mirror the + * canonical side only. + */ +export const NAMED_KEYS = new Set([ + "enter", + "escape", + "backspace", + "delete", + "tab", + "space", + "arrowup", + "arrowdown", + "arrowleft", + "arrowright", + "home", + "end", + "pageup", + "pagedown", + "insert", +]); + +export function isFunctionKey(token: string): boolean { + return /^f([1-9]|1[0-2])$/.test(token); +} + +/** Mode used when promoting a legacy string binding (no explicit mode). */ +export function defaultModeForChord(chord: string): "physical" | "named" { + const parts = canonicalizeChord(chord).split("+"); + const key = parts[parts.length - 1]; + if (!key) return "physical"; + if (NAMED_KEYS.has(key) || isFunctionKey(key)) return "named"; + return "physical"; +} + +/** Normalize a stored binding (string or v2 object) into `{ mode, chord }`. */ +export function parseBinding(binding: ShortcutBinding): ParsedBinding { + if (typeof binding === "string") { + return { mode: defaultModeForChord(binding), chord: binding }; + } + return { mode: binding.mode, chord: binding.chord }; +} + +/** + * Compact storage form: physical → bare string (matches legacy storage and + * shipped registry defaults); logical / named → v2 object. + */ +export function serializeBinding(parsed: ParsedBinding): ShortcutBinding { + const chord = canonicalizeChord(parsed.chord); + if (parsed.mode === "physical") return chord; + return { version: 2, mode: parsed.mode, chord }; +} + +/** + * Resolve a binding to the event.code-form chord react-hotkeys-hook matches + * against. Logical bindings are translated through the current layout map + * (e.g. logical `meta+z` on QWERTZ becomes `meta+y` because the Z character + * lives on physical KeyY there). Single source of truth shared by useHotkey, + * useHotkeyDisplay, useFormatBinding, the conflict detector, and the + * terminal-forwarding reverse index. + */ +export function bindingToDispatchChord( + binding: ShortcutBinding | null, + layoutMap: ReadonlyMap<string, string> | null, +): string | null { + if (!binding) return null; + const parsed = parseBinding(binding); + if (parsed.mode !== "logical") return parsed.chord; + return translateLogicalChord(parsed.chord, layoutMap) ?? parsed.chord; +} + +/** Two bindings refer to the same chord under the same matching semantics. */ +export function bindingsEqual( + a: ShortcutBinding | null, + b: ShortcutBinding | null, +): boolean { + if (a === null || b === null) return a === b; + const pa = parseBinding(a); + const pb = parseBinding(b); + return ( + pa.mode === pb.mode && + canonicalizeChord(pa.chord) === canonicalizeChord(pb.chord) + ); +} + +// Registry's canonical token form ("slash") → layout-map's unshifted glyph form ("/"). +const PUNCT_ALIAS_TO_GLYPH: Record<string, string> = { + slash: "/", + backslash: "\\", + comma: ",", + period: ".", + semicolon: ";", + quote: "'", + backquote: "`", + minus: "-", + equal: "=", + bracketleft: "[", + bracketright: "]", +}; + +/** + * Translate a logical chord ("meta+p") into the equivalent event.code-based + * chord for the user's current layout. On US QWERTY: `meta+p` → `meta+p`. + * On Dvorak: `meta+p` → `meta+r` (physical KeyR prints "p"). Named/F-keys + * pass through unchanged. Returns null when the produced character isn't on + * the keyboard — caller falls back to the untranslated chord. + */ +export function translateLogicalChord( + chord: string, + layoutMap: ReadonlyMap<string, string> | null, +): string | null { + if (!layoutMap) return null; + const canonical = canonicalizeChord(chord); + const parts = canonical.split("+"); + const key = parts[parts.length - 1]; + if (!key) return null; + if (NAMED_KEYS.has(key) || isFunctionKey(key)) return canonical; + + const targetGlyph = PUNCT_ALIAS_TO_GLYPH[key] ?? key; + for (const [scanCode, glyph] of layoutMap) { + if (glyph.toLowerCase() === targetGlyph.toLowerCase()) { + parts[parts.length - 1] = normalizeToken(scanCode); + return parts.join("+"); + } + } + return null; +} diff --git a/apps/desktop/src/renderer/hotkeys/utils/index.ts b/apps/desktop/src/renderer/hotkeys/utils/index.ts new file mode 100644 index 00000000000..de0c7b781bf --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/utils/index.ts @@ -0,0 +1,13 @@ +export { + bindingsEqual, + bindingToDispatchChord, + defaultModeForChord, + parseBinding, + serializeBinding, + translateLogicalChord, +} from "./binding"; +export { + isTerminalReservedEvent, + matchesChord, + resolveHotkeyFromEvent, +} from "./resolveHotkeyFromEvent"; diff --git a/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.test.ts b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.test.ts new file mode 100644 index 00000000000..d46ee4154ed --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.test.ts @@ -0,0 +1,445 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { HOTKEYS, type HotkeyId } from "../registry"; +import { useHotkeyOverridesStore } from "../stores/hotkeyOverridesStore"; +import type { HotkeyDefinition, ShortcutBinding } from "../types"; +import { + canonicalizeChord, + eventToChord, + isIgnorableKey, + isTerminalReservedEvent, + matchesChord, + normalizeToken, + resolveHotkeyFromEvent, + TERMINAL_RESERVED_CHORDS, +} from "./resolveHotkeyFromEvent"; + +// Minimal stub — the renderer references `navigator` only at import time. +// Bun's test runtime doesn't have a DOM navigator by default; registry.ts +// detects platform via `navigator.platform` and falls back to "mac" when +// navigator is undefined. We only assert platform-agnostic behavior here. + +describe("normalizeToken", () => { + it("maps code aliases to canonical names", () => { + expect(normalizeToken("ControlLeft")).toBe("ctrl"); + expect(normalizeToken("ControlRight")).toBe("ctrl"); + expect(normalizeToken("MetaLeft")).toBe("meta"); + expect(normalizeToken("ShiftRight")).toBe("shift"); + expect(normalizeToken("AltLeft")).toBe("alt"); + expect(normalizeToken("OSLeft")).toBe("meta"); + }); + + it("strips key/digit/numpad prefixes from event.code", () => { + expect(normalizeToken("KeyA")).toBe("a"); + expect(normalizeToken("KeyZ")).toBe("z"); + expect(normalizeToken("Digit1")).toBe("1"); + expect(normalizeToken("Digit0")).toBe("0"); + expect(normalizeToken("Numpad5")).toBe("5"); + }); + + it("lowercases physical key names and keeps punctuation tokens", () => { + expect(normalizeToken("BracketLeft")).toBe("bracketleft"); + expect(normalizeToken("BracketRight")).toBe("bracketright"); + expect(normalizeToken("Comma")).toBe("comma"); + expect(normalizeToken("Slash")).toBe("slash"); + expect(normalizeToken("Backslash")).toBe("backslash"); + expect(normalizeToken("Semicolon")).toBe("semicolon"); + }); + + it("aliases short arrow names to canonical", () => { + expect(normalizeToken("up")).toBe("arrowup"); + expect(normalizeToken("down")).toBe("arrowdown"); + expect(normalizeToken("left")).toBe("arrowleft"); + expect(normalizeToken("right")).toBe("arrowright"); + expect(normalizeToken("esc")).toBe("escape"); + expect(normalizeToken("return")).toBe("enter"); + }); + + it("canonicalizes arrow event.code to the same as short form", () => { + expect(normalizeToken("ArrowUp")).toBe("arrowup"); + expect(normalizeToken("ArrowDown")).toBe("arrowdown"); + }); +}); + +describe("isIgnorableKey", () => { + it("rejects empty normalized keys", () => { + expect(isIgnorableKey("")).toBe(true); + }); + + it("rejects every modifier alias", () => { + for (const m of ["meta", "ctrl", "control", "alt", "shift"]) { + expect(isIgnorableKey(m)).toBe(true); + } + }); + + it("rejects lock keys", () => { + expect(isIgnorableKey("capslock")).toBe(true); + expect(isIgnorableKey("numlock")).toBe(true); + expect(isIgnorableKey("scrolllock")).toBe(true); + }); + + it("allows regular letters, digits, and punctuation", () => { + expect(isIgnorableKey("a")).toBe(false); + expect(isIgnorableKey("1")).toBe(false); + expect(isIgnorableKey("bracketleft")).toBe(false); + expect(isIgnorableKey("arrowup")).toBe(false); + }); +}); + +describe("canonicalizeChord", () => { + it("sorts modifiers alphabetically and preserves the key", () => { + expect(canonicalizeChord("meta+alt+up")).toBe("alt+meta+arrowup"); + expect(canonicalizeChord("shift+ctrl+k")).toBe("ctrl+shift+k"); + }); + + it("treats `control` and `ctrl` as the same modifier", () => { + expect(canonicalizeChord("control+k")).toBe("ctrl+k"); + expect(canonicalizeChord("Control+K")).toBe("ctrl+k"); + }); + + it("normalizes key aliases across equivalent chord spellings", () => { + expect(canonicalizeChord("meta+alt+up")).toBe( + canonicalizeChord("alt+meta+arrowup"), + ); + expect(canonicalizeChord("ctrl+shift+bracketleft")).toBe( + canonicalizeChord("shift+ctrl+bracketleft"), + ); + }); + + it("is idempotent", () => { + const once = canonicalizeChord("meta+shift+l"); + expect(canonicalizeChord(once)).toBe(once); + }); + + // Regression: OS_RESERVED had `ctrl+alt+delete` written non-canonical, which + // meant the "Reserved by OS" warning never fired for that chord. Fix wraps + // the table in `.map(canonicalizeChord)`. Assert the canonical form here so + // future additions can't silently break the warning the same way. + it("sorts all modifiers alphabetically (ctrl+alt+delete → alt+ctrl+delete)", () => { + expect(canonicalizeChord("ctrl+alt+delete")).toBe("alt+ctrl+delete"); + }); +}); + +interface StubInit { + type?: string; + code?: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + altGraph?: boolean; + isComposing?: boolean; + keyCode?: number; +} +function ev(init: StubInit): KeyboardEvent { + return { + type: init.type ?? "keydown", + code: init.code ?? "", + key: "", + ctrlKey: !!init.ctrlKey, + metaKey: !!init.metaKey, + altKey: !!init.altKey, + shiftKey: !!init.shiftKey, + isComposing: !!init.isComposing, + keyCode: init.keyCode ?? 0, + getModifierState: (mod: string) => + mod === "AltGraph" ? !!init.altGraph : false, + } as unknown as KeyboardEvent; +} + +describe("resolveHotkeyFromEvent — live override index", () => { + let originalOverrides: Record<string, ShortcutBinding | null>; + beforeEach(() => { + originalOverrides = useHotkeyOverridesStore.getState().overrides; + }); + afterEach(() => { + useHotkeyOverridesStore.setState({ overrides: originalOverrides }); + }); + + // Resolve once so registry reorders / removals surface as a test failure + // here instead of silently skipping the cases below. The type predicate + // narrows to a HotkeyDefinition whose .key is guaranteed non-null after + // the filter, so sampleDef.key can be passed to string-only helpers below. + const sampleEntry = Object.entries(HOTKEYS).find( + (entry): entry is [HotkeyId, HotkeyDefinition & { key: string }] => + entry[1].key !== null, + ); + if (!sampleEntry) throw new Error("HOTKEYS has no bound default"); + const [sampleId, sampleDef] = sampleEntry; + + it("resolves a default binding when no override is set", () => { + const event = buildEventFromChord(sampleDef.key); + expect(resolveHotkeyFromEvent(event)).toBe(sampleId); + }); + + it("resolves a rebound chord after an override is saved", () => { + useHotkeyOverridesStore.setState({ + overrides: { [sampleId]: "meta+shift+f10" }, + }); + const event = buildEventFromChord("meta+shift+f10"); + expect(resolveHotkeyFromEvent(event)).toBe(sampleId); + }); + + it("does NOT resolve the old default after the user rebinds away from it", () => { + useHotkeyOverridesStore.setState({ + overrides: { [sampleId]: "meta+shift+f10" }, + }); + const event = buildEventFromChord(sampleDef.key); + expect(resolveHotkeyFromEvent(event)).toBeNull(); + }); + + it("does NOT resolve a hotkey the user explicitly unassigned (null override)", () => { + useHotkeyOverridesStore.setState({ + overrides: { [sampleId]: null }, + }); + const event = buildEventFromChord(sampleDef.key); + expect(resolveHotkeyFromEvent(event)).toBeNull(); + }); +}); + +/** + * Turns a chord string (e.g. `meta+shift+f10`, `ctrl+bracketleft`) into a + * KeyboardEvent stub with matching `event.code` and modifier flags. + */ +function buildEventFromChord(chord: string): KeyboardEvent { + const parts = chord.toLowerCase().split("+"); + const mods = { + metaKey: parts.includes("meta"), + ctrlKey: parts.includes("ctrl") || parts.includes("control"), + altKey: parts.includes("alt"), + shiftKey: parts.includes("shift"), + }; + const key = parts.find( + (p) => !["meta", "ctrl", "control", "alt", "shift"].includes(p), + ); + const code = chordKeyToCode(key ?? ""); + return { + type: "keydown", + code, + key: "", + ...mods, + } as unknown as KeyboardEvent; +} + +// Inverse of normalizeToken for the tokens the registry uses. Only needs to +// cover what tests exercise. +function chordKeyToCode(key: string): string { + if (/^[a-z]$/.test(key)) return `Key${key.toUpperCase()}`; + if (/^[0-9]$/.test(key)) return `Digit${key}`; + if (/^f([1-9]|1[0-2])$/.test(key)) return key.toUpperCase(); + switch (key) { + case "arrowup": + case "up": + return "ArrowUp"; + case "arrowdown": + case "down": + return "ArrowDown"; + case "arrowleft": + case "left": + return "ArrowLeft"; + case "arrowright": + case "right": + return "ArrowRight"; + case "bracketleft": + return "BracketLeft"; + case "bracketright": + return "BracketRight"; + case "comma": + return "Comma"; + case "slash": + return "Slash"; + case "backslash": + return "Backslash"; + case "backspace": + return "Backspace"; + case "space": + return "Space"; + case "tab": + return "Tab"; + default: + return key; + } +} + +describe("resolveHotkeyFromEvent", () => { + it("returns null for non-keydown events", () => { + expect( + resolveHotkeyFromEvent( + ev({ type: "keyup", code: "KeyP", metaKey: true }), + ), + ).toBeNull(); + }); + + it("returns null for pure modifier presses", () => { + expect( + resolveHotkeyFromEvent(ev({ code: "ControlLeft", ctrlKey: true })), + ).toBeNull(); + }); + + it("returns null for unbound chords", () => { + expect( + resolveHotkeyFromEvent( + ev({ + code: "KeyZ", + ctrlKey: true, + shiftKey: true, + altKey: true, + metaKey: true, + }), + ), + ).toBeNull(); + }); +}); + +describe("eventToChord", () => { + it("normalizes punctuation via event.code", () => { + expect(eventToChord(ev({ code: "BracketLeft", ctrlKey: true }))).toBe( + "ctrl+bracketleft", + ); + expect(eventToChord(ev({ code: "Slash", metaKey: true }))).toBe( + "meta+slash", + ); + }); + + it("returns null for pure modifiers and lock keys", () => { + expect(eventToChord(ev({ code: "ControlLeft", ctrlKey: true }))).toBeNull(); + expect(eventToChord(ev({ code: "CapsLock" }))).toBeNull(); + }); + + // AltGr on Linux/Windows is reported as ctrlKey+altKey. Without the guard, + // AltGr+E on a German layout (which produces €) would match a US + // `ctrl+alt+e` binding. Suppress both when AltGraph is set so AltGr-typed + // printables can never trigger Ctrl+Alt hotkeys. + it("suppresses ctrl/alt when AltGraph modifier is held", () => { + expect( + eventToChord( + ev({ + code: "KeyE", + ctrlKey: true, + altKey: true, + altGraph: true, + }), + ), + ).toBe("e"); + }); + + it("AltGr+letter does not match a real ctrl+alt binding", () => { + const altGrEvent = ev({ + code: "KeyE", + ctrlKey: true, + altKey: true, + altGraph: true, + }); + expect(matchesChord(altGrEvent, "ctrl+alt+e")).toBe(false); + }); + + it("real Ctrl+Alt (no AltGraph) still matches", () => { + const realCtrlAlt = ev({ + code: "KeyE", + ctrlKey: true, + altKey: true, + altGraph: false, + }); + expect(matchesChord(realCtrlAlt, "ctrl+alt+e")).toBe(true); + }); + + // IME composition: keydown during dead-key / CJK composition must not fire + // hotkeys. Safari uses keyCode 229 in lieu of isComposing. + it("returns null during IME composition (isComposing)", () => { + expect( + eventToChord(ev({ code: "KeyA", metaKey: true, isComposing: true })), + ).toBeNull(); + }); + + it("returns null when keyCode is 229 (Safari IME)", () => { + expect( + eventToChord(ev({ code: "KeyA", metaKey: true, keyCode: 229 })), + ).toBeNull(); + }); +}); + +describe("matchesChord", () => { + it("matches events regardless of modifier order in the chord string", () => { + const event = ev({ code: "KeyK", ctrlKey: true, shiftKey: true }); + expect(matchesChord(event, "ctrl+shift+k")).toBe(true); + expect(matchesChord(event, "shift+ctrl+k")).toBe(true); + }); + + it("matches short vs canonical arrow forms", () => { + const event = ev({ code: "ArrowUp", metaKey: true, altKey: true }); + expect(matchesChord(event, "meta+alt+up")).toBe(true); + expect(matchesChord(event, "alt+meta+arrowup")).toBe(true); + }); + + it("matches punctuation rebinds via event.code (bracket, backslash)", () => { + expect( + matchesChord( + ev({ code: "BracketLeft", ctrlKey: true, shiftKey: true }), + "ctrl+shift+bracketleft", + ), + ).toBe(true); + expect( + matchesChord(ev({ code: "Backslash", ctrlKey: true }), "ctrl+backslash"), + ).toBe(true); + }); + + it("does NOT match when key differs", () => { + expect(matchesChord(ev({ code: "KeyK", ctrlKey: true }), "ctrl+j")).toBe( + false, + ); + }); + + it("does NOT match when modifiers differ", () => { + expect(matchesChord(ev({ code: "KeyK", ctrlKey: true }), "meta+k")).toBe( + false, + ); + }); + + it("does NOT match a bare modifier press", () => { + expect( + matchesChord(ev({ code: "ControlLeft", ctrlKey: true }), "ctrl+k"), + ).toBe(false); + }); +}); + +describe("isTerminalReservedEvent", () => { + it.each([ + "ctrl+c", + "ctrl+d", + "ctrl+z", + "ctrl+s", + "ctrl+q", + "ctrl+backslash", + ])("detects %s", (chord) => { + const codeMap: Record<string, string> = { + "ctrl+c": "KeyC", + "ctrl+d": "KeyD", + "ctrl+z": "KeyZ", + "ctrl+s": "KeyS", + "ctrl+q": "KeyQ", + "ctrl+backslash": "Backslash", + }; + expect( + isTerminalReservedEvent(ev({ code: codeMap[chord], ctrlKey: true })), + ).toBe(true); + }); + + it("ignores non-reserved ctrl chords", () => { + expect(isTerminalReservedEvent(ev({ code: "KeyK", ctrlKey: true }))).toBe( + false, + ); + }); + + it("ignores reserved letter when extra modifier is held", () => { + expect( + isTerminalReservedEvent( + ev({ code: "KeyC", ctrlKey: true, shiftKey: true }), + ), + ).toBe(false); + }); + + it("TERMINAL_RESERVED_CHORDS stays in canonical form", () => { + for (const chord of TERMINAL_RESERVED_CHORDS) { + expect(canonicalizeChord(chord)).toBe(chord); + } + }); +}); diff --git a/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts new file mode 100644 index 00000000000..42a4585426c --- /dev/null +++ b/apps/desktop/src/renderer/hotkeys/utils/resolveHotkeyFromEvent.ts @@ -0,0 +1,154 @@ +import { HOTKEYS, type HotkeyId } from "../registry"; +import { useHotkeyOverridesStore } from "../stores/hotkeyOverridesStore"; +import { useKeyboardLayoutStore } from "../stores/keyboardLayoutStore"; +import type { ShortcutBinding } from "../types"; +import { bindingToDispatchChord } from "./binding"; + +/** + * KeyboardEvent → registered {@link HotkeyId}, or `null` if unbound. Uses the + * same `event.code` normalization as react-hotkeys-hook so the reverse index + * can't drift from the matcher. Index reflects current overrides, not frozen + * defaults — see {@link registeredAppChords}. + */ +export function resolveHotkeyFromEvent(event: KeyboardEvent): HotkeyId | null { + if (event.type !== "keydown") return null; + const chord = eventToChord(event); + if (!chord) return null; + return registeredAppChords.get(chord) ?? null; +} + +// Mirrors react-hotkeys-hook's alias table (react-hotkeys-hook/dist/index.js:3-19) +const CODE_ALIASES: Record<string, string> = { + esc: "escape", + return: "enter", + left: "arrowleft", + right: "arrowright", + up: "arrowup", + down: "arrowdown", + MetaLeft: "meta", + MetaRight: "meta", + ShiftLeft: "shift", + ShiftRight: "shift", + AltLeft: "alt", + AltRight: "alt", + OSLeft: "meta", + OSRight: "meta", + ControlLeft: "ctrl", + ControlRight: "ctrl", +}; + +export const MODIFIERS = new Set(["meta", "ctrl", "control", "alt", "shift"]); + +// Lock keys must never commit a binding on their own. +const LOCK_KEYS = new Set(["capslock", "numlock", "scrolllock"]); + +export function normalizeToken(token: string): string { + const aliased = CODE_ALIASES[token.trim()] ?? token.trim(); + return aliased.toLowerCase().replace(/key|digit|numpad/, ""); +} + +export function isIgnorableKey(normalized: string): boolean { + return !normalized || MODIFIERS.has(normalized) || LOCK_KEYS.has(normalized); +} + +/** + * Stable form for comparing chord strings. Tolerates modifier order and + * aliases: `meta+alt+up` ≡ `alt+meta+arrowup` ≡ `control+alt+arrowup`. + */ +export function canonicalizeChord(chord: string): string { + const parts = chord.toLowerCase().split("+").map(normalizeToken); + const mods: string[] = []; + const keys: string[] = []; + for (const part of parts) { + if (MODIFIERS.has(part)) { + mods.push(part === "control" ? "ctrl" : part); + } else { + keys.push(part); + } + } + mods.sort(); + return [...mods, ...keys].join("+"); +} + +/** KeyboardEvent → canonical chord (comparable to {@link canonicalizeChord} output), or null for pure modifier / synthetic presses. */ +export function eventToChord(event: KeyboardEvent): string | null { + if (event.code === undefined) return null; + // IME composition: keydown during CJK / dead-key composition must not + // trigger hotkeys. Safari reports keyCode 229 instead of isComposing. + if (event.isComposing || event.keyCode === 229) return null; + const key = normalizeToken(event.code); + if (isIgnorableKey(key)) return null; + // AltGr is reported by Chromium as ctrlKey+altKey on Windows/Linux. + // Treating that combination as Ctrl+Alt would let printable keystrokes on + // non-US layouts (e.g. AltGr+E = € on German) accidentally trigger + // ctrl+alt+e bindings. Suppress both when AltGr is held; no binding opts + // into AltGr explicitly. + const altGraph = event.getModifierState?.("AltGraph") === true; + const mods: string[] = []; + if (event.metaKey) mods.push("meta"); + if (event.ctrlKey && !altGraph) mods.push("ctrl"); + if (event.altKey && !altGraph) mods.push("alt"); + if (event.shiftKey) mods.push("shift"); + mods.sort(); + return [...mods, key].join("+"); +} + +/** True if `event` produces `chord` (tolerating modifier order / aliases). */ +export function matchesChord(event: KeyboardEvent, chord: string): boolean { + const eventChord = eventToChord(event); + if (!eventChord) return false; + return eventChord === canonicalizeChord(chord); +} + +/** Sent straight to the PTY. Canonicalized at build time so lookups via `eventToChord` / `canonicalizeChord` match directly. */ +export const TERMINAL_RESERVED_CHORDS = new Set( + ["ctrl+c", "ctrl+d", "ctrl+z", "ctrl+s", "ctrl+q", "ctrl+backslash"].map( + canonicalizeChord, + ), +); + +/** True if the event matches a chord the terminal must always receive. */ +export function isTerminalReservedEvent(event: KeyboardEvent): boolean { + const chord = eventToChord(event); + if (!chord) return false; + return TERMINAL_RESERVED_CHORDS.has(chord); +} + +function buildRegisteredAppChords( + overrides: Record<string, ShortcutBinding | null>, + layoutMap: ReadonlyMap<string, string> | null, +): Map<string, HotkeyId> { + const map = new Map<string, HotkeyId>(); + for (const id of Object.keys(HOTKEYS) as HotkeyId[]) { + const hasOverride = id in overrides; + const override = hasOverride ? overrides[id] : undefined; + // Explicit unassignment (null override) must drop from the index — else + // the terminal's isAppHotkey check would swallow the freed chord. + if (hasOverride && override === null) continue; + const binding = override ?? HOTKEYS[id].key; + if (!binding) continue; + const dispatchChord = bindingToDispatchChord(binding, layoutMap); + if (!dispatchChord) continue; + map.set(canonicalizeChord(dispatchChord), id); + } + return map; +} + +// Reassigned on each override OR layout change; `let` is required so the +// subscribe callbacks can replace the reference the resolver reads. +let registeredAppChords = buildRegisteredAppChords( + useHotkeyOverridesStore.getState().overrides, + useKeyboardLayoutStore.getState().map, +); +useHotkeyOverridesStore.subscribe((state) => { + registeredAppChords = buildRegisteredAppChords( + state.overrides, + useKeyboardLayoutStore.getState().map, + ); +}); +useKeyboardLayoutStore.subscribe((state) => { + registeredAppChords = buildRegisteredAppChords( + useHotkeyOverridesStore.getState().overrides, + state.map, + ); +}); diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 229f0cf2422..960491203e2 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -11,13 +11,13 @@ - default-src 'self': Only allow resources from same origin - script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog - style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS) - - connect-src 'self' data: blob: ws: wss: http://127.0.0.1:* %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% https://*.posthog.com https://*.sentry.io sentry-ipc: Allow WebSocket + API + Electric proxy + PostHog + Sentry + data URIs (file attachment upload via data URL) + blob URIs + localhost host-service + - connect-src 'self' data: blob: ws: wss: http://127.0.0.1:* %RELAY_URL% %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% https://*.posthog.com https://*.sentry.io sentry-ipc: Allow WebSocket + API + Electric proxy + PostHog + Sentry + data URIs (file attachment upload via data URL) + blob URIs + local host-service (127.0.0.1) + relay - img-src 'self' data: blob: https: http:: Allow images from any source (needed for favicons, browser pane webview content, and file attachment previews) - font-src 'self': Allow fonts from same origin - frame-src https: http: data: blob:: Allow webview browser pane to load any URL - child-src 'self' blob:: Allow workers from same origin + blob workers --> - <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com; style-src 'self' 'unsafe-inline'; connect-src 'self' data: blob: ws: wss: http://127.0.0.1:* %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:; img-src 'self' data: blob: https: http:; font-src 'self'; frame-src https: http: data: blob:; child-src 'self' blob:;" /> + <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com; style-src 'self' 'unsafe-inline'; connect-src 'self' data: blob: ws: wss: http://127.0.0.1:* %RELAY_URL% %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:; img-src 'self' data: blob: https: http:; font-src 'self'; frame-src https: http: data: blob:; child-src 'self' blob:;" /> </head> <body> diff --git a/apps/desktop/src/renderer/lib/auth-client.ts b/apps/desktop/src/renderer/lib/auth-client.ts index 5ee81dbaf67..fd438a7388d 100644 --- a/apps/desktop/src/renderer/lib/auth-client.ts +++ b/apps/desktop/src/renderer/lib/auth-client.ts @@ -1,7 +1,7 @@ +import { apiKeyClient } from "@better-auth/api-key/client"; import { stripeClient } from "@better-auth/stripe/client"; import type { auth } from "@superset/auth/server"; import { - apiKeyClient, customSessionClient, jwtClient, organizationClient, diff --git a/apps/desktop/src/renderer/lib/dev-chat.test.ts b/apps/desktop/src/renderer/lib/dev-chat.test.ts new file mode 100644 index 00000000000..7e4fce03190 --- /dev/null +++ b/apps/desktop/src/renderer/lib/dev-chat.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { + DEV_CHAT_MODELS, + getDesktopChatModelOptions, + isDesktopChatSessionReady, + resolveDesktopChatOrganizationId, +} from "./dev-chat"; + +describe("dev chat helpers", () => { + it("uses the mock organization in dev mode", () => { + expect(resolveDesktopChatOrganizationId(null, true)).toBe("mock-org-id"); + expect(resolveDesktopChatOrganizationId("org-123", true)).toBe( + "mock-org-id", + ); + }); + + it("keeps the real organization outside dev mode", () => { + expect(resolveDesktopChatOrganizationId("org-123", false)).toBe("org-123"); + expect(resolveDesktopChatOrganizationId(null, false)).toBeNull(); + }); + + it("treats local session ids as ready in dev mode", () => { + expect( + isDesktopChatSessionReady({ + sessionId: "session-123", + hasPersistedSession: false, + skipEnvValidation: true, + }), + ).toBe(true); + expect( + isDesktopChatSessionReady({ + sessionId: null, + hasPersistedSession: false, + skipEnvValidation: true, + }), + ).toBe(false); + }); + + it("returns the fallback model list only in dev mode", () => { + expect(getDesktopChatModelOptions(true)).toEqual(DEV_CHAT_MODELS); + expect(getDesktopChatModelOptions(false)).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/lib/dev-chat.ts b/apps/desktop/src/renderer/lib/dev-chat.ts new file mode 100644 index 00000000000..2fcec6919e6 --- /dev/null +++ b/apps/desktop/src/renderer/lib/dev-chat.ts @@ -0,0 +1,74 @@ +import type { ModelOption } from "renderer/components/Chat/ChatInterface/types"; +import { env } from "renderer/env.renderer"; +import { MOCK_ORG_ID } from "shared/constants"; + +export const DEV_CHAT_MODELS: ModelOption[] = [ + { + id: "anthropic/claude-opus-4-7", + name: "Opus 4.7", + provider: "Anthropic", + }, + { + id: "anthropic/claude-opus-4-6", + name: "Opus 4.6", + provider: "Anthropic", + }, + { + id: "anthropic/claude-sonnet-4-6", + name: "Sonnet 4.6", + provider: "Anthropic", + }, + { + id: "anthropic/claude-haiku-4-5", + name: "Haiku 4.5", + provider: "Anthropic", + }, + { + id: "openai/gpt-5.5", + name: "GPT-5.5", + provider: "OpenAI", + }, + { + id: "openai/gpt-5.4", + name: "GPT-5.4", + provider: "OpenAI", + }, + { + id: "openai/gpt-5.3-codex", + name: "GPT-5.3 Codex", + provider: "OpenAI", + }, +]; + +export function isDesktopChatDevMode( + skipEnvValidation = env.SKIP_ENV_VALIDATION, +): boolean { + return skipEnvValidation; +} + +export function resolveDesktopChatOrganizationId( + activeOrganizationId: string | null | undefined, + skipEnvValidation = env.SKIP_ENV_VALIDATION, +): string | null { + if (skipEnvValidation) return MOCK_ORG_ID; + return activeOrganizationId ?? null; +} + +export function isDesktopChatSessionReady({ + sessionId, + hasPersistedSession, + skipEnvValidation = env.SKIP_ENV_VALIDATION, +}: { + sessionId: string | null; + hasPersistedSession: boolean; + skipEnvValidation?: boolean; +}): boolean { + if (skipEnvValidation) return Boolean(sessionId); + return hasPersistedSession; +} + +export function getDesktopChatModelOptions( + skipEnvValidation = env.SKIP_ENV_VALIDATION, +): ModelOption[] { + return skipEnvValidation ? DEV_CHAT_MODELS : []; +} diff --git a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts index 7424e33e990..3787653bac8 100644 --- a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts +++ b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.test.ts @@ -16,11 +16,18 @@ describe("formatRelativeTime", () => { Date.now = originalDateNow; }; - it('returns "now" for timestamps less than 1 minute ago', () => { + it('returns "now" for timestamps less than 5 seconds ago', () => { mockNow(); expect(formatRelativeTime(NOW)).toBe("now"); - expect(formatRelativeTime(NOW - 30 * 1000)).toBe("now"); // 30 seconds ago - expect(formatRelativeTime(NOW - 59 * 1000)).toBe("now"); // 59 seconds ago + expect(formatRelativeTime(NOW - 4 * 1000)).toBe("now"); + restoreNow(); + }); + + it("returns seconds for timestamps between 5-59 seconds ago", () => { + mockNow(); + expect(formatRelativeTime(NOW - 5 * 1000)).toBe("5s"); + expect(formatRelativeTime(NOW - 30 * 1000)).toBe("30s"); + expect(formatRelativeTime(NOW - 59 * 1000)).toBe("59s"); restoreNow(); }); diff --git a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts index a55ed489e2b..27bed8ba713 100644 --- a/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts +++ b/apps/desktop/src/renderer/lib/formatRelativeTime/formatRelativeTime.ts @@ -1,12 +1,14 @@ export function formatRelativeTime(timestamp: number): string { const now = Date.now(); const diffMs = now - timestamp; + const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); const diffMonths = Math.floor(diffDays / 30); - if (diffMinutes < 1) return "now"; + if (diffSeconds < 5) return "now"; + if (diffMinutes < 1) return `${diffSeconds}s`; if (diffMinutes < 60) return `${diffMinutes}m`; if (diffHours < 24) return `${diffHours}h`; if (diffDays < 30) return `${diffDays}d`; diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts index dca23c1f3e0..b10273700b5 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.test.ts @@ -5,132 +5,50 @@ import { } from "./githubQueryPolicy"; describe("getGitHubStatusQueryPolicy", () => { - test("enables focus-only refresh for the active changes sidebar diffs view", () => { - expect( - getGitHubStatusQueryPolicy("changes-sidebar", { - hasWorkspaceId: true, - isActive: true, - isReviewTabActive: false, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: true, - staleTime: 0, - }); - }); - - test("enables polling for the active changes sidebar review view", () => { - expect( - getGitHubStatusQueryPolicy("changes-sidebar", { - hasWorkspaceId: true, - isActive: true, - isReviewTabActive: true, - }), - ).toEqual({ - enabled: true, - refetchInterval: 10_000, - refetchOnWindowFocus: true, - staleTime: 10_000, - }); - }); - - test("disables changes sidebar status when the surface is inactive", () => { - expect( - getGitHubStatusQueryPolicy("changes-sidebar", { - hasWorkspaceId: true, - isActive: false, - isReviewTabActive: true, - }), - ).toEqual({ - enabled: false, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 10_000, - }); - }); - - test("keeps the workspace page active without interval polling", () => { - expect( - getGitHubStatusQueryPolicy("workspace-page", { - hasWorkspaceId: true, - isActive: true, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 300_000, - }); - }); - - test("keeps hover-card surfaces lazy without focus refresh", () => { - expect( - getGitHubStatusQueryPolicy("workspace-hover-card", { - hasWorkspaceId: true, - isActive: true, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 300_000, - }); - }); - - test("keeps workspace list items cheaper than full-page PR surfaces", () => { - expect( - getGitHubStatusQueryPolicy("workspace-list-item", { - hasWorkspaceId: true, - isActive: true, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 30_000, - }); - }); - - test("disables passive hover surfaces when they are not visible", () => { - expect( - getGitHubStatusQueryPolicy("workspace-row", { - hasWorkspaceId: true, - isActive: false, - }), - ).toEqual({ - enabled: false, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 300_000, - }); + test("active surfaces poll every 10s", () => { + for (const surface of ["changes-sidebar", "workspace-page"] as const) { + expect( + getGitHubStatusQueryPolicy(surface, { + hasWorkspaceId: true, + isActive: true, + }), + ).toEqual({ + enabled: true, + refetchInterval: 10_000, + refetchOnWindowFocus: true, + staleTime: 10_000, + }); + } + }); + + test("hover surfaces rely on staleTime debounce, no polling", () => { + for (const surface of [ + "workspace-list-item", + "workspace-row", + "workspace-hover-card", + ] as const) { + expect( + getGitHubStatusQueryPolicy(surface, { + hasWorkspaceId: true, + isActive: true, + }), + ).toEqual({ + enabled: true, + refetchInterval: false, + refetchOnWindowFocus: false, + staleTime: 10_000, + }); + } }); }); describe("getGitHubPRCommentsQueryPolicy", () => { - test("fetches review comments without polling when changes is open on diffs", () => { + test("polls every 30s when active with a pull request", () => { expect( getGitHubPRCommentsQueryPolicy({ hasWorkspaceId: true, hasActivePullRequest: true, isActive: true, - isReviewTabActive: false, - }), - ).toEqual({ - enabled: true, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 30_000, - }); - }); - - test("polls review comments while the review tab is active", () => { - expect( - getGitHubPRCommentsQueryPolicy({ - hasWorkspaceId: true, - hasActivePullRequest: true, - isActive: true, - isReviewTabActive: true, }), ).toEqual({ enabled: true, @@ -139,20 +57,4 @@ describe("getGitHubPRCommentsQueryPolicy", () => { staleTime: 30_000, }); }); - - test("disables comments when there is no active pull request", () => { - expect( - getGitHubPRCommentsQueryPolicy({ - hasWorkspaceId: true, - hasActivePullRequest: false, - isActive: true, - isReviewTabActive: true, - }), - ).toEqual({ - enabled: false, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: 30_000, - }); - }); }); diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts index 6109e168286..c8c118c83be 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/githubQueryPolicy.ts @@ -1,7 +1,5 @@ -const ACTIVE_GITHUB_STATUS_STALE_TIME_MS = 10_000; -const ACTIVE_GITHUB_STATUS_REFETCH_INTERVAL_MS = 10_000; -const WORKSPACE_LIST_ITEM_GITHUB_STATUS_STALE_TIME_MS = 30_000; -const PASSIVE_GITHUB_STATUS_STALE_TIME_MS = 5 * 60 * 1000; +export const GITHUB_STATUS_STALE_TIME_MS = 10_000; +const GITHUB_STATUS_REFETCH_INTERVAL_MS = 10_000; const GITHUB_PR_COMMENTS_STALE_TIME_MS = 30_000; const GITHUB_PR_COMMENTS_REFETCH_INTERVAL_MS = 30_000; @@ -22,81 +20,51 @@ export interface GitHubQueryPolicy { interface GitHubStatusQueryPolicyOptions { hasWorkspaceId: boolean; isActive?: boolean; - isReviewTabActive?: boolean; } interface GitHubPRCommentsQueryPolicyOptions { hasWorkspaceId: boolean; hasActivePullRequest: boolean; isActive?: boolean; - isReviewTabActive?: boolean; } +const HOVER_SURFACES: ReadonlySet<GitHubStatusQuerySurface> = new Set([ + "workspace-list-item", + "workspace-row", + "workspace-hover-card", +]); + /** - * Centralizes GitHub query behavior so passive hover surfaces stay cheap while - * active workspace surfaces still revalidate when they become relevant again. + * Active surfaces (changes-sidebar, workspace-page) poll every 10s. + * Hover surfaces don't poll — callers trigger refetch on hover, debounced by staleTime. */ export function getGitHubStatusQueryPolicy( surface: GitHubStatusQuerySurface, - { - hasWorkspaceId, - isActive = true, - isReviewTabActive = false, - }: GitHubStatusQueryPolicyOptions, + { hasWorkspaceId, isActive = true }: GitHubStatusQueryPolicyOptions, ): GitHubQueryPolicy { const isEnabled = hasWorkspaceId && isActive; + const isHover = HOVER_SURFACES.has(surface); - switch (surface) { - case "changes-sidebar": - return { - enabled: isEnabled, - refetchInterval: - isEnabled && isReviewTabActive - ? ACTIVE_GITHUB_STATUS_REFETCH_INTERVAL_MS - : false, - refetchOnWindowFocus: isEnabled, - staleTime: isReviewTabActive ? ACTIVE_GITHUB_STATUS_STALE_TIME_MS : 0, - }; - case "workspace-page": - return { - enabled: isEnabled, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: PASSIVE_GITHUB_STATUS_STALE_TIME_MS, - }; - case "workspace-list-item": - return { - enabled: isEnabled, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: WORKSPACE_LIST_ITEM_GITHUB_STATUS_STALE_TIME_MS, - }; - case "workspace-hover-card": - case "workspace-row": - return { - enabled: isEnabled, - refetchInterval: false, - refetchOnWindowFocus: false, - staleTime: PASSIVE_GITHUB_STATUS_STALE_TIME_MS, - }; - } + return { + enabled: isEnabled, + refetchInterval: + isEnabled && !isHover ? GITHUB_STATUS_REFETCH_INTERVAL_MS : false, + refetchOnWindowFocus: isEnabled && !isHover, + staleTime: GITHUB_STATUS_STALE_TIME_MS, + }; } export function getGitHubPRCommentsQueryPolicy({ hasWorkspaceId, hasActivePullRequest, isActive = true, - isReviewTabActive = false, }: GitHubPRCommentsQueryPolicyOptions): GitHubQueryPolicy { const isEnabled = hasWorkspaceId && isActive && hasActivePullRequest; return { enabled: isEnabled, - refetchInterval: - isEnabled && isReviewTabActive - ? GITHUB_PR_COMMENTS_REFETCH_INTERVAL_MS - : false, - refetchOnWindowFocus: isEnabled && isReviewTabActive, + refetchInterval: isEnabled ? GITHUB_PR_COMMENTS_REFETCH_INTERVAL_MS : false, + refetchOnWindowFocus: isEnabled, staleTime: GITHUB_PR_COMMENTS_STALE_TIME_MS, }; } diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts index de2aafc9ae8..ac1fd2cc036 100644 --- a/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/index.ts @@ -6,3 +6,4 @@ export { getGitHubPRCommentsQueryPolicy, getGitHubStatusQueryPolicy, } from "./githubQueryPolicy"; +export { useHoverGitHubStatus } from "./useHoverGitHubStatus"; diff --git a/apps/desktop/src/renderer/lib/githubQueryPolicy/useHoverGitHubStatus.ts b/apps/desktop/src/renderer/lib/githubQueryPolicy/useHoverGitHubStatus.ts new file mode 100644 index 00000000000..e833b9a7dca --- /dev/null +++ b/apps/desktop/src/renderer/lib/githubQueryPolicy/useHoverGitHubStatus.ts @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + GITHUB_STATUS_STALE_TIME_MS, + type GitHubStatusQuerySurface, + getGitHubStatusQueryPolicy, +} from "./githubQueryPolicy"; + +interface UseHoverGitHubStatusOptions { + workspaceId: string | null | undefined; + surface: GitHubStatusQuerySurface; + isWorktree: boolean; +} + +export function useHoverGitHubStatus({ + workspaceId, + surface, + isWorktree, +}: UseHoverGitHubStatusOptions) { + const [hasHovered, setHasHovered] = useState(false); + + const queryPolicy = getGitHubStatusQueryPolicy(surface, { + hasWorkspaceId: !!workspaceId, + isActive: hasHovered && isWorktree, + }); + + const { + data: githubStatus, + dataUpdatedAt, + isStale, + refetch, + } = electronTrpc.workspaces.getGitHubStatus.useQuery( + { workspaceId: workspaceId ?? "" }, + queryPolicy, + ); + + const pendingRefetchRef = useRef<ReturnType<typeof setTimeout> | null>(null); + useEffect( + () => () => { + if (pendingRefetchRef.current) clearTimeout(pendingRefetchRef.current); + }, + [], + ); + + const onMouseEnter = () => { + if (!hasHovered) { + setHasHovered(true); + } else if (isStale) { + if (pendingRefetchRef.current) { + clearTimeout(pendingRefetchRef.current); + pendingRefetchRef.current = null; + } + void refetch(); + } else if (!pendingRefetchRef.current) { + const msUntilStale = + GITHUB_STATUS_STALE_TIME_MS - (Date.now() - dataUpdatedAt); + pendingRefetchRef.current = setTimeout( + () => { + pendingRefetchRef.current = null; + void refetch(); + }, + Math.max(0, msUntilStale), + ); + } + }; + + return { githubStatus, hasHovered, onMouseEnter }; +} diff --git a/apps/desktop/src/renderer/lib/host-service-auth.ts b/apps/desktop/src/renderer/lib/host-service-auth.ts new file mode 100644 index 00000000000..21567f55550 --- /dev/null +++ b/apps/desktop/src/renderer/lib/host-service-auth.ts @@ -0,0 +1,24 @@ +import { getJwt } from "./auth-client"; + +const secrets = new Map<string, string>(); + +export function setHostServiceSecret(hostUrl: string, secret: string): void { + secrets.set(hostUrl, secret); +} + +export function removeHostServiceSecret(hostUrl: string): void { + secrets.delete(hostUrl); +} + +export function getHostServiceHeaders(hostUrl: string): Record<string, string> { + const secret = secrets.get(hostUrl); + if (secret) return { Authorization: `Bearer ${secret}` }; + // Relay: use JWT + const jwt = getJwt(); + return jwt ? { Authorization: `Bearer ${jwt}` } : {}; +} + +export function getHostServiceWsToken(hostUrl: string): string | null { + // Local host-service: use PSK. Relay: fall back to user JWT. + return secrets.get(hostUrl) ?? getJwt(); +} diff --git a/apps/desktop/src/renderer/lib/host-service-client.ts b/apps/desktop/src/renderer/lib/host-service-client.ts index c438e868a30..737b79c4032 100644 --- a/apps/desktop/src/renderer/lib/host-service-client.ts +++ b/apps/desktop/src/renderer/lib/host-service-client.ts @@ -1,6 +1,7 @@ import type { AppRouter } from "@superset/host-service"; -import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import { createTRPCClient, httpLink } from "@trpc/client"; import superjson from "superjson"; +import { getHostServiceHeaders } from "./host-service-auth"; const clientCache = new Map< string, @@ -19,9 +20,10 @@ export function getHostServiceClientByUrl(hostUrl: string): HostServiceClient { const client = createTRPCClient<AppRouter>({ links: [ - httpBatchLink({ + httpLink({ url: `${hostUrl}/trpc`, transformer: superjson, + headers: () => getHostServiceHeaders(hostUrl), }), ], }); diff --git a/apps/desktop/src/renderer/lib/pathBasename/index.ts b/apps/desktop/src/renderer/lib/pathBasename/index.ts new file mode 100644 index 00000000000..602dae448af --- /dev/null +++ b/apps/desktop/src/renderer/lib/pathBasename/index.ts @@ -0,0 +1 @@ +export { getBaseName } from "./pathBasename"; diff --git a/apps/desktop/src/renderer/lib/pathBasename/pathBasename.test.ts b/apps/desktop/src/renderer/lib/pathBasename/pathBasename.test.ts new file mode 100644 index 00000000000..833b022bf68 --- /dev/null +++ b/apps/desktop/src/renderer/lib/pathBasename/pathBasename.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "bun:test"; +import { getBaseName } from "./pathBasename"; + +describe("getBaseName", () => { + describe("posix paths", () => { + it("returns the final segment of a standard absolute path", () => { + expect(getBaseName("/Users/alice/projects/superset")).toBe("superset"); + }); + + it("returns the final segment when the path has a file extension", () => { + expect(getBaseName("/workspace/nested/notes.txt")).toBe("notes.txt"); + }); + + it("returns the last non-empty segment for a trailing slash", () => { + expect(getBaseName("/Users/alice/projects/superset/")).toBe("superset"); + }); + + it("collapses multiple trailing slashes", () => { + expect(getBaseName("/Users/alice/projects/superset///")).toBe("superset"); + }); + + it("returns the single segment when path has no separators", () => { + expect(getBaseName("superset")).toBe("superset"); + }); + + it("returns the segment for a single-segment absolute path", () => { + expect(getBaseName("/superset")).toBe("superset"); + }); + + it("preserves dots in folder names", () => { + expect(getBaseName("/Users/alice/my.project.v2")).toBe("my.project.v2"); + }); + + it("preserves a dotfile folder name", () => { + expect(getBaseName("/Users/alice/.config")).toBe(".config"); + }); + + it("preserves spaces in folder names", () => { + expect(getBaseName("/Users/alice/My Cool Project")).toBe( + "My Cool Project", + ); + }); + + it("preserves unicode characters in folder names", () => { + expect(getBaseName("/Users/alice/プロジェクト")).toBe("プロジェクト"); + }); + + it("preserves emoji in folder names", () => { + expect(getBaseName("/Users/alice/🚀-rocket")).toBe("🚀-rocket"); + }); + + it("handles consecutive internal slashes", () => { + expect(getBaseName("/Users//alice///projects/superset")).toBe("superset"); + }); + }); + + describe("windows paths", () => { + it("returns the final segment of a backslash path", () => { + expect(getBaseName("C:\\Users\\alice\\projects\\superset")).toBe( + "superset", + ); + }); + + it("handles a trailing backslash", () => { + expect(getBaseName("C:\\Users\\alice\\projects\\superset\\")).toBe( + "superset", + ); + }); + + it("handles mixed forward and back slashes", () => { + expect(getBaseName("C:\\Users\\alice/projects\\superset")).toBe( + "superset", + ); + }); + + it("handles UNC-style paths", () => { + expect(getBaseName("\\\\server\\share\\project")).toBe("project"); + }); + + it("handles consecutive trailing backslashes", () => { + expect(getBaseName("C:\\Users\\alice\\superset\\\\\\")).toBe("superset"); + }); + }); + + describe("edge cases", () => { + it("returns the original input for an empty string", () => { + expect(getBaseName("")).toBe(""); + }); + + it("returns the original input for only forward slashes", () => { + expect(getBaseName("/")).toBe("/"); + }); + + it("returns the original input for multiple forward slashes", () => { + expect(getBaseName("///")).toBe("///"); + }); + + it("returns the original input for only backslashes", () => { + expect(getBaseName("\\")).toBe("\\"); + }); + + it("returns the drive letter for a windows drive root", () => { + expect(getBaseName("C:\\")).toBe("C:"); + }); + + it("preserves a relative path final segment", () => { + expect(getBaseName("projects/superset")).toBe("superset"); + }); + + it("preserves a dot-relative path final segment", () => { + expect(getBaseName("./projects/superset")).toBe("superset"); + }); + + it("returns '..' for a parent-directory-only input", () => { + expect(getBaseName("..")).toBe(".."); + }); + + it("returns '.' for a current-directory input", () => { + expect(getBaseName(".")).toBe("."); + }); + + it("preserves hyphens and underscores in names", () => { + expect(getBaseName("/tmp/my-cool_project-2")).toBe("my-cool_project-2"); + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/pathBasename/pathBasename.ts b/apps/desktop/src/renderer/lib/pathBasename/pathBasename.ts new file mode 100644 index 00000000000..074fbe61668 --- /dev/null +++ b/apps/desktop/src/renderer/lib/pathBasename/pathBasename.ts @@ -0,0 +1,3 @@ +export function getBaseName(absolutePath: string): string { + return absolutePath.split(/[/\\]/).filter(Boolean).pop() ?? absolutePath; +} diff --git a/apps/desktop/src/renderer/lib/pending-attachment-store.ts b/apps/desktop/src/renderer/lib/pending-attachment-store.ts new file mode 100644 index 00000000000..24473cdad3c --- /dev/null +++ b/apps/desktop/src/renderer/lib/pending-attachment-store.ts @@ -0,0 +1,101 @@ +import Dexie, { type Table } from "dexie"; + +/** + * IndexedDB store for pending workspace attachment blobs. Keyed by + * `${pendingId}/${uuid}` so we can prefix-query all blobs belonging + * to a single pending row on retry or cleanup. + * + * Dexie handles transaction lifecycle — no manual tx.complete waits, + * no "transaction has finished" footguns. + */ + +interface StoredAttachment { + key: string; // pendingId/uuid + blob: Blob; + mediaType: string; + filename: string; +} + +class PendingAttachmentsDb extends Dexie { + attachments!: Table<StoredAttachment, string>; + + constructor() { + super("superset-pending-attachments"); + this.version(1).stores({ + attachments: "&key", // primary key only + }); + } +} + +const db = new PendingAttachmentsDb(); + +/** + * Store attachment blobs from the PromptInput. + * Call before closing the modal so blobs survive for retry. + */ +export async function storeAttachments( + pendingId: string, + files: Array<{ url: string; mediaType: string; filename?: string }>, +): Promise<void> { + if (files.length === 0) return; + + const resolved = await Promise.all( + files.map(async (file) => { + const response = await fetch(file.url); + if (!response.ok) { + throw new Error( + `Failed to fetch attachment: ${response.status} ${response.statusText}`, + ); + } + const blob = await response.blob(); + return { + key: `${pendingId}/${crypto.randomUUID()}`, + blob, + mediaType: file.mediaType, + filename: file.filename ?? "attachment", + } satisfies StoredAttachment; + }), + ); + + await db.attachments.bulkPut(resolved); +} + +/** + * Load stored attachment blobs and convert them to data URLs + * for the API payload. Used on retry. + */ +export async function loadAttachments( + pendingId: string, +): Promise<Array<{ data: string; mediaType: string; filename: string }>> { + const prefix = `${pendingId}/`; + const entries = await db.attachments + .where("key") + .startsWith(prefix) + .toArray(); + + return Promise.all( + entries.map(async (entry) => ({ + data: await blobToDataUrl(entry.blob), + mediaType: entry.mediaType, + filename: entry.filename, + })), + ); +} + +/** + * Delete all stored attachments for a pending workspace. + * Call on create success or dismiss. + */ +export async function clearAttachments(pendingId: string): Promise<void> { + const prefix = `${pendingId}/`; + await db.attachments.where("key").startsWith(prefix).delete(); +} + +function blobToDataUrl(blob: Blob): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} diff --git a/apps/desktop/src/renderer/lib/ringtones/play.ts b/apps/desktop/src/renderer/lib/ringtones/play.ts new file mode 100644 index 00000000000..d807377b6c2 --- /dev/null +++ b/apps/desktop/src/renderer/lib/ringtones/play.ts @@ -0,0 +1,85 @@ +import { + CUSTOM_RINGTONE_ID, + DEFAULT_RINGTONE_ID, + getRingtoneById, +} from "shared/ringtones"; +import { electronTrpcClient } from "../trpc-client"; +import { builtInRingtoneUrls } from "./urls"; + +export interface PlayRingtoneOptions { + ringtoneId: string; + /** 0..100 — matches the existing `notificationVolume` setting shape. */ + volume: number; + muted: boolean; +} + +const builtInAudioByUrl = new Map<string, HTMLAudioElement>(); + +/** + * Resolve the bundled audio URL for a built-in ringtone id. Custom uploads are + * stored outside the Vite bundle, so they are played by main on renderer + * request instead of exposing local file paths to the web runtime. + */ +function resolveRingtoneUrl(ringtoneId: string): string | null { + const ringtone = getRingtoneById(ringtoneId); + const resolved = ringtone + ? builtInRingtoneUrls[ringtone.filename] + : undefined; + if (resolved) return resolved; + + const fallback = getRingtoneById(DEFAULT_RINGTONE_ID); + return fallback ? (builtInRingtoneUrls[fallback.filename] ?? null) : null; +} + +function getBuiltInAudio(url: string): HTMLAudioElement { + let audio = builtInAudioByUrl.get(url); + if (!audio) { + audio = new Audio(url); + audio.preload = "auto"; + builtInAudioByUrl.set(url, audio); + } + return audio; +} + +function isUserGesturePlaybackError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.name === "NotAllowedError" || + error.message.includes("user gesture") || + error.message.includes("not allowed") + ); +} + +export async function playRingtone(opts: PlayRingtoneOptions): Promise<void> { + if (opts.muted) return; + const volumePercent = Math.max(0, Math.min(100, opts.volume)); + const volume = volumePercent / 100; + if (volume === 0) return; + + if (opts.ringtoneId === CUSTOM_RINGTONE_ID) { + try { + await electronTrpcClient.ringtone.playNotification.mutate({ + ringtoneId: opts.ringtoneId, + volume: volumePercent, + }); + } catch (error) { + console.warn("[ringtone] custom playback failed:", error); + } + return; + } + + const url = resolveRingtoneUrl(opts.ringtoneId); + if (!url) return; + + const audio = getBuiltInAudio(url); + audio.volume = volume; + audio.currentTime = 0; + + try { + await audio.play(); + } catch (error) { + if (!isUserGesturePlaybackError(error)) { + console.warn("[ringtone] playback failed:", error); + } + } +} diff --git a/apps/desktop/src/renderer/lib/ringtones/urls.ts b/apps/desktop/src/renderer/lib/ringtones/urls.ts new file mode 100644 index 00000000000..02de089f8b2 --- /dev/null +++ b/apps/desktop/src/renderer/lib/ringtones/urls.ts @@ -0,0 +1,48 @@ +/** + * Vite-bundled URLs for each built-in ringtone .mp3. Keyed by the filenames + * declared in `shared/ringtones.ts`. Using `new URL(..., import.meta.url)` + * lets Vite emit hashed asset URLs in prod and serve the files in dev + * without copying them into `resources/public/`. + */ +export const builtInRingtoneUrls: Record<string, string> = { + "shamisen.mp3": new URL( + "../../../resources/sounds/shamisen.mp3", + import.meta.url, + ).href, + "arcade.mp3": new URL("../../../resources/sounds/arcade.mp3", import.meta.url) + .href, + "ping.mp3": new URL("../../../resources/sounds/ping.mp3", import.meta.url) + .href, + "supersetquick.mp3": new URL( + "../../../resources/sounds/supersetquick.mp3", + import.meta.url, + ).href, + "supersetdoowap.mp3": new URL( + "../../../resources/sounds/supersetdoowap.mp3", + import.meta.url, + ).href, + "agentisdonewoman.mp3": new URL( + "../../../resources/sounds/agentisdonewoman.mp3", + import.meta.url, + ).href, + "codecompleteafrican.mp3": new URL( + "../../../resources/sounds/codecompleteafrican.mp3", + import.meta.url, + ).href, + "codecompleteafrobeat.mp3": new URL( + "../../../resources/sounds/codecompleteafrobeat.mp3", + import.meta.url, + ).href, + "codecompleteedm.mp3": new URL( + "../../../resources/sounds/codecompleteedm.mp3", + import.meta.url, + ).href, + "comebacktothecode.mp3": new URL( + "../../../resources/sounds/comebacktothecode.mp3", + import.meta.url, + ).href, + "shabalabadingdong.mp3": new URL( + "../../../resources/sounds/shabalabadingdong.mp3", + import.meta.url, + ).href, +}; diff --git a/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts b/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts new file mode 100644 index 00000000000..c68eba7ce7f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/appearance/appearance.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + sanitizeTerminalFontFamily, +} from "./index"; + +type MeasureFn = (text: string) => { width: number }; + +/** + * Stub `document.createElement("canvas")` so `getContext("2d").measureText` + * returns widths from `measureForFont`. Non-canvas tags defer to the + * existing test-setup stub. + */ +function stubCanvas(measureForFont: (font: string) => MeasureFn) { + const originalCreate = document.createElement; + // biome-ignore lint/suspicious/noExplicitAny: bun:test `mock` wraps arbitrary fns + (document as any).createElement = mock((tag: string) => { + if (tag !== "canvas") { + // biome-ignore lint/suspicious/noExplicitAny: delegating stub accepts any tag + return (originalCreate as any).call(document, tag); + } + let currentFont = ""; + return { + getContext: (kind: string) => { + if (kind !== "2d") return null; + return { + set font(value: string) { + currentFont = value; + }, + get font() { + return currentFont; + }, + measureText: (text: string) => measureForFont(currentFont)(text), + }; + }, + }; + }); + return () => { + // biome-ignore lint/suspicious/noExplicitAny: restoring stubbed method + (document as any).createElement = originalCreate; + }; +} + +const equalWidths: MeasureFn = (text) => ({ width: text.length * 10 }); +const proportionalWidths: MeasureFn = (text) => { + let width = 0; + for (const ch of text) width += ch === "M" ? 16 : 6; + return { width }; +}; + +describe("sanitizeTerminalFontFamily", () => { + let restore: (() => void) | null = null; + + afterEach(() => { + restore?.(); + restore = null; + }); + + test("returns default for null / empty / whitespace", () => { + expect(sanitizeTerminalFontFamily(null)).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(sanitizeTerminalFontFamily(undefined)).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(sanitizeTerminalFontFamily(" ")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("trusts all-generic monospace values without canvas", () => { + expect(sanitizeTerminalFontFamily("monospace")).toBe("monospace"); + expect(sanitizeTerminalFontFamily("ui-monospace")).toBe("ui-monospace"); + }); + + test("falls back when the primary family is a proportional generic", () => { + expect(sanitizeTerminalFontFamily("sans-serif")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("serif")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + expect(sanitizeTerminalFontFamily("cursive")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + // CSS resolves the first generic, so a later monospace entry never wins. + expect(sanitizeTerminalFontFamily("cursive, monospace")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("passes through a stack whose primary generic is monospace", () => { + // The browser resolves the first generic, so "monospace, sans-serif" + // actually renders as monospace — safe. + expect(sanitizeTerminalFontFamily("monospace, sans-serif")).toBe( + "monospace, sans-serif", + ); + }); + + test("falls back when a concrete mono follows a proportional generic", () => { + // Regression: earlier logic picked the first non-generic as the primary, + // letting `sans-serif, "JetBrains Mono"` slip through even though CSS + // renders sans-serif. Validate the actual CSS primary instead. + expect(sanitizeTerminalFontFamily('sans-serif, "JetBrains Mono"')).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("passes a monospace font through when the stack already ends with monospace", () => { + restore = stubCanvas(() => equalWidths); + expect(sanitizeTerminalFontFamily('"JetBrains Mono", monospace')).toBe( + '"JetBrains Mono", monospace', + ); + }); + + test("appends a monospace fallback when the stack lacks one", () => { + // If the primary isn't installed, the browser otherwise falls back to a + // proportional default — appending "monospace" forces OS monospace. + restore = stubCanvas(() => equalWidths); + expect(sanitizeTerminalFontFamily('"JetBrains Mono"')).toBe( + '"JetBrains Mono", monospace', + ); + expect(sanitizeTerminalFontFamily("Menlo")).toBe("Menlo, monospace"); + }); + + test("falls back to default for a proportional primary family (quoted)", () => { + restore = stubCanvas(() => proportionalWidths); + expect(sanitizeTerminalFontFamily('"Inter", sans-serif')).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("falls back to default for a proportional primary family (bare)", () => { + restore = stubCanvas(() => proportionalWidths); + expect(sanitizeTerminalFontFamily("Inter")).toBe( + DEFAULT_TERMINAL_FONT_FAMILY, + ); + }); + + test("trusts the value when canvas measurement throws", () => { + restore = stubCanvas(() => () => { + throw new Error("canvas unsupported"); + }); + // Use a unique family so the module-level monospace cache doesn't mask + // the canvas error path. + expect(sanitizeTerminalFontFamily('"UnmeasurableFont-ABC-123"')).toBe( + '"UnmeasurableFont-ABC-123", monospace', + ); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/appearance/index.ts b/apps/desktop/src/renderer/lib/terminal/appearance/index.ts new file mode 100644 index 00000000000..456e0896683 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/appearance/index.ts @@ -0,0 +1,199 @@ +import type { ITheme } from "@xterm/xterm"; +import { toXtermTheme } from "renderer/stores/theme/utils"; +import { + builtInThemes, + DEFAULT_THEME_ID, + getTerminalColors, +} from "shared/themes"; + +export interface TerminalAppearance { + theme: ITheme; + background: string; + fontFamily: string; + fontSize: number; +} + +const GENERIC_FONT_FAMILIES = new Set([ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", + "emoji", + "math", + "fangsong", +]); + +function serializeFontFamilyList(families: string[]): string { + return families + .map((family) => + GENERIC_FONT_FAMILIES.has(family) + ? family + : `"${family.replaceAll('"', '\\"')}"`, + ) + .join(", "); +} + +export const DEFAULT_TERMINAL_FONT_FAMILIES = [ + "JetBrains Mono", + "JetBrainsMono Nerd Font", + "MesloLGM Nerd Font", + "MesloLGM NF", + "MesloLGS NF", + "MesloLGS Nerd Font", + "Hack Nerd Font", + "FiraCode Nerd Font", + "CaskaydiaCove Nerd Font", + "Menlo", + "Monaco", + "Courier New", + "monospace", +] as const; + +export const DEFAULT_TERMINAL_FONT_FAMILY = serializeFontFamilyList([ + ...DEFAULT_TERMINAL_FONT_FAMILIES, +]); + +export const DEFAULT_TERMINAL_FONT_SIZE = 14; + +const MONOSPACE_GENERIC_FAMILIES = new Set(["monospace", "ui-monospace"]); + +/** Parse a CSS font-family list into trimmed entries, respecting quoted names. */ +function parseFontFamilyList(cssValue: string): string[] { + const families: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (const ch of cssValue) { + if (inQuote) { + if (ch === inQuote) inQuote = null; + else current += ch; + } else if (ch === '"' || ch === "'") { + inQuote = ch; + } else if (ch === ",") { + const trimmed = current.trim(); + if (trimmed) families.push(trimmed); + current = ""; + } else { + current += ch; + } + } + const last = current.trim(); + if (last) families.push(last); + return families; +} + +const monospaceCheckCache = new Map<string, boolean>(); + +/** + * Heuristically decide whether `family` is a monospace font using canvas + * measurement — monospace fonts render narrow ("iiiiii") and wide ("MMMMMM") + * runs at the same width. Returns `true` (permissive) when the canvas API + * is unavailable (tests/SSR) so we never block a legitimate font. + */ +function isFontFamilyMonospace(family: string): boolean { + const key = family.toLowerCase(); + if (MONOSPACE_GENERIC_FAMILIES.has(key)) return true; + + const cached = monospaceCheckCache.get(key); + if (cached !== undefined) return cached; + + try { + if (typeof document === "undefined") return true; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext?.("2d"); + if (!ctx) return true; + + ctx.font = `16px "${family}"`; + const narrow = ctx.measureText("iiiiii").width; + const wide = ctx.measureText("MMMMMM").width; + // Sub-pixel jitter tolerance. + const isMono = Math.abs(narrow - wide) < 1; + monospaceCheckCache.set(key, isMono); + return isMono; + } catch { + return true; + } +} + +/** + * Guard against a persisted terminal font that would break xterm rendering + * (e.g. a proportional font like "Inter"). Returns the raw CSS value when + * the primary family is monospace; otherwise falls back to the bundled + * default so a poisoned setting can never blank the app on startup. + * + * See issue #3513. The settings UI already prevents new non-monospace + * selections for the terminal, but this recovers users whose DB was + * poisoned before the UI restriction was added. + */ +export function sanitizeTerminalFontFamily( + cssValue: string | null | undefined, +): string { + if (!cssValue || !cssValue.trim()) return DEFAULT_TERMINAL_FONT_FAMILY; + const families = parseFontFamilyList(cssValue); + if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY; + + // Validate the actual CSS primary (first entry), not the first non-generic. + // A value like `sans-serif, "JetBrains Mono"` resolves to sans-serif in the + // browser regardless of what follows, so inspecting the later entry would + // let proportional stacks slip through. + const primary = families[0]; + const primaryKey = primary.toLowerCase(); + + if (GENERIC_FONT_FAMILIES.has(primaryKey)) { + if (MONOSPACE_GENERIC_FAMILIES.has(primaryKey)) return cssValue; + console.warn( + `[terminal] Font stack "${cssValue}" has no monospace primary family; falling back to default terminal font.`, + ); + return DEFAULT_TERMINAL_FONT_FAMILY; + } + + if (!isFontFamilyMonospace(primary)) { + console.warn( + `[terminal] Font "${primary}" is not monospace; falling back to default terminal font.`, + ); + return DEFAULT_TERMINAL_FONT_FAMILY; + } + // Ensure a generic monospace tail — if the configured primary isn't + // installed on this machine, the browser falls back to the OS monospace + // generic instead of a proportional default (mirrors VS Code's behavior + // in src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts). + const hasMonoTail = families.some((f) => + MONOSPACE_GENERIC_FAMILIES.has(f.toLowerCase()), + ); + return hasMonoTail ? cssValue : `${cssValue}, monospace`; +} + +/** Reads localStorage theme cache for flash-free first paint. */ +export function getDefaultTerminalAppearance(): TerminalAppearance { + const theme = readCachedTerminalTheme(); + return { + theme, + background: theme.background ?? "#151110", + fontFamily: DEFAULT_TERMINAL_FONT_FAMILY, + fontSize: DEFAULT_TERMINAL_FONT_SIZE, + }; +} + +function readCachedTerminalTheme(): ITheme { + try { + const cachedTerminal = localStorage.getItem("theme-terminal"); + if (cachedTerminal) { + return toXtermTheme(JSON.parse(cachedTerminal)); + } + const themeId = localStorage.getItem("theme-id") ?? DEFAULT_THEME_ID; + const theme = builtInThemes.find((t) => t.id === themeId); + if (theme) { + return toXtermTheme(getTerminalColors(theme)); + } + } catch {} + const defaultTheme = builtInThemes.find((t) => t.id === DEFAULT_THEME_ID); + return defaultTheme + ? toXtermTheme(getTerminalColors(defaultTheme)) + : { background: "#151110", foreground: "#eae8e6" }; +} diff --git a/apps/desktop/src/renderer/lib/terminal/launch-command.test.ts b/apps/desktop/src/renderer/lib/terminal/launch-command.test.ts index aae5aad0531..2c1ac56f3c6 100644 --- a/apps/desktop/src/renderer/lib/terminal/launch-command.test.ts +++ b/apps/desktop/src/renderer/lib/terminal/launch-command.test.ts @@ -4,6 +4,11 @@ import { launchCommandInPane, writeCommandsInPane, } from "./launch-command"; +import { + clearTerminalSessionReady, + markTerminalSessionReady, + rejectTerminalSessionReady, +} from "./session-readiness"; describe("launchCommandInPane", () => { it("creates a terminal session and writes the command with a newline", async () => { @@ -74,6 +79,57 @@ describe("launchCommandInPane", () => { throwOnError: true, }); }); + + it("waits for the mounted session before writing when requested", async () => { + const paneId = "pane-mounted-session-ready"; + const createOrAttach = mock(async () => ({})); + const write = mock(async () => ({})); + + const launchPromise = launchCommandInPane({ + paneId, + tabId: "tab-1", + workspaceId: "ws-1", + command: "echo hello", + createOrAttach, + write, + waitForMountedSession: true, + }); + + expect(createOrAttach).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + + markTerminalSessionReady(paneId); + await launchPromise; + clearTerminalSessionReady(paneId); + + expect(write).toHaveBeenCalledWith({ + paneId, + data: "echo hello\n", + throwOnError: true, + }); + }); + + it("propagates mounted-session readiness failures", async () => { + const paneId = "pane-mounted-session-failure"; + const createOrAttach = mock(async () => ({})); + const write = mock(async () => ({})); + + const launchPromise = launchCommandInPane({ + paneId, + tabId: "tab-1", + workspaceId: "ws-1", + command: "echo hello", + createOrAttach, + write, + waitForMountedSession: true, + }); + + rejectTerminalSessionReady(paneId, new Error("attach failed")); + + await expect(launchPromise).rejects.toThrow("attach failed"); + expect(createOrAttach).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); }); describe("buildTerminalCommand", () => { diff --git a/apps/desktop/src/renderer/lib/terminal/launch-command.ts b/apps/desktop/src/renderer/lib/terminal/launch-command.ts index 1ca2fbf08c5..7d07cadca46 100644 --- a/apps/desktop/src/renderer/lib/terminal/launch-command.ts +++ b/apps/desktop/src/renderer/lib/terminal/launch-command.ts @@ -1,3 +1,5 @@ +import { waitForTerminalSessionReady } from "./session-readiness"; + interface TerminalCreateOrAttachInput { paneId: string; tabId: string; @@ -21,6 +23,11 @@ interface LaunchCommandInPaneOptions { createOrAttach: (input: TerminalCreateOrAttachInput) => Promise<unknown>; write: (input: TerminalWriteInput) => Promise<unknown>; noExecute?: boolean; + /** + * Only use this for panes that will mount immediately in the active tab. + * Background tabs must use the helper-side attach path instead. + */ + waitForMountedSession?: boolean; } function normalizeTerminalCommand(command: string): string { @@ -80,7 +87,14 @@ export async function launchCommandInPane({ createOrAttach, write, noExecute, + waitForMountedSession, }: LaunchCommandInPaneOptions): Promise<void> { + if (waitForMountedSession) { + await waitForTerminalSessionReady(paneId); + await writeCommandInPane({ paneId, command, write, noExecute }); + return; + } + await ensureTerminalAttached({ paneId, tabId, diff --git a/apps/desktop/src/renderer/lib/terminal/line-edit-translations.ts b/apps/desktop/src/renderer/lib/terminal/line-edit-translations.ts new file mode 100644 index 00000000000..b6806fe0100 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/line-edit-translations.ts @@ -0,0 +1,48 @@ +export interface LineEditChordOptions { + isMac: boolean; + isWindows: boolean; +} + +/** True when `mod` is the only non-shift modifier held. */ +function onlyMod(event: KeyboardEvent, mod: "meta" | "alt" | "ctrl"): boolean { + return ( + event.metaKey === (mod === "meta") && + event.altKey === (mod === "alt") && + event.ctrlKey === (mod === "ctrl") && + !event.shiftKey + ); +} + +/** + * Translate Mac Cmd+/Option+ and Windows Ctrl+ arrow / backspace chords into + * the escape sequences shells expect. Returns the bytes to send, or null if + * this chord isn't a line-edit translation. + * + * CONTRACT: only check `event.key` for stable named keys (Backspace, + * ArrowLeft/Right, Home, End, ...). Never `event.key` for printable + * characters — those vary by layout (`event.key === "p"` on QWERTY is `"r"` + * on Dvorak) and silently break non-US users. Use `event.code` via + * `resolveHotkeyFromEvent` for any printable-key translation. + */ +export function translateLineEditChord( + event: KeyboardEvent, + options: LineEditChordOptions, +): string | null { + const { isMac, isWindows } = options; + const { key } = event; + + if (isMac && onlyMod(event, "meta")) { + if (key === "Backspace") return "\x15\x1b[D"; + if (key === "ArrowLeft") return "\x01"; + if (key === "ArrowRight") return "\x05"; + } + if (isMac && onlyMod(event, "alt")) { + if (key === "ArrowLeft") return "\x1bb"; + if (key === "ArrowRight") return "\x1bf"; + } + if (isWindows && onlyMod(event, "ctrl")) { + if (key === "ArrowLeft") return "\x1bb"; + if (key === "ArrowRight") return "\x1bf"; + } + return null; +} diff --git a/apps/desktop/src/renderer/lib/terminal/links/buffer-helpers.test.ts b/apps/desktop/src/renderer/lib/terminal/links/buffer-helpers.test.ts new file mode 100644 index 00000000000..75f11a7a69e --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/buffer-helpers.test.ts @@ -0,0 +1,534 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * + * Adapted from VSCode's terminalLinkHelpers.test.ts + * https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkHelpers.test.ts + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "bun:test"; +import type { IBufferLine } from "@xterm/xterm"; +import { + convertLinkRangeToBuffer, + getXtermLineContent, +} from "./buffer-helpers"; + +/** + * Create a mock IBufferLine from a descriptor. + * `text` is the logical string content; `width` is the number of terminal + * columns the line occupies (may differ from text.length when wide/emoji chars + * are present). + */ +function createMockBufferLine(descriptor: { + text: string; + width: number; +}): IBufferLine { + const { text, width } = descriptor; + + // Pre-compute per-cell data: iterate the text once. + // Wide characters occupy 2 cells; the second cell has an empty char and + // width 0. Multi-codepoint characters (e.g. emoji composed of several + // code-units) report their full string as `getChars()`. + const cells: { chars: string; width: number }[] = []; + for (const char of text) { + const codePoint = char.codePointAt(0) ?? 0; + // Simple wide-char heuristic: CJK Unified Ideographs + some common ranges + const isWide = + (codePoint >= 0x1100 && + (codePoint <= 0x115f || // Hangul Jamo + codePoint === 0x2329 || + codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0x3247) || + (codePoint >= 0x3250 && codePoint <= 0x4dbf) || + (codePoint >= 0x4e00 && codePoint <= 0xa4c6) || + (codePoint >= 0xa960 && codePoint <= 0xa97c) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6b) || + (codePoint >= 0xff01 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1f000 && codePoint <= 0x1fbff) || + (codePoint >= 0x20000 && codePoint <= 0x2fffd) || + (codePoint >= 0x30000 && codePoint <= 0x3fffd))) || + // Emoji modifiers and common emoji ranges + (codePoint >= 0x1f300 && codePoint <= 0x1f9ff); + + if (isWide) { + cells.push({ chars: char, width: 2 }); + // Wide chars occupy a second cell with empty content + cells.push({ chars: "", width: 0 }); + } else { + cells.push({ chars: char, width: 1 }); + } + } + + // Pad remaining cells to `width` with empty space cells + while (cells.length < width) { + cells.push({ chars: " ", width: 1 }); + } + + return { + length: width, + isWrapped: false, + getCell(x: number) { + const cell = cells[x]; + if (!cell) return undefined as never; + return { + getChars: () => cell.chars, + getWidth: () => cell.width, + getCode: () => cell.chars.codePointAt(0) ?? 0, + isBold: () => 0, + isDim: () => 0, + isInverse: () => 0, + isItalic: () => 0, + isStrikethrough: () => 0, + isUnderline: () => 0, + isBlink: () => 0, + isInvisible: () => 0, + isOverline: () => 0, + isAttributeDefault: () => false, + getFgColorMode: () => 0, + getBgColorMode: () => 0, + getFgColor: () => 0, + getBgColor: () => 0, + } as never; + }, + translateToString( + trimRight?: boolean, + startColumn?: number, + endColumn?: number, + ) { + const start = startColumn ?? 0; + const end = endColumn ?? width; + let result = ""; + for (let i = start; i < end && i < cells.length; i++) { + const c = cells[i]; + if (c?.chars) { + result += c.chars; + } + } + if (trimRight) { + result = result.replace(/\s+$/, ""); + } + return result; + }, + } as unknown as IBufferLine; +} + +function createBufferLineArray( + descriptors: { text: string; width: number }[], +): IBufferLine[] { + return descriptors.map(createMockBufferLine); +} + +describe("buffer-helpers", () => { + describe("convertLinkRangeToBuffer", () => { + it("should convert ranges for ascii characters", () => { + const lines = createBufferLineArray([ + { text: "AA http://t", width: 11 }, + { text: ".com/f/", width: 8 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 4, + startLineNumber: 1, + endColumn: 19, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 4, y: 1 }, + end: { x: 7, y: 2 }, + }); + }); + + it("should convert ranges for wide characters before the link", () => { + const lines = createBufferLineArray([ + { text: "A文 http://", width: 11 }, + { text: "t.com/f/", width: 9 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 4, + startLineNumber: 1, + endColumn: 19, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 4 + 1, y: 1 }, + end: { x: 7 + 1, y: 2 }, + }); + }); + + it("should give correct range for links containing multi-character emoji", () => { + const lines = createBufferLineArray([{ text: "A🙂 http://", width: 11 }]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 0 + 1, + startLineNumber: 1, + endColumn: 2 + 1, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 1, y: 1 }, + end: { x: 2, y: 1 }, + }); + }); + + // Note: In a real xterm buffer, 🙂 (U+1F642) is a supplementary character + // that takes 2 JS string positions (surrogate pair) AND 2 buffer cells. + // The algorithm correctly nets to 0 offset for emoji (width +1, chars.length -1). + // This differs from CJK (BMP) chars which take 1 JS position but 2 cells (+1 offset). + it("should convert ranges for emoji characters before the link", () => { + const lines = createBufferLineArray([ + { text: "A🙂 http://", width: 11 }, + { text: "t.com/f/", width: 9 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 4 + 1, + startLineNumber: 1, + endColumn: 19 + 1, + endLineNumber: 1, + }, + 0, + ); + // Emoji offset is 0: the surrogate pair occupies 2 text positions + // matching the 2 buffer cells, so no adjustment needed. + expect(result).toEqual({ + start: { x: 5, y: 1 }, + end: { x: 8, y: 2 }, + }); + }); + + it("should convert ranges for wide characters inside the link", () => { + const lines = createBufferLineArray([ + { text: "AA http://t", width: 11 }, + { text: ".com/文/", width: 8 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 4, + startLineNumber: 1, + endColumn: 19, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 4, y: 1 }, + end: { x: 7 + 1, y: 2 }, + }); + }); + + it("should convert ranges for wide characters before and inside the link", () => { + const lines = createBufferLineArray([ + { text: "A文 http://", width: 11 }, + { text: "t.com/文/", width: 9 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 4, + startLineNumber: 1, + endColumn: 19, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 4 + 1, y: 1 }, + end: { x: 7 + 2, y: 2 }, + }); + }); + + it("should convert ranges for emoji before and wide inside the link", () => { + const lines = createBufferLineArray([ + { text: "A🙂 http://", width: 11 }, + { text: "t.com/文/", width: 9 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 4 + 1, + startLineNumber: 1, + endColumn: 19 + 1, + endLineNumber: 1, + }, + 0, + ); + // Emoji before: 0 offset. CJK inside link: +1 offset. + expect(result).toEqual({ + start: { x: 5, y: 1 }, + end: { x: 9, y: 2 }, + }); + }); + + it("should convert ranges for ascii characters (link starts on wrapped line)", () => { + const lines = createBufferLineArray([ + { text: "AAAAAAAAAAA", width: 11 }, + { text: "AA http://t", width: 11 }, + { text: ".com/f/", width: 8 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 15, + startLineNumber: 1, + endColumn: 30, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 4, y: 2 }, + end: { x: 7, y: 3 }, + }); + }); + + it("should convert ranges for wide characters before the link (link starts on wrapped line)", () => { + const lines = createBufferLineArray([ + { text: "AAAAAAAAAAA", width: 11 }, + { text: "A文 http://", width: 11 }, + { text: "t.com/f/", width: 9 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 15, + startLineNumber: 1, + endColumn: 30, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 4 + 1, y: 2 }, + end: { x: 7 + 1, y: 3 }, + }); + }); + + it("regression test #147619: CJK text with numbers", () => { + const lines = createBufferLineArray([ + { text: "获取模板 25235168 的预览图失败", width: 30 }, + ]); + expect( + convertLinkRangeToBuffer( + lines, + 30, + { + startColumn: 1, + startLineNumber: 1, + endColumn: 5, + endLineNumber: 1, + }, + 0, + ), + ).toEqual({ + start: { x: 1, y: 1 }, + end: { x: 8, y: 1 }, + }); + expect( + convertLinkRangeToBuffer( + lines, + 30, + { + startColumn: 6, + startLineNumber: 1, + endColumn: 14, + endLineNumber: 1, + }, + 0, + ), + ).toEqual({ + start: { x: 10, y: 1 }, + end: { x: 17, y: 1 }, + }); + expect( + convertLinkRangeToBuffer( + lines, + 30, + { + startColumn: 15, + startLineNumber: 1, + endColumn: 21, + endLineNumber: 1, + }, + 0, + ), + ).toEqual({ + start: { x: 19, y: 1 }, + end: { x: 30, y: 1 }, + }); + }); + + it("should convert ranges for wide characters inside the link (link starts on wrapped line)", () => { + const lines = createBufferLineArray([ + { text: "AAAAAAAAAAA", width: 11 }, + { text: "AA http://t", width: 11 }, + { text: ".com/文/", width: 8 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 15, + startLineNumber: 1, + endColumn: 30, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 4, y: 2 }, + end: { x: 7 + 1, y: 3 }, + }); + }); + + it("should convert ranges for wide characters before and inside the link #2", () => { + const lines = createBufferLineArray([ + { text: "AAAAAAAAAAA", width: 11 }, + { text: "A文 http://", width: 11 }, + { text: "t.com/文/", width: 9 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 15, + startLineNumber: 1, + endColumn: 30, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 4 + 1, y: 2 }, + end: { x: 7 + 2, y: 3 }, + }); + }); + + it("should convert ranges for several wide characters before the link", () => { + const lines = createBufferLineArray([ + { text: "A文文AAAAAA", width: 11 }, + { text: "AA文文 http", width: 11 }, + { text: "://t.com/f/", width: 11 }, + ]); + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 15, + startLineNumber: 1, + endColumn: 30, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 3 + 4, y: 2 }, + end: { x: 6 + 4, y: 3 }, + }); + }); + + it("should convert ranges for several wide characters before and inside the link", () => { + const lines = createBufferLineArray([ + { text: "A文文AAAAAA", width: 11 }, + { text: "AA文文 http", width: 11 }, + { text: "://t.com/文", width: 11 }, + { text: "文/", width: 3 }, + ]); + // Text "A文文AAAAAAA文文 http://t.com/文文/" = 28 chars + // Line 0: A(1)+文(2+pad)+文(2+pad)+A(1)*6 = 11 cells. Text offset +2 (2 CJK) + // Line 1: A(1)*2+文(2+pad)+文(2+pad)+space(1)+h(1)+t(1)+t(1)+p(1) = 11 cells. Text offset +2 (2 CJK) + // Line 2: :(1)+/(1)+/(1)+t(1)+.(1)+c(1)+o(1)+m(1)+/(1)+文(2) = 11 cells. Text offset +1 (1 CJK) + // Line 3: 文(2)+/(1) = 3 cells. Text offset +1 (1 CJK) + const result = convertLinkRangeToBuffer( + lines, + 11, + { + startColumn: 14, + startLineNumber: 1, + endColumn: 31, + endLineNumber: 1, + }, + 0, + ); + expect(result).toEqual({ + start: { x: 5, y: 2 }, + end: { x: 1, y: 4 }, + }); + }); + }); + + describe("getXtermLineContent", () => { + it("should extract text from a single line", () => { + const line = createMockBufferLine({ text: "hello world", width: 80 }); + const buffer = { + getLine: (i: number) => (i === 0 ? line : undefined), + }; + const result = getXtermLineContent(buffer as never, 0, 0, 80); + expect(result).toBe("hello world"); + }); + + it("should concatenate multiple wrapped lines", () => { + // Note: translateToString(true) trims trailing whitespace, so the + // first line's text must fill the width to preserve the trailing space. + const line0 = createMockBufferLine({ text: "hello wor!", width: 10 }); + const line1 = createMockBufferLine({ text: "ld", width: 10 }); + const buffer = { + getLine: (i: number) => { + if (i === 0) return line0; + if (i === 1) return line1; + return undefined; + }, + }; + const result = getXtermLineContent(buffer as never, 0, 1, 10); + expect(result).toBe("hello wor!ld"); + }); + + it("should cap lines to prevent excessive reads", () => { + // With cols=10, maxLineLength = max(2048, 20) = 2048 + // lineEnd is capped to lineStart + 2048 + const lines = new Map<number, IBufferLine>(); + for (let i = 0; i < 300; i++) { + lines.set(i, createMockBufferLine({ text: "a".repeat(10), width: 10 })); + } + const buffer = { + getLine: (i: number) => lines.get(i), + }; + // Even if we request 300 lines (3000 chars), it should cap + const result = getXtermLineContent(buffer as never, 0, 299, 10); + // With the cap, we should get at most ~2048 lines * cols worth of text + expect(result.length).toBeLessThanOrEqual(2048 * 10); + }); + + it("should handle missing lines gracefully", () => { + const buffer = { + getLine: () => undefined, + }; + const result = getXtermLineContent(buffer as never, 0, 0, 80); + expect(result).toBe(""); + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/links/buffer-helpers.ts b/apps/desktop/src/renderer/lib/terminal/links/buffer-helpers.ts new file mode 100644 index 00000000000..242a777674b --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/buffer-helpers.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * + * Adapted from VSCode's terminalLinkHelpers.ts + * https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkHelpers.ts + *--------------------------------------------------------------------------------------------*/ + +import type { IBuffer, IBufferLine, IBufferRange } from "@xterm/xterm"; + +/** + * A simplified IRange representation (1-based columns, 1-based lines) matching + * the shape VSCode feeds into convertLinkRangeToBuffer. + */ +export interface IRange { + startColumn: number; + startLineNumber: number; + endColumn: number; + endLineNumber: number; +} + +/** + * Convert a text-offset range (IRange) into a buffer-cell range (IBufferRange), + * correctly accounting for wide characters (CJK, emoji) that occupy 2 cells but + * only 1 logical character position. + * + * This is ported directly from VSCode to fix a class of bugs where the + * highlighted link range is shifted when wide characters appear on the same + * line. + */ +export function convertLinkRangeToBuffer( + lines: IBufferLine[], + bufferWidth: number, + range: IRange, + startLine: number, +): IBufferRange { + const bufferRange: IBufferRange = { + start: { + x: range.startColumn, + y: range.startLineNumber + startLine, + }, + end: { + x: range.endColumn - 1, + y: range.endLineNumber + startLine, + }, + }; + + // Calculate start offset caused by wide chars before the start column + let startOffset = 0; + const startWrappedLineCount = Math.ceil(range.startColumn / bufferWidth); + for (let y = 0; y < Math.min(startWrappedLineCount); y++) { + const lineLength = Math.min( + bufferWidth, + range.startColumn - 1 - y * bufferWidth, + ); + let lineOffset = 0; + const line = lines[y]; + if (!line) { + break; + } + for (let x = 0; x < Math.min(bufferWidth, lineLength + lineOffset); x++) { + const cell = line.getCell(x); + if (!cell) { + break; + } + const width = cell.getWidth(); + if (width === 2) { + lineOffset++; + } + const char = cell.getChars(); + if (char.length > 1) { + lineOffset -= char.length - 1; + } + } + startOffset += lineOffset; + } + + // Calculate end offset caused by wide chars between start and end columns + let endOffset = 0; + const endWrappedLineCount = Math.ceil(range.endColumn / bufferWidth); + for ( + let y = Math.max(0, startWrappedLineCount - 1); + y < endWrappedLineCount; + y++ + ) { + const start = + y === startWrappedLineCount - 1 + ? (range.startColumn - 1 + startOffset) % bufferWidth + : 0; + const lineLength = Math.min( + bufferWidth, + range.endColumn + startOffset - y * bufferWidth, + ); + let lineOffset = 0; + const line = lines[y]; + if (!line) { + break; + } + for ( + let x = start; + x < Math.min(bufferWidth, lineLength + lineOffset); + x++ + ) { + const cell = line.getCell(x); + if (!cell) { + break; + } + const width = cell.getWidth(); + const chars = cell.getChars(); + if (width === 2) { + lineOffset++; + } + // A wide character that can't fit at the last column causes xterm to + // place an empty marker cell (width=1, chars="") there and wrap the + // char to the next line. A normal padding cell (the 2nd half of a + // wide char that DID fit) has width=0 and should NOT trigger an + // extra offset. VSCode's original code only checks `chars === ""` + // which is overly broad; we add a width check to avoid false + // positives when a wide char's padding cell lands on the last column. + if (x === bufferWidth - 1 && chars === "" && cell.getWidth() !== 0) { + lineOffset++; + } + if (chars.length > 1) { + lineOffset -= chars.length - 1; + } + } + endOffset += lineOffset; + } + + bufferRange.start.x += startOffset; + bufferRange.end.x += startOffset + endOffset; + + // Wrap x values that overflow a line into the next line + while (bufferRange.start.x > bufferWidth) { + bufferRange.start.x -= bufferWidth; + bufferRange.start.y++; + } + while (bufferRange.end.x > bufferWidth) { + bufferRange.end.x -= bufferWidth; + bufferRange.end.y++; + } + + return bufferRange; +} + +/** + * Split terminal lines by text attributes (bold, underline, italic, etc.). + * Returns buffer ranges where each range has consistent styling. + * + * Used as a last-resort fallback for link detection: if a filename is printed + * with different styling (e.g. bold or underlined) than surrounding text, + * each styled segment can be tested as a potential file path. + * + * Vendored from VSCode's terminalLinkHelpers.ts. + */ +export function getXtermRangesByAttr( + buffer: IBuffer, + lineStart: number, + lineEnd: number, + cols: number, +): IBufferRange[] { + let bufferRangeStart: { x: number; y: number } | undefined; + let lastFgAttr = -1; + let lastBgAttr = -1; + const ranges: IBufferRange[] = []; + for (let y = lineStart; y <= lineEnd; y++) { + const line = buffer.getLine(y); + if (!line) { + continue; + } + for (let x = 0; x < cols; x++) { + const cell = line.getCell(x); + if (!cell) { + break; + } + // Re-construct the attributes from fg and bg. This relies on + // xterm's internal buffer bit layout (same approach as VSCode). + const thisFgAttr = + cell.isBold() | + cell.isInverse() | + cell.isStrikethrough() | + cell.isUnderline(); + const thisBgAttr = cell.isDim() | cell.isItalic(); + if (lastFgAttr === -1 || lastBgAttr === -1) { + bufferRangeStart = { x, y }; + } else { + if (lastFgAttr !== thisFgAttr || lastBgAttr !== thisBgAttr) { + if (bufferRangeStart) { + ranges.push({ + start: bufferRangeStart, + end: { x, y }, + }); + } + bufferRangeStart = { x, y }; + } + } + lastFgAttr = thisFgAttr; + lastBgAttr = thisBgAttr; + } + } + return ranges; +} + +/** + * Extract the text content from a range of terminal buffer lines. + * Caps the maximum read length to prevent excessive reads on very long output. + */ +export function getXtermLineContent( + buffer: IBuffer, + lineStart: number, + lineEnd: number, + cols: number, +): string { + const maxLineLength = Math.max(2048, cols * 2); + const cappedEnd = Math.min(lineEnd, lineStart + maxLineLength); + let content = ""; + for (let i = lineStart; i <= cappedEnd; i++) { + const line = buffer.getLine(i); + if (line) { + content += line.translateToString(true, 0, cols); + } + } + return content; +} diff --git a/apps/desktop/src/renderer/lib/terminal/links/index.ts b/apps/desktop/src/renderer/lib/terminal/links/index.ts new file mode 100644 index 00000000000..392f6cf43e3 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/index.ts @@ -0,0 +1,20 @@ +export type { IRange } from "./buffer-helpers"; +export { + convertLinkRangeToBuffer, + getXtermLineContent, +} from "./buffer-helpers"; + +export { LinkDetectorAdapter } from "./link-detector-adapter"; + +export { + type ResolvedLink, + type StatCallback, + TerminalLinkResolver, +} from "./link-resolver"; + +export { + type DetectedLink, + LocalLinkDetector, +} from "./local-link-detector"; + +export { WordLinkDetector } from "./word-link-detector"; diff --git a/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.test.ts b/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.test.ts new file mode 100644 index 00000000000..f49a3204728 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.test.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Tests for LinkDetectorAdapter — the bridge between LocalLinkDetector + * and xterm's ILinkProvider interface. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "bun:test"; +import type { ILink } from "@xterm/xterm"; +import { LinkDetectorAdapter } from "./link-detector-adapter"; +import type { StatCallback } from "./link-resolver"; +import { TerminalLinkResolver } from "./link-resolver"; +import { LocalLinkDetector } from "./local-link-detector"; + +// --------------------------------------------------------------------------- +// Mock terminal buffer +// --------------------------------------------------------------------------- + +function createMockTerminal( + lineDescriptors: { text: string; isWrapped?: boolean }[], + cols = 80, +) { + const lines = lineDescriptors.map((desc) => ({ + translateToString: ( + _trim?: boolean, + startColumn?: number, + endColumn?: number, + ) => { + const start = startColumn ?? 0; + const end = endColumn ?? cols; + let result = desc.text; + // Pad to cols width for consistency + result = result.padEnd(cols); + result = result.substring(start, end); + if (_trim) result = result.replace(/\s+$/, ""); + return result; + }, + isWrapped: desc.isWrapped ?? false, + length: cols, + getCell: (x: number) => + ({ + getChars: () => (x < desc.text.length ? desc.text[x] : " "), + getWidth: () => 1, + }) as never, + })); + + return { + cols, + buffer: { + active: { + length: lines.length, + getLine: (i: number) => lines[i] ?? null, + viewportY: 0, + }, + }, + } as never; +} + +function createAdapter( + lineDescriptors: (string | { text: string; isWrapped?: boolean })[], + validPaths: string[], + opts?: { initialCwd?: string; userHome?: string; cols?: number }, +) { + const descriptors = lineDescriptors.map((d) => + typeof d === "string" ? { text: d } : d, + ); + const statMock: StatCallback = async (path) => { + if (validPaths.includes(path)) { + return { isDirectory: false }; + } + return null; + }; + const resolver = new TerminalLinkResolver(statMock); + const cols = opts?.cols ?? 80; + const terminal = createMockTerminal(descriptors, cols); + const detector = new LocalLinkDetector(resolver); + + const adapter = new LinkDetectorAdapter(terminal, detector); + return { adapter, terminal }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("LinkDetectorAdapter", () => { + it("should implement ILinkProvider.provideLinks", async () => { + const { adapter } = createAdapter( + ["see /foo/bar.ts for details"], + ["/foo/bar.ts"], + ); + + const links = await new Promise<ILink[] | undefined>((resolve) => { + adapter.provideLinks(1, resolve); + }); + + expect(links).toBeDefined(); + expect(links).toHaveLength(1); + expect(links?.[0]?.text).toBe("/foo/bar.ts"); + }); + + it("should return undefined when no links found", async () => { + const { adapter } = createAdapter(["just regular text"], []); + + const links = await new Promise<ILink[] | undefined>((resolve) => { + adapter.provideLinks(1, resolve); + }); + + expect(links).toBeUndefined(); + }); + + it("should set correct buffer ranges", async () => { + const { adapter } = createAdapter( + ["see /foo/bar.ts for details"], + ["/foo/bar.ts"], + ); + + const links = await new Promise<ILink[] | undefined>((resolve) => { + adapter.provideLinks(1, resolve); + }); + + const range = links?.[0]?.range; + expect(range).toBeDefined(); + // "/foo/bar.ts" starts at index 4 in "see /foo/bar.ts for details" + expect(range?.start.y).toBe(1); + expect(range?.start.x).toBe(5); // 1-based: index 4 + 1 + expect(range?.end.x).toBe(15); // 1-based: index 4 + 11 + }); + + it("should detect multiple links", async () => { + const { adapter } = createAdapter( + ["error in /foo/a.ts and /foo/b.ts"], + ["/foo/a.ts", "/foo/b.ts"], + ); + + const links = await new Promise<ILink[] | undefined>((resolve) => { + adapter.provideLinks(1, resolve); + }); + + expect(links).toHaveLength(2); + }); + + it("should handle multi-line buffer (only detect for requested line)", async () => { + const { adapter } = createAdapter( + ["line one", "see /foo/bar.ts", "line three"], + ["/foo/bar.ts"], + ); + + // Request line 2 (1-based) + const links = await new Promise<ILink[] | undefined>((resolve) => { + adapter.provideLinks(2, resolve); + }); + + expect(links).toHaveLength(1); + expect(links?.[0]?.text).toBe("/foo/bar.ts"); + }); + + it("should return undefined for out-of-range lines", async () => { + const { adapter } = createAdapter(["hello"], ["/foo/bar.ts"]); + + const links = await new Promise<ILink[] | undefined>((resolve) => { + adapter.provideLinks(99, resolve); + }); + + expect(links).toBeUndefined(); + }); + + it("should include line/col suffix in range but call activate with path info", async () => { + const { adapter } = createAdapter(["/foo/bar.ts:42:10"], ["/foo/bar.ts"]); + + const links = await new Promise<ILink[] | undefined>((resolve) => { + adapter.provideLinks(1, resolve); + }); + + expect(links).toHaveLength(1); + // The full text includes the suffix + expect(links?.[0]?.text).toBe("/foo/bar.ts:42:10"); + }); + + it("should detect paths spanning wrapped lines", async () => { + // Simulate a 30-col terminal where a long path wraps + const { adapter } = createAdapter( + [ + // Line 1: "see /parent/cwd/apps/web/sr" (30 chars) + { text: "see /parent/cwd/apps/web/sr" }, + // Line 2 (wrapped): "c/app/page.tsx:1 for info" (continues from line 1) + { text: "c/app/page.tsx:1 for info", isWrapped: true }, + ], + ["/parent/cwd/apps/web/src/app/page.tsx"], + { cols: 30, initialCwd: "/parent/cwd" }, + ); + + // Request line 1 (the start of the wrapped path) + const links = await new Promise<ILink[] | undefined>((resolve) => { + adapter.provideLinks(1, resolve); + }); + + expect(links).toBeDefined(); + expect(links?.length).toBeGreaterThanOrEqual(1); + // The detected text should be the full path including suffix + const pathLink = links?.find((l) => + l.text.includes("apps/web/src/app/page.tsx"), + ); + expect(pathLink).toBeDefined(); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.ts b/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.ts new file mode 100644 index 00000000000..7f5da9edc2f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/link-detector-adapter.ts @@ -0,0 +1,255 @@ +/*--------------------------------------------------------------------------------------------- + * Adapted from VSCode's terminalLinkDetectorAdapter.ts + * https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts + * + * Bridges LocalLinkDetector to xterm's ILinkProvider interface. + * Handles multi-line wrapped paths by gathering context lines. + * Deduplicates in-flight requests per buffer line (VSCode pattern). + *--------------------------------------------------------------------------------------------*/ + +import type { IBufferLine, ILink, ILinkProvider, Terminal } from "@xterm/xterm"; +import { + convertLinkRangeToBuffer, + getXtermLineContent, + getXtermRangesByAttr, +} from "./buffer-helpers"; +import type { DetectedLink, LocalLinkDetector } from "./local-link-detector"; + +/** Maximum characters of context to gather around the hovered line. */ +const MAX_LINK_LENGTH = 500; + +/** + * Adapts a LocalLinkDetector into xterm's ILinkProvider. + * + * When xterm calls `provideLinks(bufferLineNumber)`, this adapter: + * 1. Deduplicates in-flight requests for the same line + * 2. Gathers wrapped context lines (previous + current + next) + * 3. Concatenates them into a single text block + * 4. Delegates to LocalLinkDetector.detect() + * 5. If no links found, tries styled-text detection (getXtermRangesByAttr) + * 6. Maps detected ranges back to buffer coordinates using + * convertLinkRangeToBuffer (handles wide chars correctly) + */ +export class LinkDetectorAdapter implements ILinkProvider { + /** + * Cache of in-flight link detection requests per buffer line. + * Prevents duplicate async work when xterm requests the same line + * multiple times during rapid mouse movement (VSCode pattern). + */ + private _activeRequests = new Map<number, Promise<ILink[]>>(); + + constructor( + private readonly _terminal: Terminal, + private readonly _detector: LocalLinkDetector, + private readonly _onActivate?: ( + event: MouseEvent, + link: DetectedLink, + ) => void, + private readonly _onHover?: (event: MouseEvent, link: DetectedLink) => void, + private readonly _onLeave?: () => void, + ) {} + + provideLinks( + bufferLineNumber: number, + callback: (links: ILink[] | undefined) => void, + ): void { + // Reuse in-flight request for this line if one exists + let request = this._activeRequests.get(bufferLineNumber); + if (!request) { + request = this._provideLinks(bufferLineNumber); + this._activeRequests.set(bufferLineNumber, request); + } + request.then( + (links) => { + this._activeRequests.delete(bufferLineNumber); + callback(links.length > 0 ? links : undefined); + }, + () => { + this._activeRequests.delete(bufferLineNumber); + callback(undefined); + }, + ); + } + + private async _provideLinks(bufferLineNumber: number): Promise<ILink[]> { + const buffer = this._terminal.buffer.active; + const cols = this._terminal.cols; + + // Gather wrapped context lines around the target line. + // VSCode caps context to maxLinkLength chars on either side. + let startLine = bufferLineNumber - 1; + let endLine = startLine; + + const lines: IBufferLine[] = []; + const currentLine = buffer.getLine(startLine); + if (!currentLine) return []; + lines.push(currentLine); + + const maxCharacterContext = Math.max(MAX_LINK_LENGTH, cols); + const maxLineContext = Math.ceil(maxCharacterContext / cols); + const minStartLine = Math.max(startLine - maxLineContext, 0); + const maxEndLine = Math.min(endLine + maxLineContext, buffer.length); + + // Walk backward through wrapped lines + while (startLine >= minStartLine && buffer.getLine(startLine)?.isWrapped) { + const prevLine = buffer.getLine(startLine - 1); + if (!prevLine) break; + lines.unshift(prevLine); + startLine--; + } + + // Walk forward through wrapped lines + while (endLine < maxEndLine && buffer.getLine(endLine + 1)?.isWrapped) { + const nextLine = buffer.getLine(endLine + 1); + if (!nextLine) break; + lines.push(nextLine); + endLine++; + } + + // Concatenate all gathered lines into one text block + const text = getXtermLineContent(buffer, startLine, endLine, cols); + if (!text) return []; + + const detectedLinks = await this._detector.detect(text); + let result = this._mapDetectedLinks( + detectedLinks, + lines, + cols, + startLine, + bufferLineNumber, + ); + + // VENDORED FROM VSCODE (terminalLocalLinkDetector.ts lines 220-252): + // Styled-text fallback — if no links found, split lines by terminal + // attributes (bold/underline/italic) and try each styled segment as + // a file path. Catches filenames that the app printed with styling. + // To disable: remove or comment out this block. + if (result.length === 0) { + result = await this._detectStyledTextLinks( + startLine, + endLine, + bufferLineNumber, + ); + } + + return result; + } + + /** + * Styled-text fallback: split lines by terminal attributes and try each + * segment as a file path. Vendored from VSCode's TerminalLocalLinkDetector. + */ + private async _detectStyledTextLinks( + startLine: number, + endLine: number, + bufferLineNumber: number, + ): Promise<ILink[]> { + const buffer = this._terminal.buffer.active; + const cols = this._terminal.cols; + const result: ILink[] = []; + + const rangeCandidates = getXtermRangesByAttr( + buffer, + startLine, + endLine, + cols, + ); + + for (const rangeCandidate of rangeCandidates) { + let text = ""; + for (let y = rangeCandidate.start.y; y <= rangeCandidate.end.y; y++) { + const line = buffer.getLine(y); + if (!line) break; + const lineStartX = + y === rangeCandidate.start.y ? rangeCandidate.start.x : 0; + const lineEndX = + y === rangeCandidate.end.y ? rangeCandidate.end.x : cols - 1; + text += line.translateToString(false, lineStartX, lineEndX); + } + + if (!text.trim()) continue; + + // Adjust to 1-based for xterm link API (matches VSCode's HACK comment) + const range = { + start: { + x: rangeCandidate.start.x + 1, + y: rangeCandidate.start.y + 1, + }, + end: { + x: rangeCandidate.end.x, + y: rangeCandidate.end.y + 1, + }, + }; + + // Only include if overlaps with requested line + if (range.end.y < bufferLineNumber || range.start.y > bufferLineNumber) { + continue; + } + + const detectedLinks = await this._detector.detect(text.trim()); + for (const detected of detectedLinks) { + result.push({ + range, + text: detected.text, + activate: (event: MouseEvent) => { + this._onActivate?.(event, detected); + }, + hover: (event: MouseEvent) => { + this._onHover?.(event, detected); + }, + leave: () => { + this._onLeave?.(); + }, + }); + } + } + + return result; + } + + private _mapDetectedLinks( + detectedLinks: DetectedLink[], + lines: IBufferLine[], + cols: number, + startLine: number, + bufferLineNumber: number, + ): ILink[] { + const result: ILink[] = []; + + for (const detected of detectedLinks) { + // Convert text offsets to buffer range, accounting for wide chars + const range = convertLinkRangeToBuffer( + lines, + cols, + { + startColumn: detected.startIndex + 1, // 1-based + startLineNumber: 1, + endColumn: detected.endIndex + 1, + endLineNumber: 1, + }, + startLine, + ); + + // Only include links that overlap with the requested line + if (range.end.y < bufferLineNumber || range.start.y > bufferLineNumber) { + continue; + } + + result.push({ + range, + text: detected.text, + activate: (event: MouseEvent) => { + this._onActivate?.(event, detected); + }, + hover: (event: MouseEvent) => { + this._onHover?.(event, detected); + }, + leave: () => { + this._onLeave?.(); + }, + }); + } + + return result; + } +} diff --git a/apps/desktop/src/renderer/lib/terminal/links/link-resolver.test.ts b/apps/desktop/src/renderer/lib/terminal/links/link-resolver.test.ts new file mode 100644 index 00000000000..49747c0cbac --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/link-resolver.test.ts @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Link resolver tests + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, jest } from "bun:test"; +import { TerminalLinkResolver } from "./link-resolver"; + +describe("TerminalLinkResolver", () => { + let resolver: TerminalLinkResolver; + let statMock: jest.Mock< + (path: string) => Promise<{ + isDirectory: boolean; + resolvedPath?: string; + } | null> + >; + + beforeEach(() => { + statMock = jest.fn(); + resolver = new TerminalLinkResolver(statMock); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("resolveLink", () => { + it("should pass path to stat callback", async () => { + statMock.mockResolvedValue({ isDirectory: false }); + const result = await resolver.resolveLink("/foo/bar.ts"); + expect(result).toEqual({ + path: "/foo/bar.ts", + isDirectory: false, + }); + expect(statMock).toHaveBeenCalledWith("/foo/bar.ts"); + }); + + it("should pass relative paths through to stat (host resolves)", async () => { + statMock.mockResolvedValue({ + isDirectory: false, + resolvedPath: "/workspace/src/file.ts", + }); + const result = await resolver.resolveLink("src/file.ts"); + expect(result).toEqual({ + path: "/workspace/src/file.ts", + isDirectory: false, + }); + expect(statMock).toHaveBeenCalledWith("src/file.ts"); + }); + + it("should pass tilde paths through to stat (host resolves)", async () => { + statMock.mockResolvedValue({ + isDirectory: false, + resolvedPath: "/home/user/foo.ts", + }); + const result = await resolver.resolveLink("~/foo.ts"); + expect(result).toEqual({ + path: "/home/user/foo.ts", + isDirectory: false, + }); + expect(statMock).toHaveBeenCalledWith("~/foo.ts"); + }); + + it("should prefer resolvedPath from stat over input path", async () => { + statMock.mockResolvedValue({ + isDirectory: false, + resolvedPath: "/absolute/resolved/file.ts", + }); + const result = await resolver.resolveLink("file.ts"); + expect(result?.path).toBe("/absolute/resolved/file.ts"); + }); + + it("should fall back to input path when resolvedPath not provided", async () => { + statMock.mockResolvedValue({ isDirectory: false }); + const result = await resolver.resolveLink("/foo/bar.ts"); + expect(result?.path).toBe("/foo/bar.ts"); + }); + + it("should return null for paths that don't exist", async () => { + statMock.mockResolvedValue(null); + const result = await resolver.resolveLink("/nonexistent.ts"); + expect(result).toBeNull(); + }); + + it("should return null for stat errors", async () => { + statMock.mockRejectedValue(new Error("ENOENT")); + const result = await resolver.resolveLink("/nonexistent.ts"); + expect(result).toBeNull(); + }); + + it("should detect directories", async () => { + statMock.mockResolvedValue({ isDirectory: true }); + const result = await resolver.resolveLink("/some/dir"); + expect(result).toEqual({ + path: "/some/dir", + isDirectory: true, + }); + }); + + it("should strip file:// URI scheme before calling stat", async () => { + statMock.mockResolvedValue({ isDirectory: false }); + await resolver.resolveLink("file:///foo/bar.ts"); + expect(statMock).toHaveBeenCalledWith("/foo/bar.ts"); + }); + + it("should decode URL-encoded file:// paths", async () => { + statMock.mockResolvedValue({ isDirectory: false }); + await resolver.resolveLink("file:///foo/bar%20baz.ts"); + expect(statMock).toHaveBeenCalledWith("/foo/bar baz.ts"); + }); + + it("should strip line/column suffix before calling stat", async () => { + statMock.mockResolvedValue({ isDirectory: false }); + await resolver.resolveLink("/foo/bar.ts:42:10"); + expect(statMock).toHaveBeenCalledWith("/foo/bar.ts"); + }); + + it("should return null for empty paths", async () => { + const result = await resolver.resolveLink(""); + expect(result).toBeNull(); + }); + + it("should return null for whitespace-only paths", async () => { + const result = await resolver.resolveLink(" "); + expect(result).toBeNull(); + }); + }); + + describe("caching", () => { + it("should cache resolved results", async () => { + statMock.mockResolvedValue({ isDirectory: false }); + await resolver.resolveLink("/foo/bar.ts"); + await resolver.resolveLink("/foo/bar.ts"); + expect(statMock).toHaveBeenCalledTimes(1); + }); + + it("should cache null results", async () => { + statMock.mockResolvedValue(null); + await resolver.resolveLink("/nonexistent.ts"); + await resolver.resolveLink("/nonexistent.ts"); + expect(statMock).toHaveBeenCalledTimes(1); + }); + + it("should expire cache after TTL", async () => { + statMock.mockResolvedValue({ isDirectory: false }); + resolver = new TerminalLinkResolver(statMock, { cacheTtlMs: 50 }); + await resolver.resolveLink("/foo/bar.ts"); + expect(statMock).toHaveBeenCalledTimes(1); + + await new Promise((r) => setTimeout(r, 60)); + + await resolver.resolveLink("/foo/bar.ts"); + expect(statMock).toHaveBeenCalledTimes(2); + }); + + it("should cache different paths independently", async () => { + statMock.mockImplementation(async (path) => { + if (path === "/foo.ts") return { isDirectory: false }; + return null; + }); + + const r1 = await resolver.resolveLink("/foo.ts"); + const r2 = await resolver.resolveLink("/bar.ts"); + + expect(r1).not.toBeNull(); + expect(r2).toBeNull(); + expect(statMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("resolveMultipleCandidates", () => { + it("should return the first candidate that exists", async () => { + statMock.mockImplementation(async (path) => { + if (path === "bar.ts") + return { isDirectory: false, resolvedPath: "/workspace/bar.ts" }; + return null; + }); + + const result = await resolver.resolveMultipleCandidates([ + "foo.ts", + "bar.ts", + "baz.ts", + ]); + expect(result).toEqual({ + path: "/workspace/bar.ts", + isDirectory: false, + }); + }); + + it("should return null when no candidates exist", async () => { + statMock.mockResolvedValue(null); + const result = await resolver.resolveMultipleCandidates([ + "foo.ts", + "bar.ts", + ]); + expect(result).toBeNull(); + }); + + it("should return null for empty candidate list", async () => { + const result = await resolver.resolveMultipleCandidates([]); + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/links/link-resolver.ts b/apps/desktop/src/renderer/lib/terminal/links/link-resolver.ts new file mode 100644 index 00000000000..3b773e3caae --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/link-resolver.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Adapted from VSCode's terminalLinkResolver.ts + * https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkResolver.ts + * + * Resolves terminal link paths against the filesystem with TTL caching. + * + * Unlike VSCode (which resolves paths in the renderer then routes stat via + * URI scheme), we delegate all path resolution to the host service's statPath + * endpoint. The renderer only strips suffixes/query strings and handles + * file:// URIs before passing the raw path to the stat callback. + *--------------------------------------------------------------------------------------------*/ + +import { + removeLinkQueryString, + removeLinkSuffix, +} from "@superset/shared/terminal-link-parsing"; + +/** + * The result of resolving a link path against the filesystem. + */ +export interface ResolvedLink { + /** The absolute, resolved path. */ + path: string; + /** Whether the path points to a directory. */ + isDirectory: boolean; +} + +/** + * Callback that checks whether a path exists on disk. + * + * The callback receives a path that may be absolute or relative. The host + * service resolves relative paths against the workspace root, tilde paths + * against $HOME, etc. — all resolution happens server-side. + * + * Return `{ isDirectory, resolvedPath? }` if the path exists, or `null` if + * it doesn't. `resolvedPath` allows the host to report the final absolute + * path after server-side resolution. + */ +export type StatCallback = ( + path: string, +) => Promise<{ isDirectory: boolean; resolvedPath?: string } | null>; + +interface CacheEntry { + value: ResolvedLink | null; +} + +const DEFAULT_CACHE_TTL_MS = 10_000; + +export interface TerminalLinkResolverConfig { + cacheTtlMs?: number; +} + +/** + * Validates terminal link paths against the filesystem via a stat callback. + * Results are cached with a configurable TTL (default 10 seconds) following + * VSCode's pattern. + * + * Path resolution (relative, tilde, etc.) is handled by the stat callback + * (host service), not the renderer. The resolver only strips link suffixes + * and handles file:// URI decoding. + */ +export class TerminalLinkResolver { + private readonly _cache = new Map<string, CacheEntry>(); + private _cacheTtl: ReturnType<typeof setTimeout> | null = null; + private readonly _ttlMs: number; + + constructor( + private readonly _stat: StatCallback, + config?: TerminalLinkResolverConfig, + ) { + this._ttlMs = config?.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + } + + /** + * Resolve a single link string, checking if it exists via the stat callback. + */ + async resolveLink(link: string): Promise<ResolvedLink | null> { + if (!link || !link.trim()) { + return null; + } + + // Check cache first + const cached = this._cache.get(link); + if (cached !== undefined) { + return cached.value; + } + + // Strip line/column suffix and query string for path resolution + let linkPath = removeLinkSuffix(link); + linkPath = removeLinkQueryString(linkPath); + + if (!linkPath) { + this._cacheSet(link, null); + return null; + } + + // Handle file:// URIs (decode to plain path) + if (linkPath.startsWith("file://")) { + try { + const url = new URL(linkPath); + linkPath = decodeURIComponent(url.pathname); + } catch { + try { + linkPath = decodeURIComponent(linkPath.replace(/^file:\/\//, "")); + } catch { + // Malformed URI — use as-is with scheme stripped + linkPath = linkPath.replace(/^file:\/\//, ""); + } + } + } + + // Pass the path to the stat callback. The host service handles all + // resolution (relative → workspace root, ~ → $HOME, etc.) + try { + const stat = await this._stat(linkPath); + if (stat) { + const result: ResolvedLink = { + path: stat.resolvedPath ?? linkPath, + isDirectory: stat.isDirectory, + }; + this._cacheSet(link, result); + return result; + } + this._cacheSet(link, null); + return null; + } catch { + this._cacheSet(link, null); + return null; + } + } + + /** + * Try multiple path candidates in order, returning the first one that exists. + */ + async resolveMultipleCandidates( + candidates: string[], + ): Promise<ResolvedLink | null> { + for (const candidate of candidates) { + const result = await this.resolveLink(candidate); + if (result) { + return result; + } + } + return null; + } + + /** + * Clear the cache (for testing or when the terminal CWD changes). + */ + clearCache(): void { + this._cache.clear(); + if (this._cacheTtl !== null) { + clearTimeout(this._cacheTtl); + this._cacheTtl = null; + } + } + + private _cacheSet(key: string, value: ResolvedLink | null): void { + if (this._cacheTtl !== null) { + clearTimeout(this._cacheTtl); + } + this._cacheTtl = setTimeout(() => { + this._cache.clear(); + this._cacheTtl = null; + }, this._ttlMs); + + this._cache.set(key, { value }); + } +} diff --git a/apps/desktop/src/renderer/lib/terminal/links/local-link-detector.test.ts b/apps/desktop/src/renderer/lib/terminal/links/local-link-detector.test.ts new file mode 100644 index 00000000000..63513ae777f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/local-link-detector.test.ts @@ -0,0 +1,290 @@ +/*--------------------------------------------------------------------------------------------- + * Adapted from VSCode's terminalLocalLinkDetector.test.ts + * https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLocalLinkDetector.test.ts + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "bun:test"; +import type { StatCallback } from "./link-resolver"; +import { TerminalLinkResolver } from "./link-resolver"; +import { LocalLinkDetector } from "./local-link-detector"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Create a detector with a mock stat callback. The stat callback simulates + * the host service's statPath: it checks if the path (or the path resolved + * against a workspace root) matches any of the valid paths. + */ +function createDetector(validPaths: string[], workspaceRoot = "/parent/cwd") { + const statMock: StatCallback = async (path) => { + // Simulate host-side resolution: try raw path, then resolved against root + if (validPaths.includes(path)) { + return { isDirectory: false, resolvedPath: path }; + } + // Simulate host resolving relative paths against workspace root + // (mirrors what the host service's statPath does with path.resolve) + if (!path.startsWith("/") && !path.startsWith("~")) { + const parts = `${workspaceRoot}/${path}`.split("/").filter(Boolean); + const normalized: string[] = []; + for (const p of parts) { + if (p === ".") continue; + if (p === ".." && normalized.length > 0) { + normalized.pop(); + } else { + normalized.push(p); + } + } + const resolved = `/${normalized.join("/")}`; + if (validPaths.includes(resolved)) { + return { isDirectory: false, resolvedPath: resolved }; + } + } + // Simulate host resolving tilde + if (path.startsWith("~/")) { + const resolved = `/home${path.substring(1)}`; + if (validPaths.includes(resolved)) { + return { isDirectory: false, resolvedPath: resolved }; + } + } + return null; + }; + const resolver = new TerminalLinkResolver(statMock); + return new LocalLinkDetector(resolver); +} + +function formatLink( + fmt: string, + path: string, + line?: string, + col?: string, +): string { + return fmt + .replace("{0}", path) + .replace("{1}", line ?? "") + .replace("{2}", col ?? ""); +} + +// --------------------------------------------------------------------------- +// Unix link paths +// --------------------------------------------------------------------------- + +const unixLinks: { link: string; resolved: string }[] = [ + // Absolute + { link: "/foo", resolved: "/foo" }, + { link: "/foo/bar", resolved: "/foo/bar" }, + { link: "/foo/bar+more", resolved: "/foo/bar+more" }, + // User home + { link: "~/foo", resolved: "/home/foo" }, + // Relative + { link: "./foo", resolved: "/parent/cwd/foo" }, + { link: "../foo", resolved: "/parent/foo" }, + { link: "foo/bar", resolved: "/parent/cwd/foo/bar" }, + { link: "foo/bar+more", resolved: "/parent/cwd/foo/bar+more" }, +]; + +// Line/column suffix formats (from VSCode's test suite) +const suffixFormats: { fmt: string; line?: string; col?: string }[] = [ + { fmt: "{0}" }, + { fmt: '{0}" on line {1}', line: "5" }, + { fmt: '{0}" on line {1}, column {2}', line: "5", col: "3" }, + { fmt: "{0}({1})", line: "5" }, + { fmt: "{0} ({1})", line: "5" }, + { fmt: "{0}({1},{2})", line: "5", col: "3" }, + { fmt: "{0} ({1},{2})", line: "5", col: "3" }, + { fmt: "{0}({1}, {2})", line: "5", col: "3" }, + { fmt: "{0}:{1}", line: "5" }, + { fmt: "{0}:{1}:{2}", line: "5", col: "3" }, + { fmt: "{0}[{1}]", line: "5" }, + { fmt: "{0}[{1},{2}]", line: "5", col: "3" }, + { fmt: "{0}#{1}", line: "5" }, +]; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("LocalLinkDetector", () => { + describe("detect", () => { + it("should return empty for empty text", async () => { + const detector = createDetector([]); + const result = await detector.detect(""); + expect(result).toEqual([]); + }); + + it("should return empty when text exceeds max length", async () => { + const detector = createDetector([`/${"a".repeat(100)}`]); + const result = await detector.detect("a".repeat(2001)); + expect(result).toEqual([]); + }); + + it("should detect absolute paths", async () => { + const detector = createDetector(["/foo/bar.ts"]); + const result = await detector.detect("see /foo/bar.ts for details"); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/foo/bar.ts"); + }); + + it("should detect relative paths resolved against cwd", async () => { + const detector = createDetector(["/parent/cwd/src/file.ts"]); + const result = await detector.detect("error in ./src/file.ts"); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/parent/cwd/src/file.ts"); + }); + + it("should detect tilde paths", async () => { + const detector = createDetector(["/home/.config/foo"]); + const result = await detector.detect("see ~/.config/foo"); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/home/.config/foo"); + }); + + it("should NOT detect paths that don't exist", async () => { + const detector = createDetector([]); // nothing exists + const result = await detector.detect("see /nonexistent/file.ts"); + expect(result).toEqual([]); + }); + + it("should detect multiple links on one line", async () => { + const detector = createDetector(["/parent/cwd/foo", "/parent/cwd/bar"]); + const result = await detector.detect("./foo ./bar"); + expect(result).toHaveLength(2); + expect(result[0]?.resolvedPath).toBe("/parent/cwd/foo"); + expect(result[1]?.resolvedPath).toBe("/parent/cwd/bar"); + }); + + it("should preserve line/column suffix info", async () => { + const detector = createDetector(["/parent/cwd/file.ts"]); + const result = await detector.detect("./file.ts:42:10"); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/parent/cwd/file.ts"); + expect(result[0]?.row).toBe(42); + expect(result[0]?.col).toBe(10); + }); + + it("should handle parenthetical line/col format", async () => { + const detector = createDetector(["/parent/cwd/file.ts"]); + const result = await detector.detect("./file.ts(5, 3)"); + expect(result).toHaveLength(1); + expect(result[0]?.row).toBe(5); + expect(result[0]?.col).toBe(3); + }); + + it("should skip URLs (http/https)", async () => { + const detector = createDetector([]); + const result = await detector.detect("visit https://example.com/foo/bar"); + expect(result).toEqual([]); + }); + + it("should limit resolved links per line", async () => { + // Create 15 valid paths — detector should stop at MAX_RESOLVED_LINKS (10) + const paths = Array.from({ length: 15 }, (_, i) => `/parent/cwd/f${i}`); + const detector = createDetector(paths); + const text = paths.map((_, i) => `./f${i}`).join(" "); + const result = await detector.detect(text); + expect(result.length).toBeLessThanOrEqual(10); + }); + + it("should skip links exceeding max link length", async () => { + const longPath = `/foo/${"a".repeat(1025)}`; + const detector = createDetector([longPath]); + const result = await detector.detect(longPath); + expect(result).toEqual([]); + }); + + it("should detect git diff paths", async () => { + const detector = createDetector(["/parent/cwd/foo/bar"]); + const result = await detector.detect("--- a/foo/bar"); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/parent/cwd/foo/bar"); + }); + + it("should detect git diff --git paths", async () => { + const detector = createDetector(["/parent/cwd/foo/bar"]); + const result = await detector.detect("diff --git a/foo/bar b/foo/bar"); + expect(result).toHaveLength(2); + }); + }); + + describe("Unix path formats with suffixes", () => { + for (const { link, resolved } of unixLinks) { + for (const { fmt, line, col } of suffixFormats) { + const formatted = formatLink(fmt, link, line, col); + it(`should detect "${formatted}"`, async () => { + const detector = createDetector([resolved]); + const result = await detector.detect(formatted); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]?.resolvedPath).toBe(resolved); + if (line) { + expect(result[0]?.row).toBe(Number.parseInt(line, 10)); + } + if (col) { + expect(result[0]?.col).toBe(Number.parseInt(col, 10)); + } + }); + } + } + }); + + describe("fallback matchers", () => { + it("should detect Python-style errors", async () => { + const detector = createDetector(["/path/to/file.py"]); + const result = await detector.detect( + ' File "/path/to/file.py", line 42', + ); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/path/to/file.py"); + expect(result[0]?.row).toBe(42); + }); + + it("should detect Rust-style errors", async () => { + const detector = createDetector(["/parent/cwd/src/main.rs"]); + const result = await detector.detect(" --> src/main.rs:10:5"); + expect(result).toHaveLength(1); + expect(result[0]?.row).toBe(10); + expect(result[0]?.col).toBe(5); + }); + + it("should detect C++ compile errors", async () => { + const detector = createDetector(["/path/to/file.cpp"]); + const result = await detector.detect( + "/path/to/file.cpp(339): error C2065", + ); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/path/to/file.cpp"); + expect(result[0]?.row).toBe(339); + }); + + it("should detect Node.js stack traces", async () => { + const detector = createDetector(["/path/to/file.js"]); + const result = await detector.detect( + " at Object.<anonymous> (/path/to/file.js:10:5)", + ); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/path/to/file.js"); + expect(result[0]?.row).toBe(10); + }); + + it("should not use fallback if primary detection found links", async () => { + // Primary detection should find /path/to/file.ts:10:5 + // Fallback should not run since primary succeeded + const detector = createDetector(["/path/to/file.ts"]); + const result = await detector.detect("/path/to/file.ts:10:5"); + expect(result).toHaveLength(1); + // Should have line info from primary suffix detection + expect(result[0]?.row).toBe(10); + }); + }); + + describe("trimmed candidates", () => { + it("should try trimmed path when original has trailing punctuation", async () => { + // Path followed by a bracket that gets included in the match — + // generateTrimmedCandidates strips it so the stat succeeds. + const detector = createDetector(["/foo/bar"]); + const result = await detector.detect("see /foo/bar."); + expect(result).toHaveLength(1); + expect(result[0]?.resolvedPath).toBe("/foo/bar"); + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/links/local-link-detector.ts b/apps/desktop/src/renderer/lib/terminal/links/local-link-detector.ts new file mode 100644 index 00000000000..a125f8a78c9 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/local-link-detector.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Adapted from VSCode's terminalLocalLinkDetector.ts + * https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLocalLinkDetector.ts + * + * Detects local file-path links in terminal text, validating each candidate + * path against the filesystem before returning it as a link. + *--------------------------------------------------------------------------------------------*/ + +import { + detectFallbackLinks, + detectLinks, + generateTrimmedCandidates, + getCurrentOS, + type IParsedLink, + removeLinkSuffix, +} from "@superset/shared/terminal-link-parsing"; +import type { TerminalLinkResolver } from "./link-resolver"; + +const MAX_LINE_LENGTH = 2000; +const MAX_RESOLVED_LINKS_IN_LINE = 10; +const MAX_RESOLVED_LINK_LENGTH = 1024; + +/** + * A detected and validated local file link. + */ +export interface DetectedLink { + /** The full matched text in the terminal line (including suffix). */ + text: string; + /** The start column in the line (0-based). */ + startIndex: number; + /** The end column in the line (0-based, exclusive). */ + endIndex: number; + /** The validated absolute path on disk. */ + resolvedPath: string; + /** Whether the path is a directory. */ + isDirectory: boolean; + /** Line number from the suffix, if any. */ + row: number | undefined; + /** Column number from the suffix, if any. */ + col: number | undefined; + /** End line number from the suffix, if any. */ + rowEnd: number | undefined; + /** End column number from the suffix, if any. */ + colEnd: number | undefined; + /** The original parsed link data (for debugging). */ + parsedLink?: IParsedLink; +} + +/** + * Detects local file-system links in a line of terminal text. + * + * The flow: + * 1. Parse the line with `detectLinks()` (vendored from VSCode) + * 2. For each parsed link, build candidate paths (raw, trimmed variants) + * 3. Validate each candidate via the resolver (which delegates to the host) + * 4. Only return links that point to real files/directories + * 5. If no primary links found, try fallback matchers (Python, Rust, C++, etc.) + * + * All path resolution (relative → workspace root, ~ → $HOME) happens on the + * host service, not in the renderer. + */ +export class LocalLinkDetector { + constructor(private readonly _resolver: TerminalLinkResolver) {} + + async detect(text: string): Promise<DetectedLink[]> { + if (!text || text.length > MAX_LINE_LENGTH) { + return []; + } + + const links: DetectedLink[] = []; + let resolvedCount = 0; + + const os = getCurrentOS(); + const parsedLinks = detectLinks(text, os); + + for (const parsedLink of parsedLinks) { + if (parsedLink.path.text.length > MAX_RESOLVED_LINK_LENGTH) { + continue; + } + + // Skip URLs — they're handled by the URL link provider + if (this._isUrl(parsedLink.path.text)) { + continue; + } + + // Build candidate paths to try + const candidates = this._buildCandidates(parsedLink.path.text); + + // Also generate trimmed candidates (strip trailing punctuation) + const trimmedCandidates: string[] = []; + for (const candidate of candidates) { + for (const trimmed of generateTrimmedCandidates(candidate)) { + trimmedCandidates.push(trimmed.path); + } + } + const allCandidates = [...candidates, ...trimmedCandidates]; + + const resolved = + await this._resolver.resolveMultipleCandidates(allCandidates); + + if (resolved) { + const linkStart = parsedLink.prefix?.index ?? parsedLink.path.index; + const linkEnd = parsedLink.suffix + ? parsedLink.suffix.suffix.index + + parsedLink.suffix.suffix.text.length + : parsedLink.path.index + parsedLink.path.text.length; + + links.push({ + text: text.substring(linkStart, linkEnd), + startIndex: linkStart, + endIndex: linkEnd, + resolvedPath: resolved.path, + isDirectory: resolved.isDirectory, + row: parsedLink.suffix?.row, + col: parsedLink.suffix?.col, + rowEnd: parsedLink.suffix?.rowEnd, + colEnd: parsedLink.suffix?.colEnd, + parsedLink, + }); + } + + if (++resolvedCount >= MAX_RESOLVED_LINKS_IN_LINE) { + break; + } + } + + // If no primary links found, try fallback matchers + if (links.length === 0) { + const fallbacks = detectFallbackLinks(text); + for (const fallback of fallbacks) { + if (fallback.link.length > MAX_RESOLVED_LINK_LENGTH) { + continue; + } + + const resolved = await this._resolver.resolveLink(fallback.path); + if (resolved) { + links.push({ + text: fallback.link, + startIndex: fallback.index, + endIndex: fallback.index + fallback.link.length, + resolvedPath: resolved.path, + isDirectory: resolved.isDirectory, + row: fallback.line, + col: fallback.col, + rowEnd: undefined, + colEnd: undefined, + }); + } + } + } + + // SUPERSET ADDITION (not in VSCode's shared fallback matchers): + // Last resort — treat the whole trimmed line as a path candidate. + // Safe because we validate via stat (false positives are filtered out). + // Matches VSCode's `/^ *(?<link>(?<path>.+))/` whole-line fallback in + // terminalLocalLinkDetector.ts. Kept here (not in shared fallback + // matchers) because unvalidated consumers like v1 FilePathLinkProvider + // would get false positives from URLs, version strings, etc. + // + // To disable: remove or comment out this block. The word link detector + // (WordLinkDetector) provides similar coverage for bare filenames. + if (links.length === 0 && text.trim().length <= MAX_RESOLVED_LINK_LENGTH) { + const trimmed = text.trim(); + const resolved = await this._resolver.resolveLink(trimmed); + if (resolved) { + const startIndex = text.indexOf(trimmed); + links.push({ + text: trimmed, + startIndex, + endIndex: startIndex + trimmed.length, + resolvedPath: resolved.path, + isDirectory: resolved.isDirectory, + row: undefined, + col: undefined, + rowEnd: undefined, + colEnd: undefined, + }); + } + } + + return links; + } + + private _isUrl(text: string): boolean { + return ( + text.startsWith("http://") || + text.startsWith("https://") || + text.startsWith("ftp://") + ); + } + + /** + * Build candidate paths from the raw link text. + * The raw path is sent to the host for resolution — we only strip + * the line/column suffix here. + */ + private _buildCandidates(pathText: string): string[] { + const candidates: string[] = []; + + const cleanPath = removeLinkSuffix(pathText); + if (!cleanPath) { + return candidates; + } + + candidates.push(cleanPath); + + // For relative paths with leading ../, also try without the ../ prefix + const parentPrefixMatch = cleanPath.match(/^(\.\.[/\\])+/); + if (parentPrefixMatch) { + candidates.push(cleanPath.replace(/^(\.\.[/\\])+/, "")); + } + + return candidates; + } +} diff --git a/apps/desktop/src/renderer/lib/terminal/links/word-link-detector.ts b/apps/desktop/src/renderer/lib/terminal/links/word-link-detector.ts new file mode 100644 index 00000000000..7e49bd9217e --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/links/word-link-detector.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Adapted from VSCode's terminalWordLinkDetector.ts + * https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/browser/terminalWordLinkDetector.ts + * + * Lowest-priority link detector: splits terminal text into words and + * validates each against the filesystem. Unlike VSCode (which opens a + * workspace search), we directly open the file if it exists. + *--------------------------------------------------------------------------------------------*/ + +import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm"; +import type { TerminalLinkResolver } from "./link-resolver"; + +const MAX_LINE_LENGTH = 2000; +const MAX_WORD_LINK_LENGTH = 100; + +/** + * Default word separators (matches VSCode's terminal.integrated.wordSeparators). + * Includes powerline symbols (U+E0B0 to U+E0BF). + */ +const DEFAULT_WORD_SEPARATORS = " ()[]{}',\"`─''|"; + +function buildSeparatorRegex(separators: string): RegExp { + let powerlineSymbols = ""; + for (let i = 0xe0b0; i <= 0xe0bf; i++) { + powerlineSymbols += String.fromCharCode(i); + } + const escaped = separators.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`[${escaped}${powerlineSymbols}]`, "g"); +} + +interface WordLink { + text: string; + startIndex: number; + endIndex: number; +} + +/** + * Word-based link detector. Splits terminal lines by word separators and + * validates each word against the filesystem. Only words that resolve to + * actual files become links. + * + * Registered as lowest priority — only runs if the primary file-path + * and URL detectors found nothing for the line. + */ +export class WordLinkDetector implements ILinkProvider { + private readonly _separatorRegex: RegExp; + + constructor( + private readonly _terminal: Terminal, + private readonly _resolver: TerminalLinkResolver, + private readonly _onActivate?: ( + event: MouseEvent, + resolvedPath: string, + ) => void, + private readonly _onHover?: ( + event: MouseEvent, + resolvedPath: string, + ) => void, + private readonly _onLeave?: () => void, + ) { + this._separatorRegex = buildSeparatorRegex(DEFAULT_WORD_SEPARATORS); + } + + provideLinks( + bufferLineNumber: number, + callback: (links: ILink[] | undefined) => void, + ): void { + this._provideLinks(bufferLineNumber).then( + (links) => callback(links.length > 0 ? links : undefined), + () => callback(undefined), + ); + } + + private async _provideLinks(bufferLineNumber: number): Promise<ILink[]> { + const buffer = this._terminal.buffer.active; + const line = buffer.getLine(bufferLineNumber - 1); + if (!line) return []; + + const text = line.translateToString(true); + if (!text || text.length > MAX_LINE_LENGTH) return []; + + const words = this._parseWords(text); + const links: ILink[] = []; + + for (const word of words) { + if (!word.text || word.text.length > MAX_WORD_LINK_LENGTH) continue; + + // Strip trailing colon (common in "file.txt: error") + let wordText = word.text; + if (wordText.endsWith(":")) { + wordText = wordText.slice(0, -1); + } + + // Skip words that don't look like filenames (must contain a dot) + if (!wordText.includes(".")) continue; + + // Skip URLs + if (wordText.startsWith("http://") || wordText.startsWith("https://")) + continue; + + const resolved = await this._resolver.resolveLink(wordText); + if (!resolved) continue; + + links.push({ + range: { + start: { x: word.startIndex + 1, y: bufferLineNumber }, + end: { + x: word.startIndex + wordText.length + 1, + y: bufferLineNumber, + }, + }, + text: wordText, + activate: (event: MouseEvent) => { + this._onActivate?.(event, resolved.path); + }, + hover: (event: MouseEvent) => { + this._onHover?.(event, resolved.path); + }, + leave: () => { + this._onLeave?.(); + }, + }); + } + + return links; + } + + private _parseWords(text: string): WordLink[] { + const words: WordLink[] = []; + const splitWords = text.split(this._separatorRegex); + let runningIndex = 0; + for (const word of splitWords) { + words.push({ + text: word, + startIndex: runningIndex, + endIndex: runningIndex + word.length, + }); + runningIndex += word.length + 1; + } + return words; + } +} diff --git a/apps/desktop/src/renderer/lib/terminal/session-readiness.ts b/apps/desktop/src/renderer/lib/terminal/session-readiness.ts new file mode 100644 index 00000000000..90482d3329d --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/session-readiness.ts @@ -0,0 +1,54 @@ +type SessionReadyWaiter = { + resolve: () => void; + reject: (error: Error) => void; +}; + +const readyPaneIds = new Set<string>(); +const waitersByPaneId = new Map<string, Set<SessionReadyWaiter>>(); + +function resolveWaiters(paneId: string): void { + const waiters = waitersByPaneId.get(paneId); + if (!waiters) return; + waitersByPaneId.delete(paneId); + for (const waiter of waiters) { + waiter.resolve(); + } +} + +function rejectWaiters(paneId: string, error: Error): void { + const waiters = waitersByPaneId.get(paneId); + if (!waiters) return; + waitersByPaneId.delete(paneId); + for (const waiter of waiters) { + waiter.reject(error); + } +} + +export function clearTerminalSessionReady(paneId: string): void { + readyPaneIds.delete(paneId); +} + +export function markTerminalSessionReady(paneId: string): void { + readyPaneIds.add(paneId); + resolveWaiters(paneId); +} + +export function rejectTerminalSessionReady(paneId: string, error: Error): void { + readyPaneIds.delete(paneId); + rejectWaiters(paneId, error); +} + +export function waitForTerminalSessionReady(paneId: string): Promise<void> { + if (readyPaneIds.has(paneId)) { + return Promise.resolve(); + } + + return new Promise<void>((resolve, reject) => { + let waiters = waitersByPaneId.get(paneId); + if (!waiters) { + waiters = new Set(); + waitersByPaneId.set(paneId, waiters); + } + waiters.add({ resolve, reject }); + }); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts new file mode 100644 index 00000000000..85faa897cdb --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -0,0 +1,75 @@ +import { ClipboardAddon } from "@xterm/addon-clipboard"; +import { ImageAddon } from "@xterm/addon-image"; +import { LigaturesAddon } from "@xterm/addon-ligatures"; +import { ProgressAddon } from "@xterm/addon-progress"; +import { SearchAddon } from "@xterm/addon-search"; +import { Unicode11Addon } from "@xterm/addon-unicode11"; +import { WebglAddon } from "@xterm/addon-webgl"; +import type { Terminal as XTerm } from "@xterm/xterm"; + +export interface LoadAddonsResult { + searchAddon: SearchAddon; + progressAddon: ProgressAddon; + dispose: () => void; +} + +// Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). +let suggestedRendererType: "webgl" | "dom" | undefined; + +/** + * Load optional addons onto an already-opened terminal. Returns a cleanup + * function and addon instances. WebGL is deferred to rAF to avoid + * racing with xterm's post-open viewport sync. + */ +export function loadAddons(terminal: XTerm): LoadAddonsResult { + let disposed = false; + let webglAddon: WebglAddon | null = null; + + terminal.loadAddon(new ClipboardAddon()); + + const unicode11 = new Unicode11Addon(); + terminal.loadAddon(unicode11); + terminal.unicode.activeVersion = "11"; + + terminal.loadAddon(new ImageAddon()); + + const searchAddon = new SearchAddon(); + terminal.loadAddon(searchAddon); + + const progressAddon = new ProgressAddon(); + terminal.loadAddon(progressAddon); + + try { + terminal.loadAddon(new LigaturesAddon()); + } catch {} + + const rafId = requestAnimationFrame(() => { + if (disposed || suggestedRendererType === "dom") return; + + try { + webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon?.dispose(); + webglAddon = null; + terminal.refresh(0, terminal.rows - 1); + }); + terminal.loadAddon(webglAddon); + } catch { + suggestedRendererType = "dom"; + webglAddon = null; + } + }); + + return { + searchAddon, + progressAddon, + dispose: () => { + disposed = true; + cancelAnimationFrame(rafId); + try { + webglAddon?.dispose(); + } catch {} + webglAddon = null; + }, + }; +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts new file mode 100644 index 00000000000..acc7662c1ee --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts @@ -0,0 +1,9 @@ +const backgroundTerminalIds = new Set<string>(); + +export function markTerminalForBackground(terminalId: string): void { + backgroundTerminalIds.add(terminalId); +} + +export function consumeTerminalBackgroundIntent(terminalId: string): boolean { + return backgroundTerminalIds.delete(terminalId); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.test.ts b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.test.ts new file mode 100644 index 00000000000..950f51793ea --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { ILinkProvider, Terminal as XTerm } from "@xterm/xterm"; +import { TerminalLinkManager } from "./terminal-link-manager"; + +function createMockTerminal() { + const registeredProviders: ILinkProvider[] = []; + const disposedProviders: ILinkProvider[] = []; + const terminal = { + options: { + linkHandler: null, + }, + registerLinkProvider: (provider: ILinkProvider) => { + registeredProviders.push(provider); + return { + dispose: () => { + disposedProviders.push(provider); + }, + }; + }, + buffer: { + active: { + getLine: () => null, + }, + }, + cols: 80, + } as unknown as XTerm; + + return { terminal, registeredProviders, disposedProviders }; +} + +describe("TerminalLinkManager", () => { + it("routes OSC 8 hyperlinks through the terminal URL handler", () => { + const { terminal } = createMockTerminal(); + const manager = new TerminalLinkManager(terminal); + const onUrlClick = mock(); + const onLinkHover = mock(); + const onLinkLeave = mock(); + + manager.setHandlers({ + stat: async () => null, + onUrlClick, + onLinkHover, + onLinkLeave, + }); + + const linkHandler = terminal.options.linkHandler; + expect(linkHandler).toBeTruthy(); + expect(linkHandler?.allowNonHttpProtocols).toBe(false); + + const event = {} as MouseEvent; + linkHandler?.activate(event, "https://example.com", { + start: { x: 1, y: 1 }, + end: { x: 20, y: 1 }, + }); + linkHandler?.hover?.(event, "https://example.com", { + start: { x: 1, y: 1 }, + end: { x: 20, y: 1 }, + }); + linkHandler?.leave?.(event, "https://example.com", { + start: { x: 1, y: 1 }, + end: { x: 20, y: 1 }, + }); + + expect(onUrlClick).toHaveBeenCalledWith(event, "https://example.com"); + expect(onLinkHover).toHaveBeenCalledWith(event, { kind: "url" }); + expect(onLinkLeave).toHaveBeenCalled(); + }); + + it("clears only the OSC link handler it installed", () => { + const { terminal, disposedProviders } = createMockTerminal(); + const manager = new TerminalLinkManager(terminal); + + manager.setHandlers({ + stat: async () => null, + onUrlClick: mock(), + }); + + const installedHandler = terminal.options.linkHandler; + expect(installedHandler).toBeTruthy(); + + manager.dispose(); + + expect(terminal.options.linkHandler).toBeNull(); + expect(disposedProviders.length).toBe(2); + }); + + it("does not clear a link handler installed by another owner", () => { + const { terminal } = createMockTerminal(); + const manager = new TerminalLinkManager(terminal); + + manager.setHandlers({ + stat: async () => null, + onUrlClick: mock(), + }); + + const replacementHandler = { + activate: mock(), + }; + terminal.options.linkHandler = replacementHandler; + + manager.dispose(); + + expect(terminal.options.linkHandler).toBe(replacementHandler); + }); +}); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts new file mode 100644 index 00000000000..07d265713d8 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-link-manager.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * Adapted from VSCode's terminalLinkManager.ts + * https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts + * + * Manages link provider registration for a terminal instance. + * Handles lifecycle (dispose old providers before re-registering), + * resolver caching, and priority ordering. + *--------------------------------------------------------------------------------------------*/ + +import type { ILinkHandler, Terminal as XTerm } from "@xterm/xterm"; +import { UrlLinkProvider } from "../../screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers"; +import type { DetectedLink } from "./links"; +import { + LinkDetectorAdapter, + LocalLinkDetector, + type StatCallback, + TerminalLinkResolver, + WordLinkDetector, +} from "./links"; + +export type LinkHoverInfo = + | { kind: "file"; isDirectory: boolean } + | { kind: "url" }; + +/** + * Link handler callbacks for the v2 terminal. + */ +export interface TerminalLinkHandlers { + /** Called when a file path link is activated (Cmd/Ctrl+click). */ + onFileLinkClick?: (event: MouseEvent, link: DetectedLink) => void; + /** Called when a URL link is activated. */ + onUrlClick?: (event: MouseEvent, url: string) => void; + /** Called when the mouse enters a detected link (file path or URL). */ + onLinkHover?: (event: MouseEvent, info: LinkHoverInfo) => void; + /** Called when the mouse leaves a previously hovered link. */ + onLinkLeave?: () => void; + /** + * Stat callback to validate file paths exist. Called via the host service + * which handles all path resolution (relative, tilde, etc.) server-side. + */ + stat?: StatCallback; +} + +interface LinkProviderDisposable { + dispose(): void; +} + +/** + * Manages all link providers for a single terminal instance. + * + * Providers are registered in priority order (xterm uses first match): + * 1. LocalLinkDetector (file paths with validation) + styled-text fallback + * 2. UrlLinkProvider (hard-wrapped URL detection) + * 3. WordLinkDetector (bare filenames like "AGENTS.md") + */ +export class TerminalLinkManager { + private _disposables: LinkProviderDisposable[] = []; + private _resolver: TerminalLinkResolver | null = null; + private _handlers: TerminalLinkHandlers | null = null; + private _oscLinkHandler: ILinkHandler | null = null; + + constructor(private readonly _terminal: XTerm) {} + + /** + * Set link handlers and register providers. Safe to call multiple times — + * old providers are disposed before new ones are registered. The resolver + * is reused to preserve the stat cache. + */ + setHandlers(handlers: TerminalLinkHandlers): void { + this._handlers = handlers; + this._register(); + } + + /** + * Re-register providers (e.g. after terminal is created). + * No-op if handlers haven't been set yet. + */ + ensureRegistered(): void { + if (this._handlers) { + this._register(); + } + } + + dispose(): void { + for (const d of this._disposables) d.dispose(); + this._disposables = []; + this._clearOscLinkHandler(); + this._resolver?.clearCache(); + this._resolver = null; + this._handlers = null; + } + + private _clearOscLinkHandler(): void { + if (this._terminal.options.linkHandler === this._oscLinkHandler) { + this._terminal.options.linkHandler = null; + } + this._oscLinkHandler = null; + } + + private _register(): void { + const handlers = this._handlers; + if (!handlers?.stat) return; + + // Dispose old providers to prevent duplicates + for (const d of this._disposables) d.dispose(); + this._disposables = []; + this._clearOscLinkHandler(); + + // Reuse resolver to preserve stat cache across re-registrations. + if (!this._resolver) { + this._resolver = new TerminalLinkResolver(handlers.stat); + } + + const onLinkHover = handlers.onLinkHover; + const onLinkLeave = handlers.onLinkLeave; + + // 1. File path detector (highest priority) + const detector = new LocalLinkDetector(this._resolver); + const adapter = new LinkDetectorAdapter( + this._terminal, + detector, + handlers.onFileLinkClick, + onLinkHover + ? (event, link) => + onLinkHover(event, { + kind: "file", + isDirectory: link.isDirectory, + }) + : undefined, + onLinkLeave, + ); + this._disposables.push(this._terminal.registerLinkProvider(adapter)); + + // 2. URL link provider (handles hard-wrapped URLs) + if (handlers.onUrlClick) { + const onUrlClick = handlers.onUrlClick; + const urlProvider = new UrlLinkProvider( + this._terminal, + (event, uri) => { + onUrlClick(event, uri); + }, + onLinkHover + ? (event) => onLinkHover(event, { kind: "url" }) + : undefined, + onLinkLeave, + ); + this._disposables.push(this._terminal.registerLinkProvider(urlProvider)); + + // xterm always registers its own OSC 8 hyperlink provider first. Without + // this, OSC 8 links use xterm's default confirm() + window.open() path, + // which is blocked in Electron and also bypasses our link preferences. + this._oscLinkHandler = { + allowNonHttpProtocols: false, + activate: (event, uri) => { + onUrlClick(event, uri); + }, + hover: onLinkHover + ? (event) => onLinkHover(event, { kind: "url" }) + : undefined, + leave: onLinkLeave ? () => onLinkLeave() : undefined, + }; + this._terminal.options.linkHandler = this._oscLinkHandler; + } + + // 3. SUPERSET ADDITION: Word link detector (lowest priority). + // Adapted from VSCode's TerminalWordLinkDetector. VSCode opens a + // workspace search on click; ours opens the file directly if it + // exists (validated via stat). Catches bare filenames like + // "AGENTS.md" that have no path separator or line suffix. + // To disable: remove or comment out this block. + if (handlers.onFileLinkClick) { + const onFileClick = handlers.onFileLinkClick; + const wordDetector = new WordLinkDetector( + this._terminal, + this._resolver, + (event, resolvedPath) => { + onFileClick(event, { + text: resolvedPath, + startIndex: 0, + endIndex: 0, + resolvedPath, + isDirectory: false, + row: undefined, + col: undefined, + rowEnd: undefined, + colEnd: undefined, + }); + }, + onLinkHover + ? (event) => onLinkHover(event, { kind: "file", isDirectory: false }) + : undefined, + onLinkLeave, + ); + this._disposables.push(this._terminal.registerLinkProvider(wordDetector)); + } + } +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts new file mode 100644 index 00000000000..6423555fce4 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -0,0 +1,447 @@ +import type { ProgressAddon } from "@xterm/addon-progress"; +import type { SearchAddon } from "@xterm/addon-search"; +import type { TerminalAppearance } from "./appearance"; +import { + type LinkHoverInfo, + type TerminalLinkHandlers, + TerminalLinkManager, +} from "./terminal-link-manager"; +import { + attachToContainer, + createRuntime, + detachFromContainer, + disposeRuntime, + type TerminalRuntime, + updateRuntimeAppearance, +} from "./terminal-runtime"; +import { + type ConnectionState, + clearLogs, + connect, + createTransport, + disposeTransport, + sendDispose, + sendInput, + sendResize, + type TerminalLogEntry, + type TerminalTransport, +} from "./terminal-ws-transport"; + +interface RegistryEntry { + terminalId: string; + instanceId: string; + runtime: TerminalRuntime | null; + transport: TerminalTransport; + linkManager: TerminalLinkManager | null; + /** Stored until linkManager is created (mount called after setLinkHandlers). */ + pendingLinkHandlers: TerminalLinkHandlers | null; +} + +class TerminalRuntimeRegistryImpl { + private entries = new Map<string, RegistryEntry>(); + private entryKeysByTerminalId = new Map<string, Set<string>>(); + + private getEntryKey(terminalId: string, instanceId = terminalId): string { + return `${terminalId}\u0000${instanceId}`; + } + + private getOrCreateEntry( + terminalId: string, + instanceId = terminalId, + ): RegistryEntry { + const key = this.getEntryKey(terminalId, instanceId); + let entry = this.entries.get(key); + if (entry) return entry; + + entry = { + terminalId, + instanceId, + runtime: null, + transport: createTransport(), + linkManager: null, + pendingLinkHandlers: null, + }; + + this.entries.set(key, entry); + let keys = this.entryKeysByTerminalId.get(terminalId); + if (!keys) { + keys = new Set(); + this.entryKeysByTerminalId.set(terminalId, keys); + } + keys.add(key); + return entry; + } + + private getEntry( + terminalId: string, + instanceId?: string, + ): RegistryEntry | null { + if (instanceId) { + return this.entries.get(this.getEntryKey(terminalId, instanceId)) ?? null; + } + return this.getPrimaryEntry(terminalId); + } + + private getPrimaryEntry(terminalId: string): RegistryEntry | null { + const defaultEntry = this.entries.get(this.getEntryKey(terminalId)); + if (defaultEntry) return defaultEntry; + + const keys = this.entryKeysByTerminalId.get(terminalId); + const firstKey = keys?.values().next().value; + return firstKey ? (this.entries.get(firstKey) ?? null) : null; + } + + private getEntries(terminalId: string): RegistryEntry[] { + const keys = this.entryKeysByTerminalId.get(terminalId); + if (!keys) return []; + return Array.from(keys) + .map((key) => this.entries.get(key)) + .filter((entry): entry is RegistryEntry => Boolean(entry)); + } + + private deleteEntry(entry: RegistryEntry) { + const key = this.getEntryKey(entry.terminalId, entry.instanceId); + this.entries.delete(key); + const keys = this.entryKeysByTerminalId.get(entry.terminalId); + if (!keys) return; + keys.delete(key); + if (keys.size === 0) { + this.entryKeysByTerminalId.delete(entry.terminalId); + } + } + + private serializeExistingRuntime( + terminalId: string, + excludedInstanceId: string, + ): string | undefined { + for (const entry of this.getEntries(terminalId)) { + if (entry.instanceId === excludedInstanceId || !entry.runtime) continue; + try { + return entry.runtime.serializeAddon.serialize({ scrollback: 1000 }); + } catch { + return undefined; + } + } + return undefined; + } + + /** + * Ensure the xterm runtime exists and attach it to `container`. + * Synchronous. DOM-only — the WebSocket transport is untouched. + * + * Matches VSCode's pattern (`TerminalInstance.attachToElement`) and + * Tabby's (`XTermFrontend.attach`): the terminal renders immediately + * with a blank cursor, the backend pipe catches up via `connect()` once + * the caller has confirmed the server session exists. Decoupling the + * DOM from the transport is what lets a terminal survive workspace + * switches without an in-flight WebSocket being opened against a + * nonexistent session. + */ + mount( + terminalId: string, + container: HTMLDivElement, + appearance: TerminalAppearance, + instanceId = terminalId, + ) { + const entry = this.getOrCreateEntry(terminalId, instanceId); + + if (!entry.runtime) { + entry.runtime = createRuntime(terminalId, appearance, { + initialBuffer: this.serializeExistingRuntime(terminalId, instanceId), + }); + entry.linkManager = new TerminalLinkManager(entry.runtime.terminal); + if (entry.pendingLinkHandlers) { + entry.linkManager.setHandlers(entry.pendingLinkHandlers); + entry.pendingLinkHandlers = null; + } + } else { + updateRuntimeAppearance(entry.runtime, appearance); + } + + const { runtime, transport } = entry; + attachToContainer(runtime, container, () => { + sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); + }); + } + + /** + * Open (or re-use) the WebSocket transport for this terminal. + * The WebSocket route can create the server session when the URL includes + * workspaceId; initialCommand is sent as the first frame after open. + * + * Idempotent: no-op if already connected/connecting to the same URL. + */ + connect( + terminalId: string, + wsUrl: string, + instanceId = terminalId, + options: { initialCommand?: string } = {}, + ) { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return; + connect(entry.transport, entry.runtime.terminal, wsUrl, options); + } + + /** + * Swap the transport onto a new URL when it's already been brought up + * once. Used by effects watching `websocketUrl` — they fire on initial + * mount when the transport is still `"disconnected"` and the mount effect + * owns the initial connect. + * + * Skipped states: `"disconnected"` (never opened; caller should use + * `connect()` from the mount path). Allowed states: `"connecting"` (connect() + * cleanly aborts the in-flight socket), `"open"` (standard swap), and + * `"closed"` (previously live and mid-auto-reconnect — swap the URL so the + * reconnect targets the new endpoint). + */ + reconnect(terminalId: string, wsUrl: string, instanceId = terminalId) { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return; + if (entry.transport.connectionState === "disconnected") return; + if (entry.transport.currentUrl === wsUrl) return; + connect(entry.transport, entry.runtime.terminal, wsUrl); + } + + /** + * Set link handler callbacks for a terminal. Safe to call before or after + * mount(). If the runtime already exists, link providers are re-registered. + */ + setLinkHandlers( + terminalId: string, + handlers: TerminalLinkHandlers, + instanceId = terminalId, + ) { + const entry = this.getOrCreateEntry(terminalId, instanceId); + if (entry.linkManager) { + entry.linkManager.setHandlers(handlers); + } else { + entry.pendingLinkHandlers = handlers; + } + } + + /** + * Park the wrapper in the hidden body-level container. Runtime and + * transport stay alive; DOM is moved off the React-controlled tree so + * it survives the parent unmount without re-entering xterm.open(). + */ + detach(terminalId: string, instanceId = terminalId) { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return; + + detachFromContainer(entry.runtime); + } + + updateAppearance( + terminalId: string, + appearance: TerminalAppearance, + instanceId = terminalId, + ) { + const entry = this.getEntry(terminalId, instanceId); + if (!entry?.runtime) return; + + const prevCols = entry.runtime.terminal.cols; + const prevRows = entry.runtime.terminal.rows; + + updateRuntimeAppearance(entry.runtime, appearance); + + const { cols, rows } = entry.runtime.terminal; + if (cols !== prevCols || rows !== prevRows) { + sendResize(entry.transport, cols, rows); + } + } + + private disposeEntry( + entry: RegistryEntry, + options: { clearPersistedState?: boolean } = {}, + ) { + entry.linkManager?.dispose(); + disposeTransport(entry.transport); + if (entry.runtime) { + disposeRuntime(entry.runtime, options); + } + this.deleteEntry(entry); + } + + /** + * Release the renderer-side terminal runtime only. This detaches the xterm + * view and closes the WebSocket, but it does not tell host-service to kill + * the underlying PTY. Use this for pane/sidebar lifecycle cleanup. + */ + release(terminalId: string, instanceId?: string) { + const entries = instanceId + ? [this.getEntry(terminalId, instanceId)].filter( + (entry): entry is RegistryEntry => Boolean(entry), + ) + : this.getEntries(terminalId); + for (const entry of entries) { + this.disposeEntry(entry, { clearPersistedState: false }); + } + } + + /** + * Kill the host-service terminal session and remove all renderer-side state. + * This is destructive and should only be used from explicit kill actions. + */ + dispose(terminalId: string) { + for (const entry of this.getEntries(terminalId)) { + sendDispose(entry.transport); + this.disposeEntry(entry); + } + } + + getSelection(terminalId: string, instanceId?: string): string { + const entry = this.getEntry(terminalId, instanceId); + return entry?.runtime?.terminal.getSelection() ?? ""; + } + + clear(terminalId: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); + entry?.runtime?.terminal.clear(); + } + + scrollToBottom(terminalId: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); + entry?.runtime?.terminal.scrollToBottom(); + } + + paste(terminalId: string, text: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); + entry?.runtime?.terminal.paste(text); + } + + /** Send raw input to the terminal via the WebSocket transport (bypasses xterm). */ + writeInput(terminalId: string, data: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); + if (!entry) return; + sendInput(entry.transport, data); + } + + findNext(terminalId: string, query: string, instanceId?: string): boolean { + const entry = this.getEntry(terminalId, instanceId); + return entry?.runtime?.searchAddon?.findNext(query) ?? false; + } + + findPrevious( + terminalId: string, + query: string, + instanceId?: string, + ): boolean { + const entry = this.getEntry(terminalId, instanceId); + return entry?.runtime?.searchAddon?.findPrevious(query) ?? false; + } + + clearSearch(terminalId: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); + entry?.runtime?.searchAddon?.clearDecorations(); + } + + getTerminal(terminalId: string, instanceId?: string) { + return this.getEntry(terminalId, instanceId)?.runtime?.terminal ?? null; + } + + getSearchAddon(terminalId: string, instanceId?: string): SearchAddon | null { + return this.getEntry(terminalId, instanceId)?.runtime?.searchAddon ?? null; + } + + getProgressAddon( + terminalId: string, + instanceId?: string, + ): ProgressAddon | null { + return ( + this.getEntry(terminalId, instanceId)?.runtime?.progressAddon ?? null + ); + } + + getAllTerminalIds(): Set<string> { + return new Set(this.entryKeysByTerminalId.keys()); + } + + has(terminalId: string): boolean { + return this.entryKeysByTerminalId.has(terminalId); + } + + getConnectionState(terminalId: string, instanceId?: string): ConnectionState { + return ( + this.getEntry(terminalId, instanceId)?.transport.connectionState ?? + "disconnected" + ); + } + + getTitle(terminalId: string, instanceId?: string): string | null | undefined { + return this.getEntry(terminalId, instanceId)?.transport.title; + } + + getLogs( + terminalId: string, + instanceId?: string, + ): readonly TerminalLogEntry[] { + return this.getEntry(terminalId, instanceId)?.transport.logs ?? EMPTY_LOGS; + } + + clearLogs(terminalId: string, instanceId?: string): void { + const entry = this.getEntry(terminalId, instanceId); + if (!entry) return; + clearLogs(entry.transport); + } + + onStateChange( + terminalId: string, + listener: () => void, + instanceId = terminalId, + ): () => void { + const entry = this.getOrCreateEntry(terminalId, instanceId); + entry.transport.stateListeners.add(listener); + return () => { + entry.transport.stateListeners.delete(listener); + }; + } + + onTitleChange( + terminalId: string, + listener: () => void, + instanceId = terminalId, + ): () => void { + const entry = this.getOrCreateEntry(terminalId, instanceId); + entry.transport.titleListeners.add(listener); + return () => { + entry.transport.titleListeners.delete(listener); + }; + } + + onLogsChange( + terminalId: string, + listener: () => void, + instanceId = terminalId, + ): () => void { + const entry = this.getOrCreateEntry(terminalId, instanceId); + entry.transport.logListeners.add(listener); + return () => { + entry.transport.logListeners.delete(listener); + }; + } +} + +// Stable empty reference so useSyncExternalStore on a missing entry doesn't +// thrash from getSnapshot returning a fresh array each call. +const EMPTY_LOGS: readonly TerminalLogEntry[] = Object.freeze( + [], +) as readonly []; + +// In dev, preserve the singleton across Vite HMR so active WebSocket +// connections and xterm instances aren't orphaned on module re-evaluation. +// import.meta.hot is undefined in production so this is a plain `new` call. +export const terminalRuntimeRegistry: TerminalRuntimeRegistryImpl = + (import.meta.hot?.data?.registry as + | TerminalRuntimeRegistryImpl + | undefined) ?? new TerminalRuntimeRegistryImpl(); + +if (import.meta.hot) { + import.meta.hot.data.registry = terminalRuntimeRegistry; +} + +export type { + ConnectionState, + LinkHoverInfo, + TerminalLinkHandlers, + TerminalLogEntry, +}; diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts new file mode 100644 index 00000000000..d4b9a8c149f --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -0,0 +1,408 @@ +import { FitAddon } from "@xterm/addon-fit"; +import type { ProgressAddon } from "@xterm/addon-progress"; +import type { SearchAddon } from "@xterm/addon-search"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal as XTerm } from "@xterm/xterm"; +import { resolveHotkeyFromEvent } from "renderer/hotkeys"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts"; +import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; +import type { TerminalAppearance } from "./appearance"; +import { translateLineEditChord } from "./line-edit-translations"; +import { loadAddons } from "./terminal-addons"; + +const SERIALIZE_SCROLLBACK = 1000; +const STORAGE_KEY_PREFIX = "terminal-buffer:"; +const DIMS_KEY_PREFIX = "terminal-dims:"; +const DEFAULT_COLS = 120; +const DEFAULT_ROWS = 32; +const RESIZE_DEBOUNCE_MS = 75; + +// xterm's _keyDown calls stopPropagation after processing, so any chord we +// want the host (react-hotkeys-hook, Electron menu accelerators) or the shell +// (Ctrl+A/E/U escape sequences for line edit) to see must short-circuit xterm +// before it runs. (VSCode pattern: terminalInstance.ts:1116-1175.) +// +// Kitty keyboard protocol is enabled, which means every Mac Cmd chord xterm +// sees gets CSI-u encoded and leaks into TUIs as a literal char. Ghostty +// sidesteps this by suppressing all super/Cmd chords on macOS before the +// encoder runs (ghostty/src/input/key_encode.zig:534-545). We do the same via +// shouldBubbleClipboardShortcut's Mac branch. +function createKeyEventHandler(terminal: XTerm) { + const platform = + typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; + const isMac = platform.includes("mac"); + const isWindows = platform.includes("win"); + + return (event: KeyboardEvent): boolean => { + if (resolveHotkeyFromEvent(event) !== null) return false; + + const translation = translateLineEditChord(event, { isMac, isWindows }); + if (translation !== null) { + if (event.type === "keydown") { + event.preventDefault(); + terminal.input(translation, true); + } + return false; + } + + if (shouldSelectAllShortcut(event, isMac)) { + if (event.type === "keydown") { + event.preventDefault(); + terminal.selectAll(); + } + return false; + } + + if ( + shouldBubbleClipboardShortcut(event, { + isMac, + isWindows, + hasSelection: terminal.hasSelection(), + }) + ) { + // Do NOT preventDefault — the browser's keydown → paste-command pipeline + // is what fires the `paste` event on xterm's textarea. VS Code and Tabby + // preventDefault here only because they implement paste themselves via + // the command system / ClipboardAddon; we rely on xterm's built-in paste + // listener, so the default must run. + return false; + } + + return true; + }; +} + +export interface TerminalRuntime { + terminalId: string; + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; + searchAddon: SearchAddon | null; + progressAddon: ProgressAddon | null; + wrapper: HTMLDivElement; + container: HTMLDivElement | null; + resizeObserver: ResizeObserver | null; + _disposeResizeObserver: (() => void) | null; + lastCols: number; + lastRows: number; + _disposeAddons: (() => void) | null; +} + +function createTerminal( + cols: number, + rows: number, + appearance: TerminalAppearance, +): { + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; +} { + const fitAddon = new FitAddon(); + const serializeAddon = new SerializeAddon(); + const terminal = new XTerm({ + cols, + rows, + cursorBlink: true, + fontFamily: appearance.fontFamily, + fontSize: appearance.fontSize, + theme: appearance.theme, + allowProposedApi: true, + scrollback: DEFAULT_TERMINAL_SCROLLBACK, + macOptionIsMeta: false, + cursorStyle: "block", + cursorInactiveStyle: "outline", + vtExtensions: { kittyKeyboard: true }, + scrollbar: { showScrollbar: false }, + }); + terminal.loadAddon(fitAddon); + terminal.loadAddon(serializeAddon); + return { terminal, fitAddon, serializeAddon }; +} + +function persistBuffer(terminalId: string, serializeAddon: SerializeAddon) { + try { + const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); + localStorage.setItem(`${STORAGE_KEY_PREFIX}${terminalId}`, data); + } catch {} +} + +function restoreBuffer(terminalId: string, terminal: XTerm) { + try { + const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${terminalId}`); + if (data) terminal.write(data); + } catch {} +} + +function clearPersistedBuffer(terminalId: string) { + try { + localStorage.removeItem(`${STORAGE_KEY_PREFIX}${terminalId}`); + } catch {} +} + +function persistDimensions(terminalId: string, cols: number, rows: number) { + try { + localStorage.setItem( + `${DIMS_KEY_PREFIX}${terminalId}`, + JSON.stringify({ cols, rows }), + ); + } catch {} +} + +function loadSavedDimensions( + terminalId: string, +): { cols: number; rows: number } | null { + try { + const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${terminalId}`); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clearPersistedDimensions(terminalId: string) { + try { + localStorage.removeItem(`${DIMS_KEY_PREFIX}${terminalId}`); + } catch {} +} + +function hostIsVisible(container: HTMLDivElement | null): boolean { + if (!container) return false; + return container.clientWidth > 0 && container.clientHeight > 0; +} + +// Body-level hidden container that owns wrapper divs of terminals whose +// React component is currently unmounted (e.g. workspace switch). Keeps +// xterm attached to the document so it survives provider remounts without +// a detach/reattach flash — VSCode's setVisible(false) model. Looked up +// by DOM id so it's HMR-safe (module-level `let` would leak on re-eval). +// `inert` removes the whole subtree from the tab order and the accessibility +// tree, and also moves focus out of it — so a parked terminal's internal +// <textarea> can't receive keystrokes meant for the active pane. +const PARKING_CONTAINER_ID = "v2-terminal-parking"; +function getParkingContainer(): HTMLDivElement { + const existing = document.getElementById(PARKING_CONTAINER_ID); + if (existing) return existing as HTMLDivElement; + + const el = document.createElement("div"); + el.id = PARKING_CONTAINER_ID; + el.setAttribute("inert", ""); + el.setAttribute("aria-hidden", "true"); + el.style.position = "fixed"; + el.style.left = "-9999px"; + el.style.top = "-9999px"; + el.style.width = "100vw"; + el.style.height = "100vh"; + el.style.overflow = "hidden"; + el.style.pointerEvents = "none"; + document.body.appendChild(el); + return el; +} + +function measureAndResize(runtime: TerminalRuntime): boolean { + if (!hostIsVisible(runtime.container)) return false; + const { terminal } = runtime; + const buffer = terminal.buffer.active; + const wasPinnedToBottom = buffer.viewportY >= buffer.baseY; + const savedViewportY = buffer.viewportY; + const prevCols = terminal.cols; + const prevRows = terminal.rows; + + runtime.fitAddon.fit(); + runtime.lastCols = terminal.cols; + runtime.lastRows = terminal.rows; + + if (wasPinnedToBottom) { + terminal.scrollToBottom(); + } else { + const targetY = Math.min(savedViewportY, terminal.buffer.active.baseY); + if (terminal.buffer.active.viewportY !== targetY) { + terminal.scrollToLine(targetY); + } + } + + terminal.refresh(0, Math.max(0, terminal.rows - 1)); + + return terminal.cols !== prevCols || terminal.rows !== prevRows; +} + +function createResizeScheduler( + runtime: TerminalRuntime, + onResize?: () => void, +): { + observe: ResizeObserverCallback; + dispose: () => void; +} { + let timeoutId: ReturnType<typeof setTimeout> | null = null; + + const dispose = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const run = () => { + timeoutId = null; + const changed = measureAndResize(runtime); + if (changed) onResize?.(); + }; + + const observe: ResizeObserverCallback = (entries) => { + if ( + entries.some( + (entry) => + entry.contentRect.width <= 0 || entry.contentRect.height <= 0, + ) + ) { + dispose(); + return; + } + dispose(); + timeoutId = setTimeout(run, RESIZE_DEBOUNCE_MS); + }; + + return { observe, dispose }; +} + +export function createRuntime( + terminalId: string, + appearance: TerminalAppearance, + options: { initialBuffer?: string } = {}, +): TerminalRuntime { + const savedDims = loadSavedDimensions(terminalId); + const cols = savedDims?.cols ?? DEFAULT_COLS; + const rows = savedDims?.rows ?? DEFAULT_ROWS; + + const { terminal, fitAddon, serializeAddon } = createTerminal( + cols, + rows, + appearance, + ); + + const wrapper = document.createElement("div"); + wrapper.style.width = "100%"; + wrapper.style.height = "100%"; + terminal.open(wrapper); + + terminal.attachCustomKeyEventHandler(createKeyEventHandler(terminal)); + + // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, + // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) + const addonsResult = loadAddons(terminal); + if (options.initialBuffer !== undefined) { + terminal.write(options.initialBuffer); + } else { + restoreBuffer(terminalId, terminal); + } + + return { + terminalId, + terminal, + fitAddon, + serializeAddon, + searchAddon: addonsResult.searchAddon, + progressAddon: addonsResult.progressAddon, + wrapper, + container: null, + resizeObserver: null, + _disposeResizeObserver: null, + lastCols: cols, + lastRows: rows, + _disposeAddons: addonsResult.dispose, + }; +} + +export function attachToContainer( + runtime: TerminalRuntime, + container: HTMLDivElement, + onResize?: () => void, +) { + // If we're already attached to this exact container, do nothing. Prevents + // redundant refresh/focus/fit from transient remounts during provider key + // churn — VSCode setVisible() is idempotent for the same host element. + const sameContainer = + runtime.container === container && + runtime.wrapper.parentElement === container; + if (sameContainer && runtime.resizeObserver) { + return; + } + + runtime.container = container; + container.appendChild(runtime.wrapper); + if (measureAndResize(runtime)) onResize?.(); + + runtime._disposeResizeObserver?.(); + runtime._disposeResizeObserver = null; + runtime.resizeObserver?.disconnect(); + const scheduler = createResizeScheduler(runtime, onResize); + const observer = new ResizeObserver(scheduler.observe); + observer.observe(container); + runtime.resizeObserver = observer; + runtime._disposeResizeObserver = scheduler.dispose; + + runtime.terminal.focus(); +} + +export function detachFromContainer(runtime: TerminalRuntime) { + persistBuffer(runtime.terminalId, runtime.serializeAddon); + persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); + runtime._disposeResizeObserver?.(); + runtime._disposeResizeObserver = null; + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + // Park instead of .remove() so xterm survives the React unmount — + // see getParkingContainer. + getParkingContainer().appendChild(runtime.wrapper); + runtime.container = null; +} + +export function updateRuntimeAppearance( + runtime: TerminalRuntime, + appearance: TerminalAppearance, +) { + const { terminal } = runtime; + terminal.options.theme = appearance.theme; + + const fontChanged = + terminal.options.fontFamily !== appearance.fontFamily || + terminal.options.fontSize !== appearance.fontSize; + + if (fontChanged) { + terminal.options.fontFamily = appearance.fontFamily; + terminal.options.fontSize = appearance.fontSize; + if (hostIsVisible(runtime.container)) { + measureAndResize(runtime); + } + } +} + +export function disposeRuntime( + runtime: TerminalRuntime, + options: { clearPersistedState?: boolean } = {}, +) { + const clearPersistedState = options.clearPersistedState ?? true; + if (!clearPersistedState) { + persistBuffer(runtime.terminalId, runtime.serializeAddon); + persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); + } + runtime._disposeAddons?.(); + runtime._disposeAddons = null; + runtime._disposeResizeObserver?.(); + runtime._disposeResizeObserver = null; + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + runtime.wrapper.remove(); + runtime.terminal.dispose(); + if (clearPersistedState) { + clearPersistedBuffer(runtime.terminalId); + clearPersistedDimensions(runtime.terminalId); + } +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts new file mode 100644 index 00000000000..4a9e34fc5c0 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -0,0 +1,335 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; + +export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; + +export type TerminalLogLevel = "info" | "warn" | "error"; + +export interface TerminalLogEntry { + id: number; + timestamp: number; + level: TerminalLogLevel; + message: string; +} + +type TerminalServerMessage = + | { type: "data"; data: string } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number } + | { type: "replay"; data: string } + | { type: "title"; title: string | null }; + +export interface TerminalTransport { + socket: WebSocket | null; + connectionState: ConnectionState; + /** The URL the socket is currently connected (or connecting) to. */ + currentUrl: string | null; + title: string | null | undefined; + onDataDisposable: { dispose(): void } | null; + stateListeners: Set<() => void>; + titleListeners: Set<() => void>; + /** + * Transport-level status log (WebSocket close/error/reconnect notices). + * Surfaced to the pane UI instead of being written into the xterm buffer, + * so terminal scrollback stays clean. + */ + logs: TerminalLogEntry[]; + logListeners: Set<() => void>; + /** Internal: auto-reconnect timer. */ + _reconnectTimer: ReturnType<typeof setTimeout> | null; + /** Internal: reconnect attempt count for backoff. */ + _reconnectAttempt: number; + /** The xterm instance used for reconnection. */ + _terminal: XTerm | null; + /** Set when the server sends an exit message — no reconnect after this. */ + _exited: boolean; +} + +const MAX_LOG_ENTRIES = 200; +let logIdCounter = 0; + +function setConnectionState( + transport: TerminalTransport, + state: ConnectionState, +) { + transport.connectionState = state; + for (const listener of transport.stateListeners) { + listener(); + } +} + +function setTerminalTitle( + transport: TerminalTransport, + title: string | null | undefined, +) { + if (transport.title === title) return; + transport.title = title; + for (const listener of transport.titleListeners) { + listener(); + } +} + +function pushLog( + transport: TerminalTransport, + level: TerminalLogLevel, + message: string, +) { + logIdCounter += 1; + const entry: TerminalLogEntry = { + id: logIdCounter, + timestamp: Date.now(), + level, + message, + }; + const next = + transport.logs.length >= MAX_LOG_ENTRIES + ? [ + ...transport.logs.slice(transport.logs.length - MAX_LOG_ENTRIES + 1), + entry, + ] + : [...transport.logs, entry]; + transport.logs = next; + for (const listener of transport.logListeners) { + listener(); + } +} + +export function clearLogs(transport: TerminalTransport) { + if (transport.logs.length === 0) return; + transport.logs = []; + for (const listener of transport.logListeners) { + listener(); + } +} + +const MAX_RECONNECT_DELAY = 10_000; +const BASE_RECONNECT_DELAY = 500; +const MAX_RECONNECT_ATTEMPTS = 10; + +export function createTransport(): TerminalTransport { + return { + socket: null, + connectionState: "disconnected", + currentUrl: null, + title: undefined, + onDataDisposable: null, + stateListeners: new Set(), + titleListeners: new Set(), + logs: [], + logListeners: new Set(), + _reconnectTimer: null, + _reconnectAttempt: 0, + _terminal: null, + _exited: false, + }; +} + +function scheduleReconnect(transport: TerminalTransport) { + if (transport._reconnectTimer) return; + if (transport._exited) return; + if (!transport.currentUrl || !transport._terminal) return; + if (transport._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) return; + + const delay = Math.min( + BASE_RECONNECT_DELAY * 2 ** transport._reconnectAttempt, + MAX_RECONNECT_DELAY, + ); + transport._reconnectAttempt++; + + transport._reconnectTimer = setTimeout(() => { + transport._reconnectTimer = null; + if ( + transport.connectionState === "closed" && + transport.currentUrl && + transport._terminal + ) { + connect(transport, transport._terminal, transport.currentUrl); + } + }, delay); +} + +function cancelReconnect(transport: TerminalTransport) { + if (transport._reconnectTimer) { + clearTimeout(transport._reconnectTimer); + transport._reconnectTimer = null; + } +} + +function formatWsEndpoint(wsUrl: string | null): string { + if (!wsUrl) return "unknown endpoint"; + try { + const url = new URL(wsUrl); + return `${url.protocol}//${url.host}${url.pathname}`; + } catch { + return "invalid terminal WebSocket URL"; + } +} + +function formatCloseDetails(event: CloseEvent): string { + const code = event.code || "unknown"; + const reason = event.reason ? `, reason: ${event.reason}` : ""; + return `code: ${code}${reason}`; +} + +export function connect( + transport: TerminalTransport, + terminal: XTerm, + wsUrl: string, + options: { initialCommand?: string } = {}, +) { + // Idempotent: skip if already connected/connecting to the same endpoint. + const isActive = + transport.connectionState === "open" || + transport.connectionState === "connecting"; + if (isActive && transport.currentUrl === wsUrl) return; + + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + + cancelReconnect(transport); + transport.currentUrl = wsUrl; + transport._terminal = terminal; + transport._exited = false; + setConnectionState(transport, "connecting"); + const socket = new WebSocket(wsUrl); + transport.socket = socket; + + socket.addEventListener("open", () => { + if (transport.socket !== socket) return; + transport._reconnectAttempt = 0; + setConnectionState(transport, "open"); + sendResize(transport, terminal.cols, terminal.rows); + if (options.initialCommand) { + socket.send( + JSON.stringify({ + type: "initialCommand", + data: options.initialCommand, + }), + ); + } + }); + + socket.addEventListener("message", (event) => { + if (transport.socket !== socket) return; + let message: TerminalServerMessage; + try { + message = JSON.parse(String(event.data)) as TerminalServerMessage; + } catch { + terminal.writeln("\r\n[terminal] invalid server payload"); + return; + } + + if (message.type === "data" || message.type === "replay") { + terminal.write(message.data); + return; + } + + if (message.type === "title") { + setTerminalTitle(transport, message.title); + return; + } + + if (message.type === "error") { + terminal.writeln(`\r\n[terminal] ${message.message}`); + return; + } + + if (message.type === "exit") { + transport._exited = true; + cancelReconnect(transport); + terminal.writeln( + `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, + ); + } + }); + + socket.addEventListener("close", (event) => { + if (transport.socket !== socket) return; + setConnectionState(transport, "closed"); + transport.socket = null; + if (!transport._exited && event.code !== 1000) { + const willReconnect = + !transport._reconnectTimer && + Boolean(transport.currentUrl && transport._terminal) && + transport._reconnectAttempt < MAX_RECONNECT_ATTEMPTS; + pushLog( + transport, + willReconnect ? "warn" : "error", + `WebSocket closed while connected to ${formatWsEndpoint(transport.currentUrl)} (${formatCloseDetails(event)}). ${willReconnect ? "Reconnecting..." : "Max reconnect attempts reached."}`, + ); + } + // Auto-reconnect on unexpected close (host-service restart, network blip) + scheduleReconnect(transport); + }); + + socket.addEventListener("error", () => { + if (transport.socket !== socket) return; + pushLog( + transport, + "error", + `WebSocket error while connecting to ${formatWsEndpoint(transport.currentUrl)}. Check host-service or relay connectivity.`, + ); + }); + + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = terminal.onData((data) => { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "input", data })); + }); +} + +export function disconnect(transport: TerminalTransport) { + cancelReconnect(transport); + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + transport.currentUrl = null; + transport._terminal = null; + transport._reconnectAttempt = 0; + setTerminalTitle(transport, undefined); + setConnectionState(transport, "disconnected"); + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = null; +} + +export function sendResize( + transport: TerminalTransport, + cols: number, + rows: number, +) { + if (!transport.socket || transport.socket.readyState !== WebSocket.OPEN) + return; + transport.socket.send(JSON.stringify({ type: "resize", cols, rows })); +} + +export function sendInput(transport: TerminalTransport, data: string) { + if (!transport.socket || transport.socket.readyState !== WebSocket.OPEN) + return; + transport.socket.send(JSON.stringify({ type: "input", data })); +} + +export function sendDispose(transport: TerminalTransport) { + if (transport.socket?.readyState === WebSocket.OPEN) { + transport.socket.send(JSON.stringify({ type: "dispose" })); + } +} + +export function disposeTransport(transport: TerminalTransport) { + cancelReconnect(transport); + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + transport.currentUrl = null; + transport._terminal = null; + transport._reconnectAttempt = 0; + setTerminalTitle(transport, undefined); + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = null; + transport.stateListeners.clear(); + transport.titleListeners.clear(); + transport.logs = []; + transport.logListeners.clear(); +} diff --git a/apps/desktop/src/renderer/lib/trpc-storage.ts b/apps/desktop/src/renderer/lib/trpc-storage.ts index 833eb84c76d..5f79e53c78f 100644 --- a/apps/desktop/src/renderer/lib/trpc-storage.ts +++ b/apps/desktop/src/renderer/lib/trpc-storage.ts @@ -1,17 +1,6 @@ -import type { HotkeysState } from "shared/hotkeys"; import { createJSONStorage, type StateStorage } from "zustand/middleware"; import { electronTrpcClient } from "./trpc-client"; -/** - * Flag to skip the next hotkeys persist operation. - * Used when syncing from remote to avoid echo writes. - */ -let skipNextHotkeysPersist = false; - -export function setSkipNextHotkeysPersist(skip: boolean): void { - skipNextHotkeysPersist = skip; -} - /** * Creates a Zustand storage adapter that uses tRPC for persistence. * This ensures all state is persisted through the centralized appState lowdb instance. @@ -256,27 +245,6 @@ export const trpcThemeStorage = createJSONStorage(() => }), ); -/** - * Zustand storage adapter for hotkeys state using tRPC - */ -export const trpcHotkeysStorage = createJSONStorage(() => - createTrpcStorageAdapter({ - get: async () => { - const hotkeysState = await electronTrpcClient.uiState.hotkeys.get.query(); - return { hotkeysState }; - }, - set: (input) => { - // Skip persistence when syncing from remote to avoid echo writes - if (skipNextHotkeysPersist) { - skipNextHotkeysPersist = false; - return Promise.resolve(); - } - const state = input as { hotkeysState: HotkeysState }; - return electronTrpcClient.uiState.hotkeys.set.mutate(state.hotkeysState); - }, - }), -); - /** * Zustand storage adapter for ringtone state using tRPC. * Only the selectedRingtoneId is persisted. diff --git a/apps/desktop/src/renderer/lib/v2-workspace-host.ts b/apps/desktop/src/renderer/lib/v2-workspace-host.ts deleted file mode 100644 index 044e70a291f..00000000000 --- a/apps/desktop/src/renderer/lib/v2-workspace-host.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { env } from "renderer/env.renderer"; - -export type WorkspaceHostTarget = - | { kind: "local" } - | { kind: "cloud" } - | { kind: "device"; deviceId: string }; - -export function getCloudWorkspaceHostUrl(): string { - return `${env.NEXT_PUBLIC_API_URL}/api/v2-workspaces/cloud/host`; -} - -export function getWorkspaceHostUrlForDevice(deviceId: string): string { - return `${env.NEXT_PUBLIC_API_URL}/api/v2-devices/${deviceId}/host`; -} - -export function getWorkspaceHostUrlForWorkspace(workspaceId: string): string { - return `${env.NEXT_PUBLIC_API_URL}/api/v2-workspaces/${workspaceId}/host`; -} - -export function resolveCreateWorkspaceHostUrl( - target: WorkspaceHostTarget, - localHostUrl: string | null, -): string | null { - switch (target.kind) { - case "local": - return localHostUrl; - case "cloud": - return getCloudWorkspaceHostUrl(); - case "device": - return getWorkspaceHostUrlForDevice(target.deviceId); - } -} diff --git a/apps/desktop/src/renderer/react-query/projects/index.ts b/apps/desktop/src/renderer/react-query/projects/index.ts index 432360696d0..f3588f68e89 100644 --- a/apps/desktop/src/renderer/react-query/projects/index.ts +++ b/apps/desktop/src/renderer/react-query/projects/index.ts @@ -1,4 +1,12 @@ export { processOpenNewResults } from "./processOpenNewResults"; +export { + type ProjectSetupResult, + useFinalizeProjectSetup, +} from "./useFinalizeProjectSetup"; +export { + hostProjectListQueryKey, + useHostProjectIds, +} from "./useHostProjectIds"; export { useOpenFromPath } from "./useOpenFromPath"; export { useOpenNew } from "./useOpenNew"; export { useOpenProject } from "./useOpenProject"; diff --git a/apps/desktop/src/renderer/react-query/projects/useFinalizeProjectSetup/index.ts b/apps/desktop/src/renderer/react-query/projects/useFinalizeProjectSetup/index.ts new file mode 100644 index 00000000000..0dc04037528 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/useFinalizeProjectSetup/index.ts @@ -0,0 +1,4 @@ +export { + type ProjectSetupResult, + useFinalizeProjectSetup, +} from "./useFinalizeProjectSetup"; diff --git a/apps/desktop/src/renderer/react-query/projects/useFinalizeProjectSetup/useFinalizeProjectSetup.ts b/apps/desktop/src/renderer/react-query/projects/useFinalizeProjectSetup/useFinalizeProjectSetup.ts new file mode 100644 index 00000000000..0592290fd37 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/useFinalizeProjectSetup/useFinalizeProjectSetup.ts @@ -0,0 +1,35 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { hostProjectListQueryKey } from "../useHostProjectIds"; + +export interface ProjectSetupResult { + projectId: string; + repoPath: string; + mainWorkspaceId: string | null; +} + +/** + * Side effects to apply after a project is created or set up on a host: + * make sure it shows up in the sidebar, and invalidate the cached host + * project list so callers re-evaluate `needsSetup`. + */ +export function useFinalizeProjectSetup() { + const { ensureProjectInSidebar, ensureWorkspaceInSidebar } = + useDashboardSidebarState(); + const queryClient = useQueryClient(); + + return useCallback( + (hostUrl: string, result: ProjectSetupResult) => { + if (result.mainWorkspaceId) { + ensureWorkspaceInSidebar(result.mainWorkspaceId, result.projectId); + } else { + ensureProjectInSidebar(result.projectId); + } + void queryClient.invalidateQueries({ + queryKey: hostProjectListQueryKey(hostUrl), + }); + }, + [ensureProjectInSidebar, ensureWorkspaceInSidebar, queryClient], + ); +} diff --git a/apps/desktop/src/renderer/react-query/projects/useHostProjectIds/index.ts b/apps/desktop/src/renderer/react-query/projects/useHostProjectIds/index.ts new file mode 100644 index 00000000000..9682b7df4a2 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/useHostProjectIds/index.ts @@ -0,0 +1,4 @@ +export { + hostProjectListQueryKey, + useHostProjectIds, +} from "./useHostProjectIds"; diff --git a/apps/desktop/src/renderer/react-query/projects/useHostProjectIds/useHostProjectIds.ts b/apps/desktop/src/renderer/react-query/projects/useHostProjectIds/useHostProjectIds.ts new file mode 100644 index 00000000000..5543514cc9b --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/useHostProjectIds/useHostProjectIds.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; + +export const hostProjectListQueryKey = (hostUrl: string | null) => + ["project", "list", hostUrl] as const; + +/** + * IDs of projects already set up on the given host. Returns `null` when the + * host couldn't be reached (treat as "unknown" — no setup indicator). + */ +export function useHostProjectIds(hostUrl: string | null): Set<string> | null { + const { data } = useQuery({ + queryKey: hostProjectListQueryKey(hostUrl), + enabled: !!hostUrl, + queryFn: async () => { + if (!hostUrl) return null; + try { + const client = getHostServiceClientByUrl(hostUrl); + const rows = await client.project.list.query(); + return new Set(rows.map((row) => row.id)); + } catch (err) { + console.warn("useHostProjectIds: failed to list projects", { + hostUrl, + err, + }); + return null; + } + }, + }); + + return data ?? null; +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts index de8e33ee071..83198b01362 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts @@ -1,6 +1,10 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { + getWorkspaceFocusTargetAfterRemoval, + removeWorkspaceFromGroups, +} from "./utils/workspace-removal"; type CloseContext = { previousGrouped: ReturnType< @@ -13,6 +17,7 @@ type CloseContext = { >["workspaces"]["getAll"]["getData"] extends () => infer R ? R : never; + wasViewingClosed: boolean; }; /** @@ -31,6 +36,8 @@ export function useCloseWorkspace( return electronTrpc.workspaces.close.useMutation({ ...options, onMutate: async ({ id }) => { + const wasViewingClosed = params.workspaceId === id; + // Cancel outgoing refetches to avoid overwriting optimistic update await Promise.all([ utils.workspaces.getAll.cancel(), @@ -38,42 +45,38 @@ export function useCloseWorkspace( ]); // Snapshot previous values for rollback - const previousGrouped = utils.workspaces.getAllGrouped.getData(); + const previousGrouped = + utils.workspaces.getAllGrouped.getData() ?? + (wasViewingClosed + ? await utils.workspaces.getAllGrouped.fetch().catch((error) => { + console.warn( + "Failed to fetch grouped workspaces during close", + error, + ); + return undefined; + }) + : undefined); const previousAll = utils.workspaces.getAll.getData(); + // If the closed workspace is currently being viewed, navigate away using + // the pre-removal order. + if (wasViewingClosed) { + const targetWorkspaceId = getWorkspaceFocusTargetAfterRemoval( + previousGrouped, + id, + ); + if (targetWorkspaceId) { + navigateToWorkspace(targetWorkspaceId, navigate); + } else { + navigate({ to: "/workspace" }); + } + } + // Optimistically remove workspace from getAllGrouped cache if (previousGrouped) { utils.workspaces.getAllGrouped.setData( undefined, - previousGrouped - .map((group) => { - const isTopLevelWorkspace = group.workspaces.some( - (w) => w.id === id, - ); - const workspaces = group.workspaces.filter((w) => w.id !== id); - const sections = group.sections.map((section) => ({ - ...section, - workspaces: section.workspaces.filter((w) => w.id !== id), - })); - - return { - ...group, - workspaces, - sections, - topLevelItems: isTopLevelWorkspace - ? group.topLevelItems.filter((item) => item.id !== id) - : group.topLevelItems, - }; - }) - .filter( - (group) => - group.workspaces.length + - group.sections.reduce( - (sum, section) => sum + section.workspaces.length, - 0, - ) > - 0, - ), + removeWorkspaceFromGroups(previousGrouped, id), ); } @@ -86,9 +89,9 @@ export function useCloseWorkspace( } // Return context for rollback - return { previousGrouped, previousAll } as CloseContext; + return { previousGrouped, previousAll, wasViewingClosed } as CloseContext; }, - onError: (_err, _variables, context) => { + onError: async (err, variables, context, ...rest) => { // Rollback to previous state on error if (context?.previousGrouped !== undefined) { utils.workspaces.getAllGrouped.setData( @@ -99,6 +102,10 @@ export function useCloseWorkspace( if (context?.previousAll !== undefined) { utils.workspaces.getAll.setData(undefined, context.previousAll); } + if (context?.wasViewingClosed) { + navigateToWorkspace(variables.id, navigate); + } + await options?.onError?.(err, variables, context, ...rest); }, onSuccess: async (data, variables, ...rest) => { // Invalidate to ensure consistency with backend state @@ -106,27 +113,6 @@ export function useCloseWorkspace( // Invalidate project queries since close updates project metadata await utils.projects.getRecents.invalidate(); - // If the closed workspace is currently being viewed, navigate away - if (params.workspaceId === variables.id) { - // Try to navigate to previous workspace first, then next - const prevWorkspaceId = - await utils.workspaces.getPreviousWorkspace.fetch({ - id: variables.id, - }); - const nextWorkspaceId = await utils.workspaces.getNextWorkspace.fetch({ - id: variables.id, - }); - - const targetWorkspaceId = prevWorkspaceId ?? nextWorkspaceId; - - if (targetWorkspaceId) { - navigateToWorkspace(targetWorkspaceId, navigate); - } else { - // No other workspaces, navigate to workspace index (shows StartView) - navigate({ to: "/workspace" }); - } - } - // Call user's onSuccess if provided await options?.onSuccess?.(data, variables, ...rest); }, diff --git a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts index a557cc18ee0..d2fde811115 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts @@ -1,6 +1,10 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { + getWorkspaceFocusTargetAfterRemoval, + removeWorkspaceFromGroups, +} from "./utils/workspace-removal"; type DeleteContext = { previousGrouped: ReturnType< @@ -14,7 +18,6 @@ type DeleteContext = { ? R : never; wasViewingDeleted: boolean; - navigatedTo: string | null; }; export function useDeleteWorkspace( @@ -28,65 +31,41 @@ export function useDeleteWorkspace( ...options, onMutate: async ({ id }) => { const wasViewingDeleted = params.workspaceId === id; - let navigatedTo: string | null = null; + + await Promise.all([ + utils.workspaces.getAll.cancel(), + utils.workspaces.getAllGrouped.cancel(), + ]); + + const previousGrouped = + utils.workspaces.getAllGrouped.getData() ?? + (wasViewingDeleted + ? await utils.workspaces.getAllGrouped.fetch().catch((error) => { + console.warn( + "Failed to fetch grouped workspaces during delete", + error, + ); + return undefined; + }) + : undefined); + const previousAll = utils.workspaces.getAll.getData(); if (wasViewingDeleted) { - const prevWorkspaceId = - await utils.workspaces.getPreviousWorkspace.fetch({ id }); - const nextWorkspaceId = await utils.workspaces.getNextWorkspace.fetch({ + const targetWorkspaceId = getWorkspaceFocusTargetAfterRemoval( + previousGrouped, id, - }); - const targetWorkspaceId = prevWorkspaceId ?? nextWorkspaceId; - + ); if (targetWorkspaceId) { - navigatedTo = targetWorkspaceId; navigateToWorkspace(targetWorkspaceId, navigate); } else { - navigatedTo = "/workspace"; navigate({ to: "/workspace" }); } } - await Promise.all([ - utils.workspaces.getAll.cancel(), - utils.workspaces.getAllGrouped.cancel(), - ]); - - const previousGrouped = utils.workspaces.getAllGrouped.getData(); - const previousAll = utils.workspaces.getAll.getData(); - if (previousGrouped) { utils.workspaces.getAllGrouped.setData( undefined, - previousGrouped - .map((group) => { - const isTopLevelWorkspace = group.workspaces.some( - (w) => w.id === id, - ); - const workspaces = group.workspaces.filter((w) => w.id !== id); - const sections = group.sections.map((section) => ({ - ...section, - workspaces: section.workspaces.filter((w) => w.id !== id), - })); - - return { - ...group, - workspaces, - sections, - topLevelItems: isTopLevelWorkspace - ? group.topLevelItems.filter((item) => item.id !== id) - : group.topLevelItems, - }; - }) - .filter( - (group) => - group.workspaces.length + - group.sections.reduce( - (sum, section) => sum + section.workspaces.length, - 0, - ) > - 0, - ), + removeWorkspaceFromGroups(previousGrouped, id), ); } @@ -101,7 +80,6 @@ export function useDeleteWorkspace( previousGrouped, previousAll, wasViewingDeleted, - navigatedTo, } as DeleteContext; }, onSettled: async (...args) => { diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts index 0309bf5e764..5b944577d6f 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts @@ -53,7 +53,7 @@ export function scheduleDeleteDialogOpen({ /** * Coordinates opening the delete dialog from a ContextMenu item selection. * - * When "Close Worktree" is selected, we wait for ContextMenu close and then: + * When "Close Workspace" is selected, we wait for ContextMenu close and then: * 1) prevent Radix auto-focus from returning to the trigger * 2) open the delete dialog */ diff --git a/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.test.ts b/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.test.ts new file mode 100644 index 00000000000..c47a637a6e0 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test"; +import { + getWorkspaceFocusTargetAfterRemoval, + removeWorkspaceFromGroups, +} from "./workspace-removal"; + +const groups = [ + { + workspaces: [ + { id: "w1", tabOrder: 1 }, + { id: "w4", tabOrder: 4 }, + ], + sections: [ + { + id: "s1", + tabOrder: 2, + workspaces: [ + { id: "w2", tabOrder: 1 }, + { id: "w3", tabOrder: 2 }, + ], + }, + ], + topLevelItems: [ + { id: "w1", kind: "workspace" as const, tabOrder: 1 }, + { id: "s1", kind: "section" as const, tabOrder: 2 }, + { id: "w4", kind: "workspace" as const, tabOrder: 3 }, + ], + }, +]; + +describe("getWorkspaceFocusTargetAfterRemoval", () => { + it("selects next, then previous, in sidebar visual order", () => { + expect(getWorkspaceFocusTargetAfterRemoval(groups, "w2")).toBe("w3"); + expect(getWorkspaceFocusTargetAfterRemoval(groups, "w4")).toBe("w3"); + expect( + getWorkspaceFocusTargetAfterRemoval( + [ + { + workspaces: [{ id: "w1", tabOrder: 1 }], + sections: [], + topLevelItems: [ + { id: "w1", kind: "workspace" as const, tabOrder: 1 }, + ], + }, + ], + "w1", + ), + ).toBeNull(); + }); +}); + +describe("removeWorkspaceFromGroups", () => { + it("removes section and top-level workspaces", () => { + expect( + removeWorkspaceFromGroups(groups, "w2")[0]?.sections[0]?.workspaces.map( + (workspace) => workspace.id, + ), + ).toEqual(["w3"]); + expect( + removeWorkspaceFromGroups(groups, "w4")[0]?.topLevelItems.map( + (item) => item.id, + ), + ).toEqual(["w1", "s1"]); + }); +}); diff --git a/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.ts b/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.ts new file mode 100644 index 00000000000..f9cceda9e1e --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/utils/workspace-removal.ts @@ -0,0 +1,105 @@ +import { getActiveIdAfterRemoval } from "@superset/panes"; + +type WorkspaceLike = { + id: string; + tabOrder: number; +}; + +type SectionLike = { + id: string; + workspaces: WorkspaceLike[]; +}; + +type TopLevelItemLike = { + id: string; + kind: "workspace" | "section"; + tabOrder: number; +}; + +type WorkspaceGroupLike = { + workspaces: WorkspaceLike[]; + sections: SectionLike[]; + topLevelItems: TopLevelItemLike[]; +}; + +function compareTopLevelItems( + left: TopLevelItemLike, + right: TopLevelItemLike, +): number { + return ( + left.tabOrder - right.tabOrder || + (left.kind === right.kind ? 0 : left.kind === "section" ? -1 : 1) + ); +} + +function hasVisibleWorkspaces(group: WorkspaceGroupLike): boolean { + return ( + group.workspaces.length > 0 || + group.sections.some((section) => section.workspaces.length > 0) + ); +} + +function getWorkspaceIdsFromGroups( + groups: readonly WorkspaceGroupLike[] | undefined, +): string[] { + return (groups ?? []).flatMap((group) => + [...group.topLevelItems].sort(compareTopLevelItems).flatMap((item) => { + if (item.kind === "workspace") { + return group.workspaces.some((workspace) => workspace.id === item.id) + ? [item.id] + : []; + } + + const section = group.sections.find((section) => section.id === item.id); + return section + ? [...section.workspaces] + .sort((left, right) => left.tabOrder - right.tabOrder) + .map((workspace) => workspace.id) + : []; + }), + ); +} + +export function getWorkspaceFocusTargetAfterRemoval( + groups: readonly WorkspaceGroupLike[] | undefined, + removedWorkspaceId: string, +): string | null { + return getActiveIdAfterRemoval( + getWorkspaceIdsFromGroups(groups), + removedWorkspaceId, + removedWorkspaceId, + ); +} + +export function removeWorkspaceFromGroups<TGroup extends WorkspaceGroupLike>( + groups: readonly TGroup[], + workspaceId: string, +): TGroup[] { + return groups + .map((group) => { + const isTopLevelWorkspace = group.workspaces.some( + (workspace) => workspace.id === workspaceId, + ); + const workspaces = group.workspaces.filter( + (workspace) => workspace.id !== workspaceId, + ); + // Keep empty sections: getAllGrouped returns user-created sections even + // when they have no workspaces, so the optimistic cache should match. + const sections = group.sections.map((section) => ({ + ...section, + workspaces: section.workspaces.filter( + (workspace) => workspace.id !== workspaceId, + ), + })); + + return { + ...group, + workspaces, + sections, + topLevelItems: isTopLevelWorkspace + ? group.topLevelItems.filter((item) => item.id !== workspaceId) + : group.topLevelItems, + } as TGroup; + }) + .filter(hasVisibleWorkspaces); +} diff --git a/apps/desktop/src/renderer/routes/-layout.tsx b/apps/desktop/src/renderer/routes/-layout.tsx index e45d9dda2a8..4b1e1ff7957 100644 --- a/apps/desktop/src/renderer/routes/-layout.tsx +++ b/apps/desktop/src/renderer/routes/-layout.tsx @@ -1,5 +1,6 @@ import { Alerter } from "@superset/ui/atoms/Alert"; import type { ReactNode } from "react"; +import { PostHogSurfaceTagger } from "renderer/components/PostHogSurfaceTagger"; import { PostHogUserIdentifier } from "renderer/components/PostHogUserIdentifier"; import { TelemetrySync } from "renderer/components/TelemetrySync"; import { ThemedToaster } from "renderer/components/ThemedToaster"; @@ -12,6 +13,7 @@ export function RootLayout({ children }: { children: ReactNode }) { <PostHogProvider> <ElectronTRPCProvider> <PostHogUserIdentifier /> + <PostHogSurfaceTagger /> <TelemetrySync /> <AuthProvider> {children} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/AutomationBody.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/AutomationBody.tsx new file mode 100644 index 00000000000..8cc0c67ba40 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/AutomationBody.tsx @@ -0,0 +1,58 @@ +import type { SelectAutomation } from "@superset/db/schema"; +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { EmojiTextInput } from "renderer/components/EmojiTextInput"; +import { MarkdownEditor } from "renderer/components/MarkdownEditor"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; +import { useProjectFileSearch } from "../../../hooks/useProjectFileSearch"; + +export function AutomationBody({ + automation, +}: { + automation: SelectAutomation; +}) { + const [name, setName] = useState(automation.name); + const [prompt, setPrompt] = useState(automation.prompt); + + const updateMutation = useMutation({ + mutationFn: (patch: { name?: string; prompt?: string }) => + apiTrpcClient.automation.update.mutate({ id: automation.id, ...patch }), + }); + + const hostTarget: WorkspaceHostTarget = automation.targetHostId + ? { kind: "host", hostId: automation.targetHostId } + : { kind: "local" }; + const searchFiles = useProjectFileSearch({ + hostTarget, + projectId: automation.v2ProjectId, + }); + + return ( + <div className="flex-1 overflow-y-auto px-8 py-6"> + <EmojiTextInput + value={name} + onChange={setName} + onBlur={(next) => { + const trimmed = next.trim(); + if (trimmed && trimmed !== automation.name) { + updateMutation.mutate({ name: trimmed }); + } + }} + placeholder="Automation title" + className="mb-6 text-2xl font-semibold" + /> + <MarkdownEditor + content={prompt} + onChange={setPrompt} + onSave={(next) => { + if (next !== automation.prompt) { + updateMutation.mutate({ prompt: next }); + } + }} + placeholder="Add prompt e.g. look for crashes in $sentry" + searchFiles={searchFiles} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/index.ts new file mode 100644 index 00000000000..f3bee5e7173 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationBody/index.ts @@ -0,0 +1 @@ +export { AutomationBody } from "./AutomationBody"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailHeader/AutomationDetailHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailHeader/AutomationDetailHeader.tsx new file mode 100644 index 00000000000..5afe39deac0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailHeader/AutomationDetailHeader.tsx @@ -0,0 +1,92 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@superset/ui/breadcrumb"; +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuPause, LuPlay, LuTrash2 } from "react-icons/lu"; + +interface AutomationDetailHeaderProps { + name: string; + enabled: boolean; + onBack: () => void; + onToggleEnabled: () => void; + onDelete: () => void; + onRunNow: () => void; + toggleDisabled?: boolean; + deleteDisabled?: boolean; + runNowDisabled?: boolean; +} + +export function AutomationDetailHeader({ + name, + enabled, + onBack, + onToggleEnabled, + onDelete, + onRunNow, + toggleDisabled, + deleteDisabled, + runNowDisabled, +}: AutomationDetailHeaderProps) { + return ( + <header className="flex items-center justify-between border-b px-8 py-4"> + <Breadcrumb> + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink onClick={onBack} className="cursor-pointer"> + Automations + </BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbPage>{name}</BreadcrumbPage> + </BreadcrumbItem> + </BreadcrumbList> + </Breadcrumb> + + <div className="flex items-center gap-2"> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={onToggleEnabled} + disabled={toggleDisabled} + aria-label={enabled ? "Pause" : "Resume"} + > + {enabled ? ( + <LuPause className="size-4" /> + ) : ( + <LuPlay className="size-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent>{enabled ? "Pause" : "Resume"}</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={onDelete} + disabled={deleteDisabled} + aria-label="Delete" + > + <LuTrash2 className="size-4" /> + </Button> + </TooltipTrigger> + <TooltipContent>Delete</TooltipContent> + </Tooltip> + <Button size="sm" onClick={onRunNow} disabled={runNowDisabled}> + <LuPlay className="size-4" /> + Run now + </Button> + </div> + </header> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailHeader/index.ts new file mode 100644 index 00000000000..15dea386450 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailHeader/index.ts @@ -0,0 +1 @@ +export { AutomationDetailHeader } from "./AutomationDetailHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx new file mode 100644 index 00000000000..bcf42853e57 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/AutomationDetailSidebar.tsx @@ -0,0 +1,189 @@ +import type { + SelectAutomation, + SelectAutomationRun, +} from "@superset/db/schema"; +import { formatDateTimeInTimezone } from "@superset/shared/rrule"; +import { cn } from "@superset/ui/utils"; +import { useMutation } from "@tanstack/react-query"; +import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; +import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions"; +import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; +import { AgentPicker } from "../../../components/AgentPicker"; +import { ProjectPicker } from "../../../components/ProjectPicker"; +import { SchedulePicker } from "../../../components/SchedulePicker"; +import { TimezonePicker } from "../../../components/TimezonePicker"; +import { WorkspacePicker } from "../../../components/WorkspacePicker"; +import { useRecentProjects } from "../../../hooks/useRecentProjects"; +import { PreviousRunsList } from "../PreviousRunsList"; +import { Row } from "./components/Row"; +import { Section } from "./components/Section"; +import { SectionTitle } from "./components/SectionTitle"; + +interface AutomationDetailSidebarProps { + automation: SelectAutomation; + recentRuns: SelectAutomationRun[]; +} + +export function AutomationDetailSidebar({ + automation, + recentRuns, +}: AutomationDetailSidebarProps) { + const { agents: enabledAgents } = useEnabledAgents(); + const recentProjects = useRecentProjects(); + const { localHostId } = useWorkspaceHostOptions(); + const selectedProject = recentProjects.find( + (p) => p.id === automation.v2ProjectId, + ); + + const hostTarget: WorkspaceHostTarget = + automation.targetHostId && automation.targetHostId !== localHostId + ? { kind: "host", hostId: automation.targetHostId } + : { kind: "local" }; + + const updateMutation = useMutation({ + mutationFn: ( + patch: Partial< + Parameters<typeof apiTrpcClient.automation.update.mutate>[0] + >, + ) => + apiTrpcClient.automation.update.mutate({ id: automation.id, ...patch }), + }); + + const lastRunAt = recentRuns + .map((run) => run.scheduledFor) + .map((d) => (d ? new Date(d) : null)) + .filter((d): d is Date => d !== null) + .sort((a, b) => b.getTime() - a.getTime())[0]; + + return ( + <aside className="flex w-[368px] shrink-0 flex-col border-l overflow-hidden"> + <div className="flex flex-col gap-8 p-6 pb-2 shrink-0"> + <Section title="Status"> + <Row + label="Status" + value={ + <span className="inline-flex items-center gap-2"> + <span + className={cn( + "inline-block size-2 shrink-0 rounded-full", + automation.enabled + ? "bg-emerald-500" + : "border border-muted-foreground/60", + )} + /> + {automation.enabled ? "Active" : "Paused"} + </span> + } + /> + <Row + label="Next run" + value={ + automation.enabled && automation.nextRunAt + ? formatDateTimeInTimezone( + new Date(automation.nextRunAt), + automation.timezone, + ) + : "—" + } + /> + <Row + label="Last ran" + value={ + lastRunAt + ? formatDateTimeInTimezone(lastRunAt, automation.timezone) + : "—" + } + /> + </Section> + + <Section title="Details"> + <Row + label="Device" + value={ + <DevicePicker + className="-mr-4" + hostTarget={hostTarget} + onSelectHostTarget={(target) => { + const nextHostId = + target.kind === "host" + ? target.hostId + : (localHostId ?? null); + updateMutation.mutate({ targetHostId: nextHostId }); + }} + /> + } + /> + <Row + label="Project" + value={ + <ProjectPicker + className="-mr-4" + selectedProject={selectedProject} + recentProjects={recentProjects} + onSelectProject={(v2ProjectId) => + updateMutation.mutate({ v2ProjectId }) + } + /> + } + /> + <Row + label="Workspace" + value={ + <WorkspacePicker + className="-mr-4" + hostId={automation.targetHostId ?? null} + projectId={automation.v2ProjectId} + value={automation.v2WorkspaceId} + onChange={(v2WorkspaceId) => + updateMutation.mutate({ v2WorkspaceId }) + } + /> + } + /> + <Row + label="Repeats" + value={ + <SchedulePicker + className="-mr-4" + rrule={automation.rrule} + onRruleChange={(rrule) => updateMutation.mutate({ rrule })} + /> + } + /> + <Row + label="Agent" + value={ + <AgentPicker + className="-mr-4" + value={automation.agentConfig.id} + onChange={(id) => { + const config = enabledAgents.find((a) => a.id === id); + if (config) updateMutation.mutate({ agentConfig: config }); + }} + /> + } + /> + <Row + label="Timezone" + value={ + <TimezonePicker + className="-mr-4" + value={automation.timezone} + onChange={(timezone) => updateMutation.mutate({ timezone })} + /> + } + /> + </Section> + </div> + + <div className="mt-8 flex min-h-0 flex-1 flex-col gap-2 pl-6 pr-3 pb-6"> + <SectionTitle>Previous runs</SectionTitle> + <div className="min-h-0 flex-1 overflow-y-auto"> + <PreviousRunsList runs={recentRuns} /> + </div> + </div> + </aside> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Row/Row.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Row/Row.tsx new file mode 100644 index 00000000000..84475cf5618 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Row/Row.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export function Row({ label, value }: { label: string; value: ReactNode }) { + return ( + <div className="flex min-h-8 items-center justify-between gap-4 text-sm"> + <span className="text-muted-foreground">{label}</span> + <div className="flex min-w-0 justify-end">{value}</div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Row/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Row/index.ts new file mode 100644 index 00000000000..790925d7729 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Row/index.ts @@ -0,0 +1 @@ +export { Row } from "./Row"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Section/Section.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Section/Section.tsx new file mode 100644 index 00000000000..b7eb0830de9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Section/Section.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import { SectionTitle } from "../SectionTitle"; + +export function Section({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( + <section className="flex flex-col gap-3"> + <SectionTitle>{title}</SectionTitle> + <div className="flex flex-col">{children}</div> + </section> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Section/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Section/index.ts new file mode 100644 index 00000000000..8d4b3810207 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/Section/index.ts @@ -0,0 +1 @@ +export { Section } from "./Section"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/SectionTitle/SectionTitle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/SectionTitle/SectionTitle.tsx new file mode 100644 index 00000000000..43036dc79f8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/SectionTitle/SectionTitle.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export function SectionTitle({ children }: { children: ReactNode }) { + return ( + <span className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground"> + {children} + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/SectionTitle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/SectionTitle/index.ts new file mode 100644 index 00000000000..26a2bb52a78 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/components/SectionTitle/index.ts @@ -0,0 +1 @@ +export { SectionTitle } from "./SectionTitle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/index.ts new file mode 100644 index 00000000000..cd959d39ad2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/AutomationDetailSidebar/index.ts @@ -0,0 +1 @@ +export { AutomationDetailSidebar } from "./AutomationDetailSidebar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx new file mode 100644 index 00000000000..060b2b06586 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/PreviousRunsList.tsx @@ -0,0 +1,98 @@ +import type { SelectAutomationRun } from "@superset/db/schema"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { formatDistanceStrict } from "date-fns"; +import { useNow } from "renderer/hooks/useNow"; + +const STATUS_DOT: Record<SelectAutomationRun["status"], string> = { + dispatched: "bg-emerald-500", + dispatching: "bg-amber-500", + skipped_offline: "bg-red-500", + dispatch_failed: "bg-red-500", +}; + +interface PreviousRunsListProps { + runs: SelectAutomationRun[]; +} + +function formatAgo(date: Date, now: Date): string { + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + if (seconds < 60) return "less than a minute ago"; + return `${formatDistanceStrict(date, now)} ago`; +} + +export function PreviousRunsList({ runs }: PreviousRunsListProps) { + const navigate = useNavigate(); + const now = useNow(); + + if (runs.length === 0) { + return <p className="text-sm italic text-muted-foreground">No runs yet</p>; + } + + const handleOpenRun = (run: SelectAutomationRun) => { + if (!run.v2WorkspaceId) return; + localStorage.setItem("lastViewedWorkspaceId", run.v2WorkspaceId); + navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: run.v2WorkspaceId }, + search: { + terminalId: run.terminalSessionId ?? undefined, + chatSessionId: run.chatSessionId ?? undefined, + }, + }); + }; + + return ( + <ul className="flex flex-col gap-0.5 text-sm"> + {runs.map((run) => { + const clickable = !!run.v2WorkspaceId; + const row = ( + <button + type="button" + disabled={!clickable} + onClick={() => handleOpenRun(run)} + className={cn( + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left", + clickable + ? "cursor-pointer hover:bg-accent/40" + : "cursor-default opacity-70", + )} + > + <span + role="img" + aria-label={run.status} + className={cn( + "inline-block size-2 shrink-0 rounded-full", + STATUS_DOT[run.status], + )} + /> + <span className="truncate">{run.title || "Automation"}</span> + <span className="ml-auto shrink-0 truncate text-muted-foreground"> + {run.scheduledFor + ? formatAgo(new Date(run.scheduledFor), now) + : "—"} + </span> + </button> + ); + return ( + <li key={run.id}> + {run.error ? ( + <Tooltip> + <TooltipTrigger asChild>{row}</TooltipTrigger> + <TooltipContent + side="left" + className="max-w-xs whitespace-pre-wrap" + > + {run.error} + </TooltipContent> + </Tooltip> + ) : ( + row + )} + </li> + ); + })} + </ul> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/index.ts new file mode 100644 index 00000000000..95555f0b1bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/components/PreviousRunsList/index.ts @@ -0,0 +1 @@ +export { PreviousRunsList } from "./PreviousRunsList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/page.tsx new file mode 100644 index 00000000000..b84d9ad3145 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/$automationId/page.tsx @@ -0,0 +1,122 @@ +import type { + SelectAutomation, + SelectAutomationRun, +} from "@superset/db/schema"; +import { alert } from "@superset/ui/atoms/Alert"; +import { toast } from "@superset/ui/sonner"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMutation } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { AutomationBody } from "./components/AutomationBody"; +import { AutomationDetailHeader } from "./components/AutomationDetailHeader"; +import { AutomationDetailSidebar } from "./components/AutomationDetailSidebar"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/automations/$automationId/", +)({ + component: AutomationDetailPage, +}); + +const RECENT_RUNS_LIMIT = 10; + +function AutomationDetailPage() { + const { automationId } = Route.useParams(); + const navigate = useNavigate(); + const collections = useCollections(); + + const { data: automationRows } = useLiveQuery( + (q) => + q + .from({ a: collections.automations }) + .where(({ a }) => eq(a.id, automationId)) + .select(({ a }) => ({ ...a })), + [collections.automations, automationId], + ); + const automation = automationRows?.[0] as SelectAutomation | undefined; + + const { data: runRows = [] } = useLiveQuery( + (q) => + q + .from({ r: collections.automationRuns }) + .where(({ r }) => eq(r.automationId, automationId)) + .orderBy(({ r }) => r.createdAt, "desc") + .limit(RECENT_RUNS_LIMIT) + .select(({ r }) => ({ ...r })), + [collections.automationRuns, automationId], + ); + const recentRuns = runRows as SelectAutomationRun[]; + + const setEnabledMutation = useMutation({ + mutationFn: (enabled: boolean) => + apiTrpcClient.automation.setEnabled.mutate({ id: automationId, enabled }), + }); + + const runNowMutation = useMutation({ + mutationFn: () => + apiTrpcClient.automation.runNow.mutate({ id: automationId }), + }); + + const deleteMutation = useMutation({ + mutationFn: () => + apiTrpcClient.automation.delete.mutate({ id: automationId }), + onSuccess: () => navigate({ to: "/automations" }), + }); + + if (!automation) { + return ( + <div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground"> + Automation not found. + </div> + ); + } + + return ( + <div className="flex h-full w-full flex-1 overflow-hidden"> + <div className="flex flex-1 flex-col overflow-hidden"> + <AutomationDetailHeader + name={automation.name} + enabled={automation.enabled} + onBack={() => navigate({ to: "/automations" })} + onToggleEnabled={() => setEnabledMutation.mutate(!automation.enabled)} + onDelete={() => { + alert({ + title: "Delete automation?", + description: `"${automation.name}" will stop firing and its run history will be removed. This can't be undone.`, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Delete", + variant: "destructive", + onClick: () => { + toast.promise(deleteMutation.mutateAsync(), { + loading: "Deleting automation...", + success: `"${automation.name}" deleted`, + error: (err) => + err instanceof Error + ? err.message + : "Failed to delete automation", + }); + }, + }, + ], + }); + }} + onRunNow={() => runNowMutation.mutate()} + toggleDisabled={setEnabledMutation.isPending} + deleteDisabled={deleteMutation.isPending} + runNowDisabled={runNowMutation.isPending} + /> + + <AutomationBody key={automation.id} automation={automation} /> + </div> + + <AutomationDetailSidebar + automation={automation} + recentRuns={recentRuns} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentCell/AgentCell.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentCell/AgentCell.tsx new file mode 100644 index 00000000000..ee3f984621b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentCell/AgentCell.tsx @@ -0,0 +1,22 @@ +import { LuCpu } from "react-icons/lu"; +import { usePresetIcon } from "renderer/assets/app-icons/preset-icons"; + +export function AgentCell({ + agentId, + label, +}: { + agentId: string; + label: string; +}) { + const icon = usePresetIcon(agentId); + return ( + <span className="inline-flex items-center gap-1.5"> + {icon ? ( + <img src={icon} alt="" className="size-3.5 shrink-0 object-contain" /> + ) : ( + <LuCpu className="size-3.5 shrink-0" /> + )} + <span className="truncate">{label}</span> + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentCell/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentCell/index.ts new file mode 100644 index 00000000000..ddacb9f2ae2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentCell/index.ts @@ -0,0 +1 @@ +export { AgentCell } from "./AgentCell"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/AgentPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/AgentPicker.tsx new file mode 100644 index 00000000000..bd1a2e11c49 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/AgentPicker.tsx @@ -0,0 +1,81 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { getPresetIcon } from "@superset/ui/icons/preset-icons"; +import { useNavigate } from "@tanstack/react-router"; +import { HiCheck } from "react-icons/hi2"; +import { LuCpu, LuSettings } from "react-icons/lu"; +import { + useIsDarkTheme, + usePresetIcon, +} from "renderer/assets/app-icons/preset-icons"; +import { PickerTrigger } from "renderer/components/PickerTrigger"; +import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; + +interface AgentPickerProps { + value: string; + onChange: (next: string) => void; + className?: string; +} + +export function AgentPicker({ value, onChange, className }: AgentPickerProps) { + const navigate = useNavigate(); + const { agents } = useEnabledAgents(); + const isDark = useIsDarkTheme(); + const selectedAgent = agents.find((agent) => agent.id === value); + const selectedIcon = usePresetIcon(value); + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <PickerTrigger + className={className} + icon={ + selectedIcon ? ( + <img + src={selectedIcon} + alt="" + className="size-3.5 shrink-0 object-contain" + /> + ) : ( + <LuCpu className="size-4 shrink-0" /> + ) + } + label={selectedAgent?.label ?? "Select agent"} + /> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-56"> + {agents.map((agent) => { + const icon = getPresetIcon(agent.id, isDark); + return ( + <DropdownMenuItem + key={agent.id} + onSelect={() => onChange(agent.id)} + > + {icon ? ( + <img + src={icon} + alt="" + className="size-3.5 shrink-0 object-contain" + /> + ) : ( + <LuCpu className="size-4 shrink-0" /> + )} + <span className="flex-1 truncate">{agent.label}</span> + {value === agent.id && <HiCheck className="size-4" />} + </DropdownMenuItem> + ); + })} + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={() => navigate({ to: "/settings/agents" })}> + <LuSettings className="size-4 shrink-0" /> + <span className="flex-1">Configure agents…</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/index.ts new file mode 100644 index 00000000000..b160727b99c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AgentPicker/index.ts @@ -0,0 +1 @@ +export { AgentPicker } from "./AgentPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/AutomationsEmptyState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/AutomationsEmptyState.tsx new file mode 100644 index 00000000000..c23c14c0d07 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/AutomationsEmptyState.tsx @@ -0,0 +1,35 @@ +import { Fragment } from "react"; +import { + AUTOMATION_TEMPLATE_CATEGORIES, + type AutomationTemplate, +} from "../../templates"; +import { TemplateCard } from "../TemplateCard"; + +interface AutomationsEmptyStateProps { + onSelectTemplate: (template: AutomationTemplate) => void; +} + +export function AutomationsEmptyState({ + onSelectTemplate, +}: AutomationsEmptyStateProps) { + return ( + <div className="mx-auto max-w-5xl flex flex-col gap-8"> + {AUTOMATION_TEMPLATE_CATEGORIES.map((category) => ( + <Fragment key={category.id}> + <section className="flex flex-col gap-3"> + <h2 className="text-sm font-medium">{category.label}</h2> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> + {category.templates.map((template) => ( + <TemplateCard + key={template.id} + template={template} + onSelect={onSelectTemplate} + /> + ))} + </div> + </section> + </Fragment> + ))} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/index.ts new file mode 100644 index 00000000000..77c5735479b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/AutomationsEmptyState/index.ts @@ -0,0 +1 @@ +export { AutomationsEmptyState } from "./AutomationsEmptyState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CellWithIcon/CellWithIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CellWithIcon/CellWithIcon.tsx new file mode 100644 index 00000000000..7881f411f20 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CellWithIcon/CellWithIcon.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +export function CellWithIcon({ + icon, + label, +}: { + icon: ReactNode; + label: string; +}) { + return ( + <span className="inline-flex items-center gap-1.5"> + {icon} + <span className="truncate">{label}</span> + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CellWithIcon/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CellWithIcon/index.ts new file mode 100644 index 00000000000..d49dff2fe12 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CellWithIcon/index.ts @@ -0,0 +1 @@ +export { CellWithIcon } from "./CellWithIcon"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/CreateAutomationDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/CreateAutomationDialog.tsx new file mode 100644 index 00000000000..7109616971f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/CreateAutomationDialog.tsx @@ -0,0 +1,298 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { toast } from "@superset/ui/sonner"; +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; +import { LuX } from "react-icons/lu"; +import { EmojiTextInput } from "renderer/components/EmojiTextInput"; +import { MarkdownEditor } from "renderer/components/MarkdownEditor"; +import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { DevicePicker } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker"; +import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions"; +import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; +import { hideAll as hideAllTippy } from "tippy.js"; +import { useProjectFileSearch } from "../../hooks/useProjectFileSearch"; +import { useRecentProjects } from "../../hooks/useRecentProjects"; +import type { AutomationTemplate } from "../../templates"; +import { AgentPicker } from "../AgentPicker"; +import { ProjectPicker } from "../ProjectPicker"; +import { SchedulePicker } from "../SchedulePicker"; +import { WorkspacePicker } from "../WorkspacePicker"; +import { TemplateGalleryPanel } from "./components/TemplateGalleryPanel"; + +export type AutomationCreatedPayload = { id: string; name: string }; + +interface CreateAutomationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (automation: AutomationCreatedPayload) => void; + initialTemplate?: AutomationTemplate | null; +} + +const DEFAULT_TIMEZONE = + Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + +const DEFAULT_RRULE = "FREQ=DAILY;BYHOUR=9;BYMINUTE=0"; + +export function CreateAutomationDialog({ + open, + onOpenChange, + onCreated, + initialTemplate, +}: CreateAutomationDialogProps) { + const [view, setView] = useState<"compose" | "gallery">("compose"); + const [name, setName] = useState(""); + const [prompt, setPrompt] = useState(""); + const [hostTarget, setHostTarget] = useState<WorkspaceHostTarget>({ + kind: "local", + }); + const [selectedProjectId, setSelectedProjectId] = useState<string | null>( + null, + ); + const [agentType, setAgentType] = useState("claude"); + const [rrule, setRrule] = useState(DEFAULT_RRULE); + const [v2WorkspaceId, setV2WorkspaceId] = useState<string | null>(null); + + const { localHostId } = useWorkspaceHostOptions(); + const recentProjects = useRecentProjects(); + const { agents: enabledAgents } = useEnabledAgents(); + const searchFiles = useProjectFileSearch({ + hostTarget, + projectId: selectedProjectId, + }); + const selectedProject = recentProjects.find( + (project) => project.id === selectedProjectId, + ); + const selectedAgentConfig = enabledAgents.find( + (agent) => agent.id === agentType, + ); + + // Default to first project once the Electric-synced list lands. + useEffect(() => { + if (!open) return; + if (selectedProjectId) return; + const first = recentProjects[0]; + if (first) setSelectedProjectId(first.id); + }, [open, selectedProjectId, recentProjects]); + + const applyTemplate = useCallback((template: AutomationTemplate) => { + setName(template.name); + setPrompt(template.prompt); + if (template.agentType) setAgentType(template.agentType); + if (template.rrule) setRrule(template.rrule); + }, []); + + // Pre-fill when opened with an initialTemplate (from the empty-state gallery). + useEffect(() => { + if (!open) return; + if (!initialTemplate) return; + applyTemplate(initialTemplate); + }, [open, initialTemplate, applyTemplate]); + + useEffect(() => { + if (!open) { + setView("compose"); + setName(""); + setPrompt(""); + setHostTarget({ kind: "local" }); + setSelectedProjectId(null); + setAgentType("claude"); + setRrule(DEFAULT_RRULE); + setV2WorkspaceId(null); + } + }, [open]); + + const targetHostId = + hostTarget.kind === "host" ? hostTarget.hostId : localHostId; + + const createMutation = useMutation({ + mutationFn: () => { + if (!selectedAgentConfig) throw new Error("No agent selected"); + if (!selectedProjectId) throw new Error("No project selected"); + return apiTrpcClient.automation.create.mutate({ + name, + prompt, + agentConfig: selectedAgentConfig, + targetHostId: targetHostId ?? null, + v2ProjectId: selectedProjectId, + v2WorkspaceId, + rrule: rrule.trim(), + timezone: DEFAULT_TIMEZONE, + mcpScope: [], + }); + }, + onSuccess: (result) => { + toast.success(`Automation "${result.name}" created`); + onCreated({ id: result.id, name: result.name }); + }, + onError: (error) => { + console.error("[CreateAutomation] create failed:", error); + }, + }); + + const humanReadableCreateError = (() => { + if (!createMutation.isError) return null; + const error = createMutation.error; + if (!(error instanceof Error)) return "Failed to create automation"; + // Raw Postgres errors are multi-line SQL dumps — keep the first line only. + const firstLine = error.message.split("\n")[0]?.trim(); + if (!firstLine) return "Failed to create automation"; + return firstLine.length > 160 ? `${firstLine.slice(0, 160)}…` : firstLine; + })(); + + const canSubmit = + name.trim().length > 0 && + prompt.trim().length > 0 && + !!selectedProjectId && + !!targetHostId && + !!selectedAgentConfig && + rrule.trim().length > 0 && + !createMutation.isPending; + + const handleTemplatePicked = (template: AutomationTemplate) => { + applyTemplate(template); + setView("compose"); + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent + className="sm:max-w-[960px] p-0 gap-0 overflow-hidden" + aria-describedby={undefined} + showCloseButton={false} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + onEscapeKeyDown={(event) => { + // Radix listens at document-capture phase, so it intercepts Escape + // before the editor's target-level Suggestion handler runs. If any + // tippy popup is visible (emoji / file / slash), hide it here and + // preventDefault so the dialog doesn't close too. + if (!document.querySelector('.tippy-box[data-state="visible"]')) { + return; + } + event.preventDefault(); + hideAllTippy(); + }} + > + <div + className="flex flex-col overflow-hidden transition-[height] duration-200 ease-out" + style={{ height: view === "compose" ? 400 : 560 }} + > + {view === "compose" ? ( + <> + <DialogHeader className="flex-row items-center gap-2 p-4 pb-0 space-y-0"> + <div className="flex-1"> + <DialogTitle className="sr-only">New automation</DialogTitle> + <EmojiTextInput + value={name} + onChange={setName} + placeholder="Automation title" + className="text-base font-medium" + /> + </div> + <Button + variant="outline" + size="sm" + onClick={() => setView("gallery")} + > + Use template + </Button> + <DialogClose asChild> + <Button variant="ghost" size="icon-sm" aria-label="Close"> + <LuX className="size-4" /> + </Button> + </DialogClose> + </DialogHeader> + + <div className="flex-1 min-h-0 px-4 pt-2 flex flex-col overflow-y-auto"> + <MarkdownEditor + content={prompt} + onChange={setPrompt} + placeholder="Add prompt e.g. look for crashes in $sentry" + className="flex-1 flex flex-col min-h-0" + editorClassName="flex-1 min-h-[200px]" + searchFiles={searchFiles} + /> + + {humanReadableCreateError && ( + <p className="text-destructive text-sm mt-2 line-clamp-2"> + {humanReadableCreateError} + </p> + )} + </div> + + <DialogFooter className="flex-row items-center justify-between gap-2 border-t p-3 sm:justify-between"> + <div className="flex items-center gap-2"> + <DevicePicker + className="w-[160px]" + hostTarget={hostTarget} + onSelectHostTarget={(next) => { + setHostTarget(next); + setV2WorkspaceId(null); + }} + /> + <ProjectPicker + className="w-[120px]" + selectedProject={selectedProject} + recentProjects={recentProjects} + onSelectProject={(id) => { + setSelectedProjectId(id); + setV2WorkspaceId(null); + }} + /> + <WorkspacePicker + className="w-[160px]" + hostId={targetHostId ?? null} + projectId={selectedProjectId} + value={v2WorkspaceId} + onChange={setV2WorkspaceId} + /> + <SchedulePicker + className="w-[164px]" + rrule={rrule} + onRruleChange={setRrule} + /> + <AgentPicker + className="w-[100px]" + value={agentType} + onChange={setAgentType} + /> + </div> + + <div className="flex items-center gap-2"> + <DialogClose asChild> + <Button variant="ghost">Cancel</Button> + </DialogClose> + <Button + disabled={!canSubmit} + onClick={() => createMutation.mutate()} + > + {createMutation.isPending ? "Creating…" : "Create"} + </Button> + </div> + </DialogFooter> + </> + ) : ( + <> + <DialogTitle className="sr-only"> + Automation templates + </DialogTitle> + <TemplateGalleryPanel + onBack={() => setView("compose")} + onSelectTemplate={handleTemplatePicked} + /> + </> + )} + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/components/TemplateGalleryPanel/TemplateGalleryPanel.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/components/TemplateGalleryPanel/TemplateGalleryPanel.tsx new file mode 100644 index 00000000000..a0d2b81ab25 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/components/TemplateGalleryPanel/TemplateGalleryPanel.tsx @@ -0,0 +1,57 @@ +import { Button } from "@superset/ui/button"; +import { DialogClose } from "@superset/ui/dialog"; +import { LuArrowLeft, LuX } from "react-icons/lu"; +import { + AUTOMATION_TEMPLATE_CATEGORIES, + type AutomationTemplate, +} from "../../../../templates"; +import { TemplateCard } from "../../../TemplateCard"; + +interface TemplateGalleryPanelProps { + onBack: () => void; + onSelectTemplate: (template: AutomationTemplate) => void; +} + +export function TemplateGalleryPanel({ + onBack, + onSelectTemplate, +}: TemplateGalleryPanelProps) { + return ( + <div className="flex flex-col h-full min-h-0"> + <div className="flex items-center gap-2 p-4 pb-3 border-b"> + <Button + variant="ghost" + size="icon-sm" + onClick={onBack} + aria-label="Back" + > + <LuArrowLeft className="size-4" /> + </Button> + <h2 className="flex-1 text-base font-medium">Automation templates</h2> + <DialogClose asChild> + <Button variant="ghost" size="icon-sm" aria-label="Close"> + <LuX className="size-4" /> + </Button> + </DialogClose> + </div> + <div className="flex-1 min-h-0 overflow-y-auto p-4 flex flex-col gap-6"> + {AUTOMATION_TEMPLATE_CATEGORIES.map((category) => ( + <section key={category.id} className="flex flex-col gap-3"> + <h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> + {category.label} + </h3> + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> + {category.templates.map((template) => ( + <TemplateCard + key={template.id} + template={template} + onSelect={onSelectTemplate} + /> + ))} + </div> + </section> + ))} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/components/TemplateGalleryPanel/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/components/TemplateGalleryPanel/index.ts new file mode 100644 index 00000000000..770234a74c5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/components/TemplateGalleryPanel/index.ts @@ -0,0 +1 @@ +export { TemplateGalleryPanel } from "./TemplateGalleryPanel"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/index.ts new file mode 100644 index 00000000000..686c51a5f2b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/CreateAutomationDialog/index.ts @@ -0,0 +1 @@ +export { CreateAutomationDialog } from "./CreateAutomationDialog"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ProjectPicker/ProjectPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ProjectPicker/ProjectPicker.tsx new file mode 100644 index 00000000000..7ef75ebd1d2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ProjectPicker/ProjectPicker.tsx @@ -0,0 +1,82 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useState } from "react"; +import { HiCheck } from "react-icons/hi2"; +import { LuFolder } from "react-icons/lu"; +import { PickerTrigger } from "renderer/components/PickerTrigger"; +import type { ProjectOption } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; + +interface ProjectPickerProps { + selectedProject: ProjectOption | undefined; + recentProjects: ProjectOption[]; + onSelectProject: (projectId: string) => void; + className?: string; +} + +export function ProjectPicker({ + selectedProject, + recentProjects, + onSelectProject, + className, +}: ProjectPickerProps) { + const [open, setOpen] = useState(false); + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <PickerTrigger + className={className} + icon={ + selectedProject ? ( + <ProjectThumbnail + projectName={selectedProject.name} + githubOwner={selectedProject.githubOwner} + className="!size-5" + /> + ) : ( + <LuFolder className="size-5 shrink-0" /> + ) + } + label={selectedProject?.name ?? "Select project"} + /> + </PopoverTrigger> + <PopoverContent align="start" className="w-60 p-0"> + <Command> + <CommandInput placeholder="Search projects..." /> + <CommandList> + <CommandEmpty>No projects found.</CommandEmpty> + <CommandGroup> + {recentProjects.map((project) => ( + <CommandItem + key={project.id} + value={project.name} + onSelect={() => { + onSelectProject(project.id); + setOpen(false); + }} + > + <ProjectThumbnail + projectName={project.name} + githubOwner={project.githubOwner} + /> + {project.name} + {project.id === selectedProject?.id && ( + <HiCheck className="ml-auto size-4" /> + )} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ProjectPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ProjectPicker/index.ts new file mode 100644 index 00000000000..157bced0682 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/ProjectPicker/index.ts @@ -0,0 +1 @@ +export { ProjectPicker } from "./ProjectPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/SchedulePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/SchedulePicker.tsx new file mode 100644 index 00000000000..910769625b5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/SchedulePicker.tsx @@ -0,0 +1,222 @@ +import { + buildRrule, + describeSchedule, + matchPreset, + type PresetMatch, + type Weekday, +} from "@superset/shared/rrule"; +import { Input } from "@superset/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { useMemo, useState } from "react"; +import { LuClock } from "react-icons/lu"; +import { PickerTrigger } from "renderer/components/PickerTrigger"; + +type PresetKind = PresetMatch["kind"]; + +interface SchedulePickerState { + kind: PresetKind; + hour: number; + minute: number; + day: Weekday; + customRrule: string; +} + +interface SchedulePickerProps { + rrule: string; + onRruleChange: (rrule: string) => void; + className?: string; +} + +const PRESET_OPTIONS: { value: PresetKind; label: string }[] = [ + { value: "hourly", label: "Hourly" }, + { value: "daily", label: "Daily" }, + { value: "weekdays", label: "Weekdays" }, + { value: "weekly", label: "Weekly" }, + { value: "custom", label: "Custom" }, +]; + +const DAY_OPTIONS: { value: Weekday; label: string }[] = [ + { value: "MO", label: "Monday" }, + { value: "TU", label: "Tuesday" }, + { value: "WE", label: "Wednesday" }, + { value: "TH", label: "Thursday" }, + { value: "FR", label: "Friday" }, + { value: "SA", label: "Saturday" }, + { value: "SU", label: "Sunday" }, +]; + +/** Derive the picker's structured state from an RRULE string. */ +function stateFromRrule(rrule: string): SchedulePickerState { + const match = matchPreset(rrule); + const base: SchedulePickerState = { + kind: match.kind, + hour: 9, + minute: 0, + day: "MO", + customRrule: "", + }; + switch (match.kind) { + case "daily": + case "weekdays": + return { ...base, hour: match.hour, minute: match.minute }; + case "weekly": + return { + ...base, + hour: match.hour, + minute: match.minute, + day: match.day, + }; + case "custom": + return { ...base, customRrule: match.rrule }; + default: + return base; + } +} + +/** Serialize the picker state back into an RRULE string. */ +function rruleFromState(state: SchedulePickerState): string { + switch (state.kind) { + case "hourly": + return buildRrule({ kind: "hourly" }); + case "daily": + return buildRrule({ + kind: "daily", + hour: state.hour, + minute: state.minute, + }); + case "weekdays": + return buildRrule({ + kind: "weekdays", + hour: state.hour, + minute: state.minute, + }); + case "weekly": + return buildRrule({ + kind: "weekly", + day: state.day, + hour: state.hour, + minute: state.minute, + }); + case "custom": + return state.customRrule.trim(); + } +} + +function formatTimeInputValue(hour: number, minute: number): string { + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +} + +function parseTimeInputValue( + value: string, +): { hour: number; minute: number } | null { + const [h, m] = value.split(":"); + const hour = Number.parseInt(h ?? "", 10); + const minute = Number.parseInt(m ?? "", 10); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null; + return { hour, minute }; +} + +export function SchedulePicker({ + rrule, + onRruleChange, + className, +}: SchedulePickerProps) { + const [state, setState] = useState<SchedulePickerState>(() => + stateFromRrule(rrule), + ); + + const update = (patch: Partial<SchedulePickerState>) => { + const next = { ...state, ...patch }; + setState(next); + onRruleChange(rruleFromState(next)); + }; + + const triggerLabel = useMemo(() => describeSchedule(rrule), [rrule]); + + return ( + <Popover> + <PopoverTrigger asChild> + <PickerTrigger + className={className} + icon={<LuClock className="size-4 shrink-0" />} + label={triggerLabel} + /> + </PopoverTrigger> + <PopoverContent className="w-72" align="start" side="top" sideOffset={8}> + <div className="flex flex-col gap-3"> + <span className="text-xs font-medium text-muted-foreground"> + Schedule + </span> + + <Select + value={state.kind} + onValueChange={(value) => update({ kind: value as PresetKind })} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {PRESET_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + + {state.kind === "weekly" && ( + <Select + value={state.day} + onValueChange={(value) => update({ day: value as Weekday })} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {DAY_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + + {(state.kind === "daily" || + state.kind === "weekdays" || + state.kind === "weekly") && ( + <Input + type="time" + // color-scheme tells Chromium to render native controls (the + // clock icon) in a theme-appropriate color — without it the icon + // stays a dim gray regardless of background. + className="dark:[color-scheme:dark] [&::-webkit-calendar-picker-indicator]:opacity-70 [&::-webkit-calendar-picker-indicator]:hover:opacity-100" + value={formatTimeInputValue(state.hour, state.minute)} + onChange={(event) => { + const parsed = parseTimeInputValue(event.target.value); + if (parsed) update(parsed); + }} + /> + )} + + {state.kind === "custom" && ( + <Input + autoFocus + placeholder="FREQ=WEEKLY;BYDAY=FR;BYHOUR=9;BYMINUTE=0" + className="font-mono text-xs" + value={state.customRrule} + onChange={(event) => update({ customRrule: event.target.value })} + /> + )} + </div> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/index.ts new file mode 100644 index 00000000000..c9a9b93cfee --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/SchedulePicker/index.ts @@ -0,0 +1 @@ +export { SchedulePicker } from "./SchedulePicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/TemplateCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/TemplateCard.tsx new file mode 100644 index 00000000000..1a0cee905ab --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/TemplateCard.tsx @@ -0,0 +1,39 @@ +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@superset/ui/card"; +import type { AutomationTemplate } from "../../templates"; + +interface TemplateCardProps { + template: AutomationTemplate; + onSelect: (template: AutomationTemplate) => void; +} + +export function TemplateCard({ template, onSelect }: TemplateCardProps) { + return ( + <Card + role="button" + tabIndex={0} + onClick={() => onSelect(template)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(template); + } + }} + className="py-4 cursor-pointer transition-all duration-150 hover:border-border/80 hover:bg-accent/30 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + > + <CardHeader> + <CardTitle className="flex items-center gap-2 text-sm"> + <span className="text-lg leading-none">{template.emoji}</span> + {template.name} + </CardTitle> + <CardDescription className="line-clamp-2"> + {template.description} + </CardDescription> + </CardHeader> + </Card> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/index.ts new file mode 100644 index 00000000000..e5ee5d4f025 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TemplateCard/index.ts @@ -0,0 +1 @@ +export { TemplateCard } from "./TemplateCard"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TimezonePicker/TimezonePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TimezonePicker/TimezonePicker.tsx new file mode 100644 index 00000000000..382f36fc81e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TimezonePicker/TimezonePicker.tsx @@ -0,0 +1,76 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useMemo, useState } from "react"; +import { HiCheck } from "react-icons/hi2"; +import { LuGlobe } from "react-icons/lu"; +import { PickerTrigger } from "renderer/components/PickerTrigger"; + +interface TimezonePickerProps { + value: string; + onChange: (timezone: string) => void; + className?: string; +} + +function listTimezones(): string[] { + const supported = + typeof Intl.supportedValuesOf === "function" + ? Intl.supportedValuesOf("timeZone") + : []; + return supported.length > 0 ? supported : ["UTC"]; +} + +export function TimezonePicker({ + value, + onChange, + className, +}: TimezonePickerProps) { + const [open, setOpen] = useState(false); + const timezones = useMemo(listTimezones, []); + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <PickerTrigger + className={className} + icon={<LuGlobe className="size-4 shrink-0" />} + label={value} + /> + </PopoverTrigger> + <PopoverContent + align="start" + side="top" + sideOffset={8} + className="w-64 p-0" + > + <Command> + <CommandInput placeholder="Search timezones..." /> + <CommandList> + <CommandEmpty>No matching timezone.</CommandEmpty> + <CommandGroup> + {timezones.map((tz) => ( + <CommandItem + key={tz} + value={tz} + onSelect={() => { + onChange(tz); + setOpen(false); + }} + > + <span className="truncate">{tz}</span> + {tz === value && <HiCheck className="ml-auto size-4" />} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TimezonePicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TimezonePicker/index.ts new file mode 100644 index 00000000000..1dbcd19324d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/TimezonePicker/index.ts @@ -0,0 +1 @@ +export { TimezonePicker } from "./TimezonePicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/WorkspacePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/WorkspacePicker.tsx new file mode 100644 index 00000000000..d70dc0512d6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/WorkspacePicker.tsx @@ -0,0 +1,111 @@ +import type { SelectV2Workspace } from "@superset/db/schema"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo, useState } from "react"; +import { HiCheck } from "react-icons/hi2"; +import { LuGitBranch, LuSparkles } from "react-icons/lu"; +import { PickerTrigger } from "renderer/components/PickerTrigger"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +interface WorkspacePickerProps { + hostId: string | null; + projectId: string | null; + value: string | null; + onChange: (workspaceId: string | null) => void; + className?: string; +} + +export function WorkspacePicker({ + hostId, + projectId, + value, + onChange, + className, +}: WorkspacePickerProps) { + const [open, setOpen] = useState(false); + const collections = useCollections(); + + const { data: allWorkspaces = [] } = useLiveQuery( + (q) => + q + .from({ w: collections.v2Workspaces }) + .orderBy(({ w }) => w.createdAt, "desc") + .select(({ w }) => ({ ...w })), + [collections.v2Workspaces], + ); + + const workspaces = useMemo(() => { + const rows = allWorkspaces as SelectV2Workspace[]; + if (!hostId || !projectId) return []; + return rows.filter((w) => w.hostId === hostId && w.projectId === projectId); + }, [allWorkspaces, hostId, projectId]); + + const selected = value ? workspaces.find((w) => w.id === value) : null; + const label = selected ? selected.name : "New workspace"; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <PickerTrigger + className={className} + icon={ + selected ? ( + <LuGitBranch className="size-4 shrink-0" /> + ) : ( + <LuSparkles className="size-4 shrink-0" /> + ) + } + label={label} + /> + </PopoverTrigger> + <PopoverContent + align="start" + side="top" + sideOffset={8} + className="w-60 p-0" + > + <Command> + <CommandInput placeholder="Search workspaces..." /> + <CommandList> + <CommandGroup> + <CommandItem + value="__new__" + onSelect={() => { + onChange(null); + setOpen(false); + }} + > + <LuSparkles className="size-4" /> + <span>New workspace</span> + {!selected && <HiCheck className="ml-auto size-4" />} + </CommandItem> + {workspaces.map((workspace) => ( + <CommandItem + key={workspace.id} + value={workspace.name} + onSelect={() => { + onChange(workspace.id); + setOpen(false); + }} + > + <LuGitBranch className="size-4" /> + <span className="truncate">{workspace.name}</span> + {workspace.id === selected?.id && ( + <HiCheck className="ml-auto size-4" /> + )} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/index.ts new file mode 100644 index 00000000000..2703c29a445 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/components/WorkspacePicker/index.ts @@ -0,0 +1 @@ +export { WorkspacePicker } from "./WorkspacePicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/index.ts new file mode 100644 index 00000000000..0f31993518e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/index.ts @@ -0,0 +1 @@ +export { useProjectFileSearch } from "./useProjectFileSearch"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/useProjectFileSearch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/useProjectFileSearch.ts new file mode 100644 index 00000000000..116930ee6be --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useProjectFileSearch/useProjectFileSearch.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; +import type { FileMentionSearchFn } from "renderer/components/MarkdownEditor/components/FileMention"; +import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import type { WorkspaceHostTarget } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types"; + +const SEARCH_LIMIT = 15; + +export function useProjectFileSearch({ + hostTarget, + projectId, +}: { + hostTarget: WorkspaceHostTarget; + projectId: string | null; +}): FileMentionSearchFn | undefined { + const hostUrl = useHostTargetUrl(hostTarget); + + return useCallback<FileMentionSearchFn>( + async (query) => { + if (!projectId || !hostUrl) return []; + const client = getHostServiceClientByUrl(hostUrl); + const result = await client.filesystem.searchFiles.query({ + projectId, + query, + limit: SEARCH_LIMIT, + }); + return result.matches.map((match) => ({ + id: match.absolutePath, + name: match.name, + relativePath: match.relativePath, + isDirectory: match.kind === "directory", + })); + }, + [hostUrl, projectId], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useRecentProjects/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useRecentProjects/index.ts new file mode 100644 index 00000000000..6bb43d79d2a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useRecentProjects/index.ts @@ -0,0 +1 @@ +export { useRecentProjects } from "./useRecentProjects"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useRecentProjects/useRecentProjects.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useRecentProjects/useRecentProjects.ts new file mode 100644 index 00000000000..50a515e8bca --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/hooks/useRecentProjects/useRecentProjects.ts @@ -0,0 +1,44 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import type { ProjectOption } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +export function useRecentProjects(): ProjectOption[] { + const collections = useCollections(); + + const { data: v2Projects } = useLiveQuery( + (q) => + q + .from({ projects: collections.v2Projects }) + .select(({ projects }) => ({ ...projects })), + [collections], + ); + + const { data: githubRepositories } = useLiveQuery( + (q) => + q.from({ repos: collections.githubRepositories }).select(({ repos }) => ({ + id: repos.id, + owner: repos.owner, + name: repos.name, + })), + [collections], + ); + + return useMemo(() => { + const repoById = new Map( + (githubRepositories ?? []).map((repo) => [repo.id, repo]), + ); + return (v2Projects ?? []).map((project) => { + const repo = project.githubRepositoryId + ? (repoById.get(project.githubRepositoryId) ?? null) + : null; + return { + id: project.id, + name: project.name, + githubOwner: repo?.owner ?? null, + githubRepoName: repo?.name ?? null, + needsSetup: null, + }; + }); + }, [githubRepositories, v2Projects]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx new file mode 100644 index 00000000000..f5624d3bc5f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/layout.tsx @@ -0,0 +1,24 @@ +import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; +import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; + +export const Route = createFileRoute("/_authenticated/_dashboard/automations")({ + component: AutomationsLayout, +}); + +function AutomationsLayout() { + const navigate = useNavigate(); + const { hasAccess, gateFeature } = usePaywall(); + const allowed = hasAccess(GATED_FEATURES.AUTOMATIONS); + const handledRef = useRef(false); + + useEffect(() => { + if (allowed || handledRef.current) return; + handledRef.current = true; + gateFeature(GATED_FEATURES.AUTOMATIONS, () => {}); + navigate({ to: "/v2-workspaces", replace: true }); + }, [allowed, gateFeature, navigate]); + + if (!allowed) return null; + return <Outlet />; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx new file mode 100644 index 00000000000..a395f53eb3d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/page.tsx @@ -0,0 +1,488 @@ +import type { + SelectAutomation, + SelectUser, + SelectV2Host, + SelectV2Workspace, +} from "@superset/db/schema"; +import { COMPANY } from "@superset/shared/constants"; +import { describeSchedule } from "@superset/shared/rrule"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@superset/ui/table"; +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; +import { cn } from "@superset/ui/utils"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMutation } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { HiOutlineComputerDesktop } from "react-icons/hi2"; +import { + LuEllipsis, + LuGitBranch, + LuPencil, + LuPlay, + LuPlus, + LuSparkles, + LuTrash2, +} from "react-icons/lu"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { authClient } from "renderer/lib/auth-client"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { AgentCell } from "./components/AgentCell"; +import { AutomationsEmptyState } from "./components/AutomationsEmptyState"; +import { CellWithIcon } from "./components/CellWithIcon"; +import { CreateAutomationDialog } from "./components/CreateAutomationDialog"; +import { useRecentProjects } from "./hooks/useRecentProjects"; +import type { AutomationTemplate } from "./templates"; + +export const Route = createFileRoute("/_authenticated/_dashboard/automations/")( + { + component: AutomationsPage, + }, +); + +type Scope = "mine" | "team"; + +function AutomationsPage() { + const navigate = useNavigate(); + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const currentUserId = session?.user?.id; + + const [createOpen, setCreateOpen] = useState(false); + const [initialTemplate, setInitialTemplate] = + useState<AutomationTemplate | null>(null); + const [scope, setScope] = useState<Scope>("mine"); + const [pendingDelete, setPendingDelete] = useState<SelectAutomation | null>( + null, + ); + + const runNowMutation = useMutation({ + mutationFn: ({ id }: { id: string; name: string }) => + apiTrpcClient.automation.runNow.mutate({ id }), + onSuccess: (_, { name }) => toast.success(`Running "${name}" now`), + onError: (error) => + toast.error( + error instanceof Error ? error.message : "Failed to trigger run", + ), + }); + + const deleteMutation = useMutation({ + mutationFn: ({ id }: { id: string; name: string }) => + apiTrpcClient.automation.delete.mutate({ id }), + onSuccess: (_, { name }) => { + setPendingDelete(null); + toast.success(`"${name}" deleted`); + }, + onError: (error) => + toast.error( + error instanceof Error ? error.message : "Failed to delete automation", + ), + }); + + const { data: automationRows = [], isReady: automationsReady } = useLiveQuery( + (q) => + q + .from({ a: collections.automations }) + .orderBy(({ a }) => a.createdAt, "desc") + .select(({ a }) => ({ ...a })), + [collections.automations], + ); + const automations = automationRows as SelectAutomation[]; + + const { data: userRows = [] } = useLiveQuery( + (q) => + q.from({ u: collections.users }).select(({ u }) => ({ + id: u.id, + name: u.name, + email: u.email, + })), + [collections.users], + ); + const recentProjects = useRecentProjects(); + const { data: workspaceRows = [] } = useLiveQuery( + (q) => + q + .from({ w: collections.v2Workspaces }) + .select(({ w }) => ({ id: w.id, name: w.name })), + [collections.v2Workspaces], + ); + const { data: hostRows = [] } = useLiveQuery( + (q) => + q + .from({ h: collections.v2Hosts }) + .select(({ h }) => ({ machineId: h.machineId, name: h.name })), + [collections.v2Hosts], + ); + + const usersById = useMemo( + () => + new Map( + (userRows as Pick<SelectUser, "id" | "name" | "email">[]).map((u) => [ + u.id, + u, + ]), + ), + [userRows], + ); + const projectsById = useMemo( + () => new Map(recentProjects.map((p) => [p.id, p])), + [recentProjects], + ); + const workspacesById = useMemo( + () => + new Map( + (workspaceRows as Pick<SelectV2Workspace, "id" | "name">[]).map((w) => [ + w.id, + w, + ]), + ), + [workspaceRows], + ); + const hostsById = useMemo( + () => + new Map( + (hostRows as Pick<SelectV2Host, "machineId" | "name">[]).map((h) => [ + h.machineId, + h, + ]), + ), + [hostRows], + ); + + const mineCount = useMemo( + () => + currentUserId + ? automations.filter((a) => a.ownerUserId === currentUserId).length + : 0, + [automations, currentUserId], + ); + const teamCount = automations.length - mineCount; + + const visible = useMemo(() => { + if (!currentUserId) return automations; + return scope === "mine" + ? automations.filter((a) => a.ownerUserId === currentUserId) + : automations.filter((a) => a.ownerUserId !== currentUserId); + }, [automations, scope, currentUserId]); + + const handleSelectTemplate = (template: AutomationTemplate) => { + setInitialTemplate(template); + setCreateOpen(true); + }; + + const handleDialogOpenChange = (next: boolean) => { + setCreateOpen(next); + if (!next) setInitialTemplate(null); + }; + + return ( + <div className="flex h-full w-full flex-1 flex-col overflow-hidden"> + <header className="flex items-start justify-between border-b px-8 py-6"> + <div> + <h1 className="text-2xl font-semibold">Automations</h1> + <p className="mt-1 text-sm text-muted-foreground"> + Run agents on a schedule to automate work.{" "} + <Button + asChild + variant="link" + size="sm" + className="p-0 h-auto align-baseline" + > + <a + href={`${COMPANY.DOCS_URL}/automations`} + target="_blank" + rel="noreferrer" + > + Learn more + </a> + </Button> + </p> + </div> + <Button type="button" onClick={() => setCreateOpen(true)}> + <LuPlus className="size-4" /> + New automation + </Button> + </header> + + <div className="flex-1 overflow-y-auto px-8 py-6"> + {!automationsReady ? null : automations.length === 0 ? ( + <AutomationsEmptyState onSelectTemplate={handleSelectTemplate} /> + ) : ( + <> + <div className="mb-4 flex justify-end"> + <ToggleGroup + type="single" + variant="outline" + size="sm" + value={scope} + onValueChange={(v) => { + if (v) setScope(v as Scope); + }} + > + <ToggleGroupItem value="mine"> + Mine{" "} + <span className="ml-1 text-muted-foreground"> + {mineCount} + </span> + </ToggleGroupItem> + <ToggleGroupItem value="team"> + Team{" "} + <span className="ml-1 text-muted-foreground"> + {teamCount} + </span> + </ToggleGroupItem> + </ToggleGroup> + </div> + + {visible.length === 0 ? ( + <div className="rounded-md border border-dashed px-8 py-12 text-center text-sm text-muted-foreground"> + {scope === "mine" + ? "You haven't created any automations yet." + : "Nobody on your team has shared automations yet."} + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead>Name</TableHead> + {scope === "team" && <TableHead>Owner</TableHead>} + <TableHead>Project</TableHead> + <TableHead>Workspace</TableHead> + <TableHead>Device</TableHead> + <TableHead>Agent</TableHead> + <TableHead>Schedule</TableHead> + <TableHead className="w-8" /> + </TableRow> + </TableHeader> + <TableBody> + {visible.map((automation) => { + const owner = usersById.get(automation.ownerUserId); + const project = projectsById.get(automation.v2ProjectId); + const workspace = automation.v2WorkspaceId + ? workspacesById.get(automation.v2WorkspaceId) + : null; + const workspaceLabel = !automation.v2WorkspaceId + ? "New workspace" + : (workspace?.name ?? "Deleted"); + const host = automation.targetHostId + ? hostsById.get(automation.targetHostId) + : null; + return ( + <TableRow + key={automation.id} + className="cursor-pointer" + onClick={() => + navigate({ + to: "/automations/$automationId", + params: { automationId: automation.id }, + }) + } + > + <TableCell + className={cn( + "font-medium", + !automation.enabled && "text-muted-foreground", + )} + > + <span className="inline-flex items-center gap-2"> + <span + className={cn( + "inline-block size-2 rounded-full shrink-0", + automation.enabled + ? "bg-emerald-500" + : "border border-muted-foreground/60", + )} + /> + <span className="truncate">{automation.name}</span> + {!automation.enabled && ( + <Badge + variant="secondary" + className="text-[10px]" + > + paused + </Badge> + )} + </span> + </TableCell> + {scope === "team" && ( + <TableCell className="text-muted-foreground"> + {owner?.name ?? owner?.email ?? "—"} + </TableCell> + )} + <TableCell className="text-muted-foreground"> + <span className="inline-flex items-center gap-1.5"> + {project ? ( + <ProjectThumbnail + projectName={project.name} + githubOwner={project.githubOwner} + className="!size-4" + /> + ) : null} + <span className="truncate"> + {project?.name ?? "—"} + </span> + </span> + </TableCell> + <TableCell className="text-muted-foreground"> + <CellWithIcon + icon={ + automation.v2WorkspaceId ? ( + <LuGitBranch className="size-3.5 shrink-0" /> + ) : ( + <LuSparkles className="size-3.5 shrink-0" /> + ) + } + label={workspaceLabel} + /> + </TableCell> + <TableCell className="text-muted-foreground"> + <CellWithIcon + icon={ + <HiOutlineComputerDesktop className="size-3.5 shrink-0" /> + } + label={host?.name ?? "Auto"} + /> + </TableCell> + <TableCell className="text-muted-foreground"> + <AgentCell + agentId={automation.agentConfig.id} + label={automation.agentConfig.label} + /> + </TableCell> + <TableCell className="text-muted-foreground"> + {describeSchedule(automation.rrule)} + </TableCell> + <TableCell> + {automation.ownerUserId === currentUserId && ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="icon-sm" + onClick={(e) => e.stopPropagation()} + aria-label="Row actions" + > + <LuEllipsis className="size-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + onClick={(e) => e.stopPropagation()} + > + <DropdownMenuItem + onSelect={() => + navigate({ + to: "/automations/$automationId", + params: { + automationId: automation.id, + }, + }) + } + > + <LuPencil className="size-4" /> + Edit + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => + runNowMutation.mutate({ + id: automation.id, + name: automation.name, + }) + } + > + <LuPlay className="size-4" /> + Run now + </DropdownMenuItem> + <DropdownMenuItem + variant="destructive" + onSelect={() => setPendingDelete(automation)} + > + <LuTrash2 className="size-4" /> + Delete + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + )} + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + )} + </> + )} + </div> + + <CreateAutomationDialog + open={createOpen} + onOpenChange={handleDialogOpenChange} + initialTemplate={initialTemplate} + onCreated={() => handleDialogOpenChange(false)} + /> + + <AlertDialog + open={!!pendingDelete} + onOpenChange={(next) => { + if (!next) setPendingDelete(null); + }} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete automation?</AlertDialogTitle> + <AlertDialogDescription> + {pendingDelete ? ( + <> + "{pendingDelete.name}" will stop firing and its run history + will be removed. This can't be undone. + </> + ) : null} + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + disabled={deleteMutation.isPending} + onClick={() => { + if (pendingDelete) { + deleteMutation.mutate({ + id: pendingDelete.id, + name: pendingDelete.name, + }); + } + }} + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/data.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/data.ts new file mode 100644 index 00000000000..e35b6f527a1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/data.ts @@ -0,0 +1,164 @@ +/** + * A template is a partial automation + presentation metadata. Applying a + * template pre-fills the create-automation form with name/prompt/agent/rrule; + * device, project, and timezone still come from the user's current selection. + */ +export interface AutomationTemplate { + id: string; + // --- presentation --- + emoji: string; + description: string; + // --- automation defaults --- + name: string; + prompt: string; + agentType?: string; + rrule?: string; +} + +export interface AutomationTemplateCategory { + id: string; + label: string; + templates: AutomationTemplate[]; +} + +const DAILY_9AM = "FREQ=DAILY;BYHOUR=9;BYMINUTE=0"; +const WEEKDAYS_9AM = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"; +const WEEKLY_MONDAY_9AM = "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0"; +const WEEKLY_FRIDAY_5PM = "FREQ=WEEKLY;BYDAY=FR;BYHOUR=17;BYMINUTE=0"; + +export const AUTOMATION_TEMPLATE_CATEGORIES: AutomationTemplateCategory[] = [ + { + id: "status-reports", + label: "Status reports", + templates: [ + { + id: "standup", + emoji: "🟣", + description: "Summarize yesterday's git activity for standup.", + name: "Daily standup digest", + prompt: + "Summarize yesterday's git activity in this repo for a standup. Group by author. Call out blockers and anything that didn't land.", + rrule: WEEKDAYS_9AM, + }, + { + id: "weekly-pr-digest", + emoji: "📝", + description: + "Synthesize this week's PRs, rollouts, incidents, and reviews into a weekly update.", + name: "Weekly team update", + prompt: + "Synthesize this week's merged PRs, rollouts, incidents, and reviews into a concise weekly update. Group by theme. Link each item.", + rrule: WEEKLY_FRIDAY_5PM, + }, + { + id: "team-pr-recap", + emoji: "🗞️", + description: + "Summarize last week's PRs by teammate and theme; highlight risks.", + name: "Weekly PR recap", + prompt: + "Summarize last week's PRs grouped by teammate and theme. Highlight risks, regressions, and anything needing follow-up.", + rrule: WEEKLY_MONDAY_9AM, + }, + ], + }, + { + id: "release-prep", + label: "Release prep", + templates: [ + { + id: "release-notes", + emoji: "📖", + description: + "Draft weekly release notes from merged PRs (include links when available).", + name: "Weekly release notes draft", + prompt: + "Draft release notes for the last 7 days of merged PRs. Group by feature / fix / chore. Include PR links when available.", + rrule: WEEKLY_FRIDAY_5PM, + }, + { + id: "pre-release-check", + emoji: "✅", + description: + "Before tagging, verify changelog, migrations, feature flags, and tests.", + name: "Pre-release audit", + prompt: + "Pre-release audit: verify the changelog is up to date, pending migrations have been run, feature flags default correctly, and tests are green. Flag anything that should block the release.", + rrule: WEEKLY_FRIDAY_5PM, + }, + { + id: "changelog-update", + emoji: "✏️", + description: + "Update the changelog with this week's highlights and key PR links.", + name: "Changelog refresh", + prompt: + "Update CHANGELOG.md with this week's highlights. Include key PR links and keep the tone consistent with previous entries.", + rrule: WEEKLY_FRIDAY_5PM, + }, + ], + }, + { + id: "quality", + label: "Quality & health", + templates: [ + { + id: "bug-scan", + emoji: "🐞", + description: + "Scan recent commits (since the last run, or last 24h) for likely bugs and propose minimal fixes.", + name: "Daily bug scan", + prompt: + "Scan commits from the last 24 hours for likely bugs, regressions, or unsafe patterns. Propose minimal fixes with diffs where possible.", + rrule: DAILY_9AM, + }, + { + id: "ci-failures", + emoji: "🧪", + description: + "Summarize CI failures and flaky tests from the last CI window; suggest top fixes.", + name: "CI health digest", + prompt: + "Summarize CI failures and flaky tests from the last 24 hours. Group by root cause. Suggest the top three fixes to make.", + rrule: DAILY_9AM, + }, + { + id: "benchmark-regressions", + emoji: "👍", + description: + "Compare recent changes to benchmarks or traces and flag regressions early.", + name: "Benchmark regression watch", + prompt: + "Compare recent changes against benchmarks and traces. Flag regressions early and suggest which commits to investigate first.", + rrule: DAILY_9AM, + }, + ], + }, + { + id: "growth", + label: "Growth", + templates: [ + { + id: "skill-deepening", + emoji: "🌳", + description: + "From recent PRs and reviews, suggest next skills to deepen.", + name: "Skill growth suggestions", + prompt: + "Based on my recent PRs and code review comments, suggest 3–5 skills I should deepen next quarter. Be concrete and link evidence.", + rrule: WEEKLY_MONDAY_9AM, + }, + { + id: "small-side-project", + emoji: "🎮", + description: "Create a small classic game with minimal scope.", + name: "Weekend side project", + prompt: + "Scaffold a small classic game (snake, pong, minesweeper, etc.) with minimal scope. Use whatever language fits this repo. Keep it to one file if possible.", + }, + ], + }, +]; + +export const AUTOMATION_TEMPLATES_FLAT: AutomationTemplate[] = + AUTOMATION_TEMPLATE_CATEGORIES.flatMap((category) => category.templates); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/index.ts new file mode 100644 index 00000000000..e7bae5bb9d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/automations/templates/index.ts @@ -0,0 +1,8 @@ +export type { + AutomationTemplate, + AutomationTemplateCategory, +} from "./data"; +export { + AUTOMATION_TEMPLATE_CATEGORIES, + AUTOMATION_TEMPLATES_FLAT, +} from "./data"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx new file mode 100644 index 00000000000..d0a58650ff6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/AddRepositoryModals.tsx @@ -0,0 +1,27 @@ +import { toast } from "@superset/ui/sonner"; +import { + useAddRepositoryModalActive, + useCloseAddRepositoryModal, + useResolveNewProjectModal, +} from "renderer/stores/add-repository-modal"; +import { NewProjectModal } from "./components/NewProjectModal"; + +export function AddRepositoryModals() { + const active = useAddRepositoryModalActive(); + const close = useCloseAddRepositoryModal(); + const resolveNewProject = useResolveNewProjectModal(); + + return ( + <NewProjectModal + open={active.kind === "new-project"} + onOpenChange={(open) => { + if (!open) close(); + }} + onSuccess={(result) => { + toast.success("Project created."); + resolveNewProject({ projectId: result.projectId }); + }} + onError={(message) => toast.error(`Create failed: ${message}`)} + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx new file mode 100644 index 00000000000..6d90c4f3f72 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/NewProjectModal.tsx @@ -0,0 +1,300 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { cn } from "@superset/ui/utils"; +import { useEffect, useState } from "react"; +import { FaGithub } from "react-icons/fa"; +import { + LuFolderOpen, + LuFolderPlus, + LuLayoutTemplate, + LuX, +} from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + type ProjectSetupResult, + useFinalizeProjectSetup, +} from "renderer/react-query/projects"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +type NewProjectMode = "clone" | "empty" | "template"; + +interface NewProjectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (result: ProjectSetupResult) => void; + onError?: (message: string) => void; +} + +const OPTIONS: { + mode: NewProjectMode; + label: string; + suffix?: string; + icon: typeof FaGithub; + disabled?: boolean; +}[] = [ + { + mode: "clone", + label: "Clone from GitHub", + icon: FaGithub, + }, + { + mode: "empty", + label: "Empty", + suffix: "(coming soon)", + icon: LuFolderPlus, + disabled: true, + }, + { + mode: "template", + label: "Template", + suffix: "(coming soon)", + icon: LuLayoutTemplate, + disabled: true, + }, +]; + +function deriveProjectNameFromUrl(url: string): string { + const trimmed = url.trim().replace(/\.git$/i, ""); + const segments = trimmed.split(/[/:]/).filter(Boolean); + return segments[segments.length - 1] ?? ""; +} + +export function NewProjectModal({ + open, + onOpenChange, + onSuccess, + onError, +}: NewProjectModalProps) { + const { activeHostUrl } = useLocalHostService(); + const finalizeSetup = useFinalizeProjectSetup(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + const { data: homeDir } = electronTrpc.window.getHomeDir.useQuery(); + + const [mode, setMode] = useState<NewProjectMode>("clone"); + const [parentDir, setParentDir] = useState(""); + const [url, setUrl] = useState(""); + const [error, setError] = useState<string | null>(null); + const [working, setWorking] = useState(false); + + useEffect(() => { + if (parentDir || !homeDir) return; + setParentDir(`${homeDir}/.superset/projects`); + }, [homeDir, parentDir]); + + const reset = () => { + setUrl(""); + setError(null); + setWorking(false); + }; + + const handleOpenChange = (next: boolean) => { + if (!next && working) return; + if (!next) reset(); + onOpenChange(next); + }; + + const handleBrowse = async () => { + try { + const result = await selectDirectory.mutateAsync({ + title: "Select project location", + defaultPath: parentDir || undefined, + }); + if (!result.canceled && result.path) { + setParentDir(result.path); + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const createFromClone = async () => { + if (!activeHostUrl) { + setError("Host service not available"); + return; + } + const trimmedUrl = url.trim(); + const trimmedParent = parentDir.trim(); + if (!trimmedUrl) { + setError("Please enter a repository URL"); + return; + } + if (!trimmedParent) { + setError("Please select a project location"); + return; + } + const name = deriveProjectNameFromUrl(trimmedUrl); + if (!name) { + setError("Could not derive a project name from the URL"); + return; + } + + setWorking(true); + setError(null); + try { + const client = getHostServiceClientByUrl(activeHostUrl); + const result = await client.project.create.mutate({ + name, + mode: { kind: "clone", parentDir: trimmedParent, url: trimmedUrl }, + }); + finalizeSetup(activeHostUrl, result); + onSuccess?.(result); + reset(); + onOpenChange(false); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + onError?.(message); + } finally { + setWorking(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-xl"> + <DialogHeader> + <DialogTitle>New project</DialogTitle> + <DialogDescription className="sr-only"> + Create a new project by cloning a repository. + </DialogDescription> + </DialogHeader> + + <div className="flex flex-col gap-4"> + <div className="flex flex-col gap-1.5"> + <label + htmlFor="project-path" + className="text-xs font-medium text-muted-foreground" + > + Location + </label> + <div className="flex gap-1.5"> + <Input + id="project-path" + value={parentDir} + onChange={(e) => setParentDir(e.target.value)} + disabled={working} + className="flex-1 font-mono text-xs" + /> + <Button + type="button" + variant="outline" + size="icon" + onClick={handleBrowse} + disabled={working || selectDirectory.isPending} + className="shrink-0" + aria-label="Browse for directory" + > + <LuFolderOpen className="size-4" /> + </Button> + </div> + </div> + + <div className="grid grid-cols-3 gap-2"> + {OPTIONS.map((option) => { + const selected = mode === option.mode; + const isDisabled = option.disabled || working; + return ( + <button + key={option.mode} + type="button" + disabled={isDisabled} + onClick={() => { + setMode(option.mode); + setError(null); + }} + className={cn( + "flex flex-col items-center gap-2 rounded-lg border px-3 py-4 text-center transition-colors", + selected + ? "border-transparent bg-primary/5" + : "border-border/60", + !isDisabled && !selected && "hover:bg-accent/30", + isDisabled && "opacity-50 cursor-not-allowed", + )} + > + <option.icon + className={cn( + "size-5", + selected ? "text-primary" : "text-muted-foreground", + )} + /> + <div className="flex flex-col items-center gap-0.5 text-xs font-medium text-foreground leading-tight"> + <span>{option.label}</span> + {option.suffix && ( + <span className="text-[11px] font-normal text-muted-foreground"> + {option.suffix} + </span> + )} + </div> + </button> + ); + })} + </div> + + {mode === "clone" && ( + <div className="flex flex-col gap-1.5"> + <label + htmlFor="clone-url" + className="text-xs font-medium text-muted-foreground" + > + Repository URL + </label> + <Input + id="clone-url" + value={url} + onChange={(e) => setUrl(e.target.value)} + placeholder="https://github.com/owner/repo.git" + disabled={working} + onKeyDown={(e) => { + if (e.key === "Enter" && !working) { + void createFromClone(); + } + }} + autoFocus + /> + </div> + )} + + {error && ( + <div className="flex items-start gap-2 rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2"> + <span className="flex-1 text-xs text-destructive">{error}</span> + <button + type="button" + onClick={() => setError(null)} + className="shrink-0 rounded p-0.5 text-destructive/70 hover:text-destructive transition-colors" + aria-label="Dismiss error" + > + <LuX className="size-3.5" /> + </button> + </div> + )} + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={working} + > + Cancel + </Button> + <Button + onClick={() => void createFromClone()} + disabled={working || mode !== "clone"} + > + {working ? "Cloning…" : "Clone"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/index.ts new file mode 100644 index 00000000000..1fb78225d72 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/components/NewProjectModal/index.ts @@ -0,0 +1 @@ +export { NewProjectModal } from "./NewProjectModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts new file mode 100644 index 00000000000..ceca8dd81eb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/index.ts @@ -0,0 +1,4 @@ +export { + type UseFolderFirstImportResult, + useFolderFirstImport, +} from "./useFolderFirstImport"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts new file mode 100644 index 00000000000..26167172f75 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport/useFolderFirstImport.ts @@ -0,0 +1,102 @@ +import { useCallback } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { getBaseName } from "renderer/lib/pathBasename"; +import { + type ProjectSetupResult, + useFinalizeProjectSetup, +} from "renderer/react-query/projects"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +export interface UseFolderFirstImportResult { + start: () => Promise<ProjectSetupResult | null>; +} + +interface MatchingProject { + id: string; + name: string; +} + +export function useFolderFirstImport(options?: { + onError?: (message: string) => void; + onMultipleProjects?: (input: { candidates: MatchingProject[] }) => void; +}): UseFolderFirstImportResult { + const { activeHostUrl } = useLocalHostService(); + const finalizeSetup = useFinalizeProjectSetup(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + const { onError, onMultipleProjects } = options ?? {}; + + const start = useCallback(async (): Promise<ProjectSetupResult | null> => { + if (!activeHostUrl) { + onError?.("Host service not available"); + return null; + } + + let repoPath: string; + try { + const picked = await selectDirectory.mutateAsync({ + title: "Import existing folder", + }); + if (picked.canceled || !picked.path) return null; + repoPath = picked.path; + } catch (err) { + onError?.(err instanceof Error ? err.message : String(err)); + return null; + } + + const client = getHostServiceClientByUrl(activeHostUrl); + let candidates: MatchingProject[]; + try { + const response = await client.project.findByPath.query({ repoPath }); + candidates = response.candidates; + } catch (err) { + onError?.(err instanceof Error ? err.message : String(err)); + return null; + } + + const [only, ...rest] = candidates; + if (rest.length > 0) { + if (onMultipleProjects) { + onMultipleProjects({ candidates }); + } else { + onError?.( + `Multiple projects use this repository (${candidates.length}). Open the project you want from settings to set it up on this device.`, + ); + } + return null; + } + + try { + let result: ProjectSetupResult; + if (only) { + const setupResult = await client.project.setup.mutate({ + projectId: only.id, + mode: { kind: "import", repoPath }, + }); + result = { + projectId: only.id, + repoPath: setupResult.repoPath, + mainWorkspaceId: setupResult.mainWorkspaceId, + }; + } else { + result = await client.project.create.mutate({ + name: getBaseName(repoPath), + mode: { kind: "importLocal", repoPath }, + }); + } + finalizeSetup(activeHostUrl, result); + return result; + } catch (err) { + onError?.(err instanceof Error ? err.message : String(err)); + return null; + } + }, [ + activeHostUrl, + finalizeSetup, + onError, + onMultipleProjects, + selectDirectory, + ]); + + return { start }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts new file mode 100644 index 00000000000..fcc618e3efb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/index.ts @@ -0,0 +1 @@ +export { AddRepositoryModals } from "./AddRepositoryModals"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index 815311dacd3..d52c7d9db3f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -1,35 +1,202 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + DragOverlay, + KeyboardSensor, + MeasuringStrategy, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; +import { DashboardSidebarHoverCardOverlay } from "./components/DashboardSidebarHoverCardOverlay"; +import { DashboardSidebarPortsList } from "./components/DashboardSidebarPortsList"; import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection"; +import { DashboardSidebarSectionRenameProvider } from "./components/DashboardSidebarSectionRenameContext"; import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData"; import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts"; +import { DashboardSidebarHoverProvider } from "./providers/DashboardSidebarHoverProvider"; +import type { DashboardSidebarProject } from "./types"; interface DashboardSidebarProps { isCollapsed?: boolean; } +interface SortableProjectWrapperProps { + project: DashboardSidebarProject; + isCollapsed: boolean; + isDraggingProject: boolean; + workspaceShortcutLabels: Map<string, string>; + onWorkspaceHover: (workspaceId: string) => void | Promise<void>; + onToggleCollapse: (projectId: string) => void; +} + +const SortableProjectWrapper = memo(function SortableProjectWrapper({ + project, + isCollapsed, + isDraggingProject, + workspaceShortcutLabels, + onWorkspaceHover, + onToggleCollapse, +}: SortableProjectWrapperProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: project.id }); + + return ( + <div + ref={setNodeRef} + style={{ + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + }} + > + <DashboardSidebarProjectSection + project={project} + isSidebarCollapsed={isCollapsed} + isDraggingProject={isDraggingProject} + workspaceShortcutLabels={workspaceShortcutLabels} + onWorkspaceHover={onWorkspaceHover} + onToggleCollapse={onToggleCollapse} + dragHandleListeners={listeners} + dragHandleAttributes={attributes} + /> + </div> + ); +}); + export function DashboardSidebar({ isCollapsed = false, }: DashboardSidebarProps) { const { groups, refreshWorkspacePullRequest, toggleProjectCollapsed } = useDashboardSidebarData(); const workspaceShortcutLabels = useDashboardSidebarShortcuts(groups); + const { reorderProjects } = useDashboardSidebarState(); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const [activeProject, setActiveProject] = + useState<DashboardSidebarProject | null>(null); + + // Local project order — syncs from groups, updated on drag end + const [projectOrder, setProjectOrder] = useState(() => + groups.map((p) => p.id), + ); + useEffect(() => { + setProjectOrder(groups.map((p) => p.id)); + }, [groups]); + + const orderedGroups = useMemo(() => { + const byId = new Map(groups.map((g) => [g.id, g])); + return projectOrder + .map((id) => byId.get(id)) + .filter((g): g is DashboardSidebarProject => g != null); + }, [groups, projectOrder]); + + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) { + const oldIndex = projectOrder.indexOf(String(active.id)); + const newIndex = projectOrder.indexOf(String(over.id)); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(projectOrder, oldIndex, newIndex); + setProjectOrder(reordered); + reorderProjects(reordered); + } + } + setActiveProject(null); + }, + [projectOrder, reorderProjects], + ); return ( - <div className="flex h-full flex-col border-r border-border bg-muted/45 dark:bg-muted/35"> - <DashboardSidebarHeader isCollapsed={isCollapsed} /> - - <div className="flex-1 overflow-y-auto hide-scrollbar"> - {groups.map((project) => ( - <DashboardSidebarProjectSection - key={project.id} - project={project} - isSidebarCollapsed={isCollapsed} - workspaceShortcutLabels={workspaceShortcutLabels} - onWorkspaceHover={refreshWorkspacePullRequest} - onToggleCollapse={toggleProjectCollapsed} - /> - ))} - </div> - </div> + <DashboardSidebarSectionRenameProvider> + <DashboardSidebarHoverProvider> + <DashboardSidebarHoverCardOverlay> + <div className="flex h-full flex-col border-r border-border bg-muted/45 dark:bg-muted/35"> + <DashboardSidebarHeader isCollapsed={isCollapsed} /> + + <div className="flex-1 overflow-y-auto hide-scrollbar"> + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + measuring={{ + droppable: { strategy: MeasuringStrategy.Always }, + }} + onDragStart={({ active }) => { + const project = groups.find((p) => p.id === active.id); + setActiveProject(project ?? null); + }} + onDragEnd={handleDragEnd} + onDragCancel={() => setActiveProject(null)} + > + <SortableContext + items={projectOrder} + strategy={verticalListSortingStrategy} + > + {orderedGroups.map((project) => ( + <SortableProjectWrapper + key={project.id} + project={project} + isCollapsed={isCollapsed} + isDraggingProject={activeProject != null} + workspaceShortcutLabels={workspaceShortcutLabels} + onWorkspaceHover={refreshWorkspacePullRequest} + onToggleCollapse={toggleProjectCollapsed} + /> + ))} + </SortableContext> + + {createPortal( + <DragOverlay dropAnimation={null}> + {activeProject && ( + <div className="bg-background shadow-lg border-b border-border"> + <DashboardSidebarProjectSection + project={activeProject} + isSidebarCollapsed={isCollapsed} + isDraggingProject + workspaceShortcutLabels={workspaceShortcutLabels} + onWorkspaceHover={() => {}} + onToggleCollapse={() => {}} + /> + </div> + )} + </DragOverlay>, + document.body, + )} + </DndContext> + </div> + {!isCollapsed && <DashboardSidebarPortsList />} + </div> + </DashboardSidebarHoverCardOverlay> + </DashboardSidebarHoverProvider> + </DashboardSidebarSectionRenameProvider> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/DashboardSidebarDeleteDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/DashboardSidebarDeleteDialog.tsx index 06d0d815f48..8f10b423181 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/DashboardSidebarDeleteDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/DashboardSidebarDeleteDialog.tsx @@ -1,61 +1,70 @@ -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@superset/ui/alert-dialog"; -import { Button } from "@superset/ui/button"; +import { DestroyConfirmPane } from "./components/DestroyConfirmPane"; +import { TeardownFailedPane } from "./components/TeardownFailedPane"; +import { useDestroyDialogState } from "./hooks/useDestroyDialogState"; interface DashboardSidebarDeleteDialogProps { + workspaceId: string; + workspaceName: string; open: boolean; onOpenChange: (open: boolean) => void; - onConfirm: () => void; - title: string; - description: string; - isPending?: boolean; + /** Fires after a successful destroy (any warnings reported via toast). */ + onDeleted?: () => void; } +/** + * Dispatches between confirm and teardown-failed panes based on the error + * returned by `workspaceCleanup.destroy`. Dirty-worktree state is surfaced + * inline as a banner on the confirm pane so the user only sees one warning + * before the destroy runs. + */ export function DashboardSidebarDeleteDialog({ + workspaceId, + workspaceName, open, onOpenChange, - onConfirm, - title, - description, - isPending = false, + onDeleted, }: DashboardSidebarDeleteDialogProps) { + const { + deleteBranch, + setDeleteBranch, + hasChanges, + hasUnpushedCommits, + isCheckingStatus, + error, + handleOpenChange, + run, + } = useDestroyDialogState({ + workspaceId, + workspaceName, + open, + onOpenChange, + onDeleted, + }); + + if (error?.kind === "teardown-failed") { + return ( + <TeardownFailedPane + open={open} + onOpenChange={handleOpenChange} + cause={error.cause} + onForceDelete={() => run(true)} + /> + ); + } + + const hasWarnings = hasChanges || hasUnpushedCommits; + return ( - <AlertDialog + <DestroyConfirmPane open={open} - onOpenChange={isPending ? undefined : onOpenChange} - > - <AlertDialogContent className="max-w-[340px] gap-0 p-0"> - <AlertDialogHeader className="px-4 pt-4 pb-2"> - <AlertDialogTitle className="font-medium">{title}</AlertDialogTitle> - <AlertDialogDescription>{description}</AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2"> - <Button - variant="ghost" - size="sm" - className="h-7 px-3 text-xs" - onClick={() => onOpenChange(false)} - disabled={isPending} - > - Cancel - </Button> - <Button - variant="destructive" - size="sm" - className="h-7 px-3 text-xs" - onClick={onConfirm} - disabled={isPending} - > - {isPending ? "Deleting..." : "Delete"} - </Button> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> + onOpenChange={handleOpenChange} + workspaceName={workspaceName} + deleteBranch={deleteBranch} + onDeleteBranchChange={setDeleteBranch} + hasChanges={hasChanges} + hasUnpushedCommits={hasUnpushedCommits} + isCheckingStatus={isCheckingStatus} + onConfirm={() => run(hasWarnings)} + /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/DestroyConfirmPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/DestroyConfirmPane.tsx new file mode 100644 index 00000000000..b54ede75067 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/DestroyConfirmPane.tsx @@ -0,0 +1,101 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { Checkbox } from "@superset/ui/checkbox"; +import { Label } from "@superset/ui/label"; +import { useId } from "react"; + +interface DestroyConfirmPaneProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceName: string; + deleteBranch: boolean; + onDeleteBranchChange: (next: boolean) => void; + hasChanges: boolean; + hasUnpushedCommits: boolean; + isCheckingStatus: boolean; + onConfirm: () => void; +} + +export function DestroyConfirmPane({ + open, + onOpenChange, + workspaceName, + deleteBranch, + onDeleteBranchChange, + hasChanges, + hasUnpushedCommits, + isCheckingStatus, + onConfirm, +}: DestroyConfirmPaneProps) { + const checkboxId = useId(); + const hasWarnings = hasChanges || hasUnpushedCommits; + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent className="max-w-[340px] gap-0 p-0"> + <AlertDialogHeader className="px-4 pt-4 pb-2"> + <AlertDialogTitle className="font-medium"> + Delete workspace "{workspaceName}"? + </AlertDialogTitle> + <AlertDialogDescription> + This removes the worktree from disk. The cloud workspace record will + also be removed. + </AlertDialogDescription> + </AlertDialogHeader> + {hasWarnings && ( + <div className="px-4 pb-2"> + <div className="text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-md px-2.5 py-1.5"> + {hasChanges && hasUnpushedCommits + ? "Has uncommitted changes and unpushed commits" + : hasChanges + ? "Has uncommitted changes" + : "Has unpushed commits"} + </div> + </div> + )} + <div className="px-4 pb-2"> + <div className="flex items-center gap-2"> + <Checkbox + id={checkboxId} + checked={deleteBranch} + onCheckedChange={(checked) => + onDeleteBranchChange(checked === true) + } + /> + <Label + htmlFor={checkboxId} + className="text-xs text-muted-foreground cursor-pointer select-none" + > + Also delete local branch + </Label> + </div> + </div> + <AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2"> + <Button + variant="ghost" + size="sm" + className="h-7 px-3 text-xs" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button + variant="destructive" + size="sm" + className="h-7 px-3 text-xs" + onClick={onConfirm} + disabled={isCheckingStatus} + > + Delete + </Button> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/index.ts new file mode 100644 index 00000000000..14fdb370d38 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/DestroyConfirmPane/index.ts @@ -0,0 +1 @@ +export { DestroyConfirmPane } from "./DestroyConfirmPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/TeardownFailedPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/TeardownFailedPane.tsx new file mode 100644 index 00000000000..90b3555ba7d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/TeardownFailedPane.tsx @@ -0,0 +1,68 @@ +import type { TeardownFailureCause } from "@superset/host-service"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import stripAnsi from "strip-ansi"; +import { formatTeardownReason } from "./formatTeardownReason"; + +interface TeardownFailedPaneProps { + open: boolean; + onOpenChange: (open: boolean) => void; + cause: TeardownFailureCause; + /** Re-runs destroy with `force: true` — skips teardown entirely. */ + onForceDelete: () => void; +} + +/** Shown when `.superset/teardown.sh` exited non-zero or timed out. */ +export function TeardownFailedPane({ + open, + onOpenChange, + cause, + onForceDelete, +}: TeardownFailedPaneProps) { + const reason = formatTeardownReason(cause); + // Strip ANSI so raw PTY bytes render readably in the <pre>. + const cleanTail = stripAnsi(cause.outputTail ?? ""); + + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent className="max-w-[500px] gap-0 p-0"> + <AlertDialogHeader className="px-4 pt-4 pb-2"> + <AlertDialogTitle className="font-medium">{reason}</AlertDialogTitle> + <AlertDialogDescription> + Delete anyway will skip the teardown script entirely. + </AlertDialogDescription> + </AlertDialogHeader> + {cleanTail && ( + <pre className="mx-4 mb-2 max-h-48 overflow-auto rounded border bg-muted px-2 py-1.5 text-[11px] leading-relaxed whitespace-pre-wrap font-mono"> + {cleanTail} + </pre> + )} + <AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2"> + <Button + variant="ghost" + size="sm" + className="h-7 px-3 text-xs" + onClick={() => onOpenChange(false)} + > + Cancel + </Button> + <Button + variant="destructive" + size="sm" + className="h-7 px-3 text-xs" + onClick={onForceDelete} + > + Delete anyway + </Button> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/formatTeardownReason.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/formatTeardownReason.ts new file mode 100644 index 00000000000..8c0f002c4aa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/formatTeardownReason.ts @@ -0,0 +1,16 @@ +import type { TeardownFailureCause } from "@superset/host-service"; +import { TEARDOWN_TIMEOUT_MS } from "@superset/shared/constants"; + +/** Human-readable one-liner for the dialog title when teardown fails. */ +export function formatTeardownReason(cause: TeardownFailureCause): string { + if (cause.timedOut) { + return `Teardown timed out after ${Math.round(TEARDOWN_TIMEOUT_MS / 1000)}s`; + } + if (cause.exitCode != null) { + return `Teardown exited with code ${cause.exitCode}`; + } + if (cause.signal != null) { + return `Teardown terminated by signal ${cause.signal}`; + } + return "Teardown failed to start"; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/index.ts new file mode 100644 index 00000000000..5f3624f7146 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/components/TeardownFailedPane/index.ts @@ -0,0 +1 @@ +export { TeardownFailedPane } from "./TeardownFailedPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/index.ts new file mode 100644 index 00000000000..3a79f75e96c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/index.ts @@ -0,0 +1 @@ +export { useDestroyDialogState } from "./useDestroyDialogState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts new file mode 100644 index 00000000000..4f916cc1922 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarDeleteDialog/hooks/useDestroyDialogState/useDestroyDialogState.ts @@ -0,0 +1,128 @@ +import { toast } from "@superset/ui/sonner"; +import { useCallback, useRef, useState } from "react"; +import type { DestroyWorkspaceSuccess } from "renderer/hooks/host-service/useDestroyWorkspace"; +import { + type DestroyWorkspaceError, + useDestroyWorkspace, +} from "renderer/hooks/host-service/useDestroyWorkspace"; +import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences/useV2UserPreferences"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useNavigateAwayFromWorkspace } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace"; +import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; + +const STATUS_STALE_TIME_MS = 5_000; + +interface UseDestroyDialogStateOptions { + workspaceId: string; + workspaceName: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onDeleted?: () => void; +} + +export function useDestroyDialogState({ + workspaceId, + workspaceName, + open, + onOpenChange, + onDeleted, +}: UseDestroyDialogStateOptions) { + const { destroy } = useDestroyWorkspace(workspaceId); + const { markDeleting, clearDeleting } = useDeletingWorkspaces(); + const navigateAway = useNavigateAwayFromWorkspace(); + + const { preferences, setDeleteLocalBranch: setDeleteBranch } = + useV2UserPreferences(); + const deleteBranch = preferences.deleteLocalBranch; + + const { data: canDeleteData, isPending: isCheckingStatus } = + electronTrpc.workspaces.canDelete.useQuery( + { id: workspaceId }, + { + enabled: open, + staleTime: STATUS_STALE_TIME_MS, + refetchOnWindowFocus: false, + }, + ); + const hasChanges = canDeleteData?.hasChanges ?? false; + const hasUnpushedCommits = canDeleteData?.hasUnpushedCommits ?? false; + + const [error, setError] = useState<DestroyWorkspaceError | null>(null); + const inFlight = useRef(false); + + const handleOpenChange = useCallback( + (next: boolean) => { + if (!next) setError(null); + onOpenChange(next); + }, + [onOpenChange], + ); + + const run = useCallback( + async (force: boolean) => { + if (inFlight.current) return; + inFlight.current = true; + + // Navigate off the doomed workspace FIRST. Closing the dialog + // and hiding the row were swallowing the nav otherwise. + navigateAway(workspaceId); + + setError(null); + onOpenChange(false); + markDeleting(workspaceId); + toast(`Deleting "${workspaceName}"...`); + + try { + let result: DestroyWorkspaceSuccess; + try { + result = await destroy({ deleteBranch, force }); + } catch (firstErr) { + const e = firstErr as DestroyWorkspaceError; + // Race: preflight said clean but worktree was dirty by the time + // destroy ran. The user already confirmed once — don't make them + // confirm a second "uncommitted changes" warning, just force. + if (e.kind === "conflict" && !force) { + result = await destroy({ deleteBranch, force: true }); + } else { + throw firstErr; + } + } + for (const warning of result.warnings) toast.warning(warning); + onDeleted?.(); + } catch (err) { + const e = err as DestroyWorkspaceError; + if (e.kind === "teardown-failed") { + setError(e); + onOpenChange(true); + } else { + toast.error(`Failed to delete ${workspaceName}: ${e.message}`); + } + } finally { + clearDeleting(workspaceId); + inFlight.current = false; + } + }, + [ + destroy, + deleteBranch, + workspaceName, + workspaceId, + onOpenChange, + onDeleted, + markDeleting, + clearDeleting, + navigateAway, + ], + ); + + return { + deleteBranch, + setDeleteBranch, + hasChanges, + hasUnpushedCommits, + isCheckingStatus, + error, + handleOpenChange, + run, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx index 62ce65e4cbb..45241287d5f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHeader/DashboardSidebarHeader.tsx @@ -1,17 +1,31 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { LuLayers, LuPlus } from "react-icons/lu"; -import { - STROKE_WIDTH, - STROKE_WIDTH_THICK, -} from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { useFeatureFlagEnabled } from "posthog-js/react"; +import { HiMiniPlus, HiOutlineClipboardDocumentList } from "react-icons/hi2"; import { - useEffectiveHotkeysMap, - useHotkeysStore, -} from "renderer/stores/hotkeys"; + LuClock, + LuFolderInput, + LuFolderPlus, + LuLayers, + LuPlus, +} from "react-icons/lu"; +import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; +import { useHotkeyDisplay } from "renderer/hotkeys"; +import { useFolderFirstImport } from "renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport"; +import { OrganizationDropdown } from "renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown"; +import { useTasksFilterStore } from "renderer/routes/_authenticated/_dashboard/tasks/stores/tasks-filter-state"; +import { STROKE_WIDTH_THICK } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { useOpenNewProjectModal } from "renderer/stores/add-repository-modal"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; -import { formatHotkeyText } from "shared/hotkeys"; interface DashboardSidebarHeaderProps { isCollapsed?: boolean; @@ -20,21 +34,72 @@ interface DashboardSidebarHeaderProps { export function DashboardSidebarHeader({ isCollapsed = false, }: DashboardSidebarHeaderProps) { + const openModal = useOpenNewWorkspaceModal(); + const openNewProject = useOpenNewProjectModal(); const navigate = useNavigate(); + const folderImport = useFolderFirstImport({ + onError: (message) => { + toast.error(`Import failed: ${message}`); + }, + onMultipleProjects: ({ candidates }) => { + toast.error("Import failed", { + description: `Multiple projects use this repository (${candidates.length}). Choose the project in settings to set it up on this device.`, + action: { + label: "Open Projects", + onClick: () => navigate({ to: "/settings/projects" }), + }, + }); + }, + }); + + const handleImportFolder = async () => { + const result = await folderImport.start(); + if (result) { + toast.success("Project ready — open it from the sidebar."); + } + }; + const shortcutText = useHotkeyDisplay("NEW_WORKSPACE").text; const matchRoute = useMatchRoute(); - const openModal = useOpenNewWorkspaceModal(); - const platform = useHotkeysStore((state) => state.platform); - const effective = useEffectiveHotkeysMap(); - const shortcutText = formatHotkeyText(effective.NEW_WORKSPACE, platform); - const isWorkspacesPageOpen = !!matchRoute({ to: "/v2-workspaces" }); + const { gateFeature } = usePaywall(); + const isWorkspacesListOpen = !!matchRoute({ to: "/v2-workspaces" }); + const isTasksOpen = !!matchRoute({ to: "/tasks", fuzzy: true }); + const isAutomationsOpen = !!matchRoute({ to: "/automations", fuzzy: true }); + + const showAutomations = useFeatureFlagEnabled( + FEATURE_FLAGS.AUTOMATIONS_ACCESS, + ); + + const { + tab: lastTab, + assignee: lastAssignee, + search: lastSearch, + } = useTasksFilterStore(); const handleWorkspacesClick = () => { navigate({ to: "/v2-workspaces" }); }; + const handleAutomationsClick = () => { + gateFeature(GATED_FEATURES.AUTOMATIONS, () => { + navigate({ to: "/automations" }); + }); + }; + + const handleTasksClick = () => { + gateFeature(GATED_FEATURES.TASKS, () => { + const search: Record<string, string> = {}; + if (lastTab !== "all") search.tab = lastTab; + if (lastAssignee) search.assignee = lastAssignee; + if (lastSearch) search.search = lastSearch; + navigate({ to: "/tasks", search }); + }); + }; + if (isCollapsed) { return ( <div className="flex flex-col items-center gap-2 border-b border-border py-2"> + <OrganizationDropdown variant="collapsed" /> + <Tooltip delayDuration={300}> <TooltipTrigger asChild> <button @@ -42,17 +107,55 @@ export function DashboardSidebarHeader({ onClick={handleWorkspacesClick} className={cn( "flex size-8 items-center justify-center rounded-md transition-colors", - isWorkspacesPageOpen + isWorkspacesListOpen ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} > - <LuLayers className="size-4" strokeWidth={STROKE_WIDTH} /> + <LuLayers className="size-4" /> </button> </TooltipTrigger> <TooltipContent side="right">Workspaces</TooltipContent> </Tooltip> + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleTasksClick} + className={cn( + "flex size-8 items-center justify-center rounded-md transition-colors", + isTasksOpen + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + )} + > + <HiOutlineClipboardDocumentList className="size-4" /> + </button> + </TooltipTrigger> + <TooltipContent side="right">Tasks</TooltipContent> + </Tooltip> + + {showAutomations && ( + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleAutomationsClick} + className={cn( + "flex size-8 items-center justify-center rounded-md transition-colors", + isAutomationsOpen + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + )} + > + <LuClock className="size-4" /> + </button> + </TooltipTrigger> + <TooltipContent side="right">Automations</TooltipContent> + </Tooltip> + )} + <Tooltip delayDuration={300}> <TooltipTrigger asChild> <button @@ -67,44 +170,132 @@ export function DashboardSidebarHeader({ New Workspace ({shortcutText}) </TooltipContent> </Tooltip> + + <DropdownMenu> + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <button + type="button" + aria-label="Add repository" + className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" + > + <LuFolderPlus className="size-4" /> + </button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="right">Add repository</TooltipContent> + </Tooltip> + <DropdownMenuContent align="start"> + <DropdownMenuItem onSelect={() => openNewProject()}> + <HiMiniPlus className="size-4" /> + Create new project + </DropdownMenuItem> + <DropdownMenuItem onSelect={handleImportFolder}> + <LuFolderInput className="size-4" /> + Import project + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ); } return ( <div className="flex flex-col gap-1 border-b border-border px-2 pt-2 pb-2"> + <OrganizationDropdown variant="expanded" /> + <button type="button" onClick={handleWorkspacesClick} className={cn( "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors", - isWorkspacesPageOpen + isWorkspacesListOpen ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} > - <div className="flex size-5 items-center justify-center"> - <LuLayers className="size-4" strokeWidth={STROKE_WIDTH} /> - </div> + <LuLayers className="size-4 shrink-0" /> <span className="flex-1 text-left">Workspaces</span> </button> - <button - type="button" - onClick={() => openModal()} - className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" - > - <LuPlus className="size-4 shrink-0" strokeWidth={STROKE_WIDTH_THICK} /> - <span className="flex-1 text-left">New Workspace</span> - <span + {showAutomations && ( + <button + type="button" + onClick={handleAutomationsClick} className={cn( - "shrink-0 text-[10px] font-mono tabular-nums text-muted-foreground/60", - "opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100", + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors", + isAutomationsOpen + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", )} > - {shortcutText} - </span> + <LuClock className="size-4 shrink-0" /> + <span className="flex-1 text-left">Automations</span> + </button> + )} + + <button + type="button" + onClick={handleTasksClick} + className={cn( + "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors", + isTasksOpen + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + )} + > + <HiOutlineClipboardDocumentList className="size-4 shrink-0" /> + <span className="flex-1 text-left">Tasks</span> </button> + + <div className="flex items-center gap-1"> + <button + type="button" + onClick={() => openModal()} + className="group flex flex-1 min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" + > + <LuPlus + className="size-4 shrink-0" + strokeWidth={STROKE_WIDTH_THICK} + /> + <span className="flex-1 text-left">New Workspace</span> + <span + className={cn( + "shrink-0 text-[10px] font-mono tabular-nums text-muted-foreground/60", + "opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100", + )} + > + {shortcutText} + </span> + </button> + <DropdownMenu> + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <button + type="button" + aria-label="Add repository" + className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground" + > + <LuFolderPlus className="size-4" /> + </button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="right">Add repository</TooltipContent> + </Tooltip> + <DropdownMenuContent align="end"> + <DropdownMenuItem onSelect={() => openNewProject()}> + <HiMiniPlus className="size-4" /> + Create new project + </DropdownMenuItem> + <DropdownMenuItem onSelect={handleImportFolder}> + <LuFolderInput className="size-4" /> + Import project + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.css b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.css new file mode 100644 index 00000000000..7219daef45e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.css @@ -0,0 +1,10 @@ +/* Animate the sidebar hover card sliding between rows. Targets the + * Radix popper wrapper (which carries the position transform) only after + * it has been placed at its anchor (`data-…="ready"`), so the initial + * measuring jump from translate(0, -200%) is not animated. */ +[data-radix-popper-content-wrapper]:has( + > [data-dashboard-sidebar-hover-card="ready"] + ) { + transition: transform 220ms cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx new file mode 100644 index 00000000000..7d6cc1495f8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/DashboardSidebarHoverCardOverlay.tsx @@ -0,0 +1,81 @@ +import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover"; +import type { RefObject } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; +import { useDashboardSidebarHover } from "../../providers/DashboardSidebarHoverProvider"; +import { DashboardSidebarWorkspaceHoverCardContent } from "../DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent"; +import "./DashboardSidebarHoverCardOverlay.css"; + +type Measurable = { getBoundingClientRect(): DOMRect }; + +export function DashboardSidebarHoverCardOverlay({ + children, +}: { + children: React.ReactNode; +}) { + const { + hoveredId, + anchorElement, + payload, + contextMenuOpen, + cancelClose, + requestClose, + forceClose, + } = useDashboardSidebarHover(); + + const virtualRef = useRef<Measurable | null>(null); + virtualRef.current = anchorElement; + + const open = hoveredId !== null && payload !== null && !contextMenuOpen; + const diffStats = useDiffStats(hoveredId ?? ""); + + // Suppress the transform transition until Radix has placed the popover at + // its real anchor — otherwise the initial jump from the off-screen measuring + // position (translate(0, -200%)) gets animated. + const [hasPositioned, setHasPositioned] = useState(false); + const frameRef = useRef<number | null>(null); + useEffect(() => { + if (!open) { + setHasPositioned(false); + return; + } + const first = requestAnimationFrame(() => { + frameRef.current = requestAnimationFrame(() => setHasPositioned(true)); + }); + frameRef.current = first; + return () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + }; + }, [open]); + + return ( + <Popover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) forceClose(); + }} + > + {children} + <PopoverAnchor virtualRef={virtualRef as RefObject<Measurable>} /> + {payload && ( + <PopoverContent + side="right" + align="start" + className="w-72" + data-dashboard-sidebar-hover-card={hasPositioned ? "ready" : ""} + onOpenAutoFocus={(event) => event.preventDefault()} + onPointerEnter={cancelClose} + onPointerLeave={() => { + if (hoveredId) requestClose(hoveredId); + }} + > + <DashboardSidebarWorkspaceHoverCardContent + workspace={payload.workspace} + diffStats={diffStats} + onEditBranchClick={payload.onEditBranchClick} + /> + </PopoverContent> + )} + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/index.ts new file mode 100644 index 00000000000..5d26d8f7664 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarHoverCardOverlay/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarHoverCardOverlay } from "./DashboardSidebarHoverCardOverlay"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx new file mode 100644 index 00000000000..c9402725dae --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/DashboardSidebarPortsList.tsx @@ -0,0 +1,73 @@ +import { COMPANY } from "@superset/shared/constants"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuChevronRight, LuCircleHelp, LuRadioTower } from "react-icons/lu"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { usePortsStore } from "renderer/stores"; +import { DashboardSidebarPortGroup } from "./components/DashboardSidebarPortGroup"; +import { useDashboardSidebarPortsData } from "./hooks/useDashboardSidebarPortsData"; + +const PORTS_DOCS_URL = `${COMPANY.DOCS_URL}/ports`; + +export function DashboardSidebarPortsList() { + const isCollapsed = usePortsStore((state) => state.isListCollapsed); + const toggleCollapsed = usePortsStore((state) => state.toggleListCollapsed); + const { totalPortCount, workspacePortGroups } = + useDashboardSidebarPortsData(); + + if (totalPortCount === 0) { + return null; + } + + const handleOpenPortsDocs = (e: React.MouseEvent) => { + e.stopPropagation(); + window.open(PORTS_DOCS_URL, "_blank"); + }; + + return ( + <div className="border-t border-border pt-3"> + <div className="group flex w-full items-center gap-1.5 px-3 pb-2 font-medium text-[11px] text-muted-foreground/70 uppercase tracking-wider transition-colors hover:text-muted-foreground"> + <button + type="button" + aria-expanded={!isCollapsed} + onClick={toggleCollapsed} + className="flex items-center gap-1.5 focus-visible:text-muted-foreground focus-visible:outline-none" + > + <span className="relative size-3"> + <LuRadioTower + className="absolute inset-0 size-3 transition-opacity group-hover:opacity-0" + strokeWidth={STROKE_WIDTH} + /> + <LuChevronRight + className={`absolute inset-0 size-3 opacity-0 transition-[opacity,transform] group-hover:opacity-100 ${isCollapsed ? "" : "rotate-90"}`} + strokeWidth={STROKE_WIDTH} + /> + </span> + Ports + </button> + + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleOpenPortsDocs} + className="ml-auto rounded p-0.5 opacity-0 transition-opacity hover:bg-muted/50 group-hover:opacity-100" + > + <LuCircleHelp className="size-3" strokeWidth={STROKE_WIDTH} /> + </button> + </TooltipTrigger> + <TooltipContent side="top" sideOffset={4}> + <p className="text-xs">Learn about port labels</p> + </TooltipContent> + </Tooltip> + <span className="text-[10px] font-normal">{totalPortCount}</span> + </div> + {!isCollapsed && ( + <div className="max-h-72 space-y-2 overflow-y-auto pb-1 hide-scrollbar"> + {workspacePortGroups.map((group) => ( + <DashboardSidebarPortGroup key={group.workspaceId} group={group} /> + ))} + </div> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/DashboardSidebarPortBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/DashboardSidebarPortBadge.tsx new file mode 100644 index 00000000000..df59547e439 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/DashboardSidebarPortBadge.tsx @@ -0,0 +1,146 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import type { MouseEvent } from "react"; +import { LuExternalLink, LuLoaderCircle, LuX } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { getOpenTargetClickIntent } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { useDashboardSidebarPortKill } from "../../hooks/useDashboardSidebarPortKill"; +import type { DashboardSidebarPort } from "../../hooks/useDashboardSidebarPortsData"; + +interface DashboardSidebarPortBadgeProps { + port: DashboardSidebarPort; +} + +export function DashboardSidebarPortBadge({ + port, +}: DashboardSidebarPortBadgeProps) { + const navigate = useNavigate(); + const openUrl = electronTrpc.external.openUrl.useMutation(); + const { isPending, killPort } = useDashboardSidebarPortKill(); + const canOpenInBrowser = port.hostType === "local-device"; + const hostLabel = + port.hostType === "local-device" ? "Local device" : "Remote host"; + + const handleWorkspaceClick = () => { + void navigateToV2Workspace(port.workspaceId, navigate, { + search: { + terminalId: port.terminalId, + focusRequestId: crypto.randomUUID(), + }, + }); + }; + + const handleOpenInBrowser = (event: MouseEvent<HTMLButtonElement>) => { + if (!canOpenInBrowser) return; + + const url = `http://localhost:${port.port}`; + const intent = getOpenTargetClickIntent(event); + if (intent === "openExternally") { + if (openUrl.isPending) return; + openUrl.mutate(url); + return; + } + + void navigateToV2Workspace(port.workspaceId, navigate, { + search: { + openUrl: url, + openUrlTarget: intent === "openInNewTab" ? "new-tab" : "current-tab", + openUrlRequestId: crypto.randomUUID(), + }, + }); + }; + + const handleClose = () => { + if (isPending) return; + void killPort(port); + }; + + return ( + <Tooltip> + <TooltipTrigger asChild> + <div + className={cn( + "group relative mb-1 inline-flex max-w-full items-center gap-1 rounded-md", + "bg-primary/10 text-xs text-primary transition-colors hover:bg-primary/20", + isPending && "opacity-70", + )} + > + <button + type="button" + onClick={handleWorkspaceClick} + className="flex max-w-40 min-w-0 items-center gap-1 rounded-md px-2 py-1 font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + > + {port.label ? ( + <> + <span className="min-w-0 truncate">{port.label}</span> + <span className="shrink-0 font-mono font-normal text-muted-foreground"> + {port.port} + </span> + </> + ) : ( + <span className="font-mono text-muted-foreground"> + {port.port} + </span> + )} + </button> + {canOpenInBrowser && ( + <button + type="button" + onClick={handleOpenInBrowser} + disabled={openUrl.isPending} + aria-label={`Open ${port.label || `port ${port.port}`} in browser`} + className="text-muted-foreground opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 group-hover:opacity-100" + > + <LuExternalLink className="size-3.5" strokeWidth={STROKE_WIDTH} /> + </button> + )} + <button + type="button" + onClick={handleClose} + disabled={isPending} + aria-busy={isPending} + aria-label={`Close ${port.label || `port ${port.port}`}`} + className="pr-1 text-muted-foreground opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-70 group-hover:opacity-100" + > + {isPending ? ( + <LuLoaderCircle + className="size-3.5 animate-spin" + strokeWidth={STROKE_WIDTH} + /> + ) : ( + <LuX className="size-3.5" strokeWidth={STROKE_WIDTH} /> + )} + </button> + </div> + </TooltipTrigger> + <TooltipContent side="top" sideOffset={6} showArrow={false}> + <div className="space-y-1 text-xs"> + {port.label && <div className="font-medium">{port.label}</div>} + <div + className={`font-mono ${port.label ? "text-muted-foreground" : "font-medium"}`} + > + localhost:{port.port} + </div> + <div className="text-muted-foreground">{hostLabel}</div> + {(port.processName || port.pid != null) && ( + <div className="text-muted-foreground"> + {port.processName} + {port.pid != null && ` (pid ${port.pid})`} + </div> + )} + {!canOpenInBrowser && ( + <div className="text-[10px] text-muted-foreground/70"> + Browser open unavailable from this device + </div> + )} + <div className="text-[10px] text-muted-foreground/70"> + Click to open workspace + </div> + </div> + </TooltipContent> + </Tooltip> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/index.ts new file mode 100644 index 00000000000..098aca2af7d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortBadge/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarPortBadge } from "./DashboardSidebarPortBadge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx new file mode 100644 index 00000000000..8ac7e8749fa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/DashboardSidebarPortGroup.tsx @@ -0,0 +1,84 @@ +import { OverflowFadeContainer } from "@superset/ui/overflow-fade-container"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { LuLoaderCircle, LuX } from "react-icons/lu"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { STROKE_WIDTH } from "renderer/screens/main/components/WorkspaceSidebar/constants"; +import { useDashboardSidebarPortKill } from "../../hooks/useDashboardSidebarPortKill"; +import type { DashboardSidebarPortGroup as DashboardSidebarPortGroupType } from "../../hooks/useDashboardSidebarPortsData"; +import { DashboardSidebarPortBadge } from "../DashboardSidebarPortBadge"; + +interface DashboardSidebarPortGroupProps { + group: DashboardSidebarPortGroupType; +} + +export function DashboardSidebarPortGroup({ + group, +}: DashboardSidebarPortGroupProps) { + const navigate = useNavigate(); + const { isPending, killPorts } = useDashboardSidebarPortKill(); + + const handleWorkspaceClick = () => { + void navigateToV2Workspace(group.workspaceId, navigate); + }; + + const handleCloseAll = () => { + if (isPending) return; + void killPorts(group.ports); + }; + + return ( + <div> + <div className="group flex items-center px-3 py-1"> + <button + type="button" + onClick={handleWorkspaceClick} + className="truncate text-left text-xs text-muted-foreground transition-colors hover:text-sidebar-foreground" + > + {group.workspaceName} + </button> + <span className="ml-1.5 rounded bg-muted px-1 py-0.5 text-[9px] uppercase leading-none text-muted-foreground"> + {group.hostType === "local-device" ? "Local" : "Remote"} + </span> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleCloseAll} + disabled={isPending} + aria-busy={isPending} + className={cn( + "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted/50 hover:text-primary", + "disabled:pointer-events-none disabled:opacity-60", + )} + > + {isPending ? ( + <LuLoaderCircle + className="size-3 animate-spin" + strokeWidth={STROKE_WIDTH} + /> + ) : ( + <LuX className="size-3" strokeWidth={STROKE_WIDTH} /> + )} + </button> + </TooltipTrigger> + <TooltipContent side="top" sideOffset={4}> + <p className="text-xs">Close all ports</p> + </TooltipContent> + </Tooltip> + </div> + <OverflowFadeContainer + observeChildren + className="grid auto-cols-max grid-flow-col grid-rows-2 gap-1 overflow-x-auto px-3 pb-1 hide-scrollbar" + > + {group.ports.map((port) => ( + <DashboardSidebarPortBadge + key={`${port.hostId}:${port.terminalId}:${port.port}`} + port={port} + /> + ))} + </OverflowFadeContainer> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/index.ts new file mode 100644 index 00000000000..9ba80d10775 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/components/DashboardSidebarPortGroup/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarPortGroup } from "./DashboardSidebarPortGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/index.ts new file mode 100644 index 00000000000..db4316db7ad --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/index.ts @@ -0,0 +1 @@ +export { useDashboardSidebarPortKill } from "./useDashboardSidebarPortKill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/useDashboardSidebarPortKill.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/useDashboardSidebarPortKill.ts new file mode 100644 index 00000000000..cf06d656bd8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortKill/useDashboardSidebarPortKill.ts @@ -0,0 +1,10 @@ +import { usePortKillActions } from "renderer/hooks/ports/usePortKillActions"; +import type { DashboardSidebarPort } from "../useDashboardSidebarPortsData"; + +const HOST_PORTS_QUERY_PREFIX = ["host-service", "ports", "getAll"] as const; + +export function useDashboardSidebarPortKill() { + return usePortKillActions<DashboardSidebarPort>({ + refreshQueryKey: HOST_PORTS_QUERY_PREFIX, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/index.ts new file mode 100644 index 00000000000..5c46ecffe6a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/index.ts @@ -0,0 +1,5 @@ +export { + type DashboardSidebarPort, + type DashboardSidebarPortGroup, + useDashboardSidebarPortsData, +} from "./useDashboardSidebarPortsData"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.test.ts new file mode 100644 index 00000000000..ce9b2537c8f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "bun:test"; +import type { PortChangedPayload } from "@superset/workspace-client"; +import { + applyPortEventsToHostPortsResult, + deriveHostPortQueryTargets, + groupDashboardSidebarPorts, + type HostPortsResult, +} from "./useDashboardSidebarPortsData.utils"; + +function createResult(): HostPortsResult { + return { + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://localhost:4567", + ports: [ + { + port: 5173, + pid: 123, + processName: "node", + terminalId: "terminal-1", + workspaceId: "workspace-1", + detectedAt: 1, + address: "127.0.0.1", + label: "Frontend", + }, + ], + }; +} + +function createPortEvent( + eventType: PortChangedPayload["eventType"], + overrides: Partial<PortChangedPayload["port"]> = {}, +): PortChangedPayload { + return { + eventType, + label: "Vite", + occurredAt: 2, + port: { + port: 5173, + pid: 456, + processName: "vite", + terminalId: "terminal-1", + workspaceId: "workspace-1", + detectedAt: 2, + address: "0.0.0.0", + ...overrides, + }, + }; +} + +describe("applyPortEventsToHostPortsResult", () => { + it("applies a remove/add update as a single final port row", () => { + const result = applyPortEventsToHostPortsResult(createResult(), [ + createPortEvent("remove", { pid: 123, processName: "node" }), + createPortEvent("add"), + ]); + + expect(result?.ports).toHaveLength(1); + expect(result?.ports[0]).toMatchObject({ + port: 5173, + pid: 456, + processName: "vite", + address: "0.0.0.0", + label: "Vite", + }); + }); + + it("keeps the same cache object for a remove event that does not match", () => { + const initial = createResult(); + const result = applyPortEventsToHostPortsResult(initial, [ + createPortEvent("remove", { port: 3000 }), + ]); + + expect(result).toBe(initial); + }); + + it("creates an initial host result when an add event arrives before the snapshot", () => { + const result = applyPortEventsToHostPortsResult( + undefined, + [ + createPortEvent("add", { + port: 4000, + pid: 999, + processName: "newproc", + }), + ], + { + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://localhost:4567", + }, + ); + + expect(result).toMatchObject({ + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://localhost:4567", + }); + expect(result?.ports).toHaveLength(1); + expect(result?.ports[0]).toMatchObject({ + port: 4000, + pid: 999, + processName: "newproc", + address: "0.0.0.0", + label: "Vite", + }); + }); + + it("does not create an initial host result for a remove-only event", () => { + const result = applyPortEventsToHostPortsResult( + undefined, + [createPortEvent("remove")], + { + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://localhost:4567", + }, + ); + + expect(result).toBeUndefined(); + }); + + it("appends a new add event to an existing snapshot", () => { + const result = applyPortEventsToHostPortsResult(createResult(), [ + createPortEvent("add", { port: 4000, pid: 999, processName: "newproc" }), + ]); + + expect(result?.ports).toHaveLength(2); + expect(result?.ports.find((port) => port.port === 4000)).toMatchObject({ + port: 4000, + pid: 999, + processName: "newproc", + label: "Vite", + }); + }); + + it("replaces an existing row on add for the same terminal port", () => { + const result = applyPortEventsToHostPortsResult(createResult(), [ + createPortEvent("add", { pid: 999, processName: "newproc" }), + ]); + + expect(result?.ports).toHaveLength(1); + expect(result?.ports[0]).toMatchObject({ + port: 5173, + pid: 999, + processName: "newproc", + label: "Vite", + }); + }); +}); + +describe("deriveHostPortQueryTargets", () => { + it("groups workspace ids by host, sorts them, and resolves local/remote host urls", () => { + const targets = deriveHostPortQueryTargets({ + activeHostUrl: "http://127.0.0.1:4567", + hosts: [ + { + organizationId: "org-1", + machineId: "remote-machine", + isOnline: true, + }, + { + organizationId: "org-1", + machineId: "local-machine", + isOnline: true, + }, + ], + machineId: "local-machine", + relayUrl: "https://relay.example.com", + workspaces: [ + { + id: "workspace-b", + name: "Workspace B", + hostId: "local-machine", + }, + { + id: "workspace-a", + name: "Workspace A", + hostId: "local-machine", + }, + { + id: "workspace-c", + name: "Workspace C", + hostId: "remote-machine", + }, + ], + }); + + expect(targets).toEqual([ + { + machineId: "remote-machine", + hostType: "remote-device", + hostUrl: "https://relay.example.com/hosts/org-1:remote-machine", + workspaceIds: ["workspace-c"], + }, + { + machineId: "local-machine", + hostType: "local-device", + hostUrl: "http://127.0.0.1:4567", + workspaceIds: ["workspace-a", "workspace-b"], + }, + ]); + }); + + it("skips offline remote hosts and local hosts without an active URL", () => { + const targets = deriveHostPortQueryTargets({ + activeHostUrl: null, + hosts: [ + { + organizationId: "org-1", + machineId: "remote-machine", + isOnline: false, + }, + { + organizationId: "org-1", + machineId: "local-machine", + isOnline: true, + }, + ], + machineId: "local-machine", + relayUrl: "https://relay.example.com", + workspaces: [ + { + id: "workspace-remote", + name: "Remote", + hostId: "remote-machine", + }, + { + id: "workspace-local", + name: "Local", + hostId: "local-machine", + }, + ], + }); + + expect(targets).toEqual([]); + }); +}); + +describe("groupDashboardSidebarPorts", () => { + it("groups ports by workspace and sorts workspaces and ports", () => { + const groups = groupDashboardSidebarPorts({ + hostPortResults: [ + { + hostId: "host-1", + hostType: "local-device", + hostUrl: "http://127.0.0.1:4567", + ports: [ + { + port: 5173, + pid: 100, + processName: "vite", + terminalId: "terminal-1", + workspaceId: "workspace-b", + detectedAt: 1, + address: "127.0.0.1", + label: "Frontend", + }, + { + port: 3000, + pid: 101, + processName: "next", + terminalId: "terminal-2", + workspaceId: "workspace-b", + detectedAt: 1, + address: "127.0.0.1", + label: "Web", + }, + { + port: 8080, + pid: 102, + processName: "api", + terminalId: "terminal-3", + workspaceId: "workspace-a", + detectedAt: 1, + address: "127.0.0.1", + label: "API", + }, + ], + }, + ], + machineId: "host-1", + workspaces: [ + { + id: "workspace-b", + name: "Beta", + hostId: "host-1", + }, + { + id: "workspace-a", + name: "Alpha", + hostId: "host-1", + }, + ], + }); + + expect(groups.map((group) => group.workspaceName)).toEqual([ + "Alpha", + "Beta", + ]); + expect(groups[1]?.ports.map((port) => port.port)).toEqual([3000, 5173]); + expect(groups[0]?.hostType).toBe("local-device"); + }); + + it("drops ports whose workspace belongs to another host", () => { + const groups = groupDashboardSidebarPorts({ + hostPortResults: [ + { + hostId: "host-1", + hostType: "remote-device", + hostUrl: "https://relay.example.com/hosts/host-1", + ports: [ + { + port: 5173, + pid: 100, + processName: "vite", + terminalId: "terminal-1", + workspaceId: "workspace-1", + detectedAt: 1, + address: "127.0.0.1", + label: "Frontend", + }, + ], + }, + ], + machineId: "machine-1", + workspaces: [ + { + id: "workspace-1", + name: "Workspace", + hostId: "host-2", + }, + ], + }); + + expect(groups).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.ts new file mode 100644 index 00000000000..43019abbb93 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.ts @@ -0,0 +1,188 @@ +import { + getEventBus, + type PortChangedPayload, +} from "@superset/workspace-client"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + applyPortEventsToHostPortsResult, + type DashboardSidebarPortGroup, + type DashboardSidebarPortsLoadError, + deriveHostPortQueryTargets, + getHostPortsQueryKey, + groupDashboardSidebarPorts, + type HostPortsResult, +} from "./useDashboardSidebarPortsData.utils"; + +export type { + DashboardSidebarPort, + DashboardSidebarPortGroup, +} from "./useDashboardSidebarPortsData.utils"; + +const PORTS_FALLBACK_REFETCH_INTERVAL_MS = 30_000; +const PORT_EVENT_CACHE_BATCH_DELAY_MS = 100; + +export function useDashboardSidebarPortsData(): { + workspacePortGroups: DashboardSidebarPortGroup[]; + totalPortCount: number; + portLoadErrors: DashboardSidebarPortsLoadError[]; +} { + const collections = useCollections(); + const queryClient = useQueryClient(); + const { activeHostUrl, machineId } = useLocalHostService(); + + const { data: hosts = [] } = useLiveQuery( + (q) => + q.from({ hosts: collections.v2Hosts }).select(({ hosts }) => ({ + organizationId: hosts.organizationId, + machineId: hosts.machineId, + isOnline: hosts.isOnline, + })), + [collections], + ); + + const { data: workspaces = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .select(({ workspaces }) => ({ + id: workspaces.id, + name: workspaces.name, + hostId: workspaces.hostId, + })), + [collections], + ); + + const hostsToQuery = useMemo( + () => + deriveHostPortQueryTargets({ + activeHostUrl, + hosts, + machineId, + relayUrl: env.RELAY_URL, + workspaces, + }), + [activeHostUrl, hosts, machineId, workspaces], + ); + + const queries = useQueries({ + queries: hostsToQuery.map((host) => ({ + queryKey: getHostPortsQueryKey(host), + refetchInterval: PORTS_FALLBACK_REFETCH_INTERVAL_MS, + queryFn: async (): Promise<HostPortsResult> => { + const client = getHostServiceClientByUrl(host.hostUrl); + const ports = await client.ports.getAll.query({ + workspaceIds: host.workspaceIds, + }); + return { + hostId: host.machineId, + hostType: host.hostType, + hostUrl: host.hostUrl, + ports, + }; + }, + })), + }); + + useEffect(() => { + const cleanups: Array<() => void> = []; + + for (const host of hostsToQuery) { + const workspaceIds = new Set(host.workspaceIds); + const pendingEvents: PortChangedPayload[] = []; + let cacheUpdateTimer: ReturnType<typeof setTimeout> | null = null; + const flushPortEvents = () => { + cacheUpdateTimer = null; + const events = pendingEvents.splice(0); + if (events.length === 0) return; + queryClient.setQueryData<HostPortsResult | undefined>( + getHostPortsQueryKey(host), + (result) => + applyPortEventsToHostPortsResult(result, events, { + hostId: host.machineId, + hostType: host.hostType, + hostUrl: host.hostUrl, + }), + ); + }; + const enqueuePortEvent = (event: PortChangedPayload) => { + pendingEvents.push(event); + if (cacheUpdateTimer) return; + cacheUpdateTimer = setTimeout( + flushPortEvents, + PORT_EVENT_CACHE_BATCH_DELAY_MS, + ); + }; + const bus = getEventBus(host.hostUrl, () => + getHostServiceWsToken(host.hostUrl), + ); + const removeListener = bus.on( + "port:changed", + "*", + (workspaceId, event) => { + if (!workspaceIds.has(workspaceId)) return; + enqueuePortEvent(event); + }, + ); + const releaseBus = bus.retain(); + cleanups.push(() => { + if (cacheUpdateTimer) { + clearTimeout(cacheUpdateTimer); + cacheUpdateTimer = null; + } + flushPortEvents(); + removeListener(); + releaseBus(); + }); + } + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [hostsToQuery, queryClient]); + + const workspacePortGroups = useMemo( + () => + groupDashboardSidebarPorts({ + hostPortResults: queries.map((query) => query.data), + machineId, + workspaces, + }), + [queries, machineId, workspaces], + ); + + const totalPortCount = workspacePortGroups.reduce( + (sum, group) => sum + group.ports.length, + 0, + ); + + const portLoadErrors = queries.flatMap((query, index) => { + if (!query.isError && !query.isRefetchError) return []; + const host = hostsToQuery[index]; + if (!host) return []; + return [ + { + hostId: host.machineId, + hostType: host.hostType, + message: + query.error instanceof Error + ? query.error.message + : "Unable to load ports", + }, + ]; + }); + + return { + workspacePortGroups, + totalPortCount, + portLoadErrors, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.utils.ts new file mode 100644 index 00000000000..f835473b935 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/hooks/useDashboardSidebarPortsData/useDashboardSidebarPortsData.utils.ts @@ -0,0 +1,247 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import type { PortChangedPayload } from "@superset/workspace-client"; +import type { DetectedPort } from "shared/types"; +import type { DashboardSidebarWorkspaceHostType } from "../../../../types"; + +export interface DashboardSidebarPort extends RemotePort { + hostId: string; + hostType: DashboardSidebarWorkspaceHostType; + hostUrl: string; +} + +interface RemotePort extends DetectedPort { + label: string | null; +} + +export interface DashboardSidebarPortGroup { + workspaceId: string; + workspaceName: string; + hostType: DashboardSidebarWorkspaceHostType; + ports: DashboardSidebarPort[]; +} + +export interface DashboardSidebarPortsLoadError { + hostId: string; + hostType: DashboardSidebarWorkspaceHostType; + message: string; +} + +export interface HostPortsResult { + hostId: string; + hostType: DashboardSidebarWorkspaceHostType; + hostUrl: string; + ports: RemotePort[]; +} + +type HostPortsMetadata = Pick< + HostPortsResult, + "hostId" | "hostType" | "hostUrl" +>; + +export interface HostPortsQueryTarget { + machineId: string; + hostType: DashboardSidebarWorkspaceHostType; + hostUrl: string; + workspaceIds: string[]; +} + +export interface DashboardSidebarHostRow { + organizationId: string; + machineId: string; + isOnline: boolean; +} + +export interface DashboardSidebarWorkspaceRow { + id: string; + name: string; + hostId: string; +} + +export function getHostPortsQueryKey(host: HostPortsQueryTarget) { + return [ + "host-service", + "ports", + "getAll", + host.machineId, + host.hostUrl, + host.workspaceIds, + ] as const; +} + +function getPortCacheKey( + port: Pick<DetectedPort, "workspaceId" | "terminalId" | "port">, +): string { + return `${port.workspaceId}:${port.terminalId}:${port.port}`; +} + +export function applyPortEventsToHostPortsResult( + result: HostPortsResult | undefined, + events: PortChangedPayload[], + host?: HostPortsMetadata, +): HostPortsResult | undefined { + if (events.length === 0) return result; + + const initialResult = + result ?? + (events.some((event) => event.eventType === "add") && host + ? { ...host, ports: [] } + : undefined); + if (!initialResult) return result; + + let ports = initialResult.ports; + let changed = initialResult !== result; + + for (const event of events) { + const eventPortKey = getPortCacheKey(event.port); + const portsWithoutEventPort = ports.filter( + (port) => getPortCacheKey(port) !== eventPortKey, + ); + if (portsWithoutEventPort.length !== ports.length) { + changed = true; + } + + if (event.eventType === "add") { + ports = [...portsWithoutEventPort, { ...event.port, label: event.label }]; + changed = true; + } else { + ports = portsWithoutEventPort; + } + } + + if (!changed) return result; + return { ...initialResult, ports }; +} + +export function deriveHostPortQueryTargets({ + activeHostUrl, + hosts, + machineId, + relayUrl, + workspaces, +}: { + activeHostUrl: string | null; + hosts: DashboardSidebarHostRow[]; + machineId: string | null; + relayUrl: string; + workspaces: DashboardSidebarWorkspaceRow[]; +}): HostPortsQueryTarget[] { + const workspaceIdsByHostId = new Map<string, string[]>(); + for (const workspace of workspaces) { + const existing = workspaceIdsByHostId.get(workspace.hostId); + if (existing) { + existing.push(workspace.id); + } else { + workspaceIdsByHostId.set(workspace.hostId, [workspace.id]); + } + } + for (const workspaceIds of workspaceIdsByHostId.values()) { + workspaceIds.sort(); + } + + const targets = hosts.flatMap((host) => { + const workspaceIds = workspaceIdsByHostId.get(host.machineId); + if (!workspaceIds || workspaceIds.length === 0) return []; + + const isLocal = host.machineId === machineId; + if (!isLocal && !host.isOnline) return []; + + const hostUrl = isLocal + ? activeHostUrl + : `${relayUrl}/hosts/${buildHostRoutingKey(host.organizationId, host.machineId)}`; + if (!hostUrl) return []; + + return [ + { + machineId: host.machineId, + hostType: isLocal + ? ("local-device" as const) + : ("remote-device" as const), + hostUrl, + workspaceIds, + }, + ]; + }); + + // If the local v2Hosts row hasn't synced via Electric, the loop above won't + // include the local machine — which would hide its ports. Synthesize a + // local target from machineId + activeHostUrl whenever workspaces with + // hostId === machineId exist. + if ( + machineId && + activeHostUrl && + !targets.some((target) => target.machineId === machineId) + ) { + const localWorkspaceIds = workspaceIdsByHostId.get(machineId); + if (localWorkspaceIds && localWorkspaceIds.length > 0) { + targets.push({ + machineId, + hostType: "local-device", + hostUrl: activeHostUrl, + workspaceIds: localWorkspaceIds, + }); + } + } + + return targets; +} + +export function groupDashboardSidebarPorts({ + hostPortResults, + machineId, + workspaces, +}: { + hostPortResults: Array<HostPortsResult | undefined>; + machineId: string | null; + workspaces: DashboardSidebarWorkspaceRow[]; +}): DashboardSidebarPortGroup[] { + const workspacesById = new Map( + workspaces.map((workspace) => [ + workspace.id, + { + name: workspace.name, + hostId: workspace.hostId, + hostType: + workspace.hostId === machineId + ? ("local-device" as const) + : ("remote-device" as const), + }, + ]), + ); + const groupMap = new Map<string, DashboardSidebarPortGroup>(); + + for (const result of hostPortResults) { + if (!result) continue; + + for (const port of result.ports) { + const workspace = workspacesById.get(port.workspaceId); + if (!workspace) continue; + if (workspace.hostId !== result.hostId) continue; + + const dashboardPort: DashboardSidebarPort = { + ...port, + hostId: result.hostId, + hostType: result.hostType, + hostUrl: result.hostUrl, + }; + + const existing = groupMap.get(port.workspaceId); + if (existing) { + existing.ports.push(dashboardPort); + } else { + groupMap.set(port.workspaceId, { + workspaceId: port.workspaceId, + workspaceName: workspace.name, + hostType: workspace.hostType, + ports: [dashboardPort], + }); + } + } + } + + return Array.from(groupMap.values()) + .map((group) => ({ + ...group, + ports: group.ports.sort((a, b) => a.port - b.port), + })) + .sort((a, b) => a.workspaceName.localeCompare(b.workspaceName)); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/index.ts new file mode 100644 index 00000000000..b231559d058 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarPortsList/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarPortsList } from "./DashboardSidebarPortsList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index 13fc81d25a2..35f44b3c0a8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -1,10 +1,12 @@ +import type { + DraggableAttributes, + DraggableSyntheticListeners, +} from "@dnd-kit/core"; import { cn } from "@superset/ui/utils"; +import { AnimatePresence, motion } from "framer-motion"; import { useMemo } from "react"; import type { DashboardSidebarProject } from "../../types"; -import { - getProjectChildrenSections, - getProjectChildrenWorkspaces, -} from "../../utils/projectChildren"; +import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; import { DashboardSidebarCollapsedProjectContent } from "./components/DashboardSidebarCollapsedProjectContent"; import { DashboardSidebarExpandedProjectContent } from "./components/DashboardSidebarExpandedProjectContent"; import { DashboardSidebarProjectContextMenu } from "./components/DashboardSidebarProjectContextMenu"; @@ -14,23 +16,24 @@ import { useDashboardSidebarProjectSectionActions } from "./hooks/useDashboardSi interface DashboardSidebarProjectSectionProps { project: DashboardSidebarProject; isSidebarCollapsed?: boolean; + isDraggingProject?: boolean; workspaceShortcutLabels: Map<string, string>; onWorkspaceHover: (workspaceId: string) => void | Promise<void>; onToggleCollapse: (projectId: string) => void; + dragHandleListeners?: DraggableSyntheticListeners; + dragHandleAttributes?: DraggableAttributes; } export function DashboardSidebarProjectSection({ project, isSidebarCollapsed = false, + isDraggingProject = false, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, + dragHandleListeners, + dragHandleAttributes, }: DashboardSidebarProjectSectionProps) { - const allSections = useMemo( - () => getProjectChildrenSections(project.children), - [project.children], - ); - const flattenedCollapsedWorkspaces = useMemo( () => getProjectChildrenWorkspaces(project.children), [project.children], @@ -104,19 +107,33 @@ export function DashboardSidebarProjectSection({ onStartRename={startRename} onToggleCollapse={() => onToggleCollapse(project.id)} onNewWorkspace={handleNewWorkspace} + {...(dragHandleAttributes ?? {})} + {...(dragHandleListeners ?? {})} /> </DashboardSidebarProjectContextMenu> - <DashboardSidebarExpandedProjectContent - isCollapsed={project.isCollapsed} - projectChildren={project.children} - allSections={allSections} - workspaceShortcutLabels={workspaceShortcutLabels} - onWorkspaceHover={onWorkspaceHover} - onDeleteSection={deleteSection} - onRenameSection={renameSection} - onToggleSectionCollapse={toggleSectionCollapsed} - /> + <AnimatePresence initial={false}> + {!isDraggingProject && ( + <motion.div + initial={{ height: 0, opacity: 0 }} + animate={{ height: "auto", opacity: 1 }} + exit={{ height: 0, opacity: 0 }} + transition={{ duration: 0.15, ease: "easeOut" }} + className="overflow-hidden" + > + <DashboardSidebarExpandedProjectContent + projectId={project.id} + isCollapsed={project.isCollapsed} + projectChildren={project.children} + workspaceShortcutLabels={workspaceShortcutLabels} + onWorkspaceHover={onWorkspaceHover} + onDeleteSection={deleteSection} + onRenameSection={renameSection} + onToggleSectionCollapse={toggleSectionCollapsed} + /> + </motion.div> + )} + </AnimatePresence> </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 6e8365dbbd7..dbd7a960c72 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -1,12 +1,21 @@ +import { DndContext, DragOverlay } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { AnimatePresence, motion } from "framer-motion"; +import { createPortal } from "react-dom"; +import { useSidebarDnd } from "../../../../hooks/useSidebarDnd"; +import { parseId } from "../../../../hooks/useSidebarDnd/useSidebarDnd"; import type { DashboardSidebarProjectChild } from "../../../../types"; -import { DashboardSidebarSection as DashboardSidebarSectionComponent } from "../../../DashboardSidebarSection"; -import { DashboardSidebarWorkspaceItem } from "../../../DashboardSidebarWorkspaceItem"; +import { SidebarDragOverlay } from "../../../SidebarDragOverlay"; +import { SortableSectionHeader } from "../../../SortableSectionHeader"; +import { SortableWorkspaceItem } from "../../../SortableWorkspaceItem"; interface DashboardSidebarExpandedProjectContentProps { + projectId: string; isCollapsed: boolean; projectChildren: DashboardSidebarProjectChild[]; - allSections: Array<{ id: string; name: string }>; workspaceShortcutLabels: Map<string, string>; onWorkspaceHover: (workspaceId: string) => void | Promise<void>; onDeleteSection: (sectionId: string) => void; @@ -15,15 +24,32 @@ interface DashboardSidebarExpandedProjectContentProps { } export function DashboardSidebarExpandedProjectContent({ + projectId, isCollapsed, projectChildren, - allSections, workspaceShortcutLabels, onWorkspaceHover, onDeleteSection, onRenameSection, onToggleSectionCollapse, }: DashboardSidebarExpandedProjectContentProps) { + const { + sensors, + measuring, + collisionDetection, + flatItems, + sortableItems, + activeId, + activeType, + activeItem, + predictedColor, + groupInfo, + collapsedSectionIds, + workspacesById, + sectionsById, + handlers, + } = useSidebarDnd({ projectId, projectChildren }); + return ( <AnimatePresence initial={false}> {!isCollapsed && ( @@ -35,30 +61,84 @@ export function DashboardSidebarExpandedProjectContent({ className="overflow-hidden" > <div className="pb-1"> - {projectChildren.map((child) => - child.type === "workspace" ? ( - <DashboardSidebarWorkspaceItem - key={child.workspace.id} - workspace={child.workspace} - onHoverCardOpen={() => onWorkspaceHover(child.workspace.id)} - shortcutLabel={workspaceShortcutLabels.get( - child.workspace.id, - )} - /> - ) : ( - <DashboardSidebarSectionComponent - key={child.section.id} - projectId={child.section.projectId} - section={child.section} - allSections={allSections} - workspaceShortcutLabels={workspaceShortcutLabels} - onWorkspaceHover={onWorkspaceHover} - onDelete={onDeleteSection} - onRename={onRenameSection} - onToggleCollapse={onToggleSectionCollapse} - /> - ), - )} + <DndContext + sensors={sensors} + collisionDetection={collisionDetection} + measuring={measuring} + {...handlers} + > + <SortableContext + items={sortableItems} + strategy={verticalListSortingStrategy} + > + {flatItems.map((id) => { + const parsed = parseId(id); + if (!parsed) return null; + + if (parsed.type === "section") { + const section = sectionsById.get(parsed.realId); + if (!section) return null; + return ( + <SortableSectionHeader + key={String(id)} + sortableId={String(id)} + section={section} + onDelete={onDeleteSection} + onRename={onRenameSection} + onToggleCollapse={onToggleSectionCollapse} + /> + ); + } + + const workspace = workspacesById.get(parsed.realId); + if (!workspace) return null; + const group = groupInfo.get(parsed.realId); + const isInSection = !!group; + const isInCollapsedSection = + isInSection && collapsedSectionIds.has(group.sectionId); + const hidden = + isInCollapsedSection || + (activeType === "section" && isInSection); + + return ( + <AnimatePresence key={String(id)} initial={false}> + {!hidden && ( + <motion.div + initial={{ height: 0, opacity: 0 }} + animate={{ height: "auto", opacity: 1 }} + exit={{ height: 0, opacity: 0 }} + transition={{ duration: 0.15, ease: "easeOut" }} + > + <SortableWorkspaceItem + sortableId={String(id)} + workspace={workspace} + accentColor={ + activeId === id ? predictedColor : group?.color + } + isInSection={groupInfo.has(parsed.realId)} + onHoverCardOpen={() => + onWorkspaceHover(parsed.realId) + } + shortcutLabel={workspaceShortcutLabels.get( + parsed.realId, + )} + /> + </motion.div> + )} + </AnimatePresence> + ); + })} + </SortableContext> + + {createPortal( + <DragOverlay dropAnimation={null}> + {activeId ? ( + <SidebarDragOverlay activeItem={activeItem} /> + ) : null} + </DragOverlay>, + document.body, + )} + </DndContext> </div> </motion.div> )} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx index 51f356e337d..c7f93cdd38a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectContextMenu/DashboardSidebarProjectContextMenu.tsx @@ -49,7 +49,7 @@ export function DashboardSidebarProjectContextMenu({ </ContextMenuItem> <ContextMenuItem onSelect={onCreateSection}> <LuFolderPlus className="size-4 mr-2" /> - New Section + New group </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx index 38022615135..1286ad13211 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx @@ -2,7 +2,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef } from "react"; import { HiChevronRight, HiMiniPlus } from "react-icons/hi2"; -import { LuPencil } from "react-icons/lu"; import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; @@ -52,6 +51,7 @@ export const DashboardSidebarProjectRow = forwardRef< role={isRenaming ? undefined : "button"} tabIndex={isRenaming ? undefined : 0} onClick={isRenaming ? undefined : onToggleCollapse} + onDoubleClick={isRenaming ? undefined : onStartRename} onKeyDown={ isRenaming ? undefined @@ -70,10 +70,19 @@ export const DashboardSidebarProjectRow = forwardRef< {...props} > <div className="flex min-w-0 flex-1 items-center gap-2 py-0.5"> - <ProjectThumbnail - projectName={projectName} - githubOwner={githubOwner} - /> + <div className="flex size-5 shrink-0 items-center justify-center"> + <ProjectThumbnail + projectName={projectName} + githubOwner={githubOwner} + className="size-4 group-hover:hidden" + /> + <HiChevronRight + className={cn( + "hidden size-4 text-muted-foreground transition-transform group-hover:block", + !isCollapsed && "rotate-90", + )} + /> + </div> {isRenaming ? ( <RenameInput value={renameValue} @@ -85,64 +94,35 @@ export const DashboardSidebarProjectRow = forwardRef< ) : ( <span className="truncate">{projectName}</span> )} - <div className="grid shrink-0 items-center [&>*]:col-start-1 [&>*]:row-start-1"> - {!isRenaming && ( - <span className="text-xs font-normal tabular-nums text-muted-foreground transition-all duration-150 group-hover:scale-95 group-hover:opacity-0"> - ({totalWorkspaceCount}) - </span> - )} - {!isRenaming && ( - <button - type="button" - onClick={(event) => { - event.stopPropagation(); - onStartRename(); - }} - className="flex items-center justify-center opacity-0 scale-90 text-muted-foreground transition-all duration-150 group-hover:scale-100 group-hover:opacity-100 hover:text-foreground" - aria-label="Rename project" - > - <LuPencil className="size-3.5 transition-transform duration-150 group-hover:rotate-[-8deg]" /> - </button> - )} - </div> </div> - <Tooltip delayDuration={500}> - <TooltipTrigger asChild> - <button - type="button" - onClick={(event) => { - event.stopPropagation(); - onNewWorkspace(); - }} - onContextMenu={(event) => event.stopPropagation()} - className="p-1 rounded hover:bg-muted transition-colors shrink-0 ml-1" - > - <HiMiniPlus className="size-4 text-muted-foreground" /> - </button> - </TooltipTrigger> - <TooltipContent side="bottom" sideOffset={4}> - New workspace - </TooltipContent> - </Tooltip> - - <button - type="button" - onClick={(event) => { - event.stopPropagation(); - onToggleCollapse(); - }} - onContextMenu={(event) => event.stopPropagation()} - aria-expanded={!isCollapsed} - className="p-1 rounded hover:bg-muted transition-colors shrink-0 ml-1" - > - <HiChevronRight - className={cn( - "size-3.5 text-muted-foreground transition-transform duration-150", - !isCollapsed && "rotate-90", - )} - /> - </button> + {!isRenaming && ( + <div className="ml-1 flex size-6 shrink-0 items-center justify-center"> + <Tooltip delayDuration={500}> + <TooltipTrigger asChild> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onNewWorkspace(); + }} + onKeyDown={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + aria-label="New workspace" + className="hidden size-full items-center justify-center rounded transition-colors hover:bg-muted group-hover:flex group-has-[:focus]:flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + <HiMiniPlus className="size-4 text-muted-foreground" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" sideOffset={4}> + New workspace + </TooltipContent> + </Tooltip> + <span className="text-[10px] font-normal tabular-nums text-muted-foreground group-hover:hidden group-has-[:focus]:hidden"> + {totalWorkspaceCount} + </span> + </div> + )} </div> ); }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts index 833ee1f70a4..9cd46da62b9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/hooks/useDashboardSidebarProjectSectionActions/useDashboardSidebarProjectSectionActions.ts @@ -1,8 +1,10 @@ import { alert } from "@superset/ui/atoms/Alert"; import { toast } from "@superset/ui/sonner"; +import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; -import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import type { DashboardSidebarProject } from "../../../../types"; @@ -14,11 +16,15 @@ export function useDashboardSidebarProjectSectionActions({ project, }: UseDashboardSidebarProjectSectionActionsOptions) { const openModal = useOpenNewWorkspaceModal(); + const navigate = useNavigate(); + const { v2Projects: projectActions } = useOptimisticCollectionActions(); + const { requestSectionRename } = useDashboardSidebarSectionRename(); const { createSection, deleteSection, removeProjectFromSidebar, renameSection, + toggleProjectCollapsed, toggleSectionCollapsed, } = useDashboardSidebarState(); @@ -35,21 +41,11 @@ export function useDashboardSidebarProjectSectionActions({ setRenameValue(project.name); }; - const submitRename = async () => { + const submitRename = () => { setIsRenaming(false); const trimmed = renameValue.trim(); if (!trimmed || trimmed === project.name) return; - try { - await apiTrpcClient.v2Project.update.mutate({ - id: project.id, - name: trimmed, - slug: trimmed.toLowerCase().replace(/\s+/g, "-"), - }); - } catch (error) { - toast.error( - `Failed to rename: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - } + projectActions.renameProject(project.id, trimmed); }; const handleOpenInFinder = () => { @@ -57,16 +53,25 @@ export function useDashboardSidebarProjectSectionActions({ }; const handleOpenSettings = () => { - toast.info("Project settings are coming soon"); + navigate({ + to: "/settings/projects/$projectId", + params: { projectId: project.id }, + }); }; const confirmRemoveFromSidebar = () => { - alert.destructive({ + alert({ title: "Remove project from sidebar?", description: "This will remove workspaces from the sidebar and delete all project sections. The workspaces or projects won't be deleted.", - confirmText: "Remove", - onConfirm: () => removeProjectFromSidebar(project.id), + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Remove", + variant: "destructive", + onClick: () => removeProjectFromSidebar(project.id), + }, + ], }); }; @@ -75,7 +80,11 @@ export function useDashboardSidebarProjectSectionActions({ }; const handleNewSection = () => { - createSection(project.id); + const sectionId = createSection(project.id); + requestSectionRename(sectionId); + if (project.isCollapsed) { + toggleProjectCollapsed(project.id); + } }; return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/DashboardSidebarSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/DashboardSidebarSection.tsx deleted file mode 100644 index 5efaa456a2f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/DashboardSidebarSection.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useState } from "react"; -import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; -import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; -import type { DashboardSidebarSection as DashboardSidebarSectionRecord } from "../../types"; -import { DashboardSidebarSectionContent } from "./components/DashboardSidebarSectionContent"; -import { DashboardSidebarSectionContextMenu } from "./components/DashboardSidebarSectionContextMenu"; -import { DashboardSidebarSectionHeader } from "./components/DashboardSidebarSectionHeader"; - -interface DashboardSidebarSectionProps { - projectId: string; - section: DashboardSidebarSectionRecord; - allSections: Array<{ id: string; name: string }>; - workspaceShortcutLabels: Map<string, string>; - onWorkspaceHover: (workspaceId: string) => void | Promise<void>; - onDelete: (sectionId: string) => void; - onRename: (sectionId: string, name: string) => void; - onToggleCollapse: (sectionId: string) => void; -} - -export function DashboardSidebarSection({ - section, - workspaceShortcutLabels, - onWorkspaceHover, - onDelete, - onRename, - onToggleCollapse, -}: DashboardSidebarSectionProps) { - const { setSectionColor } = useDashboardSidebarState(); - const [isRenaming, setIsRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(section.name); - const hasColor = - section.color != null && section.color !== PROJECT_COLOR_DEFAULT; - const sectionBorderStyle = { - borderLeft: hasColor - ? `2px solid ${section.color}` - : "2px solid var(--color-border)", - }; - - const handleSubmitRename = () => { - const trimmed = renameValue.trim(); - if (trimmed) { - onRename(section.id, trimmed); - } - setIsRenaming(false); - }; - - const handleCancelRename = () => { - setRenameValue(section.name); - setIsRenaming(false); - }; - - return ( - <div style={sectionBorderStyle}> - <DashboardSidebarSectionContextMenu - color={section.color} - onRename={() => setIsRenaming(true)} - onSetColor={(color) => setSectionColor(section.id, color)} - onDelete={() => onDelete(section.id)} - > - <DashboardSidebarSectionHeader - section={section} - isRenaming={isRenaming} - renameValue={renameValue} - onRenameValueChange={setRenameValue} - onSubmitRename={handleSubmitRename} - onCancelRename={handleCancelRename} - onStartRename={() => { - setRenameValue(section.name); - setIsRenaming(true); - }} - onToggleCollapse={() => onToggleCollapse(section.id)} - /> - </DashboardSidebarSectionContextMenu> - - <DashboardSidebarSectionContent - section={section} - workspaceShortcutLabels={workspaceShortcutLabels} - onWorkspaceHover={onWorkspaceHover} - /> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/DashboardSidebarSectionContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/DashboardSidebarSectionContent.tsx deleted file mode 100644 index fbc6e4202e8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/DashboardSidebarSectionContent.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import type { DashboardSidebarSection } from "../../../../types"; -import { DashboardSidebarWorkspaceItem } from "../../../DashboardSidebarWorkspaceItem"; - -interface DashboardSidebarSectionContentProps { - section: DashboardSidebarSection; - workspaceShortcutLabels: Map<string, string>; - onWorkspaceHover: (workspaceId: string) => void | Promise<void>; -} - -export function DashboardSidebarSectionContent({ - section, - workspaceShortcutLabels, - onWorkspaceHover, -}: DashboardSidebarSectionContentProps) { - return ( - <AnimatePresence initial={false}> - {!section.isCollapsed && ( - <motion.div - initial={{ height: 0, opacity: 0 }} - animate={{ height: "auto", opacity: 1 }} - exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.15, ease: "easeOut" }} - className="overflow-hidden" - > - <div> - {section.workspaces.map((workspace) => ( - <DashboardSidebarWorkspaceItem - key={workspace.id} - workspace={workspace} - onHoverCardOpen={() => onWorkspaceHover(workspace.id)} - shortcutLabel={workspaceShortcutLabels.get(workspace.id)} - /> - ))} - </div> - </motion.div> - )} - </AnimatePresence> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/index.ts deleted file mode 100644 index db341d136c1..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardSidebarSectionContent } from "./DashboardSidebarSectionContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx index 74beb713ff3..ebff7132ef6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/DashboardSidebarSectionContextMenu.tsx @@ -1,22 +1,13 @@ import { ContextMenu, ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "@superset/ui/context-menu"; -import { LuPalette, LuPencil, LuTrash2 } from "react-icons/lu"; -import { ColorSelector } from "renderer/components/ColorSelector"; -import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; +import { SectionActionsMenuItems } from "./components/SectionActionsMenuItems"; +import type { DashboardSidebarSectionActionsProps } from "./types"; -interface DashboardSidebarSectionContextMenuProps { - color: string | null; - onRename: () => void; - onSetColor: (color: string | null) => void; - onDelete: () => void; +interface DashboardSidebarSectionContextMenuProps + extends DashboardSidebarSectionActionsProps { children: React.ReactNode; } @@ -30,38 +21,18 @@ export function DashboardSidebarSectionContextMenu({ return ( <ContextMenu> <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem onSelect={onRename}> - <LuPencil className="size-4 mr-2" /> - Rename - </ContextMenuItem> - <ContextMenuSub> - <ContextMenuSubTrigger> - <LuPalette className="size-4 mr-2" /> - Set Color - </ContextMenuSubTrigger> - <ContextMenuSubContent className="w-40 max-h-80 overflow-y-auto"> - <ColorSelector - variant="menu" - selectedColor={color} - onSelectColor={(selectedColor) => - onSetColor( - selectedColor === PROJECT_COLOR_DEFAULT - ? null - : selectedColor, - ) - } - /> - </ContextMenuSubContent> - </ContextMenuSub> - <ContextMenuSeparator /> - <ContextMenuItem - onSelect={onDelete} - className="text-destructive focus:text-destructive" - > - <LuTrash2 className="size-4 mr-2 text-destructive" /> - Delete Section - </ContextMenuItem> + <ContextMenuContent + onCloseAutoFocus={(event) => event.preventDefault()} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + <SectionActionsMenuItems + color={color} + kind="context" + onRename={onRename} + onSetColor={onSetColor} + onDelete={onDelete} + /> </ContextMenuContent> </ContextMenu> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/DashboardSidebarSectionActionsDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/DashboardSidebarSectionActionsDropdown.tsx new file mode 100644 index 00000000000..2fca177b1aa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/DashboardSidebarSectionActionsDropdown.tsx @@ -0,0 +1,47 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { LuEllipsis } from "react-icons/lu"; +import type { DashboardSidebarSectionActionsProps } from "../../types"; +import { SectionActionsMenuItems } from "../SectionActionsMenuItems"; + +export function DashboardSidebarSectionActionsDropdown({ + color, + onRename, + onSetColor, + onDelete, +}: DashboardSidebarSectionActionsProps) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + onPointerDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onContextMenu={(event) => event.stopPropagation()} + className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground/80 opacity-0 transition-[opacity,color,background-color] hover:bg-muted hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100" + aria-label="Group actions" + > + <LuEllipsis className="size-3.5" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="end" + className="w-44" + onCloseAutoFocus={(event) => event.preventDefault()} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + <SectionActionsMenuItems + color={color} + kind="dropdown" + onRename={onRename} + onSetColor={onSetColor} + onDelete={onDelete} + /> + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/index.ts new file mode 100644 index 00000000000..18ba11aca46 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/DashboardSidebarSectionActionsDropdown/index.ts @@ -0,0 +1 @@ +export { DashboardSidebarSectionActionsDropdown } from "./DashboardSidebarSectionActionsDropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/SectionActionsMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/SectionActionsMenuItems.tsx new file mode 100644 index 00000000000..2923e5419b8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/SectionActionsMenuItems.tsx @@ -0,0 +1,170 @@ +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +} from "@superset/ui/context-menu"; +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@superset/ui/dropdown-menu"; +import { HiCheck } from "react-icons/hi2"; +import { LuPalette, LuPencil, LuTrash2 } from "react-icons/lu"; +import { + PROJECT_COLOR_DEFAULT, + PROJECT_COLORS, +} from "shared/constants/project-colors"; +import type { + DashboardSidebarSectionActionsProps, + SectionActionsMenuKind, +} from "../../types"; + +interface SectionActionsMenuItemsProps + extends DashboardSidebarSectionActionsProps { + kind: SectionActionsMenuKind; +} + +export function SectionActionsMenuItems({ + color, + kind, + onRename, + onSetColor, + onDelete, +}: SectionActionsMenuItemsProps) { + const selectedValue = color ?? PROJECT_COLOR_DEFAULT; + const colorOptions = [ + { name: "Default", value: PROJECT_COLOR_DEFAULT }, + ...PROJECT_COLORS, + ]; + const iconClassName = kind === "context" ? "size-4 mr-2" : "size-4"; + + const renderItem = ({ + children, + destructive = false, + key, + onSelect, + }: { + children: React.ReactNode; + destructive?: boolean; + key?: string; + onSelect?: () => void; + }) => { + if (kind === "context") { + return ( + <ContextMenuItem + key={key} + onSelect={(event) => { + event.stopPropagation(); + onSelect?.(); + }} + className={ + destructive ? "text-destructive focus:text-destructive" : undefined + } + > + {children} + </ContextMenuItem> + ); + } + + return ( + <DropdownMenuItem + key={key} + onSelect={(event) => { + event.stopPropagation(); + onSelect?.(); + }} + variant={destructive ? "destructive" : "default"} + > + {children} + </DropdownMenuItem> + ); + }; + + const colorItems = colorOptions.map((projectColor) => { + const isDefault = projectColor.value === PROJECT_COLOR_DEFAULT; + const isSelected = selectedValue === projectColor.value; + + return renderItem({ + key: projectColor.value, + onSelect: () => onSetColor(isDefault ? null : projectColor.value), + children: ( + <> + <span + className="relative inline-flex size-3.5 shrink-0 items-center justify-center rounded-full border border-border/50" + style={ + isDefault ? undefined : { backgroundColor: projectColor.value } + } + > + {isDefault ? ( + <span className="size-1.5 rounded-full bg-muted-foreground/35" /> + ) : null} + </span> + <span>{projectColor.name}</span> + {isSelected ? ( + <HiCheck className="ml-auto size-3.5 text-muted-foreground" /> + ) : null} + </> + ), + }); + }); + const colorTrigger = ( + <> + <LuPalette className={iconClassName} /> + Set group color + </> + ); + + return ( + <> + {renderItem({ + onSelect: onRename, + children: ( + <> + <LuPencil className={iconClassName} /> + Rename group + </> + ), + })} + {kind === "context" ? ( + <ContextMenuSub> + <ContextMenuSubTrigger>{colorTrigger}</ContextMenuSubTrigger> + <ContextMenuSubContent className="w-40 max-h-80 overflow-y-auto"> + {colorItems} + </ContextMenuSubContent> + </ContextMenuSub> + ) : ( + <DropdownMenuSub> + <DropdownMenuSubTrigger>{colorTrigger}</DropdownMenuSubTrigger> + <DropdownMenuSubContent className="w-40 max-h-80 overflow-y-auto"> + {colorItems} + </DropdownMenuSubContent> + </DropdownMenuSub> + )} + {kind === "context" ? ( + <ContextMenuSeparator /> + ) : ( + <DropdownMenuSeparator /> + )} + {renderItem({ + destructive: true, + onSelect: onDelete, + children: ( + <> + <LuTrash2 + className={ + kind === "context" + ? "size-4 mr-2 text-destructive" + : "size-4 text-destructive" + } + /> + Delete group + </> + ), + })} + </> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/index.ts new file mode 100644 index 00000000000..dd0c6ebf935 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/components/SectionActionsMenuItems/index.ts @@ -0,0 +1 @@ +export { SectionActionsMenuItems } from "./SectionActionsMenuItems"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/index.ts index eec632d0652..1a080acb50f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/index.ts @@ -1 +1,2 @@ +export { DashboardSidebarSectionActionsDropdown } from "./components/DashboardSidebarSectionActionsDropdown"; export { DashboardSidebarSectionContextMenu } from "./DashboardSidebarSectionContextMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts new file mode 100644 index 00000000000..ced8619368d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionContextMenu/types.ts @@ -0,0 +1,8 @@ +export interface DashboardSidebarSectionActionsProps { + color: string | null; + onRename: () => void; + onSetColor: (color: string | null) => void; + onDelete: () => void; +} + +export type SectionActionsMenuKind = "context" | "dropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx index 49dd48bbcde..e36fe8c8819 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx @@ -1,7 +1,11 @@ import { cn } from "@superset/ui/utils"; -import { type ComponentPropsWithoutRef, forwardRef } from "react"; +import { + type ComponentPropsWithoutRef, + forwardRef, + type ReactNode, +} from "react"; import { HiChevronRight } from "react-icons/hi2"; -import { LuPencil } from "react-icons/lu"; +import { LuGripVertical } from "react-icons/lu"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; import type { DashboardSidebarSection } from "../../../../types"; @@ -13,8 +17,8 @@ interface DashboardSidebarSectionHeaderProps onRenameValueChange: (value: string) => void; onSubmitRename: () => void; onCancelRename: () => void; - onStartRename: () => void; onToggleCollapse: () => void; + actions?: ReactNode; } export const DashboardSidebarSectionHeader = forwardRef< @@ -29,8 +33,8 @@ export const DashboardSidebarSectionHeader = forwardRef< onRenameValueChange, onSubmitRename, onCancelRename, - onStartRename, onToggleCollapse, + actions, className, ...props }, @@ -54,12 +58,22 @@ export const DashboardSidebarSectionHeader = forwardRef< } } className={cn( - "group flex min-h-8 w-full items-center pl-2 pr-2 py-1.5 text-[11px] font-medium", + "group flex min-h-8 w-full items-center pl-5 pr-2 py-1.5 text-[13px] font-medium", "text-muted-foreground hover:bg-muted/50 transition-colors", className, )} {...props} > + <div className="mr-2 grid h-5 w-5 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing [&>*]:col-start-1 [&>*]:row-start-1"> + <HiChevronRight + className={cn( + "size-3 text-muted-foreground transition-[opacity,transform] duration-150 group-hover:opacity-0", + !section.isCollapsed && "rotate-90", + )} + /> + <LuGripVertical className="size-3 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-60" /> + </div> + <div className="flex min-w-0 flex-1 items-center gap-1.5"> {isRenaming ? ( <RenameInput @@ -67,49 +81,36 @@ export const DashboardSidebarSectionHeader = forwardRef< onChange={onRenameValueChange} onSubmit={onSubmitRename} onCancel={onCancelRename} - className="-ml-1 h-5 w-full min-w-0 px-1 py-0 text-[11px] font-medium bg-transparent border-none outline-none text-muted-foreground" + className="-ml-1 h-5 w-full min-w-0 px-1 py-0 text-[13px] font-medium bg-transparent border-none outline-none text-muted-foreground" /> ) : ( <span className="truncate">{section.name}</span> )} - - {!isRenaming && ( - <div className="grid shrink-0 items-center [&>*]:col-start-1 [&>*]:row-start-1"> - <span className="pointer-events-none text-[10px] font-normal tabular-nums transition-opacity duration-150 group-hover:opacity-0"> - ({section.workspaces.length}) - </span> - <button - type="button" - onClick={(event) => { - event.stopPropagation(); - onStartRename(); - }} - className="z-10 flex items-center justify-center opacity-0 text-muted-foreground transition-[opacity,color] duration-150 group-hover:opacity-100 hover:text-foreground" - aria-label="Rename section" - > - <LuPencil className="size-3.5" /> - </button> - </div> - )} </div> - <button - type="button" - onClick={(event) => { - event.stopPropagation(); - onToggleCollapse(); - }} - onContextMenu={(event) => event.stopPropagation()} - aria-expanded={!section.isCollapsed} - className="p-1 rounded hover:bg-muted transition-colors shrink-0 ml-1" - > - <HiChevronRight - className={cn( - "size-3 text-muted-foreground transition-transform duration-150", - !section.isCollapsed && "rotate-90", - )} - /> - </button> + {!isRenaming && ( + <div className="ml-1 flex size-5 shrink-0 items-center justify-center"> + {actions ? ( + // biome-ignore lint/a11y/noStaticElementInteractions: Nested action controls handle their own semantics; this wrapper only isolates events from the header toggle. + <div + className="peer hidden size-full items-center justify-center group-hover:flex group-has-[:focus]:flex has-[[data-state=open]]:flex" + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {actions} + </div> + ) : null} + <span + className={cn( + "text-[10px] font-normal tabular-nums text-muted-foreground", + actions && + "group-hover:hidden group-has-[:focus]:hidden peer-has-[[data-state=open]]:hidden", + )} + > + {section.workspaces.length} + </span> + </div> + )} </div> ); }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/index.ts deleted file mode 100644 index 5823f140adf..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardSidebarSection } from "./DashboardSidebarSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext/DashboardSidebarSectionRenameContext.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext/DashboardSidebarSectionRenameContext.tsx new file mode 100644 index 00000000000..775984b8ea7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext/DashboardSidebarSectionRenameContext.tsx @@ -0,0 +1,64 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +interface DashboardSidebarSectionRenameContextValue { + pendingRenameSectionId: string | null; + requestSectionRename: (sectionId: string) => void; + clearPendingSectionRename: (sectionId: string) => void; +} + +const DashboardSidebarSectionRenameContext = + createContext<DashboardSidebarSectionRenameContextValue | null>(null); + +interface DashboardSidebarSectionRenameProviderProps { + children: ReactNode; +} + +export function DashboardSidebarSectionRenameProvider({ + children, +}: DashboardSidebarSectionRenameProviderProps) { + const [pendingRenameSectionId, setPendingRenameSectionId] = useState< + string | null + >(null); + + const requestSectionRename = useCallback((sectionId: string) => { + setPendingRenameSectionId(sectionId); + }, []); + + const clearPendingSectionRename = useCallback((sectionId: string) => { + setPendingRenameSectionId((currentSectionId) => + currentSectionId === sectionId ? null : currentSectionId, + ); + }, []); + + const value = useMemo( + () => ({ + clearPendingSectionRename, + pendingRenameSectionId, + requestSectionRename, + }), + [clearPendingSectionRename, pendingRenameSectionId, requestSectionRename], + ); + + return ( + <DashboardSidebarSectionRenameContext.Provider value={value}> + {children} + </DashboardSidebarSectionRenameContext.Provider> + ); +} + +export function useDashboardSidebarSectionRename() { + const context = useContext(DashboardSidebarSectionRenameContext); + if (!context) { + throw new Error( + "useDashboardSidebarSectionRename must be used within DashboardSidebarSectionRenameProvider", + ); + } + return context; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext/index.ts new file mode 100644 index 00000000000..62352741d29 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext/index.ts @@ -0,0 +1,4 @@ +export { + DashboardSidebarSectionRenameProvider, + useDashboardSidebarSectionRename, +} from "./DashboardSidebarSectionRenameContext"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx index 391563f4b14..10b79b82d02 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/DashboardSidebarWorkspaceItem.tsx @@ -1,17 +1,24 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useDiffStats } from "renderer/hooks/host-service/useDiffStats"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; +import { useDeletingWorkspaces } from "renderer/routes/_authenticated/providers/DeletingWorkspacesProvider"; +import { RenameBranchDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; +import { useV2WorkspaceNotificationStatus } from "renderer/stores/v2-notifications"; +import { useDashboardSidebarHover } from "../../providers/DashboardSidebarHoverProvider"; import type { DashboardSidebarWorkspace } from "../../types"; import { DashboardSidebarDeleteDialog } from "../DashboardSidebarDeleteDialog"; import { DashboardSidebarCollapsedWorkspaceButton } from "./components/DashboardSidebarCollapsedWorkspaceButton"; import { DashboardSidebarExpandedWorkspaceRow } from "./components/DashboardSidebarExpandedWorkspaceRow"; import { DashboardSidebarWorkspaceContextMenu } from "./components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu"; -import { DashboardSidebarWorkspaceHoverCardContent } from "./components/DashboardSidebarWorkspaceHoverCardContent"; import { useDashboardSidebarWorkspaceItemActions } from "./hooks/useDashboardSidebarWorkspaceItemActions"; -import { getWorkspaceRowMocks } from "./utils"; interface DashboardSidebarWorkspaceItemProps { workspace: DashboardSidebarWorkspace; onHoverCardOpen?: () => void; shortcutLabel?: string; isCollapsed?: boolean; + isInSection?: boolean; } export function DashboardSidebarWorkspaceItem({ @@ -19,30 +26,36 @@ export function DashboardSidebarWorkspaceItem({ onHoverCardOpen, shortcutLabel, isCollapsed = false, + isInSection = false, }: DashboardSidebarWorkspaceItemProps) { const { id, projectId, accentColor = null, hostType, + hostIsOnline, name, branch, creationStatus, } = workspace; - const mockData = getWorkspaceRowMocks(id); + const isMainWorkspace = workspace.type === "main"; + const diffStats = useDiffStats(id); + const workspaceStatus = useV2WorkspaceNotificationStatus(id); const { cancelRename, handleClick, handleCopyPath, + handleCopyBranchName, handleCreateSection, - handleDelete, + handleDeleted, handleOpenInFinder, + handleRemoveFromSidebar, + handleToggleUnread, isActive, isDeleteDialogOpen, - isDeleting, + isUnread, isRenaming, moveWorkspaceToSection, - removeWorkspaceFromSidebar, renameValue, setIsDeleteDialogOpen, setRenameValue, @@ -52,13 +65,70 @@ export function DashboardSidebarWorkspaceItem({ workspaceId: id, projectId, workspaceName: name, + branch, + isMainWorkspace, }); - const isCreating = !!creationStatus; + const navigate = useNavigate(); + const { v2Workspaces: v2WorkspaceActions } = useOptimisticCollectionActions(); + const [renameBranchTarget, setRenameBranchTarget] = useState<string | null>( + null, + ); + const handleAfterBranchRename = (newBranchName: string) => { + v2WorkspaceActions.updateWorkspace(id, { branch: newBranchName }); + }; + const isPending = !!creationStatus; + // Keep the delete dialog outside the hidden wrapper below — the destroy + // flow reopens it into an error pane on conflict/teardown-failed. + const isDeleting = useDeletingWorkspaces().isDeleting(id); + const handlePendingClick = isPending + ? () => { + void navigate({ + to: `/pending/${id}` as string, + }); + } + : undefined; + + const { + hoveredId: hoverHoveredId, + requestOpen: hoverRequestOpen, + requestClose: hoverRequestClose, + syncIfHovered: hoverSyncIfHovered, + } = useDashboardSidebarHover(); + const rowRef = useRef<HTMLDivElement>(null); + const hoverEligible = !isPending; + const hoverPayload = useMemo( + () => ({ workspace, onEditBranchClick: setRenameBranchTarget }), + [workspace], + ); + + const handleMouseEnter = useCallback(() => { + if (!hoverEligible || !rowRef.current) return; + hoverRequestOpen(id, rowRef.current, hoverPayload); + }, [hoverEligible, hoverRequestOpen, id, hoverPayload]); + const handleMouseLeave = useCallback(() => { + if (!hoverEligible) return; + hoverRequestClose(id); + }, [hoverEligible, hoverRequestClose, id]); + + const isHovered = hoverHoveredId === id; + useEffect(() => { + if (isHovered && hostType === "local-device") onHoverCardOpen?.(); + }, [isHovered, hostType, onHoverCardOpen]); + useEffect(() => { + if (!isHovered) return; + hoverSyncIfHovered(id, hoverPayload); + }, [isHovered, hoverSyncIfHovered, id, hoverPayload]); if (isCollapsed) { const content = ( - <div className="relative flex w-full justify-center"> + // biome-ignore lint/a11y/noStaticElementInteractions: hover handlers drive a non-interactive popover, no new keyboard semantics + <div + ref={rowRef} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + className="relative flex w-full justify-center" + > {(accentColor || isActive) && ( <div className="absolute inset-y-0 left-0 w-0.5" @@ -69,11 +139,13 @@ export function DashboardSidebarWorkspaceItem({ )} <DashboardSidebarCollapsedWorkspaceButton hostType={hostType} + workspaceType={workspace.type} + hostIsOnline={hostIsOnline} isActive={isActive} - onClick={isCreating ? undefined : handleClick} - workspaceStatus={isCreating ? null : mockData.workspaceStatus} + workspaceStatus={workspaceStatus} + onClick={isPending ? handlePendingClick : handleClick} creationStatus={creationStatus} - disabled={isCreating} + disabled={isPending} aria-label={ creationStatus ? `Creating workspace: ${name}` : undefined } @@ -83,42 +155,52 @@ export function DashboardSidebarWorkspaceItem({ return ( <> - {isCreating ? ( - content - ) : ( - <DashboardSidebarWorkspaceContextMenu - projectId={projectId} - onHoverCardOpen={ - hostType === "local-device" ? onHoverCardOpen : undefined - } - hoverCardContent={ - <DashboardSidebarWorkspaceHoverCardContent - workspace={workspace} - mockData={mockData} - /> - } - onCreateSection={handleCreateSection} - onMoveToSection={(targetSectionId) => - moveWorkspaceToSection(id, projectId, targetSectionId) - } - onOpenInFinder={handleOpenInFinder} - onCopyPath={handleCopyPath} - onRemoveFromSidebar={() => removeWorkspaceFromSidebar(id)} - onRename={startRename} - onDelete={() => setIsDeleteDialogOpen(true)} - > - {content} - </DashboardSidebarWorkspaceContextMenu> - )} + <div hidden={isDeleting}> + {isPending ? ( + content + ) : ( + <DashboardSidebarWorkspaceContextMenu + projectId={projectId} + isInSection={isInSection} + isUnread={isUnread} + isLocalWorkspace={hostType === "local-device"} + onCreateSection={handleCreateSection} + onMoveToSection={(targetSectionId) => + moveWorkspaceToSection(id, projectId, targetSectionId) + } + onOpenInFinder={handleOpenInFinder} + onCopyPath={handleCopyPath} + onCopyBranchName={handleCopyBranchName} + onRemoveFromSidebar={handleRemoveFromSidebar} + onRename={startRename} + onDelete={ + isMainWorkspace ? undefined : () => setIsDeleteDialogOpen(true) + } + onToggleUnread={handleToggleUnread} + > + {content} + </DashboardSidebarWorkspaceContextMenu> + )} + </div> - {!isCreating && ( + {!isPending && !isMainWorkspace && ( <DashboardSidebarDeleteDialog + workspaceId={id} + workspaceName={name || branch} open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen} - onConfirm={handleDelete} - title={`Delete "${name || branch}"?`} - description="This will permanently delete the workspace." - isPending={isDeleting} + onDeleted={handleDeleted} + /> + )} + {renameBranchTarget && ( + <RenameBranchDialog + workspaceId={id} + currentBranchName={renameBranchTarget} + open={renameBranchTarget !== null} + onOpenChange={(open) => { + if (!open) setRenameBranchTarget(null); + }} + onAfterRename={handleAfterBranchRename} /> )} </> @@ -126,60 +208,80 @@ export function DashboardSidebarWorkspaceItem({ } const expandedContent = ( - <DashboardSidebarExpandedWorkspaceRow - workspace={workspace} - isActive={isActive} - isRenaming={isRenaming} - renameValue={renameValue} - shortcutLabel={shortcutLabel} - mockData={isCreating ? { ...mockData, workspaceStatus: null } : mockData} - onClick={isCreating ? undefined : handleClick} - onDoubleClick={isCreating ? undefined : startRename} - onDeleteClick={() => setIsDeleteDialogOpen(true)} - onRenameValueChange={setRenameValue} - onSubmitRename={submitRename} - onCancelRename={cancelRename} - /> + // biome-ignore lint/a11y/noStaticElementInteractions: hover handlers drive a non-interactive popover, no new keyboard semantics + <div + ref={rowRef} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + > + <DashboardSidebarExpandedWorkspaceRow + workspace={workspace} + isActive={isActive} + isRenaming={isRenaming} + renameValue={renameValue} + shortcutLabel={shortcutLabel} + diffStats={isPending ? null : diffStats} + workspaceStatus={workspaceStatus} + isInSection={isInSection} + onClick={isPending ? handlePendingClick : handleClick} + onDoubleClick={isPending ? undefined : startRename} + onRemoveFromSidebarClick={handleRemoveFromSidebar} + onCloseWorkspaceClick={() => setIsDeleteDialogOpen(true)} + onRenameValueChange={setRenameValue} + onSubmitRename={submitRename} + onCancelRename={cancelRename} + /> + </div> ); return ( <> - {isCreating ? ( - expandedContent - ) : ( - <DashboardSidebarWorkspaceContextMenu - projectId={projectId} - onHoverCardOpen={ - hostType === "local-device" ? onHoverCardOpen : undefined - } - hoverCardContent={ - <DashboardSidebarWorkspaceHoverCardContent - workspace={workspace} - mockData={mockData} - /> - } - onCreateSection={handleCreateSection} - onMoveToSection={(targetSectionId) => - moveWorkspaceToSection(id, projectId, targetSectionId) - } - onOpenInFinder={handleOpenInFinder} - onCopyPath={handleCopyPath} - onRemoveFromSidebar={() => removeWorkspaceFromSidebar(id)} - onRename={startRename} - onDelete={() => setIsDeleteDialogOpen(true)} - > - {expandedContent} - </DashboardSidebarWorkspaceContextMenu> - )} + <div hidden={isDeleting}> + {isPending ? ( + expandedContent + ) : ( + <DashboardSidebarWorkspaceContextMenu + projectId={projectId} + isInSection={isInSection} + isUnread={isUnread} + onCreateSection={handleCreateSection} + onMoveToSection={(targetSectionId) => + moveWorkspaceToSection(id, projectId, targetSectionId) + } + isLocalWorkspace={hostType === "local-device"} + onOpenInFinder={handleOpenInFinder} + onCopyPath={handleCopyPath} + onCopyBranchName={handleCopyBranchName} + onRemoveFromSidebar={handleRemoveFromSidebar} + onRename={startRename} + onDelete={ + isMainWorkspace ? undefined : () => setIsDeleteDialogOpen(true) + } + onToggleUnread={handleToggleUnread} + > + {expandedContent} + </DashboardSidebarWorkspaceContextMenu> + )} + </div> - {!isCreating && ( + {!isPending && !isMainWorkspace && ( <DashboardSidebarDeleteDialog + workspaceId={id} + workspaceName={name || branch} open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen} - onConfirm={handleDelete} - title={`Delete "${name || branch}"?`} - description="This will permanently delete the workspace." - isPending={isDeleting} + onDeleted={handleDeleted} + /> + )} + {renameBranchTarget && ( + <RenameBranchDialog + workspaceId={id} + currentBranchName={renameBranchTarget} + open={renameBranchTarget !== null} + onOpenChange={(open) => { + if (!open) setRenameBranchTarget(null); + }} + onAfterRename={handleAfterBranchRename} /> )} </> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx index cabc8e1045b..a1d029fe7fa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarCollapsedWorkspaceButton/DashboardSidebarCollapsedWorkspaceButton.tsx @@ -1,15 +1,20 @@ import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef } from "react"; import type { ActivePaneStatus } from "shared/tabs-types"; -import type { DashboardSidebarWorkspaceHostType } from "../../../../types"; +import type { + DashboardSidebarWorkspaceHostType, + DashboardSidebarWorkspaceType, +} from "../../../../types"; import { DashboardSidebarWorkspaceIcon } from "../DashboardSidebarWorkspaceIcon"; interface DashboardSidebarCollapsedWorkspaceButtonProps extends ComponentPropsWithoutRef<"button"> { hostType: DashboardSidebarWorkspaceHostType; + workspaceType: DashboardSidebarWorkspaceType; + hostIsOnline: boolean | null; isActive: boolean; workspaceStatus?: ActivePaneStatus | null; - creationStatus?: "preparing" | "generating-branch" | "creating"; + creationStatus?: "preparing" | "generating-branch" | "creating" | "failed"; } export const DashboardSidebarCollapsedWorkspaceButton = forwardRef< @@ -19,6 +24,8 @@ export const DashboardSidebarCollapsedWorkspaceButton = forwardRef< ( { hostType, + workspaceType, + hostIsOnline, isActive, workspaceStatus = null, creationStatus, @@ -33,14 +40,16 @@ export const DashboardSidebarCollapsedWorkspaceButton = forwardRef< ref={ref} className={cn( "relative flex items-center justify-center size-8 rounded-md", - "hover:bg-muted/50 transition-colors cursor-pointer", - isActive && "bg-muted", + "transition-colors cursor-pointer", + isActive ? "bg-muted hover:bg-muted" : "hover:bg-muted/50", className, )} {...props} > <DashboardSidebarWorkspaceIcon hostType={hostType} + workspaceType={workspaceType} + hostIsOnline={hostIsOnline} isActive={isActive} variant="collapsed" workspaceStatus={workspaceStatus} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx index ed95f8f030b..506ab6d4520 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarExpandedWorkspaceRow/DashboardSidebarExpandedWorkspaceRow.tsx @@ -1,14 +1,35 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { type ComponentPropsWithoutRef, forwardRef, useMemo } from "react"; -import { HiMiniXMark } from "react-icons/hi2"; +import { + type ComponentPropsWithoutRef, + forwardRef, + useEffect, + useMemo, + useRef, +} from "react"; +import { HiMiniMinus, HiMiniXMark } from "react-icons/hi2"; +import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; +import { HotkeyLabel } from "renderer/hotkeys"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; -import type { DashboardSidebarWorkspace } from "../../../../types"; -import type { WorkspaceRowMockData } from "../../utils"; +import type { ActivePaneStatus } from "shared/tabs-types"; +import type { + DashboardSidebarWorkspace, + DashboardSidebarWorkspacePullRequest, +} from "../../../../types"; import { getCreationStatusText } from "../../utils/getCreationStatusText"; import { DashboardSidebarWorkspaceDiffStats } from "../DashboardSidebarWorkspaceDiffStats"; import { DashboardSidebarWorkspaceIcon } from "../DashboardSidebarWorkspaceIcon"; -import { DashboardSidebarWorkspaceStatusBadge } from "../DashboardSidebarWorkspaceStatusBadge"; + +const PR_STATE_LABEL: Record< + DashboardSidebarWorkspacePullRequest["state"], + string +> = { + open: "Open", + merged: "Merged", + closed: "Closed", + draft: "Draft", +}; interface DashboardSidebarExpandedWorkspaceRowProps extends ComponentPropsWithoutRef<"div"> { @@ -17,10 +38,13 @@ interface DashboardSidebarExpandedWorkspaceRowProps isRenaming: boolean; renameValue: string; shortcutLabel?: string; - mockData: WorkspaceRowMockData; + diffStats: DiffStats | null; + workspaceStatus?: ActivePaneStatus | null; + isInSection?: boolean; onClick?: () => void; onDoubleClick?: () => void; - onDeleteClick: () => void; + onCloseWorkspaceClick: () => void; + onRemoveFromSidebarClick: () => void; onRenameValueChange: (value: string) => void; onSubmitRename: () => void; onCancelRename: () => void; @@ -37,10 +61,13 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< isRenaming, renameValue, shortcutLabel, - mockData, + diffStats, + workspaceStatus = null, + isInSection = false, onClick, onDoubleClick, - onDeleteClick, + onCloseWorkspaceClick, + onRemoveFromSidebarClick, onRenameValueChange, onSubmitRename, onCancelRename, @@ -52,19 +79,36 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< const { accentColor = null, hostType, + hostIsOnline, name, branch, pullRequest, creationStatus, } = workspace; - const showBranchSubtitle = !!name && name !== branch; - const showSubtitle = showBranchSubtitle || !!pullRequest; const showsStandaloneActiveStripe = accentColor == null; + const localRef = useRef<HTMLDivElement>(null); + const openUrl = electronTrpc.external.openUrl.useMutation(); + + useEffect(() => { + if (isActive) { + localRef.current?.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [isActive]); const creationStatusText = useMemo( () => getCreationStatusText(creationStatus), [creationStatus], ); + const isMainWorkspace = workspace.type === "main"; + const workspaceKindTitle = isMainWorkspace + ? "Main workspace" + : "Worktree workspace"; + const workspaceKindDescription = isMainWorkspace + ? "Uses the repository checkout on this host" + : "Isolated copy for parallel development"; return ( // biome-ignore lint/a11y/noStaticElementInteractions: Mirrors the legacy sidebar row UI, which includes nested action buttons. @@ -72,7 +116,11 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined} aria-disabled={creationStatus ? true : undefined} - ref={ref} + ref={(node) => { + localRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }} onClick={onClick} onKeyDown={(event) => { if (onClick && (event.key === "Enter" || event.key === " ")) { @@ -82,10 +130,14 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< }} onDoubleClick={onDoubleClick} className={cn( - "relative flex w-full items-center pl-3 pr-2 text-left text-sm", - onClick && "cursor-pointer hover:bg-muted/50", - "transition-colors group", - showSubtitle ? "py-1.5" : "py-2", + "relative flex w-full items-center pr-2 text-left text-sm", + isInSection ? "pl-7" : "pl-5", + onClick && + (isActive + ? "cursor-pointer hover:bg-muted" + : "cursor-pointer hover:bg-muted/50"), + "group", + "py-2", isActive && "bg-muted", className, )} @@ -100,173 +152,200 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< <Tooltip delayDuration={500}> <TooltipTrigger asChild> - <div className="relative mr-2.5 flex size-5 shrink-0 items-center justify-center"> - <DashboardSidebarWorkspaceIcon - hostType={hostType} - isActive={isActive} - variant="expanded" - workspaceStatus={mockData.workspaceStatus} - creationStatus={creationStatus} - /> - </div> + {pullRequest ? ( + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + openUrl.mutate(pullRequest.url); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.stopPropagation(); + } + }} + aria-label={`Open pull request #${pullRequest.number}`} + className="relative mr-2.5 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-foreground/10" + > + <DashboardSidebarWorkspaceIcon + hostType={hostType} + workspaceType={workspace.type} + hostIsOnline={hostIsOnline} + isActive={isActive} + variant="expanded" + workspaceStatus={workspaceStatus} + creationStatus={creationStatus} + pullRequestState={pullRequest.state} + /> + </button> + ) : ( + <div className="relative mr-2.5 flex size-5 shrink-0 items-center justify-center"> + <DashboardSidebarWorkspaceIcon + hostType={hostType} + workspaceType={workspace.type} + hostIsOnline={hostIsOnline} + isActive={isActive} + variant="expanded" + workspaceStatus={workspaceStatus} + creationStatus={creationStatus} + pullRequestState={null} + /> + </div> + )} </TooltipTrigger> <TooltipContent side="right" sideOffset={8}> - <p className="text-xs font-medium">Worktree workspace</p> - <p className="text-xs text-muted-foreground"> - Isolated copy for parallel development - </p> + {pullRequest ? ( + <> + <p className="text-xs font-medium"> + PR #{pullRequest.number} — {PR_STATE_LABEL[pullRequest.state]} + </p> + <p className="text-xs text-muted-foreground"> + Click to open on GitHub + </p> + </> + ) : ( + <> + <p className="text-xs font-medium"> + {isMainWorkspace + ? workspaceKindTitle + : hostType === "local-device" + ? "Local workspace" + : hostType === "remote-device" + ? hostIsOnline === false + ? "Remote workspace — device offline" + : "Remote workspace" + : "Cloud workspace"} + </p> + <p className="text-xs text-muted-foreground"> + {isMainWorkspace + ? workspaceKindDescription + : hostType === "local-device" + ? "Running on this device" + : hostType === "remote-device" + ? hostIsOnline === false + ? "The associated device isn't reachable right now" + : "Running on a paired device" + : "Hosted in the cloud"} + </p> + </> + )} </TooltipContent> </Tooltip> - <div className="flex min-w-0 flex-1 flex-col justify-center"> - {showSubtitle ? ( - <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] grid-rows-2 items-center gap-x-1.5 gap-y-0.5"> - {isRenaming ? ( - <RenameInput - value={renameValue} - onChange={onRenameValueChange} - onSubmit={onSubmitRename} - onCancel={onCancelRename} - className={cn( - "h-5 w-full -ml-1 border-none bg-transparent px-1 py-0 text-[13px] leading-tight outline-none", - !showBranchSubtitle && "row-span-2 self-center", - )} - /> - ) : ( - <span - className={cn( - "truncate text-[13px] leading-tight transition-colors", - isActive - ? "text-foreground font-medium" - : "text-foreground/80", - !showBranchSubtitle && "row-span-2 self-center", - )} - > - {name || branch} - </span> - )} - - <div className="col-start-2 row-start-1 grid h-5 shrink-0 items-center [&>*]:col-start-1 [&>*]:row-start-1"> - {creationStatusText ? ( - <span className="text-[11px] text-muted-foreground"> - {creationStatusText} - </span> - ) : ( - <> - <DashboardSidebarWorkspaceDiffStats - additions={mockData.diffStats.additions} - deletions={mockData.diffStats.deletions} - isActive={isActive} - /> - <div className="invisible flex items-center justify-end gap-1.5 opacity-0 transition-[opacity,visibility] group-hover:visible group-hover:opacity-100"> - {shortcutLabel && ( - <span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground"> - {shortcutLabel} - </span> - )} - <Tooltip delayDuration={300}> - <TooltipTrigger asChild> - <button - type="button" - onClick={(event) => { - event.stopPropagation(); - onDeleteClick(); - }} - className="flex items-center justify-center text-muted-foreground hover:text-foreground" - aria-label="Close workspace" - > - <HiMiniXMark className="size-3.5" /> - </button> - </TooltipTrigger> - <TooltipContent side="top" sideOffset={4}> - Close workspace - </TooltipContent> - </Tooltip> - </div> - </> - )} - </div> - - {showBranchSubtitle && ( - <span className="col-start-1 row-start-2 truncate font-mono text-[11px] leading-tight text-muted-foreground/60"> - {branch} - </span> - )} - - {pullRequest && ( - <DashboardSidebarWorkspaceStatusBadge - state={pullRequest.state} - prNumber={pullRequest.number} - prUrl={pullRequest.url} - className="col-start-2 row-start-2 justify-self-end" - /> + <div className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-1.5"> + {isRenaming ? ( + <RenameInput + value={renameValue} + onChange={onRenameValueChange} + onSubmit={onSubmitRename} + onCancel={onCancelRename} + className={cn( + "h-5 w-full -ml-1 border-none bg-transparent px-1 py-0 text-[13px] leading-tight outline-none", )} - </div> + /> ) : ( - <div className="flex min-h-5 items-center gap-1.5"> - {isRenaming ? ( - <RenameInput - value={renameValue} - onChange={onRenameValueChange} - onSubmit={onSubmitRename} - onCancel={onCancelRename} - className="h-5 w-full flex-1 -ml-1 border-none bg-transparent px-1 py-0 text-[13px] leading-tight outline-none" - /> - ) : ( - <span - className={cn( - "truncate text-[13px] leading-tight transition-colors flex-1", - isActive - ? "text-foreground font-medium" - : "text-foreground/80", - )} - > - {name || branch} - </span> + <span + className={cn( + "truncate text-[13px] leading-tight transition-colors", + isActive ? "text-foreground" : "text-foreground/80", )} + > + {name || branch} + </span> + )} - <div className="grid h-5 shrink-0 items-center [&>*]:col-start-1 [&>*]:row-start-1"> - {creationStatusText ? ( - <span className="text-[11px] text-muted-foreground"> - {creationStatusText} - </span> - ) : ( - <> + <div className="col-start-2 row-start-1 grid h-5 shrink-0 items-center [&>*]:col-start-1 [&>*]:row-start-1"> + {creationStatusText ? ( + <span + className={cn( + "text-[11px]", + creationStatus === "failed" + ? "text-destructive" + : "text-muted-foreground", + )} + > + {creationStatusText} + </span> + ) : ( + <> + {diffStats && + (diffStats.additions > 0 || diffStats.deletions > 0) && ( <DashboardSidebarWorkspaceDiffStats - additions={mockData.diffStats.additions} - deletions={mockData.diffStats.deletions} + additions={diffStats.additions} + deletions={diffStats.deletions} isActive={isActive} /> - <div className="invisible flex items-center justify-end gap-1.5 opacity-0 transition-[opacity,visibility] group-hover:visible group-hover:opacity-100"> - {shortcutLabel && ( - <span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground"> - {shortcutLabel} - </span> - )} - <Tooltip delayDuration={300}> - <TooltipTrigger asChild> - <button - type="button" - onClick={(event) => { + )} + <div className="invisible flex items-center justify-end gap-1.5 opacity-0 transition-[opacity,visibility] group-hover:visible group-hover:opacity-100"> + {shortcutLabel && ( + <span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground"> + {shortcutLabel} + </span> + )} + {isMainWorkspace ? ( + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onRemoveFromSidebarClick(); + }} + onKeyDown={(event) => { + if ( + event.key === "Enter" || + event.key === " " || + event.key === "Spacebar" + ) { event.stopPropagation(); - onDeleteClick(); - }} - className="flex items-center justify-center text-muted-foreground hover:text-foreground" - aria-label="Close workspace" - > - <HiMiniXMark className="size-3.5" /> - </button> - </TooltipTrigger> - <TooltipContent side="top" sideOffset={4}> - Close workspace - </TooltipContent> - </Tooltip> - </div> - </> - )} - </div> - </div> - )} + } + }} + className="flex items-center justify-center text-muted-foreground hover:text-foreground" + aria-label="Remove from sidebar" + > + <HiMiniMinus className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="top" sideOffset={4}> + <HotkeyLabel label="Remove from sidebar" /> + </TooltipContent> + </Tooltip> + ) : ( + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <button + type="button" + onClick={(event) => { + event.stopPropagation(); + onCloseWorkspaceClick(); + }} + onKeyDown={(event) => { + if ( + event.key === "Enter" || + event.key === " " || + event.key === "Spacebar" + ) { + event.stopPropagation(); + } + }} + className="flex items-center justify-center text-muted-foreground hover:text-foreground" + aria-label="Close workspace" + > + <HiMiniXMark className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="top" sideOffset={4}> + <HotkeyLabel + label="Close workspace" + id={isActive ? "CLOSE_WORKSPACE" : undefined} + /> + </TooltipContent> + </Tooltip> + )} + </div> + </> + )} + </div> </div> </div> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index 46c1cb9cdf9..c650ea308c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -8,55 +8,59 @@ import { ContextMenuSubTrigger, ContextMenuTrigger, } from "@superset/ui/context-menu"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@superset/ui/hover-card"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useState } from "react"; import { LuArrowRightLeft, + LuArrowUp, LuCopy, + LuEye, + LuEyeOff, LuFolderOpen, LuFolderPlus, - LuMinus, + LuGitBranch, LuPencil, LuTrash2, LuX, } from "react-icons/lu"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useDashboardSidebarHover } from "../../../../providers/DashboardSidebarHoverProvider"; interface DashboardSidebarWorkspaceContextMenuProps { - hoverCardContent?: React.ReactNode; projectId: string; - onHoverCardOpen?: () => void; + isInSection?: boolean; + isLocalWorkspace: boolean; + isUnread: boolean; onCreateSection: () => void; onMoveToSection: (sectionId: string | null) => void; onOpenInFinder: () => void; onCopyPath: () => void; + onCopyBranchName: () => void; onRemoveFromSidebar: () => void; onRename: () => void; - onDelete: () => void; + onDelete?: () => void; + onToggleUnread: () => void; children: React.ReactNode; } export function DashboardSidebarWorkspaceContextMenu({ projectId, - onHoverCardOpen, - hoverCardContent, + isInSection, + isLocalWorkspace, + isUnread, onCreateSection, onMoveToSection, onOpenInFinder, onCopyPath, + onCopyBranchName, onRemoveFromSidebar, onRename, onDelete, + onToggleUnread, children, }: DashboardSidebarWorkspaceContextMenuProps) { const collections = useCollections(); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const { setContextMenuOpen } = useDashboardSidebarHover(); const { data: sections = [] } = useLiveQuery( (q) => q @@ -68,97 +72,105 @@ export function DashboardSidebarWorkspaceContextMenu({ .select(({ sidebarSections }) => ({ id: sidebarSections.sectionId, name: sidebarSections.name, + color: sidebarSections.color, })), [collections, projectId], ); - const menuContent = ( - <ContextMenuContent onCloseAutoFocus={(event) => event.preventDefault()}> - <ContextMenuItem onSelect={onRename}> - <LuPencil className="size-4 mr-2" /> - Rename - </ContextMenuItem> - <ContextMenuSeparator /> - <ContextMenuItem onSelect={onOpenInFinder}> - <LuFolderOpen className="size-4 mr-2" /> - Open in Finder - </ContextMenuItem> - <ContextMenuItem onSelect={onCopyPath}> - <LuCopy className="size-4 mr-2" /> - Copy Path - </ContextMenuItem> - <ContextMenuSeparator /> - <ContextMenuSub> - <ContextMenuSubTrigger> - <LuArrowRightLeft className="size-4 mr-2" /> - Move to Section - </ContextMenuSubTrigger> - <ContextMenuSubContent> - <ContextMenuItem onSelect={onCreateSection}> - <LuFolderPlus className="size-4 mr-2" /> - New Section - </ContextMenuItem> + return ( + <ContextMenu onOpenChange={setContextMenuOpen}> + <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> + <ContextMenuContent onCloseAutoFocus={(event) => event.preventDefault()}> + <ContextMenuItem onSelect={onRename}> + <LuPencil className="size-4 mr-2" /> + Rename + </ContextMenuItem> + {isLocalWorkspace && ( + <> + <ContextMenuSeparator /> + <ContextMenuItem onSelect={onOpenInFinder}> + <LuFolderOpen className="size-4 mr-2" /> + Open in Finder + </ContextMenuItem> + <ContextMenuItem onSelect={onCopyPath}> + <LuCopy className="size-4 mr-2" /> + Copy Path + </ContextMenuItem> + </> + )} + {!isLocalWorkspace && <ContextMenuSeparator />} + <ContextMenuItem onSelect={onCopyBranchName}> + <LuGitBranch className="size-4 mr-2" /> + Copy Branch Name + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem onSelect={onToggleUnread}> + {isUnread ? ( + <> + <LuEye className="size-4 mr-2" /> + Mark as Read + </> + ) : ( + <> + <LuEyeOff className="size-4 mr-2" /> + Mark as Unread + </> + )} + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem onSelect={onCreateSection}> + <LuFolderPlus className="size-4 mr-2" /> + New group from workspace + </ContextMenuItem> + {(sections.length > 0 || isInSection) && <ContextMenuSeparator />} + {sections.length > 0 && ( + <ContextMenuSub> + <ContextMenuSubTrigger> + <LuArrowRightLeft className="size-4 mr-2" /> + Move to group + </ContextMenuSubTrigger> + <ContextMenuSubContent> + {sections.map((section) => ( + <ContextMenuItem + key={section.id} + onSelect={() => onMoveToSection(section.id)} + > + {section.color && ( + <span + className="size-2 shrink-0 rounded-full mr-2" + style={{ backgroundColor: section.color }} + /> + )} + {section.name} + </ContextMenuItem> + ))} + </ContextMenuSubContent> + </ContextMenuSub> + )} + {isInSection && ( <ContextMenuItem onSelect={() => onMoveToSection(null)}> - <LuMinus className="size-4 mr-2" /> - Ungrouped + <LuArrowUp className="size-4 mr-2" /> + Ungroup </ContextMenuItem> - {sections.map((section) => ( - <ContextMenuItem - key={section.id} - onSelect={() => onMoveToSection(section.id)} - > - {section.name} - </ContextMenuItem> - ))} - </ContextMenuSubContent> - </ContextMenuSub> - <ContextMenuSeparator /> - <ContextMenuItem - onSelect={onRemoveFromSidebar} - className="text-destructive focus:text-destructive" - > - <LuX className="size-4 mr-2 text-destructive" /> - Remove from Sidebar - </ContextMenuItem> - <ContextMenuItem - onSelect={onDelete} - className="text-destructive focus:text-destructive" - > - <LuTrash2 className="size-4 mr-2 text-destructive" /> - Delete - </ContextMenuItem> - </ContextMenuContent> - ); - - if (!hoverCardContent) { - return ( - <ContextMenu> - <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> - {menuContent} - </ContextMenu> - ); - } - - return ( - <HoverCard - open={isContextMenuOpen ? false : undefined} - openDelay={400} - closeDelay={100} - onOpenChange={(open) => { - if (open) { - onHoverCardOpen?.(); - } - }} - > - <ContextMenu onOpenChange={setIsContextMenuOpen}> - <HoverCardTrigger asChild> - <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> - </HoverCardTrigger> - {menuContent} - </ContextMenu> - <HoverCardContent side="right" align="start" className="w-72"> - {hoverCardContent} - </HoverCardContent> - </HoverCard> + )} + <ContextMenuSeparator /> + <ContextMenuItem + onSelect={onRemoveFromSidebar} + className="text-destructive focus:text-destructive" + > + <LuX className="size-4 mr-2 text-destructive" /> + Remove from Sidebar + </ContextMenuItem> + {onDelete ? ( + <ContextMenuItem + onSelect={onDelete} + className="text-destructive focus:text-destructive" + > + <LuTrash2 className="size-4 mr-2 text-destructive" /> + Delete + </ContextMenuItem> + ) : null} + </ContextMenuContent> + </ContextMenu> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDiffStats/DashboardSidebarWorkspaceDiffStats.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDiffStats/DashboardSidebarWorkspaceDiffStats.tsx index 05875fe4393..6d259313f4e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDiffStats/DashboardSidebarWorkspaceDiffStats.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceDiffStats/DashboardSidebarWorkspaceDiffStats.tsx @@ -1,5 +1,3 @@ -import { cn } from "@superset/ui/utils"; - interface DashboardSidebarWorkspaceDiffStatsProps { additions: number; deletions: number; @@ -12,15 +10,18 @@ export function DashboardSidebarWorkspaceDiffStats({ isActive, }: DashboardSidebarWorkspaceDiffStatsProps) { return ( - <div - className={cn( - "flex h-5 shrink-0 items-center rounded px-1.5 text-[10px] font-mono tabular-nums transition-[opacity,visibility] group-hover:opacity-0 group-hover:invisible", - isActive ? "bg-foreground/10" : "bg-muted/50", - )} - > + <div className="flex h-5 w-fit shrink-0 items-center justify-self-end text-[10px] font-mono tabular-nums transition-[opacity,visibility] group-hover:opacity-0 group-hover:invisible"> <div className="flex items-center gap-1.5 leading-none"> - <span className="text-emerald-500/90">+{additions}</span> - <span className="text-red-400/90">−{deletions}</span> + <span + className={isActive ? "text-emerald-500/90" : "text-muted-foreground"} + > + +{additions} + </span> + <span + className={isActive ? "text-red-400/90" : "text-muted-foreground"} + > + −{deletions} + </span> </div> </div> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx index fd04c73a833..e3dffa07be2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceHoverCardContent/DashboardSidebarWorkspaceHoverCardContent.tsx @@ -2,10 +2,15 @@ import { Button } from "@superset/ui/button"; import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { formatDistanceToNow } from "date-fns"; import { FaGithub } from "react-icons/fa"; -import { LuExternalLink, LuGlobe, LuTriangleAlert } from "react-icons/lu"; -import { useHotkeyDisplay } from "renderer/stores/hotkeys"; +import { + LuExternalLink, + LuGlobe, + LuPencil, + LuTriangleAlert, +} from "react-icons/lu"; +import type { DiffStats } from "renderer/hooks/host-service/useDiffStats"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import type { DashboardSidebarWorkspace } from "../../../../types"; -import type { WorkspaceRowMockData } from "../../utils"; import { ChecksList } from "./components/ChecksList"; import { ChecksSummary } from "./components/ChecksSummary"; import { PullRequestStatusBadge } from "./components/PullRequestStatusBadge"; @@ -13,12 +18,14 @@ import { ReviewStatus } from "./components/ReviewStatus"; interface DashboardSidebarWorkspaceHoverCardContentProps { workspace: DashboardSidebarWorkspace; - mockData: WorkspaceRowMockData; + diffStats: DiffStats | null; + onEditBranchClick?: (branchName: string) => void; } export function DashboardSidebarWorkspaceHoverCardContent({ workspace, - mockData, + diffStats, + onEditBranchClick, }: DashboardSidebarWorkspaceHoverCardContentProps) { const { name, @@ -31,7 +38,7 @@ export function DashboardSidebarWorkspaceHoverCardContent({ behindCount, createdAt, } = workspace; - const openPRDisplay = useHotkeyDisplay("OPEN_PR"); + const { keys: openPRDisplay } = useHotkeyDisplay("OPEN_PR"); const hasOpenPRShortcut = !( openPRDisplay.length === 1 && openPRDisplay[0] === "Unassigned" ); @@ -59,23 +66,37 @@ export function DashboardSidebarWorkspaceHoverCardContent({ <span className="text-[10px] uppercase tracking-wide text-muted-foreground"> Branch </span> - {repoUrl && branchExistsOnRemote ? ( - <a - href={`${repoUrl}/tree/${branch}`} - target="_blank" - rel="noopener noreferrer" - className={`flex items-center gap-1 font-mono break-all hover:underline ${hasCustomAlias ? "text-xs" : "text-sm"}`} - > - {branch} - <LuExternalLink className="size-3 shrink-0" /> - </a> - ) : ( - <code - className={`font-mono break-all block ${hasCustomAlias ? "text-xs" : "text-sm"}`} - > - {branch} - </code> - )} + <div className="flex items-center gap-1.5"> + {onEditBranchClick ? ( + <button + type="button" + onClick={() => onEditBranchClick(branch)} + className={`group/branch flex min-w-0 flex-1 items-center gap-1 font-mono break-all text-left hover:text-foreground hover:underline ${hasCustomAlias ? "text-xs" : "text-sm"}`} + title="Rename branch" + > + <span className="break-all">{branch}</span> + <LuPencil className="size-3 shrink-0 opacity-0 group-hover/branch:opacity-100 transition-opacity" /> + </button> + ) : ( + <code + className={`font-mono break-all block min-w-0 flex-1 ${hasCustomAlias ? "text-xs" : "text-sm"}`} + > + {branch} + </code> + )} + {repoUrl && branchExistsOnRemote && ( + <a + href={`${repoUrl}/tree/${branch}`} + target="_blank" + rel="noopener noreferrer" + className="shrink-0 text-muted-foreground hover:text-foreground" + title="Open branch on GitHub" + onClick={(e) => e.stopPropagation()} + > + <LuExternalLink className="size-3" /> + </a> + )} + </div> </div> <span className="text-xs text-muted-foreground block"> {formatDistanceToNow(createdAt, { addSuffix: true })} @@ -107,14 +128,14 @@ export function DashboardSidebarWorkspaceHoverCardContent({ /> )} </div> - <div className="flex items-center gap-1.5 text-xs font-mono shrink-0"> - <span className="text-emerald-500"> - +{mockData.diffStats.additions} - </span> - <span className="text-destructive-foreground"> - -{mockData.diffStats.deletions} - </span> - </div> + {diffStats && ( + <div className="flex items-center gap-1.5 text-xs font-mono shrink-0"> + <span className="text-emerald-500">+{diffStats.additions}</span> + <span className="text-destructive-foreground"> + -{diffStats.deletions} + </span> + </div> + )} </div> <p className="text-xs leading-relaxed line-clamp-2"> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx index c87f5536e6e..ed268448037 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceIcon/DashboardSidebarWorkspaceIcon.tsx @@ -1,16 +1,32 @@ import { cn } from "@superset/ui/utils"; -import { LuCloud, LuFolderGit2, LuLaptop } from "react-icons/lu"; +import { CgLaptop } from "react-icons/cg"; +import { HiExclamationTriangle } from "react-icons/hi2"; +import { + LuGitMerge, + LuGitPullRequest, + LuGitPullRequestClosed, + LuGitPullRequestDraft, +} from "react-icons/lu"; +import { RxDot } from "react-icons/rx"; +import { TbCloud, TbCloudOff } from "react-icons/tb"; import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import type { ActivePaneStatus } from "shared/tabs-types"; -import type { DashboardSidebarWorkspaceHostType } from "../../../../types"; +import type { + DashboardSidebarWorkspaceHostType, + DashboardSidebarWorkspacePullRequest, + DashboardSidebarWorkspaceType, +} from "../../../../types"; interface DashboardSidebarWorkspaceIconProps { hostType: DashboardSidebarWorkspaceHostType; + workspaceType: DashboardSidebarWorkspaceType; + hostIsOnline: boolean | null; isActive: boolean; variant: "collapsed" | "expanded"; workspaceStatus?: ActivePaneStatus | null; - creationStatus?: "preparing" | "generating-branch" | "creating"; + creationStatus?: "preparing" | "generating-branch" | "creating" | "failed"; + pullRequestState?: DashboardSidebarWorkspacePullRequest["state"] | null; } const OVERLAY_POSITION = { @@ -18,46 +34,81 @@ const OVERLAY_POSITION = { expanded: "-top-0.5 -right-0.5", } as const; +const PR_ICON_BY_STATE = { + open: LuGitPullRequest, + merged: LuGitMerge, + closed: LuGitPullRequestClosed, + draft: LuGitPullRequestDraft, +} as const; + +const PR_COLOR_BY_STATE = { + open: "text-emerald-500", + merged: "text-purple-500", + closed: "text-destructive", + draft: "text-muted-foreground", +} as const; + export function DashboardSidebarWorkspaceIcon({ hostType, + workspaceType, + hostIsOnline, isActive, variant, workspaceStatus = null, creationStatus, + pullRequestState = null, }: DashboardSidebarWorkspaceIconProps) { const overlayPosition = OVERLAY_POSITION[variant]; + const iconColor = isActive ? "text-foreground" : "text-muted-foreground"; + const isRemoteDeviceOffline = + hostType === "remote-device" && hostIsOnline === false; - return ( - <> - {creationStatus || workspaceStatus === "working" ? ( - <AsciiSpinner className="text-base" /> - ) : hostType === "cloud" ? ( - <LuCloud - className={cn( - "size-4 transition-colors", - variant === "expanded" && "transition-colors", - isActive ? "text-foreground" : "text-muted-foreground", - )} + const renderPrimaryIcon = () => { + if (pullRequestState) { + const PrIcon = PR_ICON_BY_STATE[pullRequestState]; + return ( + <PrIcon + className={cn("size-3.5", PR_COLOR_BY_STATE[pullRequestState])} strokeWidth={1.75} /> - ) : hostType === "remote-device" ? ( - <LuLaptop - className={cn( - "size-4 transition-colors", - variant === "expanded" && "transition-colors", - isActive ? "text-foreground" : "text-muted-foreground", - )} + ); + } + + if (hostType === "local-device") { + if (workspaceType === "main") { + return ( + <CgLaptop className={cn("size-4 transition-colors", iconColor)} /> + ); + } + + return <RxDot className={cn("size-4 transition-colors", iconColor)} />; + } + + if (isRemoteDeviceOffline) { + return ( + <TbCloudOff + className={cn("size-4 transition-colors", iconColor, "opacity-60")} strokeWidth={1.75} /> + ); + } + + return ( + <TbCloud + className={cn("size-4 transition-colors", iconColor)} + strokeWidth={1.75} + /> + ); + }; + + return ( + <> + {creationStatus === "failed" ? ( + <HiExclamationTriangle className="size-4 text-destructive" /> + ) : creationStatus || workspaceStatus === "working" ? ( + <AsciiSpinner className="text-base" /> ) : ( - <LuFolderGit2 - className={cn( - "size-4 transition-colors", - variant === "expanded" && "transition-colors", - isActive ? "text-foreground" : "text-muted-foreground", - )} - strokeWidth={1.75} - /> + renderPrimaryIcon() )} {workspaceStatus && workspaceStatus !== "working" && ( <span className={cn("absolute", overlayPosition)}> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts index f38886a9a1d..90455b6a8a9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/hooks/useDashboardSidebarWorkspaceItemActions/useDashboardSidebarWorkspaceItemActions.ts @@ -1,29 +1,56 @@ import { toast } from "@superset/ui/sonner"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; -import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; +import { useNavigateAwayFromWorkspace } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + useV2NotificationStore, + useV2WorkspaceIsUnread, +} from "renderer/stores/v2-notifications"; interface UseDashboardSidebarWorkspaceItemActionsOptions { workspaceId: string; projectId: string; workspaceName: string; + branch: string; + isMainWorkspace?: boolean; } export function useDashboardSidebarWorkspaceItemActions({ workspaceId, projectId, workspaceName, + branch, + isMainWorkspace = false, }: UseDashboardSidebarWorkspaceItemActionsOptions) { const navigate = useNavigate(); const matchRoute = useMatchRoute(); - const { createSection, moveWorkspaceToSection, removeWorkspaceFromSidebar } = - useDashboardSidebarState(); + const navigateAway = useNavigateAwayFromWorkspace(); + const { activeHostUrl } = useLocalHostService(); + const { copyToClipboard } = useCopyToClipboard(); + const { v2Workspaces: workspaceActions } = useOptimisticCollectionActions(); + const { requestSectionRename } = useDashboardSidebarSectionRename(); + const clearWorkspaceAttention = useV2NotificationStore( + (s) => s.clearWorkspaceAttention, + ); + const setManualUnread = useV2NotificationStore((s) => s.setManualUnread); + const isUnread = useV2WorkspaceIsUnread(workspaceId); + const { + createSection, + hideWorkspaceInSidebar, + moveWorkspaceToSection, + removeWorkspaceFromSidebar, + } = useDashboardSidebarState(); const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(workspaceName); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); const isActive = !!matchRoute({ to: "/v2-workspace/$workspaceId", @@ -33,6 +60,7 @@ export function useDashboardSidebarWorkspaceItemActions({ const handleClick = () => { if (isRenaming) return; + clearWorkspaceAttention(workspaceId); navigate({ to: "/v2-workspace/$workspaceId", params: { workspaceId }, @@ -49,67 +77,110 @@ export function useDashboardSidebarWorkspaceItemActions({ setRenameValue(workspaceName); }; - const submitRename = async () => { + const submitRename = () => { setIsRenaming(false); const trimmed = renameValue.trim(); if (!trimmed || trimmed === workspaceName) return; + workspaceActions.renameWorkspace(workspaceId, trimmed); + }; + + const handleDeleted = () => { + removeWorkspaceFromSidebar(workspaceId); + }; + + const handleRemoveFromSidebar = () => { + navigateAway(workspaceId); + if (isMainWorkspace) { + hideWorkspaceInSidebar(workspaceId, projectId); + return; + } + removeWorkspaceFromSidebar(workspaceId); + }; + + const handleCreateSection = () => { + const sectionId = createSection(projectId); + moveWorkspaceToSection(workspaceId, projectId, sectionId); + requestSectionRename(sectionId); + }; + + const resolveWorktreePath = async (): Promise<string | null> => { + if (!activeHostUrl) { + toast.error("Host service is not available"); + return null; + } + const workspace = await getHostServiceClientByUrl( + activeHostUrl, + ).workspace.get.query({ id: workspaceId }); + if (!workspace?.worktreePath) { + toast.error("Workspace path is not available"); + return null; + } + return workspace.worktreePath; + }; + + const handleOpenInFinder = async () => { try { - await apiTrpcClient.v2Workspace.update.mutate({ - id: workspaceId, - name: trimmed, - }); + const path = await resolveWorktreePath(); + if (!path) return; + await electronTrpcClient.external.openInFinder.mutate(path); } catch (error) { toast.error( - `Failed to rename: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to open in Finder: ${error instanceof Error ? error.message : "Unknown error"}`, ); } }; - const handleDelete = async () => { - setIsDeleting(true); + const handleCopyPath = async () => { try { - await apiTrpcClient.v2Workspace.delete.mutate({ id: workspaceId }); - removeWorkspaceFromSidebar(workspaceId); - setIsDeleteDialogOpen(false); - toast.success("Workspace deleted"); - if (isActive) { - navigate({ to: "/" }); - } + const path = await resolveWorktreePath(); + if (!path) return; + await copyToClipboard(path); + toast.success("Path copied"); } catch (error) { toast.error( - `Failed to delete: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to copy path: ${error instanceof Error ? error.message : "Unknown error"}`, ); - } finally { - setIsDeleting(false); } }; - const handleCreateSection = () => { - const newSectionId = createSection(projectId); - moveWorkspaceToSection(workspaceId, projectId, newSectionId); - }; - - const handleOpenInFinder = () => { - toast.info("Open in Finder is coming soon"); + const handleToggleUnread = () => { + if (isUnread) { + clearWorkspaceAttention(workspaceId); + } else { + setManualUnread(workspaceId); + } }; - const handleCopyPath = () => { - toast.info("Copy Path is coming soon"); + const handleCopyBranchName = async () => { + if (!branch) { + toast.error("Branch name is not available"); + return; + } + try { + await copyToClipboard(branch); + toast.success("Branch name copied"); + } catch (error) { + toast.error( + `Failed to copy branch name: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } }; return { cancelRename, handleClick, handleCopyPath, + handleCopyBranchName, handleCreateSection, - handleDelete, + handleDeleted, handleOpenInFinder, + handleRemoveFromSidebar, + handleToggleUnread, isActive, isDeleteDialogOpen, - isDeleting, isRenaming, + isUnread, moveWorkspaceToSection, - removeWorkspaceFromSidebar, renameValue, setIsDeleteDialogOpen, setRenameValue, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getCreationStatusText.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getCreationStatusText.ts index 0e5dbec717c..d98c17efbf8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getCreationStatusText.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getCreationStatusText.ts @@ -7,6 +7,7 @@ const CREATION_STATUS_LABELS: Record< preparing: "Preparing...", "generating-branch": "Generating...", creating: "Creating...", + failed: "Failed", } as const; export function getCreationStatusText( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts deleted file mode 100644 index cd42813fca8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/getWorkspaceRowMocks.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ActivePaneStatus } from "shared/tabs-types"; - -export interface WorkspaceRowMockData { - diffStats: { - additions: number; - deletions: number; - }; - workspaceStatus: ActivePaneStatus | null; -} - -function getSeed(input: string): number { - return [...input].reduce( - (seed, character, index) => seed + character.charCodeAt(0) * (index + 1), - 0, - ); -} - -export function getWorkspaceRowMocks( - workspaceId: string, -): WorkspaceRowMockData { - const seed = getSeed(workspaceId); - const paneStatuses: ActivePaneStatus[] = ["permission", "working", "review"]; - const status = - seed % 6 === 0 ? paneStatuses[seed % paneStatuses.length] : null; - - return { - diffStats: { - additions: (seed % 24) + 3, - deletions: (seed % 9) + 1, - }, - workspaceStatus: status, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts index d6d66d3e0c0..5298c6aa549 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/utils/index.ts @@ -1,3 +1 @@ export { getCreationStatusText } from "./getCreationStatusText"; -export type { WorkspaceRowMockData } from "./getWorkspaceRowMocks"; -export { getWorkspaceRowMocks } from "./getWorkspaceRowMocks"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx new file mode 100644 index 00000000000..7c5eac718cd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/SidebarDragOverlay.tsx @@ -0,0 +1,52 @@ +import { LuGripVertical } from "react-icons/lu"; +import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; +import type { + DashboardSidebarSection, + DashboardSidebarWorkspace, +} from "../../types"; +import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; + +type ActiveItem = + | { type: "workspace"; workspace: DashboardSidebarWorkspace } + | { type: "section"; section: DashboardSidebarSection }; + +interface SidebarDragOverlayProps { + activeItem: ActiveItem | null; +} + +export function SidebarDragOverlay({ activeItem }: SidebarDragOverlayProps) { + if (!activeItem) return null; + + if (activeItem.type === "workspace") { + return ( + <div className="bg-background shadow-lg"> + <DashboardSidebarWorkspaceItem workspace={activeItem.workspace} /> + </div> + ); + } + + const { section } = activeItem; + const hasColor = + section.color != null && section.color !== PROJECT_COLOR_DEFAULT; + + return ( + <div + className="bg-background shadow-lg" + style={{ + borderLeft: hasColor + ? `2px solid ${section.color}` + : "2px solid var(--color-border)", + }} + > + <div className="flex min-h-8 w-full items-center gap-1.5 pl-0.5 pr-2 py-1.5 text-[11px] font-medium text-muted-foreground"> + <div className="flex shrink-0 items-center justify-center w-5 h-5 opacity-60"> + <LuGripVertical className="size-3" /> + </div> + <span className="truncate">{section.name}</span> + <span className="text-[10px] font-normal tabular-nums shrink-0"> + ({section.workspaces.length}) + </span> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts new file mode 100644 index 00000000000..58e16f18656 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts @@ -0,0 +1 @@ +export { SidebarDragOverlay } from "./SidebarDragOverlay"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx new file mode 100644 index 00000000000..e090834eb9e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx @@ -0,0 +1,111 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useCallback, useEffect, useState } from "react"; +import { useDashboardSidebarSectionRename } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSectionRenameContext"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; +import type { DashboardSidebarSection } from "../../types"; +import { + DashboardSidebarSectionActionsDropdown, + DashboardSidebarSectionContextMenu, +} from "../DashboardSidebarSection/components/DashboardSidebarSectionContextMenu"; +import { DashboardSidebarSectionHeader } from "../DashboardSidebarSection/components/DashboardSidebarSectionHeader"; + +interface SortableSectionHeaderProps { + sortableId: string; + section: DashboardSidebarSection; + onDelete: (sectionId: string) => void; + onRename: (sectionId: string, name: string) => void; + onToggleCollapse: (sectionId: string) => void; +} + +export function SortableSectionHeader({ + sortableId, + section, + onDelete, + onRename, + onToggleCollapse, +}: SortableSectionHeaderProps) { + const { setSectionColor } = useDashboardSidebarState(); + const { clearPendingSectionRename, pendingRenameSectionId } = + useDashboardSidebarSectionRename(); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(section.name); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: sortableId }); + + const hasColor = + section.color != null && section.color !== PROJECT_COLOR_DEFAULT; + + const handleSubmitRename = () => { + const trimmed = renameValue.trim(); + if (trimmed) onRename(section.id, trimmed); + setIsRenaming(false); + }; + const startRename = useCallback(() => { + setRenameValue(section.name); + setIsRenaming(true); + }, [section.name]); + + useEffect(() => { + if (pendingRenameSectionId !== section.id) return; + startRename(); + clearPendingSectionRename(section.id); + }, [ + clearPendingSectionRename, + pendingRenameSectionId, + section.id, + startRename, + ]); + + return ( + <div + ref={setNodeRef} + style={{ + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + borderLeft: hasColor + ? `2px solid ${section.color}` + : "2px solid var(--color-border)", + }} + > + <DashboardSidebarSectionContextMenu + color={section.color} + onRename={startRename} + onSetColor={(color) => setSectionColor(section.id, color)} + onDelete={() => onDelete(section.id)} + > + <DashboardSidebarSectionHeader + section={section} + isRenaming={isRenaming} + renameValue={renameValue} + onRenameValueChange={setRenameValue} + onSubmitRename={handleSubmitRename} + onCancelRename={() => { + setRenameValue(section.name); + setIsRenaming(false); + }} + onToggleCollapse={() => onToggleCollapse(section.id)} + actions={ + <DashboardSidebarSectionActionsDropdown + color={section.color} + onRename={startRename} + onSetColor={(color) => setSectionColor(section.id, color)} + onDelete={() => onDelete(section.id)} + /> + } + {...attributes} + {...listeners} + /> + </DashboardSidebarSectionContextMenu> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts new file mode 100644 index 00000000000..6816fe842bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts @@ -0,0 +1 @@ +export { SortableSectionHeader } from "./SortableSectionHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx new file mode 100644 index 00000000000..67bc43abbd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx @@ -0,0 +1,52 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { DashboardSidebarWorkspace } from "../../types"; +import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; + +interface SortableWorkspaceItemProps { + sortableId: string; + workspace: DashboardSidebarWorkspace; + accentColor?: string | null; + isInSection?: boolean; + onHoverCardOpen?: () => void; + shortcutLabel?: string; +} + +export function SortableWorkspaceItem({ + sortableId, + workspace, + accentColor, + isInSection, + onHoverCardOpen, + shortcutLabel, +}: SortableWorkspaceItemProps) { + const { + setNodeRef, + attributes, + listeners, + isDragging, + transform, + transition, + } = useSortable({ id: sortableId }); + + return ( + <div + ref={setNodeRef} + style={{ + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + borderLeft: accentColor ? `2px solid ${accentColor}` : undefined, + }} + {...attributes} + {...listeners} + > + <DashboardSidebarWorkspaceItem + workspace={workspace} + onHoverCardOpen={onHoverCardOpen} + shortcutLabel={shortcutLabel} + isInSection={isInSection} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts new file mode 100644 index 00000000000..b10a73a7c4f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts @@ -0,0 +1 @@ +export { SortableWorkspaceItem } from "./SortableWorkspaceItem"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts index f17287a786e..18502f99aa0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarData/useDashboardSidebarData.ts @@ -1,14 +1,14 @@ import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; -import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider"; -import { usePendingWorkspace } from "renderer/stores/new-workspace-modal"; +import { getVisibleSidebarWorkspaces } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { MOCK_ORG_ID } from "shared/constants"; import type { DashboardSidebarProject, @@ -17,23 +17,156 @@ import type { DashboardSidebarWorkspace, } from "../../types"; -// Pending workspaces are always rendered at the end of the project's workspace list -const PENDING_WORKSPACE_TAB_ORDER = Number.MAX_SAFE_INTEGER; +// Sits above every real workspace so the pending row lines up with the real one, +// which is inserted via getPrependTabOrder. +const PENDING_WORKSPACE_TAB_ORDER = Number.MIN_SAFE_INTEGER; +const MAIN_WORKSPACE_TAB_ORDER = Number.MIN_SAFE_INTEGER; + +type LocalPullRequest = DashboardSidebarWorkspace["pullRequest"]; +type PullRequestWorkspaceRow = { + workspaceId: string; + pullRequest: LocalPullRequest; +}; + +function haveSameStrings(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function haveSameProjects( + left: DashboardSidebarProject[], + right: DashboardSidebarProject[], +): boolean { + return ( + left.length === right.length && + left.every((project, index) => project === right[index]) + ); +} + +function getPullRequestRowsFingerprint( + rows: PullRequestWorkspaceRow[], +): string { + return JSON.stringify( + rows + .map((row) => [row.workspaceId, row.pullRequest] as const) + .sort(([leftWorkspaceId], [rightWorkspaceId]) => + leftWorkspaceId.localeCompare(rightWorkspaceId), + ), + ); +} + +function getDashboardSidebarProjectFingerprint( + project: DashboardSidebarProject, +): string { + return JSON.stringify(project); +} + +function useStableStringArray(values: string[]): string[] { + const previousRef = useRef<string[] | null>(null); + + return useMemo(() => { + const previous = previousRef.current; + if (previous && haveSameStrings(previous, values)) { + return previous; + } + + previousRef.current = values; + return values; + }, [values]); +} + +function useStableLocalPullRequestsByWorkspaceId( + rows: PullRequestWorkspaceRow[] | undefined, +): Map<string, LocalPullRequest> { + const previousRef = useRef<{ + fingerprint: string; + map: Map<string, LocalPullRequest>; + } | null>(null); + + return useMemo(() => { + const nextRows = rows ?? []; + const fingerprint = getPullRequestRowsFingerprint(nextRows); + const previous = previousRef.current; + if (previous?.fingerprint === fingerprint) { + return previous.map; + } + + const map = new Map( + nextRows.map((workspace) => [ + workspace.workspaceId, + workspace.pullRequest, + ]), + ); + previousRef.current = { fingerprint, map }; + return map; + }, [rows]); +} + +function useStableDashboardSidebarProjects( + projects: DashboardSidebarProject[], +): DashboardSidebarProject[] { + const previousRef = useRef<{ + projects: DashboardSidebarProject[]; + byId: Map< + string, + { fingerprint: string; project: DashboardSidebarProject } + >; + } | null>(null); + + return useMemo(() => { + const previous = previousRef.current; + const nextById = new Map< + string, + { fingerprint: string; project: DashboardSidebarProject } + >(); + const nextProjects = projects.map((project) => { + const fingerprint = getDashboardSidebarProjectFingerprint(project); + const previousProject = previous?.byId.get(project.id); + const stableProject = + previousProject?.fingerprint === fingerprint + ? previousProject.project + : project; + + nextById.set(project.id, { fingerprint, project: stableProject }); + return stableProject; + }); + + if (previous && haveSameProjects(previous.projects, nextProjects)) { + previousRef.current = { projects: previous.projects, byId: nextById }; + return previous.projects; + } + + previousRef.current = { projects: nextProjects, byId: nextById }; + return nextProjects; + }, [projects]); +} export function useDashboardSidebarData() { const { data: session } = authClient.useSession(); const collections = useCollections(); - const { services } = useHostService(); + const { machineId, activeHostUrl } = useLocalHostService(); const { toggleProjectCollapsed } = useDashboardSidebarState(); - const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); - const pendingWorkspace = usePendingWorkspace(); + + // Query pending workspaces from the local collection + const { data: pendingWorkspaces = [] } = useLiveQuery( + (q) => + q.from({ pw: collections.pendingWorkspaces }).select(({ pw }) => ({ + id: pw.id, + projectId: pw.projectId, + name: pw.name, + branchName: pw.branchName, + status: pw.status, + })), + [collections], + ); const activeOrganizationId = env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : (session?.session?.activeOrganizationId ?? null); - const activeHostService = - activeOrganizationId !== null - ? (services.get(activeOrganizationId) ?? null) - : null; + const activeHostClient = activeHostUrl + ? getHostServiceClientByUrl(activeHostUrl) + : null; const { data: rawSidebarProjects = [] } = useLiveQuery( (q) => @@ -90,7 +223,7 @@ export function useDashboardSidebarData() { [collections], ); - const { data: sidebarWorkspaces = [] } = useLiveQuery( + const { data: rawSidebarWorkspaces = [] } = useLiveQuery( (q) => q .from({ sidebarWorkspaces: collections.v2WorkspaceLocalState }) @@ -99,42 +232,93 @@ export function useDashboardSidebarData() { ({ sidebarWorkspaces, workspaces }) => eq(sidebarWorkspaces.workspaceId, workspaces.id), ) - .leftJoin( - { devices: collections.v2Devices }, - ({ workspaces, devices }) => eq(workspaces.deviceId, devices.id), + .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.machineId), ) .orderBy( ({ sidebarWorkspaces }) => sidebarWorkspaces.sidebarState.tabOrder, "asc", ) - .select(({ sidebarWorkspaces, workspaces, devices }) => ({ + .select(({ sidebarWorkspaces, workspaces, hosts }) => ({ id: workspaces.id, projectId: sidebarWorkspaces.sidebarState.projectId, - deviceId: workspaces.deviceId, - deviceType: devices?.type ?? null, - deviceClientId: devices?.clientId ?? null, + hostId: workspaces.hostId, + type: workspaces.type, + hostIsOnline: hosts.isOnline, name: workspaces.name, branch: workspaces.branch, createdAt: workspaces.createdAt, updatedAt: workspaces.updatedAt, tabOrder: sidebarWorkspaces.sidebarState.tabOrder, sectionId: sidebarWorkspaces.sidebarState.sectionId, + isHidden: sidebarWorkspaces.sidebarState.isHidden, })), [collections], ); - const localWorkspaceIds = useMemo( - () => - sidebarWorkspaces - .filter( - (workspace) => - workspace.deviceType !== "cloud" && - workspace.deviceClientId === deviceInfo?.deviceId, + const sidebarWorkspaces = useMemo( + () => getVisibleSidebarWorkspaces(rawSidebarWorkspaces), + [rawSidebarWorkspaces], + ); + + const localStateWorkspaceIds = useMemo( + () => new Set(rawSidebarWorkspaces.map((workspace) => workspace.id)), + [rawSidebarWorkspaces], + ); + + const { data: localMainWorkspaces = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.machineId), ) + .where(({ workspaces }) => eq(workspaces.type, "main")) + .select(({ workspaces, hosts }) => ({ + id: workspaces.id, + projectId: workspaces.projectId, + hostId: workspaces.hostId, + type: workspaces.type, + hostIsOnline: hosts.isOnline, + name: workspaces.name, + branch: workspaces.branch, + createdAt: workspaces.createdAt, + updatedAt: workspaces.updatedAt, + tabOrder: MAIN_WORKSPACE_TAB_ORDER, + sectionId: null as string | null, + })), + [collections], + ); + + const visibleSidebarWorkspaces = useMemo(() => { + const sidebarProjectIds = new Set( + sidebarProjects.map((project) => project.id), + ); + const autoLocalMainWorkspaces = localMainWorkspaces.filter( + (workspace) => + !localStateWorkspaceIds.has(workspace.id) && + workspace.hostId === machineId && + sidebarProjectIds.has(workspace.projectId), + ); + + return [...autoLocalMainWorkspaces, ...sidebarWorkspaces]; + }, [ + localMainWorkspaces, + localStateWorkspaceIds, + machineId, + sidebarProjects, + sidebarWorkspaces, + ]); + + const computedLocalWorkspaceIds = useMemo( + () => + visibleSidebarWorkspaces + .filter((workspace) => workspace.hostId === machineId) .map((workspace) => workspace.id) .sort(), - [deviceInfo?.deviceId, sidebarWorkspaces], + [machineId, visibleSidebarWorkspaces], ); + const localWorkspaceIds = useStableStringArray(computedLocalWorkspaceIds); const { data: pullRequestData, refetch: refetchPullRequests } = useQuery({ queryKey: [ @@ -143,40 +327,32 @@ export function useDashboardSidebarData() { activeOrganizationId, localWorkspaceIds, ], - enabled: activeHostService !== null && localWorkspaceIds.length > 0, - refetchInterval: 15_000, + enabled: activeHostClient !== null && localWorkspaceIds.length > 0, + refetchInterval: 10_000, queryFn: () => - activeHostService?.client.pullRequests.getByWorkspaces.query({ + activeHostClient?.pullRequests.getByWorkspaces.query({ workspaceIds: localWorkspaceIds, }) ?? Promise.resolve({ workspaces: [] }), }); const refreshWorkspacePullRequest = useCallback( async (workspaceId: string) => { - if (!activeHostService || !localWorkspaceIds.includes(workspaceId)) { + if (!activeHostClient || !localWorkspaceIds.includes(workspaceId)) { return; } - await activeHostService.client.pullRequests.refreshByWorkspaces.mutate({ + await activeHostClient.pullRequests.refreshByWorkspaces.mutate({ workspaceIds: [workspaceId], }); await refetchPullRequests(); }, - [activeHostService, localWorkspaceIds, refetchPullRequests], + [activeHostClient, localWorkspaceIds, refetchPullRequests], ); - const localPullRequestsByWorkspaceId = useMemo( - () => - new Map( - (pullRequestData?.workspaces ?? []).map((workspace) => [ - workspace.workspaceId, - workspace.pullRequest, - ]), - ), - [pullRequestData?.workspaces], - ); + const localPullRequestsByWorkspaceId = + useStableLocalPullRequestsByWorkspaceId(pullRequestData?.workspaces); - const groups = useMemo<DashboardSidebarProject[]>(() => { + const computedGroups = useMemo<DashboardSidebarProject[]>(() => { const projectsById = new Map< string, DashboardSidebarProject & { @@ -216,22 +392,21 @@ export function useDashboardSidebarData() { }); } - for (const workspace of sidebarWorkspaces) { + for (const workspace of visibleSidebarWorkspaces) { const project = projectsById.get(workspace.projectId); if (!project) continue; const hostType: DashboardSidebarWorkspace["hostType"] = - workspace.deviceType === "cloud" - ? "cloud" - : workspace.deviceClientId === deviceInfo?.deviceId - ? "local-device" - : "remote-device"; + workspace.hostId === machineId ? "local-device" : "remote-device"; const sidebarWorkspace: DashboardSidebarWorkspace = { id: workspace.id, projectId: workspace.projectId, - deviceId: workspace.deviceId, + hostId: workspace.hostId, hostType, + type: workspace.type, + hostIsOnline: + hostType === "remote-device" ? workspace.hostIsOnline : null, accentColor: null, name: workspace.name, branch: workspace.branch, @@ -272,45 +447,43 @@ export function useDashboardSidebarData() { }); } - // Inject pending workspace if it exists - if (pendingWorkspace && deviceInfo?.deviceId) { - const project = projectsById.get(pendingWorkspace.projectId); - if (!project) { - // Log warning if pending workspace references non-existent project - console.warn( - `Pending workspace ${pendingWorkspace.id} references non-existent project ${pendingWorkspace.projectId}`, - ); - } else { - const pendingItem: DashboardSidebarWorkspace = { - id: pendingWorkspace.id, - projectId: pendingWorkspace.projectId, - deviceId: deviceInfo.deviceId, - hostType: "local-device", - accentColor: null, - name: pendingWorkspace.name, - branch: "", - pullRequest: null, - repoUrl: - project.githubOwner && project.githubRepoName - ? `https://github.com/${project.githubOwner}/${project.githubRepoName}` - : null, - branchExistsOnRemote: false, - previewUrl: null, - needsRebase: null, - behindCount: null, - createdAt: new Date(), - updatedAt: new Date(), - creationStatus: pendingWorkspace.status, - }; - - project.childEntries.push({ - tabOrder: PENDING_WORKSPACE_TAB_ORDER, - child: { - type: "workspace", - workspace: pendingItem, - }, - }); - } + // Inject pending workspaces (creating / failed) + for (const pw of pendingWorkspaces) { + if (pw.status === "succeeded") continue; // will appear as a real workspace + const project = projectsById.get(pw.projectId); + if (!project) continue; + + const pendingItem: DashboardSidebarWorkspace = { + id: pw.id, + projectId: pw.projectId, + hostId: "", + hostType: "local-device", + type: "worktree", + hostIsOnline: null, + accentColor: null, + name: pw.name, + branch: pw.branchName, + pullRequest: null, + repoUrl: + project.githubOwner && project.githubRepoName + ? `https://github.com/${project.githubOwner}/${project.githubRepoName}` + : null, + branchExistsOnRemote: false, + previewUrl: null, + needsRebase: null, + behindCount: null, + createdAt: new Date(), + updatedAt: new Date(), + creationStatus: pw.status, + }; + + project.childEntries.push({ + tabOrder: PENDING_WORKSPACE_TAB_ORDER, + child: { + type: "workspace", + workspace: pendingItem, + }, + }); } return sidebarProjects.flatMap((project) => { @@ -321,19 +494,42 @@ export function useDashboardSidebarData() { sectionMap: _sectionMap, ...sidebarProject } = resolvedProject; - sidebarProject.children = childEntries + + const sortedChildren = childEntries .sort((left, right) => left.tabOrder - right.tabOrder) .map(({ child }) => child); + + // Ungrouped workspaces rendered after a section header are visually + // grouped with that section (shared accent, collapse-together) and will + // be committed into it on next DnD. Reparent them here so section counts + // match what the user sees. + const children: DashboardSidebarProjectChild[] = []; + let currentSection: DashboardSidebarSection | null = null; + for (const child of sortedChildren) { + if (child.type === "section") { + currentSection = child.section; + children.push(child); + } else if (currentSection) { + currentSection.workspaces.push({ + ...child.workspace, + accentColor: currentSection.color, + }); + } else { + children.push(child); + } + } + sidebarProject.children = children; return [sidebarProject]; }); }, [ - deviceInfo?.deviceId, + machineId, localPullRequestsByWorkspaceId, - pendingWorkspace, + pendingWorkspaces, sidebarProjects, sidebarSections, - sidebarWorkspaces, + visibleSidebarWorkspaces, ]); + const groups = useStableDashboardSidebarProjects(computedGroups); return { groups, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts index 13b2f369e60..7660739882c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useDashboardSidebarShortcuts/useDashboardSidebarShortcuts.ts @@ -1,12 +1,44 @@ -import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useMemo } from "react"; +import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo, useRef } from "react"; +import { useHotkey } from "renderer/hotkeys"; import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useAppHotkey } from "renderer/stores/hotkeys"; import type { DashboardSidebarProject } from "../../types"; import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; const MAX_SHORTCUT_COUNT = 9; +function haveSameIds(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((id, index) => id === right[index]) + ); +} + +function useStableWorkspaceShortcutLabels( + workspaces: Array<{ id: string }>, +): Map<string, string> { + const previousRef = useRef<{ + workspaceIds: string[]; + labels: Map<string, string>; + } | null>(null); + + return useMemo(() => { + const workspaceIds = workspaces + .slice(0, MAX_SHORTCUT_COUNT) + .map((workspace) => workspace.id); + const previous = previousRef.current; + if (previous && haveSameIds(previous.workspaceIds, workspaceIds)) { + return previous.labels; + } + + const labels = new Map( + workspaceIds.map((workspaceId, index) => [workspaceId, `⌘${index + 1}`]), + ); + previousRef.current = { workspaceIds, labels }; + return labels; + }, [workspaces]); +} + export function useDashboardSidebarShortcuts( groups: DashboardSidebarProject[], ) { @@ -18,15 +50,8 @@ export function useDashboardSidebarShortcuts( .filter((workspace) => !workspace.creationStatus), [groups], ); - const workspaceShortcutLabels = useMemo( - () => - new Map( - flattenedWorkspaces - .slice(0, MAX_SHORTCUT_COUNT) - .map((workspace, index) => [workspace.id, `⌘${index + 1}`]), - ), - [flattenedWorkspaces], - ); + const workspaceShortcutLabels = + useStableWorkspaceShortcutLabels(flattenedWorkspaces); const switchToWorkspace = useCallback( (index: number) => { @@ -38,33 +63,43 @@ export function useDashboardSidebarShortcuts( [flattenedWorkspaces, navigate], ); - useAppHotkey("JUMP_TO_WORKSPACE_1", () => switchToWorkspace(0), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_2", () => switchToWorkspace(1), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_3", () => switchToWorkspace(2), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_4", () => switchToWorkspace(3), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_5", () => switchToWorkspace(4), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_6", () => switchToWorkspace(5), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_7", () => switchToWorkspace(6), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_8", () => switchToWorkspace(7), undefined, [ - switchToWorkspace, - ]); - useAppHotkey("JUMP_TO_WORKSPACE_9", () => switchToWorkspace(8), undefined, [ - switchToWorkspace, - ]); + useHotkey("JUMP_TO_WORKSPACE_1", () => switchToWorkspace(0)); + useHotkey("JUMP_TO_WORKSPACE_2", () => switchToWorkspace(1)); + useHotkey("JUMP_TO_WORKSPACE_3", () => switchToWorkspace(2)); + useHotkey("JUMP_TO_WORKSPACE_4", () => switchToWorkspace(3)); + useHotkey("JUMP_TO_WORKSPACE_5", () => switchToWorkspace(4)); + useHotkey("JUMP_TO_WORKSPACE_6", () => switchToWorkspace(5)); + useHotkey("JUMP_TO_WORKSPACE_7", () => switchToWorkspace(6)); + useHotkey("JUMP_TO_WORKSPACE_8", () => switchToWorkspace(7)); + useHotkey("JUMP_TO_WORKSPACE_9", () => switchToWorkspace(8)); + + const matchRoute = useMatchRoute(); + const currentWorkspaceMatch = matchRoute({ + to: "/v2-workspace/$workspaceId", + fuzzy: true, + }); + const currentWorkspaceId = + currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + + useHotkey("PREV_WORKSPACE", () => { + if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + const index = flattenedWorkspaces.findIndex( + (w) => w.id === currentWorkspaceId, + ); + if (index === -1) return; + const prevIndex = index <= 0 ? flattenedWorkspaces.length - 1 : index - 1; + navigateToV2Workspace(flattenedWorkspaces[prevIndex].id, navigate); + }); + + useHotkey("NEXT_WORKSPACE", () => { + if (!currentWorkspaceId || flattenedWorkspaces.length === 0) return; + const index = flattenedWorkspaces.findIndex( + (w) => w.id === currentWorkspaceId, + ); + if (index === -1) return; + const nextIndex = index >= flattenedWorkspaces.length - 1 ? 0 : index + 1; + navigateToV2Workspace(flattenedWorkspaces[nextIndex].id, navigate); + }); return workspaceShortcutLabels; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/index.ts new file mode 100644 index 00000000000..debafab68e8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/index.ts @@ -0,0 +1 @@ +export { useNavigateAwayFromWorkspace } from "./useNavigateAwayFromWorkspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/useNavigateAwayFromWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/useNavigateAwayFromWorkspace.ts new file mode 100644 index 00000000000..6a795a6f372 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useNavigateAwayFromWorkspace/useNavigateAwayFromWorkspace.ts @@ -0,0 +1,33 @@ +import { getActiveIdAfterRemoval } from "@superset/panes"; +import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { getFlattenedV2WorkspaceIds } from "../../utils/getFlattenedV2WorkspaceIds"; + +/** + * If the user is viewing the workspace about to be removed, jump to the + * next visible sidebar sibling (or home). No-op otherwise. Called + * directly at the callsite — not via a callback prop — because + * plumbing this through dialog onDeleting was silently dropping the nav. + */ +export function useNavigateAwayFromWorkspace() { + const navigate = useNavigate(); + const matchRoute = useMatchRoute(); + const collections = useCollections(); + + return (workspaceId: string) => { + const isViewingWorkspace = !!matchRoute({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + fuzzy: true, + }); + if (!isViewingWorkspace) return; + const ids = getFlattenedV2WorkspaceIds(collections); + const next = getActiveIdAfterRemoval(ids, workspaceId, workspaceId); + if (next) { + void navigateToV2Workspace(next, navigate); + } else { + void navigate({ to: "/" }); + } + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts new file mode 100644 index 00000000000..342f8978ef9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts @@ -0,0 +1 @@ +export { useSidebarDnd } from "./useSidebarDnd"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts new file mode 100644 index 00000000000..df654f453a7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -0,0 +1,362 @@ +import { + closestCenter, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, + KeyboardSensor, + MeasuringStrategy, + MouseSensor, + TouchSensor, + type UniqueIdentifier, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import type { + DashboardSidebarProjectChild, + DashboardSidebarSection, + DashboardSidebarWorkspace, +} from "../../types"; + +// ── ID helpers ─────────────────────────────────────────────────────── + +const WS = "ws::"; +const SEC = "sec::"; + +export const wsId = (id: string) => `${WS}${id}`; +export const secId = (id: string) => `${SEC}${id}`; +export const isSec = (id: UniqueIdentifier) => String(id).startsWith(SEC); + +export const parseId = (id: UniqueIdentifier) => { + const s = String(id); + if (s.startsWith(WS)) + return { type: "workspace" as const, realId: s.slice(WS.length) }; + if (s.startsWith(SEC)) + return { type: "section" as const, realId: s.slice(SEC.length) }; + return null; +}; + +// ── Measuring config ───────────────────────────────────────────────── + +export const measuring = { + droppable: { strategy: MeasuringStrategy.Always as const }, +}; + +// ── Build flat list from project children ──────────────────────────── + +function buildFlatItems( + children: DashboardSidebarProjectChild[], +): UniqueIdentifier[] { + const items: UniqueIdentifier[] = []; + for (const child of children) { + if (child.type === "workspace") { + items.push(wsId(child.workspace.id)); + } else { + items.push(secId(child.section.id)); + // Always include workspaces so AnimatePresence can animate collapse + for (const ws of child.section.workspaces) { + items.push(wsId(ws.id)); + } + } + } + return items; +} + +// ── Parse flat list to determine section membership ────────────────── + +interface ParsedFlatItems { + topLevel: Array<{ type: "workspace" | "section"; id: string }>; + sections: Record<string, string[]>; +} + +function parseFlatItems(items: UniqueIdentifier[]): ParsedFlatItems { + const result: ParsedFlatItems = { topLevel: [], sections: {} }; + let currentSection: string | null = null; + + for (const id of items) { + const parsed = parseId(id); + if (!parsed) continue; + if (parsed.type === "section") { + currentSection = parsed.realId; + result.topLevel.push({ type: "section", id: parsed.realId }); + result.sections[parsed.realId] = []; + } else if (parsed.type === "workspace") { + if (currentSection) { + result.sections[currentSection].push(parsed.realId); + } else { + result.topLevel.push({ type: "workspace", id: parsed.realId }); + } + } + } + return result; +} + +// ── Hook ───────────────────────────────────────────────────────────── + +interface UseSidebarDndOptions { + projectId: string; + projectChildren: DashboardSidebarProjectChild[]; +} + +export function useSidebarDnd({ + projectId, + projectChildren, +}: UseSidebarDndOptions) { + const { reorderProjectChildren, moveWorkspaceToSectionAtIndex } = + useDashboardSidebarState(); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const [flatItems, setFlatItems] = useState<UniqueIdentifier[]>(() => + buildFlatItems(projectChildren), + ); + const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null); + const activeType: "workspace" | "section" | null = activeId + ? isSec(activeId) + ? "section" + : "workspace" + : null; + const [overId, setOverId] = useState<UniqueIdentifier | null>(null); + const clonedRef = useRef<UniqueIdentifier[] | null>(null); + + // When dragging a section, SortableContext only has section IDs. + // When dragging a workspace (or idle), SortableContext has everything. + const sortableItems = useMemo(() => { + if (activeType === "section") { + return flatItems.filter((id) => isSec(id)); + } + return flatItems; + }, [flatItems, activeType]); + + // Sync from external data when items or their order/membership changes + const prevFingerprintRef = useRef(""); + useEffect(() => { + if (activeId) return; // Don't reset during active drag + const fingerprint = projectChildren + .map((c) => + c.type === "workspace" + ? c.workspace.id + : `s:${c.section.id}:${c.section.workspaces.map((w) => w.id).join("|")}`, + ) + .join(","); + if (fingerprint !== prevFingerprintRef.current) { + prevFingerprintRef.current = fingerprint; + setFlatItems(buildFlatItems(projectChildren)); + } + }, [projectChildren, activeId]); + + const collapsedSectionIds = useMemo(() => { + const set = new Set<string>(); + for (const child of projectChildren) { + if (child.type === "section" && child.section.isCollapsed) { + set.add(child.section.id); + } + } + return set; + }, [projectChildren]); + + // ── Lookups ────────────────────────────────────────────────────── + + const workspacesById = useMemo(() => { + const map = new Map<string, DashboardSidebarWorkspace>(); + for (const child of projectChildren) { + if (child.type === "workspace") { + map.set(child.workspace.id, child.workspace); + } else { + for (const ws of child.section.workspaces) { + map.set(ws.id, ws); + } + } + } + return map; + }, [projectChildren]); + + const sectionsById = useMemo(() => { + const map = new Map<string, DashboardSidebarSection>(); + for (const child of projectChildren) { + if (child.type === "section") { + map.set(child.section.id, child.section); + } + } + return map; + }, [projectChildren]); + + // Which section does each workspace belong to? (for visual grouping) + const groupInfo = useMemo(() => { + const map = new Map<string, { sectionId: string; color: string | null }>(); + let currentSection: { id: string; color: string | null } | null = null; + + for (const id of flatItems) { + const parsed = parseId(id); + if (!parsed) continue; + if (parsed.type === "section") { + const sec = sectionsById.get(parsed.realId); + currentSection = sec ? { id: sec.id, color: sec.color } : null; + } else if (parsed.type === "workspace" && currentSection) { + map.set(parsed.realId, { + sectionId: currentSection.id, + color: currentSection.color, + }); + } + } + return map; + }, [flatItems, sectionsById]); + + const activeItem = useMemo(() => { + if (!activeId) return null; + const parsed = parseId(activeId); + if (!parsed) return null; + if (parsed.type === "workspace") { + const ws = workspacesById.get(parsed.realId); + return ws ? { type: "workspace" as const, workspace: ws } : null; + } + const sec = sectionsById.get(parsed.realId); + return sec ? { type: "section" as const, section: sec } : null; + }, [activeId, workspacesById, sectionsById]); + + // Color the active workspace's ghost should show based on where it would land + const predictedColor = useMemo(() => { + if (!activeId || !overId || activeType !== "workspace") return null; + const overIndex = flatItems.indexOf(overId); + if (overIndex === -1) return null; + // If over is a section header, the workspace lands ABOVE it, + // so look for the section above the over position (skip the over itself) + const startFrom = isSec(overId) ? overIndex - 1 : overIndex; + for (let i = startFrom; i >= 0; i--) { + const p = parseId(flatItems[i]); + if (p?.type === "section") { + const sec = sectionsById.get(p.realId); + return sec?.color ?? null; + } + } + return null; // ungrouped — no section above + }, [activeId, overId, activeType, flatItems, sectionsById]); + + // ── Persistence ────────────────────────────────────────────────── + + const commitToDb = useCallback( + (items: UniqueIdentifier[]) => { + const parsed = parseFlatItems(items); + + // Top-level order (ungrouped workspaces + sections interleaved) + reorderProjectChildren(projectId, parsed.topLevel); + + // Each section's workspace order + for (const [sectionId, wsIds] of Object.entries(parsed.sections)) { + for (let i = 0; i < wsIds.length; i++) { + moveWorkspaceToSectionAtIndex(wsIds[i], projectId, sectionId, i); + } + } + }, + [projectId, reorderProjectChildren, moveWorkspaceToSectionAtIndex], + ); + + // ── Handlers ───────────────────────────────────────────────────── + + const onDragStart = useCallback( + ({ active }: DragStartEvent) => { + setActiveId(active.id); + clonedRef.current = [...flatItems]; + }, + [flatItems], + ); + + const onDragOver = useCallback(({ over }: DragOverEvent) => { + setOverId(over?.id ?? null); + }, []); + + const onDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + setActiveId(null); + setOverId(null); + + if (!over || active.id === over.id) return; + + if (isSec(active.id)) { + // Section drag: only section IDs were in the SortableContext. + // Reorder sections, then rebuild the full flat list with + // workspaces in their original positions under each section. + const sectionIds = flatItems.filter((id) => isSec(id)); + const oldIdx = sectionIds.indexOf(active.id); + const newIdx = sectionIds.indexOf(over.id); + if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return; + + const reorderedSections = arrayMove(sectionIds, oldIdx, newIdx); + + // Rebuild flat list: ungrouped workspaces first, then + // each section with its workspaces in new section order + const ungrouped: UniqueIdentifier[] = []; + const sectionGroups = new Map<string, UniqueIdentifier[]>(); + + let currentSec: string | null = null; + for (const id of flatItems) { + if (isSec(id)) { + currentSec = String(id); + sectionGroups.set(currentSec, []); + } else if (currentSec) { + sectionGroups.get(currentSec)?.push(id); + } else { + ungrouped.push(id); + } + } + + const newItems: UniqueIdentifier[] = [...ungrouped]; + for (const secSortId of reorderedSections) { + newItems.push(secSortId); + const wsInSec = sectionGroups.get(String(secSortId)) ?? []; + newItems.push(...wsInSec); + } + + setFlatItems(newItems); + commitToDb(newItems); + } else { + // Workspace drag: simple arrayMove in the full flat list + const oldIndex = flatItems.indexOf(active.id); + const overIndex = flatItems.indexOf(over.id); + if (oldIndex === -1 || overIndex === -1 || oldIndex === overIndex) + return; + + const newItems = arrayMove(flatItems, oldIndex, overIndex); + setFlatItems(newItems); + commitToDb(newItems); + } + }, + [flatItems, commitToDb], + ); + + const onDragCancel = useCallback(() => { + if (clonedRef.current) { + setFlatItems(clonedRef.current); + } + setActiveId(null); + setOverId(null); + clonedRef.current = null; + }, []); + + return { + sensors, + measuring, + collisionDetection: closestCenter, + flatItems, + sortableItems, + activeId, + activeType, + activeItem, + predictedColor, + groupInfo, + collapsedSectionIds, + workspacesById, + sectionsById, + handlers: { onDragStart, onDragOver, onDragEnd, onDragCancel }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/DashboardSidebarHoverProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/DashboardSidebarHoverProvider.tsx new file mode 100644 index 00000000000..017292f32e9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/DashboardSidebarHoverProvider.tsx @@ -0,0 +1,185 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { DashboardSidebarWorkspace } from "../../types"; + +const OPEN_DELAY_MS = 400; +const CLOSE_DELAY_MS = 100; + +export interface DashboardSidebarHoverPayload { + workspace: DashboardSidebarWorkspace; + onEditBranchClick: (branchName: string) => void; +} + +interface HoverState { + hoveredId: string | null; + anchorElement: HTMLElement | null; + payload: DashboardSidebarHoverPayload | null; +} + +interface HoverContextValue { + hoveredId: string | null; + anchorElement: HTMLElement | null; + payload: DashboardSidebarHoverPayload | null; + contextMenuOpen: boolean; + requestOpen: ( + id: string, + anchor: HTMLElement, + payload: DashboardSidebarHoverPayload, + ) => void; + requestClose: (id: string) => void; + cancelClose: () => void; + forceClose: () => void; + setContextMenuOpen: (open: boolean) => void; + syncIfHovered: (id: string, payload: DashboardSidebarHoverPayload) => void; +} + +const HoverContext = createContext<HoverContextValue | null>(null); + +export function DashboardSidebarHoverProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [state, setState] = useState<HoverState>({ + hoveredId: null, + anchorElement: null, + payload: null, + }); + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + const stateRef = useRef(state); + useEffect(() => { + stateRef.current = state; + }, [state]); + + const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + const clearOpenTimer = useCallback(() => { + if (openTimerRef.current) { + clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + }, []); + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + + const requestOpen = useCallback<HoverContextValue["requestOpen"]>( + (id, anchor, payload) => { + clearCloseTimer(); + if (stateRef.current.hoveredId !== null) { + clearOpenTimer(); + setState({ hoveredId: id, anchorElement: anchor, payload }); + return; + } + clearOpenTimer(); + openTimerRef.current = setTimeout(() => { + setState({ hoveredId: id, anchorElement: anchor, payload }); + openTimerRef.current = null; + }, OPEN_DELAY_MS); + }, + [clearCloseTimer, clearOpenTimer], + ); + + const requestClose = useCallback<HoverContextValue["requestClose"]>( + (id) => { + if (openTimerRef.current && stateRef.current.hoveredId === null) { + // Pending open for this id — cancel it. + clearOpenTimer(); + return; + } + if (stateRef.current.hoveredId !== id) return; + clearCloseTimer(); + closeTimerRef.current = setTimeout(() => { + setState({ hoveredId: null, anchorElement: null, payload: null }); + closeTimerRef.current = null; + }, CLOSE_DELAY_MS); + }, + [clearCloseTimer, clearOpenTimer], + ); + + const cancelClose = useCallback(() => { + clearCloseTimer(); + }, [clearCloseTimer]); + + const forceClose = useCallback(() => { + clearOpenTimer(); + clearCloseTimer(); + setState({ hoveredId: null, anchorElement: null, payload: null }); + }, [clearCloseTimer, clearOpenTimer]); + + const syncIfHovered = useCallback<HoverContextValue["syncIfHovered"]>( + (id, payload) => { + setState((prev) => { + if (prev.hoveredId !== id) return prev; + if ( + prev.payload?.workspace === payload.workspace && + prev.payload.onEditBranchClick === payload.onEditBranchClick + ) { + return prev; + } + return { ...prev, payload }; + }); + }, + [], + ); + + useEffect( + () => () => { + clearOpenTimer(); + clearCloseTimer(); + }, + [clearCloseTimer, clearOpenTimer], + ); + + const value = useMemo<HoverContextValue>( + () => ({ + hoveredId: state.hoveredId, + anchorElement: state.anchorElement, + payload: state.payload, + contextMenuOpen, + requestOpen, + requestClose, + cancelClose, + forceClose, + setContextMenuOpen, + syncIfHovered, + }), + [ + state.hoveredId, + state.anchorElement, + state.payload, + contextMenuOpen, + requestOpen, + requestClose, + cancelClose, + forceClose, + syncIfHovered, + ], + ); + + return ( + <HoverContext.Provider value={value}>{children}</HoverContext.Provider> + ); +} + +export function useDashboardSidebarHover() { + const ctx = useContext(HoverContext); + if (!ctx) { + throw new Error( + "useDashboardSidebarHover must be used inside DashboardSidebarHoverProvider", + ); + } + return ctx; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/index.ts new file mode 100644 index 00000000000..d8e00156972 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/providers/DashboardSidebarHoverProvider/index.ts @@ -0,0 +1,5 @@ +export { + type DashboardSidebarHoverPayload, + DashboardSidebarHoverProvider, + useDashboardSidebarHover, +} from "./DashboardSidebarHoverProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts index bd8b377c4b2..1f5f40bbded 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/types.ts @@ -3,6 +3,8 @@ export type DashboardSidebarWorkspaceHostType = | "remote-device" | "cloud"; +export type DashboardSidebarWorkspaceType = "main" | "worktree"; + export interface DashboardSidebarWorkspacePullRequestCheck { name: string; status: "success" | "failure" | "pending" | "skipped" | "cancelled"; @@ -23,8 +25,10 @@ export interface DashboardSidebarWorkspacePullRequest { export interface DashboardSidebarWorkspace { id: string; projectId: string; - deviceId: string; + hostId: string; hostType: DashboardSidebarWorkspaceHostType; + type: DashboardSidebarWorkspaceType; + hostIsOnline: boolean | null; accentColor: string | null; name: string; branch: string; @@ -36,7 +40,7 @@ export interface DashboardSidebarWorkspace { behindCount: number | null; createdAt: Date; updatedAt: Date; - creationStatus?: "preparing" | "generating-branch" | "creating"; + creationStatus?: "preparing" | "generating-branch" | "creating" | "failed"; } export interface DashboardSidebarSection { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/getFlattenedV2WorkspaceIds.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/getFlattenedV2WorkspaceIds.ts new file mode 100644 index 00000000000..6bb07fc8690 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/getFlattenedV2WorkspaceIds.ts @@ -0,0 +1,78 @@ +import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; +import { getVisibleSidebarWorkspaces } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; + +type TopLevelItem = + | { kind: "workspace"; tabOrder: number; workspaceId: string } + | { kind: "section"; tabOrder: number; sectionId: string }; + +export function getFlattenedV2WorkspaceIds( + collections: Pick< + AppCollections, + "v2SidebarProjects" | "v2SidebarSections" | "v2WorkspaceLocalState" + >, +): string[] { + const projects = Array.from( + collections.v2SidebarProjects.state.values(), + ).sort((left, right) => left.tabOrder - right.tabOrder); + const allSections = Array.from(collections.v2SidebarSections.state.values()); + const allWorkspaces = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ); + const visibleWorkspaces = getVisibleSidebarWorkspaces(allWorkspaces); + + const result: string[] = []; + + for (const project of projects) { + const projectWorkspaces = visibleWorkspaces.filter( + (workspace) => workspace.sidebarState.projectId === project.projectId, + ); + const projectSections = allSections.filter( + (section) => section.projectId === project.projectId, + ); + + const topLevelItems: TopLevelItem[] = []; + for (const workspace of projectWorkspaces) { + if (workspace.sidebarState.sectionId == null) { + topLevelItems.push({ + kind: "workspace", + tabOrder: workspace.sidebarState.tabOrder, + workspaceId: workspace.workspaceId, + }); + } + } + for (const section of projectSections) { + topLevelItems.push({ + kind: "section", + tabOrder: section.tabOrder, + sectionId: section.sectionId, + }); + } + topLevelItems.sort((left, right) => { + if (left.tabOrder !== right.tabOrder) { + return left.tabOrder - right.tabOrder; + } + if (left.kind === right.kind) return 0; + return left.kind === "section" ? -1 : 1; + }); + + for (const item of topLevelItems) { + if (item.kind === "workspace") { + result.push(item.workspaceId); + continue; + } + const sectionWorkspaces = projectWorkspaces + .filter( + (workspace) => workspace.sidebarState.sectionId === item.sectionId, + ) + .sort( + (left, right) => + left.sidebarState.tabOrder - right.sidebarState.tabOrder, + ); + for (const workspace of sectionWorkspaces) { + result.push(workspace.workspaceId); + } + } + } + + return result; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/index.ts new file mode 100644 index 00000000000..c2ef10af52d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/utils/getFlattenedV2WorkspaceIds/index.ts @@ -0,0 +1 @@ +export { getFlattenedV2WorkspaceIds } from "./getFlattenedV2WorkspaceIds"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx index a7f8eddcafe..f753391ed28 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/TopBar.tsx @@ -1,5 +1,6 @@ import { useMatchRoute, useParams } from "@tanstack/react-router"; import { HiOutlineWifi } from "react-icons/hi2"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useOnlineStatus } from "renderer/hooks/useOnlineStatus"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; @@ -7,6 +8,7 @@ import { NavigationControls } from "./components/NavigationControls"; import { OpenInMenuButton } from "./components/OpenInMenuButton"; import { OrganizationDropdown } from "./components/OrganizationDropdown"; import { ResourceConsumption } from "./components/ResourceConsumption"; +import { RightSidebarToggle } from "./components/RightSidebarToggle"; import { SearchBarTrigger } from "./components/SearchBarTrigger"; import { SidebarToggle } from "./components/SidebarToggle"; import { V2WorkspaceOpenInButton } from "./components/V2WorkspaceOpenInButton"; @@ -28,6 +30,7 @@ export function TopBar() { { enabled: !!workspaceId && !isV2WorkspaceRoute }, ); const isOnline = useOnlineStatus(); + const { isV2CloudEnabled } = useIsV2CloudEnabled(); // Default to Mac layout while loading to avoid overlap with traffic lights const isMac = platform === undefined || platform === "darwin"; @@ -82,7 +85,8 @@ export function TopBar() { projectId={workspace.project?.id} /> ) : null} - <OrganizationDropdown /> + {!isV2CloudEnabled && <OrganizationDropdown />} + {isV2WorkspaceRoute && <RightSidebarToggle />} {!isMac && <WindowControls />} </div> </div> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/NavigationControls/NavigationControls.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/NavigationControls/NavigationControls.tsx index a0c4c94de7d..43e44d79886 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/NavigationControls/NavigationControls.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/NavigationControls/NavigationControls.tsx @@ -2,8 +2,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useLocation, useRouter } from "@tanstack/react-router"; import { useEffect } from "react"; import { LuArrowLeft, LuArrowRight } from "react-icons/lu"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; -import { useAppHotkey } from "renderer/stores/hotkeys"; +import { HotkeyLabel, useHotkey } from "renderer/hotkeys"; import { HistoryDropdown } from "./components/HistoryDropdown"; export function NavigationControls() { @@ -13,8 +12,8 @@ export function NavigationControls() { const canGoBack = router.history.canGoBack(); const canGoForward = location.state.__TSR_index < router.history.length - 1; - useAppHotkey("NAVIGATE_BACK", () => router.history.back()); - useAppHotkey("NAVIGATE_FORWARD", () => router.history.forward()); + useHotkey("NAVIGATE_BACK", () => router.history.back()); + useHotkey("NAVIGATE_FORWARD", () => router.history.forward()); useEffect(() => { const handleMouseUp = (event: MouseEvent) => { @@ -45,7 +44,7 @@ export function NavigationControls() { </button> </TooltipTrigger> <TooltipContent side="bottom"> - <HotkeyTooltipContent label="Go back" hotkeyId="NAVIGATE_BACK" /> + <HotkeyLabel label="Go back" id="NAVIGATE_BACK" /> </TooltipContent> </Tooltip> @@ -61,10 +60,7 @@ export function NavigationControls() { </button> </TooltipTrigger> <TooltipContent side="bottom"> - <HotkeyTooltipContent - label="Go forward" - hotkeyId="NAVIGATE_FORWARD" - /> + <HotkeyLabel label="Go forward" id="NAVIGATE_FORWARD" /> </TooltipContent> </Tooltip> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OpenInMenuButton/OpenInMenuButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OpenInMenuButton/OpenInMenuButton.tsx index 30e12cf88d9..ecb7525c116 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OpenInMenuButton/OpenInMenuButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OpenInMenuButton/OpenInMenuButton.tsx @@ -10,14 +10,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { memo, useCallback, useMemo } from "react"; import { HiChevronDown } from "react-icons/hi2"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; import { getAppOption, OpenInExternalDropdownItems, } from "renderer/components/OpenInExternalDropdown"; +import { HotkeyLabel, useHotkey, useHotkeyDisplay } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useThemeStore } from "renderer/stores"; -import { useHotkeyText } from "renderer/stores/hotkeys"; interface OpenInMenuButtonProps { worktreePath: string; @@ -54,10 +53,10 @@ export const OpenInMenuButton = memo(function OpenInMenuButton({ () => getAppOption(resolvedApp) ?? null, [resolvedApp], ); - const openInShortcut = useHotkeyText("OPEN_IN_APP"); - const copyPathShortcut = useHotkeyText("COPY_PATH"); - const showOpenInShortcut = openInShortcut !== "Unassigned"; - const showCopyPathShortcut = copyPathShortcut !== "Unassigned"; + const openInDisplay = useHotkeyDisplay("OPEN_IN_APP"); + const copyPathDisplay = useHotkeyDisplay("COPY_PATH"); + const showOpenInShortcut = openInDisplay.text !== "Unassigned"; + const showCopyPathShortcut = copyPathDisplay.text !== "Unassigned"; const isLoading = openInApp.isPending || copyPath.isPending; const isDark = activeTheme?.type === "dark"; @@ -80,6 +79,8 @@ export const OpenInMenuButton = memo(function OpenInMenuButton({ copyPath.mutate(worktreePath); }, [worktreePath, copyPath, openInApp.isPending]); + useHotkey("OPEN_IN_APP", handleOpenInEditor); + return ( <div className="flex items-center no-drag"> {/* Main button - opens in last used app */} @@ -122,9 +123,9 @@ export const OpenInMenuButton = memo(function OpenInMenuButton({ </TooltipTrigger> <TooltipContent side="bottom" sideOffset={6}> {currentApp ? ( - <HotkeyTooltipContent + <HotkeyLabel label={`Open in ${currentApp.displayLabel ?? currentApp.label}`} - hotkeyId="OPEN_IN_APP" + id="OPEN_IN_APP" /> ) : ( "Select an editor from the dropdown" @@ -166,12 +167,16 @@ export const OpenInMenuButton = memo(function OpenInMenuButton({ return null; } return ( - <DropdownMenuShortcut>{openInShortcut}</DropdownMenuShortcut> + <DropdownMenuShortcut> + {openInDisplay.text} + </DropdownMenuShortcut> ); }} copyPathTrailing={ showCopyPathShortcut ? ( - <DropdownMenuShortcut>{copyPathShortcut}</DropdownMenuShortcut> + <DropdownMenuShortcut> + {copyPathDisplay.text} + </DropdownMenuShortcut> ) : null } subContentClassName="w-40" diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx index 3568ed3f143..df88826a400 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/OrganizationDropdown/OrganizationDropdown.tsx @@ -1,5 +1,6 @@ import { COMPANY } from "@superset/shared/constants"; import { Avatar } from "@superset/ui/atoms/Avatar"; +import { Badge } from "@superset/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -27,18 +28,23 @@ import { } from "react-icons/hi2"; import { IoBugOutline } from "react-icons/io5"; import { LuKeyboard } from "react-icons/lu"; +import { useCurrentPlan } from "renderer/hooks/useCurrentPlan"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useHotkeyText } from "renderer/stores/hotkeys"; -export function OrganizationDropdown() { +export function OrganizationDropdown({ + variant = "topbar", +}: { + variant?: "topbar" | "expanded" | "collapsed"; +}) { const { data: session } = authClient.useSession(); const collections = useCollections(); const signOutMutation = electronTrpc.auth.signOut.useMutation(); const navigate = useNavigate(); - const settingsHotkey = useHotkeyText("OPEN_SETTINGS"); - const shortcutsHotkey = useHotkeyText("SHOW_HOTKEYS"); + const settingsHotkey = useHotkeyDisplay("OPEN_SETTINGS").text; + const shortcutsHotkey = useHotkeyDisplay("SHOW_HOTKEYS").text; const activeOrganizationId = session?.session?.activeOrganizationId; @@ -65,27 +71,74 @@ export function OrganizationDropdown() { const userName = session?.user?.name; const displayName = activeOrganization?.name ?? userName ?? "Organization"; + const currentPlan = useCurrentPlan(); + const isPaid = currentPlan !== "free"; + const planLabel = currentPlan.charAt(0).toUpperCase() + currentPlan.slice(1); + const planBadge = isPaid ? ( + <Badge + variant="default" + className="px-1 py-0 text-[9px] leading-none uppercase tracking-wide h-3.5" + > + {planLabel} + </Badge> + ) : null; + + const triggerButton = + variant === "collapsed" ? ( + <button + type="button" + className="flex size-8 items-center justify-center rounded-md transition-colors text-muted-foreground hover:bg-accent/50 hover:text-foreground" + aria-label="Organization menu" + > + <Avatar + size="xs" + fullName={activeOrganization?.name} + image={activeOrganization?.logo} + className="rounded size-4" + /> + </button> + ) : variant === "expanded" ? ( + <button + type="button" + className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground min-w-0" + aria-label="Organization menu" + > + <Avatar + size="xs" + fullName={activeOrganization?.name} + image={activeOrganization?.logo} + className="rounded size-4 shrink-0" + /> + <span className="truncate">{displayName}</span> + {planBadge} + <HiChevronUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> + </button> + ) : ( + <button + type="button" + className="no-drag flex items-center gap-1.5 h-6 px-1.5 rounded border border-border/60 bg-secondary/50 hover:bg-secondary hover:border-border transition-all duration-150 ease-out focus:outline-none focus:ring-1 focus:ring-ring" + aria-label="Organization menu" + > + <Avatar + size="xs" + fullName={activeOrganization?.name} + image={activeOrganization?.logo} + className="rounded size-4" + /> + <span className="text-xs font-medium truncate max-w-32"> + {displayName} + </span> + {planBadge} + <HiChevronUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> + </button> + ); + + const contentAlign = variant === "topbar" ? "end" : "start"; + return ( <DropdownMenu> - <DropdownMenuTrigger asChild> - <button - type="button" - className="no-drag flex items-center gap-1.5 h-6 px-1.5 rounded border border-border/60 bg-secondary/50 hover:bg-secondary hover:border-border transition-all duration-150 ease-out focus:outline-none focus:ring-1 focus:ring-ring" - aria-label="Organization menu" - > - <Avatar - size="xs" - fullName={activeOrganization?.name} - image={activeOrganization?.logo} - className="rounded size-4" - /> - <span className="text-xs font-medium truncate max-w-32"> - {displayName} - </span> - <HiChevronUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger> + <DropdownMenuContent align={contentAlign} className="w-56"> {/* Organization */} <DropdownMenuItem onSelect={() => navigate({ to: "/settings/account" })} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx index ca6de63f900..8d2ecbbcdec 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/ResourceConsumption.tsx @@ -1,17 +1,38 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useLiveQuery } from "@tanstack/react-db"; import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; -import { HiOutlineArrowPath, HiOutlineCpuChip } from "react-icons/hi2"; +import { useMemo, useState } from "react"; +import { + HiOutlineArrowPath, + HiOutlineBarsArrowDown, + HiOutlineCpuChip, +} from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { getVisibleSidebarWorkspaces } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import { useTabsStore } from "renderer/stores/tabs/store"; import { AppResourceSection } from "./components/AppResourceSection"; import { MetricBadge } from "./components/MetricBadge"; import { WorkspaceResourceSection } from "./components/WorkspaceResourceSection"; -import type { UsageValues } from "./types"; +import type { SortOption, UsageValues } from "./types"; import { formatCpu, formatMemory, formatPercent } from "./utils/formatters"; import { normalizeResourceMetricsSnapshot } from "./utils/normalizeSnapshot"; +const SORT_LABELS: Record<SortOption, string> = { + memory: "Memory", + cpu: "CPU", + name: "Name", + sidebar: "Sidebar order", +}; + function getTotalUsage( cpu: number | undefined, memory: number | undefined, @@ -32,6 +53,7 @@ function getTrackedMemorySharePercent( export function ResourceConsumption() { const [open, setOpen] = useState(false); + const [sortOption, setSortOption] = useState<SortOption>("memory"); const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>( new Set(), ); @@ -43,10 +65,45 @@ export function ResourceConsumption() { const panes = useTabsStore((state) => state.panes); const setActiveTab = useTabsStore((state) => state.setActiveTab); const setFocusedPane = useTabsStore((state) => state.setFocusedPane); + const collections = useCollections(); const { data: enabled } = electronTrpc.settings.getShowResourceMonitor.useQuery(); + const { data: rawSidebarProjects = [] } = useLiveQuery( + (q) => + q + .from({ sp: collections.v2SidebarProjects }) + .orderBy(({ sp }) => sp.tabOrder, "asc") + .select(({ sp }) => ({ projectId: sp.projectId })), + [collections], + ); + + const { data: rawSidebarWorkspaces = [] } = useLiveQuery( + (q) => + q + .from({ ws: collections.v2WorkspaceLocalState }) + .orderBy(({ ws }) => ws.sidebarState.tabOrder, "asc") + .select(({ ws }) => ({ + workspaceId: ws.workspaceId, + isHidden: ws.sidebarState.isHidden, + })), + [collections], + ); + + const sidebarProjectOrder = useMemo( + () => rawSidebarProjects.map((p) => p.projectId), + [rawSidebarProjects], + ); + + const sidebarWorkspaceOrder = useMemo( + () => + getVisibleSidebarWorkspaces(rawSidebarWorkspaces).map( + (w) => w.workspaceId, + ), + [rawSidebarWorkspaces], + ); + const { data: snapshot, refetch, @@ -154,16 +211,51 @@ export function ResourceConsumption() { <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"> Resource Usage </h4> - <button - type="button" - onClick={() => refetch()} - className="p-0.5 rounded hover:bg-muted transition-colors" - aria-label="Refresh metrics" - > - <HiOutlineArrowPath - className={`h-3.5 w-3.5 text-muted-foreground ${isFetching ? "animate-spin" : ""}`} - /> - </button> + <div className="flex items-center gap-0.5"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] text-muted-foreground hover:bg-muted transition-colors" + aria-label="Sort workspaces" + > + <HiOutlineBarsArrowDown className="h-3.5 w-3.5" /> + <span>{SORT_LABELS[sortOption]}</span> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuRadioGroup + value={sortOption} + onValueChange={(value) => + setSortOption(value as SortOption) + } + > + <DropdownMenuRadioItem value="memory"> + Memory + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="cpu"> + CPU + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="name"> + Name + </DropdownMenuRadioItem> + <DropdownMenuRadioItem value="sidebar"> + Sidebar order + </DropdownMenuRadioItem> + </DropdownMenuRadioGroup> + </DropdownMenuContent> + </DropdownMenu> + <button + type="button" + onClick={() => refetch()} + className="p-0.5 rounded hover:bg-muted transition-colors" + aria-label="Refresh metrics" + > + <HiOutlineArrowPath + className={`h-3.5 w-3.5 text-muted-foreground ${isFetching ? "animate-spin" : ""}`} + /> + </button> + </div> </div> {normalizedSnapshot && ( @@ -198,6 +290,9 @@ export function ResourceConsumption() { {normalizedSnapshot && ( <WorkspaceResourceSection workspaces={normalizedSnapshot.workspaces} + sortOption={sortOption} + sidebarProjectOrder={sidebarProjectOrder} + sidebarWorkspaceOrder={sidebarWorkspaceOrder} collapsedProjects={collapsedProjects} toggleProject={toggleProject} collapsedWorkspaces={collapsedWorkspaces} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/components/WorkspaceResourceSection/WorkspaceResourceSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/components/WorkspaceResourceSection/WorkspaceResourceSection.tsx index 74db6c12895..eff4a445a35 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/components/WorkspaceResourceSection/WorkspaceResourceSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/components/WorkspaceResourceSection/WorkspaceResourceSection.tsx @@ -1,6 +1,6 @@ import { cn } from "@superset/ui/lib/utils"; import { HiOutlineChevronDown, HiOutlineChevronRight } from "react-icons/hi2"; -import type { WorkspaceMetrics } from "../../types"; +import type { SortOption, WorkspaceMetrics } from "../../types"; import { formatCpu, formatMemory } from "../../utils/formatters"; import { getUsageClasses, @@ -22,6 +22,9 @@ interface ProjectResourceGroup { interface WorkspaceResourceSectionProps { workspaces: WorkspaceMetrics[]; + sortOption: SortOption; + sidebarProjectOrder: string[]; + sidebarWorkspaceOrder: string[]; collapsedProjects: Set<string>; toggleProject: (projectId: string) => void; collapsedWorkspaces: Set<string>; @@ -59,6 +62,68 @@ function groupWorkspacesByProject( return [...projectMap.values()]; } +function sortWorkspaces( + workspaces: WorkspaceMetrics[], + sortOption: SortOption, + sidebarWorkspaceOrder: string[], +): WorkspaceMetrics[] { + const sorted = [...workspaces]; + switch (sortOption) { + case "memory": + sorted.sort((a, b) => b.memory - a.memory); + break; + case "cpu": + sorted.sort((a, b) => b.cpu - a.cpu); + break; + case "name": + sorted.sort((a, b) => a.workspaceName.localeCompare(b.workspaceName)); + break; + case "sidebar": { + const orderMap = new Map( + sidebarWorkspaceOrder.map((id, index) => [id, index]), + ); + sorted.sort( + (a, b) => + (orderMap.get(a.workspaceId) ?? Number.MAX_SAFE_INTEGER) - + (orderMap.get(b.workspaceId) ?? Number.MAX_SAFE_INTEGER), + ); + break; + } + } + return sorted; +} + +function sortProjectGroups( + groups: ProjectResourceGroup[], + sortOption: SortOption, + sidebarProjectOrder: string[], +): ProjectResourceGroup[] { + const sorted = [...groups]; + switch (sortOption) { + case "memory": + sorted.sort((a, b) => b.memory - a.memory); + break; + case "cpu": + sorted.sort((a, b) => b.cpu - a.cpu); + break; + case "name": + sorted.sort((a, b) => a.projectName.localeCompare(b.projectName)); + break; + case "sidebar": { + const orderMap = new Map( + sidebarProjectOrder.map((id, index) => [id, index]), + ); + sorted.sort( + (a, b) => + (orderMap.get(a.projectId) ?? Number.MAX_SAFE_INTEGER) - + (orderMap.get(b.projectId) ?? Number.MAX_SAFE_INTEGER), + ); + break; + } + } + return sorted; +} + function getProjectTotals(projects: ProjectResourceGroup[]) { return projects.reduce( (acc, project) => ({ @@ -71,6 +136,9 @@ function getProjectTotals(projects: ProjectResourceGroup[]) { export function WorkspaceResourceSection({ workspaces, + sortOption, + sidebarProjectOrder, + sidebarWorkspaceOrder, collapsedProjects, toggleProject, collapsedWorkspaces, @@ -79,7 +147,20 @@ export function WorkspaceResourceSection({ navigateToPane, getPaneName, }: WorkspaceResourceSectionProps) { - const projectGroups = groupWorkspacesByProject(workspaces); + const rawProjectGroups = groupWorkspacesByProject(workspaces); + const sortedProjectGroups = sortProjectGroups( + rawProjectGroups, + sortOption, + sidebarProjectOrder, + ); + const projectGroups = sortedProjectGroups.map((group) => ({ + ...group, + workspaces: sortWorkspaces( + group.workspaces, + sortOption, + sidebarWorkspaceOrder, + ), + })); const projectTotals = getProjectTotals(projectGroups); return projectGroups.map((project) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/types.ts index 5d4e20b08be..f1a76490036 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ResourceConsumption/types.ts @@ -1,5 +1,7 @@ export type UsageSeverity = "normal" | "elevated" | "high"; +export type SortOption = "memory" | "cpu" | "name" | "sidebar"; + export interface UsageValues { cpu: number; memory: number; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/RightSidebarToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/RightSidebarToggle.tsx new file mode 100644 index 00000000000..1e600ad14cf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/RightSidebarToggle.tsx @@ -0,0 +1,50 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { + LuPanelRight, + LuPanelRightClose, + LuPanelRightOpen, +} from "react-icons/lu"; +import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; +import { HotkeyLabel } from "renderer/hotkeys"; + +export function RightSidebarToggle() { + const { preferences, setRightSidebarOpen } = useV2UserPreferences(); + const isOpen = preferences.rightSidebarOpen; + + const toggle = () => setRightSidebarOpen((prev) => !prev); + + const getToggleIcon = (isHovering: boolean) => { + if (!isOpen) { + return isHovering ? ( + <LuPanelRightOpen className="size-4" strokeWidth={1.5} /> + ) : ( + <LuPanelRight className="size-4" strokeWidth={1.5} /> + ); + } + return isHovering ? ( + <LuPanelRightClose className="size-4" strokeWidth={1.5} /> + ) : ( + <LuPanelRight className="size-4" strokeWidth={1.5} /> + ); + }; + + return ( + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <button + type="button" + onClick={toggle} + className="no-drag group flex items-center justify-center size-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors" + > + <span className="group-hover:hidden">{getToggleIcon(false)}</span> + <span className="hidden group-hover:block"> + {getToggleIcon(true)} + </span> + </button> + </TooltipTrigger> + <TooltipContent side="left"> + <HotkeyLabel label="Toggle sidebar" id="TOGGLE_SIDEBAR" /> + </TooltipContent> + </Tooltip> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/index.ts new file mode 100644 index 00000000000..8990c30415b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/RightSidebarToggle/index.ts @@ -0,0 +1 @@ +export { RightSidebarToggle } from "./RightSidebarToggle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/SearchBarTrigger/SearchBarTrigger.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/SearchBarTrigger/SearchBarTrigger.tsx index 063bdda249a..c27b05fce7a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/SearchBarTrigger/SearchBarTrigger.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/SearchBarTrigger/SearchBarTrigger.tsx @@ -1,7 +1,7 @@ import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { useCallback } from "react"; import { LuSearch } from "react-icons/lu"; -import { getHotkeyKeys, useHotkeyDisplay } from "renderer/stores/hotkeys"; +import { getDispatchChord, useHotkeyDisplay } from "renderer/hotkeys"; interface SearchBarTriggerProps { workspaceName?: string; @@ -36,14 +36,12 @@ function dispatchHotkeyEvent(keys: string) { } export function SearchBarTrigger({ workspaceName }: SearchBarTriggerProps) { - const display = useHotkeyDisplay("QUICK_OPEN"); + const { keys: display } = useHotkeyDisplay("QUICK_OPEN"); const isUnassigned = display.length === 1 && display[0] === "Unassigned"; const handleClick = useCallback(() => { - const keys = getHotkeyKeys("QUICK_OPEN"); - if (keys) { - dispatchHotkeyEvent(keys); - } + const chord = getDispatchChord("QUICK_OPEN"); + if (chord) dispatchHotkeyEvent(chord); }, []); const fullPlaceholder = workspaceName diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/SidebarToggle/SidebarToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/SidebarToggle/SidebarToggle.tsx index 7d2828cf1c3..ac10ba44ddf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/SidebarToggle/SidebarToggle.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/SidebarToggle/SidebarToggle.tsx @@ -1,6 +1,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { LuPanelLeft, LuPanelLeftClose, LuPanelLeftOpen } from "react-icons/lu"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import { HotkeyLabel } from "renderer/hotkeys"; import { useWorkspaceSidebarStore } from "renderer/stores"; export function SidebarToggle() { @@ -37,10 +37,7 @@ export function SidebarToggle() { </button> </TooltipTrigger> <TooltipContent side="right"> - <HotkeyTooltipContent - label="Toggle sidebar" - hotkeyId="TOGGLE_WORKSPACE_SIDEBAR" - /> + <HotkeyLabel label="Toggle sidebar" id="TOGGLE_WORKSPACE_SIDEBAR" /> </TooltipContent> </Tooltip> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx index a0b11900b00..46984021756 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx @@ -1,37 +1,189 @@ -import { useQuery } from "@tanstack/react-query"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; -import { OpenInMenuButton } from "../OpenInMenuButton"; +import type { ExternalApp } from "@superset/local-db"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useMemo } from "react"; +import { HiChevronDown } from "react-icons/hi2"; +import { + getAppOption, + OpenInExternalDropdownItems, +} from "renderer/components/OpenInExternalDropdown"; +import { HotkeyLabel, useHotkey, useHotkeyDisplay } from "renderer/hotkeys"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useV2ProjectDefaultApp } from "renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp"; +import { useThemeStore } from "renderer/stores"; interface V2OpenInMenuButtonProps { + worktreePath: string; branch: string; - hostUrl: string; projectId: string; - workspaceId: string; } export function V2OpenInMenuButton({ + worktreePath, branch, - hostUrl, projectId, - workspaceId, }: V2OpenInMenuButtonProps) { - const workspaceQuery = useQuery({ - queryKey: ["v2-open-in-workspace", hostUrl, workspaceId], - queryFn: () => - getHostServiceClientByUrl(hostUrl).workspace.get.query({ - id: workspaceId, - }), + const activeTheme = useThemeStore((state) => state.activeTheme); + + const { app: persistedApp, setApp: persistDefaultApp } = + useV2ProjectDefaultApp(projectId); + const resolvedApp: ExternalApp = persistedApp ?? "finder"; + + const openInApp = electronTrpc.external.openInApp.useMutation({ + onSuccess: (_data, variables) => { + persistDefaultApp(variables.app); + }, + onError: (error) => toast.error(`Failed to open: ${error.message}`), + }); + const copyPath = electronTrpc.external.copyPath.useMutation({ + onSuccess: () => toast.success("Path copied to clipboard"), + onError: (error) => toast.error(`Failed to copy path: ${error.message}`), }); - if (!workspaceQuery.data?.worktreePath) { - return null; - } + const currentApp = useMemo( + () => getAppOption(resolvedApp) ?? null, + [resolvedApp], + ); + const openInDisplay = useHotkeyDisplay("OPEN_IN_APP"); + const copyPathDisplay = useHotkeyDisplay("COPY_PATH"); + const showOpenInShortcut = openInDisplay.text !== "Unassigned"; + const showCopyPathShortcut = copyPathDisplay.text !== "Unassigned"; + const isLoading = openInApp.isPending || copyPath.isPending; + const isDark = activeTheme?.type === "dark"; + + const handleOpenInEditor = useCallback(() => { + if (openInApp.isPending || copyPath.isPending) return; + openInApp.mutate({ path: worktreePath, app: resolvedApp }); + }, [worktreePath, resolvedApp, openInApp, copyPath.isPending]); + + const handleOpenInOtherApp = useCallback( + (appId: ExternalApp) => { + if (openInApp.isPending || copyPath.isPending) return; + openInApp.mutate({ path: worktreePath, app: appId }); + }, + [worktreePath, openInApp, copyPath.isPending], + ); + + const handleCopyPath = useCallback(() => { + if (openInApp.isPending || copyPath.isPending) return; + copyPath.mutate(worktreePath); + }, [worktreePath, copyPath, openInApp.isPending]); + + useHotkey("OPEN_IN_APP", handleOpenInEditor); return ( - <OpenInMenuButton - branch={branch} - projectId={projectId} - worktreePath={workspaceQuery.data.worktreePath} - /> + <div className="flex items-center no-drag"> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleOpenInEditor} + disabled={isLoading || !currentApp} + aria-label={ + currentApp + ? `Open in ${currentApp.displayLabel ?? currentApp.label}` + : "Open in editor" + } + className={cn( + "group flex items-center gap-1.5 h-6 px-1.5 sm:pl-1.5 sm:pr-2 rounded-l border border-r-0 border-border/60 bg-secondary/50 text-xs font-medium", + "transition-all duration-150 ease-out", + "hover:bg-secondary hover:border-border", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "active:scale-[0.98]", + isLoading && "opacity-50 pointer-events-none", + )} + > + {currentApp && ( + <img + src={isDark ? currentApp.darkIcon : currentApp.lightIcon} + alt="" + className="size-3.5 object-contain shrink-0" + /> + )} + {branch && ( + <span className="hidden lg:inline text-muted-foreground truncate max-w-[140px] tabular-nums"> + /{branch} + </span> + )} + <span className="hidden sm:inline text-foreground font-medium"> + Open + </span> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" sideOffset={6}> + {currentApp ? ( + <HotkeyLabel + label={`Open in ${currentApp.displayLabel ?? currentApp.label}`} + id="OPEN_IN_APP" + /> + ) : ( + "Select an editor from the dropdown" + )} + </TooltipContent> + </Tooltip> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + disabled={isLoading} + className={cn( + "flex items-center justify-center h-6 w-6 rounded-r border border-border/60 bg-secondary/50 text-muted-foreground", + "transition-all duration-150 ease-out", + "hover:bg-secondary hover:border-border hover:text-foreground", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "active:scale-[0.98]", + isLoading && "opacity-50 pointer-events-none", + )} + > + <HiChevronDown className="size-3.5" /> + </button> + </DropdownMenuTrigger> + + <DropdownMenuContent align="end" className="w-48"> + <OpenInExternalDropdownItems + isDark={isDark} + activeApp={resolvedApp} + onOpenIn={handleOpenInOtherApp} + onCopyPath={handleCopyPath} + renderAppTrailing={(appId, group) => { + if ( + appId !== resolvedApp || + !showOpenInShortcut || + group === "jetbrains" + ) { + return null; + } + return ( + <DropdownMenuShortcut> + {openInDisplay.text} + </DropdownMenuShortcut> + ); + }} + copyPathTrailing={ + showCopyPathShortcut ? ( + <DropdownMenuShortcut> + {copyPathDisplay.text} + </DropdownMenuShortcut> + ) : null + } + subContentClassName="w-40" + appContentClassName="gap-0" + appIconClassName="size-4 object-contain mr-2" + subTriggerIconClassName="size-4 object-contain mr-2" + subTriggerContentClassName="flex items-center gap-0" + copyPathContentClassName="gap-0" + copyPathIconClassName="mr-2" + /> + </DropdownMenuContent> + </DropdownMenu> + </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx index ed03ea90592..bd2b68a53e8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx @@ -1,8 +1,9 @@ -import { and, eq } from "@tanstack/db"; +import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useQuery } from "@tanstack/react-query"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { useCollections } from "../../../../../providers/CollectionsProvider"; -import { useHostService } from "../../../../../providers/HostServiceProvider/HostServiceProvider"; +import { useLocalHostService } from "../../../../../providers/LocalHostServiceProvider"; import { V2OpenInMenuButton } from "../V2OpenInMenuButton"; interface V2WorkspaceOpenInButtonProps { @@ -13,45 +14,46 @@ export function V2WorkspaceOpenInButton({ workspaceId, }: V2WorkspaceOpenInButtonProps) { const collections = useCollections(); - const { services } = useHostService(); - const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); + const { machineId, activeHostUrl } = useLocalHostService(); + const { data: workspaces = [] } = useLiveQuery( (q) => q .from({ workspaces: collections.v2Workspaces }) - .where(({ workspaces }) => eq(workspaces.id, workspaceId)), + .where(({ workspaces }) => eq(workspaces.id, workspaceId)) + .select(({ workspaces }) => ({ + id: workspaces.id, + branch: workspaces.branch, + projectId: workspaces.projectId, + hostId: workspaces.hostId, + })), [collections, workspaceId], ); const workspace = workspaces[0] ?? null; - const { data: currentDevices = [] } = useLiveQuery( - (q) => - q - .from({ devices: collections.v2Devices }) - .where(({ devices }) => - and( - eq(devices.clientId, deviceInfo?.deviceId ?? ""), - eq(devices.organizationId, workspace?.organizationId ?? ""), - ), - ), - [collections, deviceInfo?.deviceId, workspace?.organizationId], - ); - const currentDevice = currentDevices[0] ?? null; - const hostUrl = workspace - ? (services.get(workspace.organizationId)?.url ?? null) - : null; - const isLocalWorkspace = - Boolean(workspace) && workspace.deviceId === currentDevice?.id; + const isLocalWorkspace = Boolean(workspace) && workspace.hostId === machineId; + + const workspaceQuery = useQuery({ + queryKey: ["v2-open-in-workspace", activeHostUrl, workspaceId], + queryFn: () => + getHostServiceClientByUrl(activeHostUrl as string).workspace.get.query({ + id: workspaceId, + }), + enabled: !!workspace && !!activeHostUrl && isLocalWorkspace, + }); + + if (!workspace || !activeHostUrl || !isLocalWorkspace) { + return null; + } - if (!workspace || !hostUrl || !isLocalWorkspace) { + if (!workspaceQuery.data?.worktreePath) { return null; } return ( <V2OpenInMenuButton branch={workspace.branch} - hostUrl={hostUrl} + worktreePath={workspaceQuery.data.worktreePath} projectId={workspace.projectId} - workspaceId={workspace.id} /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton/WorkspaceRunButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton/WorkspaceRunButton.tsx index e82367a9885..26f9a84befb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton/WorkspaceRunButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/WorkspaceRunButton/WorkspaceRunButton.tsx @@ -15,9 +15,9 @@ import { HiMiniStop, HiMiniXMark, } from "react-icons/hi2"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useWorkspaceRunCommand } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand"; -import { useHotkeyText } from "renderer/stores/hotkeys"; import { useSetSettingsSearchQuery } from "renderer/stores/settings-state"; interface WorkspaceRunButtonProps { @@ -33,7 +33,7 @@ export const WorkspaceRunButton = memo(function WorkspaceRunButton({ }: WorkspaceRunButtonProps) { const navigate = useNavigate(); const setSettingsSearchQuery = useSetSettingsSearchQuery(); - const hotkeyText = useHotkeyText("RUN_WORKSPACE_COMMAND"); + const hotkeyText = useHotkeyDisplay("RUN_WORKSPACE_COMMAND").text; const { canForceStop, forceStopWorkspaceRun, @@ -57,7 +57,7 @@ export const WorkspaceRunButton = memo(function WorkspaceRunButton({ if (!hasRunCommand && projectId) { setSettingsSearchQuery("scripts"); void navigate({ - to: "/settings/project/$projectId/general", + to: "/settings/projects/$projectId", params: { projectId }, }); return; @@ -76,7 +76,7 @@ export const WorkspaceRunButton = memo(function WorkspaceRunButton({ if (!projectId) return; setSettingsSearchQuery("scripts"); void navigate({ - to: "/settings/project/$projectId/general", + to: "/settings/projects/$projectId", params: { projectId }, }); }, [navigate, projectId, setSettingsSearchQuery]); @@ -142,6 +142,10 @@ export const WorkspaceRunButton = memo(function WorkspaceRunButton({ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "active:scale-[0.98]", isPending && "opacity-50 pointer-events-none", + isRunning + ? "text-emerald-300 border-emerald-500/25 bg-emerald-500/10 hover:bg-emerald-500/20" + : !hasRunCommand && + "text-muted-foreground/80 border-border/40 bg-secondary/40", )} > <HiChevronDown className="size-3.5" /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 4be47ca9183..59f13cd622a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -1,16 +1,19 @@ -import { FEATURE_FLAGS } from "@superset/shared/constants"; import { createFileRoute, Outlet, useMatchRoute, useNavigate, } from "@tanstack/react-router"; -import { useFeatureFlagEnabled } from "posthog-js/react"; +import { useState } from "react"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; +import { useHotkey } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { DashboardSidebar } from "renderer/routes/_authenticated/_dashboard/components/DashboardSidebar"; +import { useDevSeedV2Sidebar } from "renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar"; +import { useMigrateV1DataToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1DataToV2"; import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar"; -import { useAppHotkey } from "renderer/stores/hotkeys"; +import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import { COLLAPSED_WORKSPACE_SIDEBAR_WIDTH, @@ -18,6 +21,7 @@ import { MAX_WORKSPACE_SIDEBAR_WIDTH, useWorkspaceSidebarStore, } from "renderer/stores/workspace-sidebar-state"; +import { AddRepositoryModals } from "./components/AddRepositoryModals"; import { TopBar } from "./components/TopBar"; export const Route = createFileRoute("/_authenticated/_dashboard")({ @@ -27,8 +31,9 @@ export const Route = createFileRoute("/_authenticated/_dashboard")({ function DashboardLayout() { const navigate = useNavigate(); const openNewWorkspaceModal = useOpenNewWorkspaceModal(); - const isV2CloudEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.V2_CLOUD) ?? false; + const { isV2CloudEnabled } = useIsV2CloudEnabled(); + useDevSeedV2Sidebar(); + useMigrateV1DataToV2(); // Get current workspace from route to pre-select project in new workspace modal const matchRoute = useMatchRoute(); const currentWorkspaceMatch = matchRoute({ @@ -55,77 +60,87 @@ function DashboardLayout() { } = useWorkspaceSidebarStore(); // Global hotkeys for dashboard - useAppHotkey( - "OPEN_SETTINGS", - () => navigate({ to: "/settings/account" }), - undefined, - [navigate], + useHotkey("OPEN_SETTINGS", () => navigate({ to: "/settings/account" })); + useHotkey("SHOW_HOTKEYS", () => navigate({ to: "/settings/keyboard" })); + useHotkey("TOGGLE_WORKSPACE_SIDEBAR", () => { + if (!isWorkspaceSidebarOpen) { + setWorkspaceSidebarOpen(true); + } else { + toggleWorkspaceSidebarCollapsed(); + } + }); + useHotkey("NEW_WORKSPACE", () => + openNewWorkspaceModal(currentWorkspace?.projectId), ); - useAppHotkey( - "SHOW_HOTKEYS", - () => navigate({ to: "/settings/keyboard" }), - undefined, - [navigate], - ); + const [deleteTarget, setDeleteTarget] = useState<{ + workspaceId: string; + workspaceName: string; + workspaceType: "worktree" | "branch"; + } | null>(null); - useAppHotkey( - "TOGGLE_WORKSPACE_SIDEBAR", + useHotkey( + "CLOSE_WORKSPACE", () => { - if (!isWorkspaceSidebarOpen) { - setWorkspaceSidebarOpen(true); - } else { - toggleWorkspaceSidebarCollapsed(); + if (currentWorkspaceId && currentWorkspace) { + setDeleteTarget({ + workspaceId: currentWorkspaceId, + workspaceName: currentWorkspace.name, + workspaceType: currentWorkspace.type, + }); } }, - undefined, - [ - isWorkspaceSidebarOpen, - setWorkspaceSidebarOpen, - toggleWorkspaceSidebarCollapsed, - ], - ); - - useAppHotkey( - "NEW_WORKSPACE", - () => openNewWorkspaceModal(currentWorkspace?.projectId), - undefined, - [openNewWorkspaceModal, currentWorkspace?.projectId], + { enabled: !!currentWorkspaceId }, ); return ( - <div className="flex flex-col h-full w-full"> - <TopBar /> - <div className="flex flex-1 min-h-0 min-w-0 overflow-hidden"> - {isWorkspaceSidebarOpen && ( - <ResizablePanel - width={workspaceSidebarWidth} - onWidthChange={setWorkspaceSidebarWidth} - isResizing={isWorkspaceSidebarResizing} - onResizingChange={setWorkspaceSidebarIsResizing} - minWidth={COLLAPSED_WORKSPACE_SIDEBAR_WIDTH} - maxWidth={MAX_WORKSPACE_SIDEBAR_WIDTH} - handleSide="right" - clampWidth={false} - onDoubleClickHandle={() => - setWorkspaceSidebarWidth(DEFAULT_WORKSPACE_SIDEBAR_WIDTH) - } - > - {isV2CloudEnabled ? ( - <DashboardSidebar isCollapsed={isWorkspaceSidebarCollapsed()} /> - ) : ( - <WorkspaceSidebar - isCollapsed={isWorkspaceSidebarCollapsed()} - activeProjectId={currentWorkspace?.projectId ?? null} - activeProjectName={currentWorkspace?.project?.name ?? null} - /> - )} - </ResizablePanel> - )} - <div className="flex flex-1 min-h-0 min-w-0"> - <Outlet /> + <div className="flex h-full w-full overflow-hidden"> + <div className="flex flex-1 flex-col min-w-0 min-h-0"> + <TopBar /> + <div className="flex flex-1 min-h-0 min-w-0 overflow-hidden"> + {isWorkspaceSidebarOpen && ( + <ResizablePanel + width={workspaceSidebarWidth} + onWidthChange={setWorkspaceSidebarWidth} + isResizing={isWorkspaceSidebarResizing} + onResizingChange={setWorkspaceSidebarIsResizing} + minWidth={COLLAPSED_WORKSPACE_SIDEBAR_WIDTH} + maxWidth={MAX_WORKSPACE_SIDEBAR_WIDTH} + handleSide="right" + clampWidth={false} + onDoubleClickHandle={() => + setWorkspaceSidebarWidth(DEFAULT_WORKSPACE_SIDEBAR_WIDTH) + } + > + {isV2CloudEnabled ? ( + <DashboardSidebar isCollapsed={isWorkspaceSidebarCollapsed()} /> + ) : ( + <WorkspaceSidebar + isCollapsed={isWorkspaceSidebarCollapsed()} + activeProjectId={currentWorkspace?.projectId ?? null} + activeProjectName={currentWorkspace?.project?.name ?? null} + /> + )} + </ResizablePanel> + )} + <div className="flex flex-1 min-h-0 min-w-0"> + <Outlet /> + </div> </div> </div> + <div id="workspace-right-sidebar-slot" className="flex h-full shrink-0" /> + <AddRepositoryModals /> + {deleteTarget && ( + <DeleteWorkspaceDialog + workspaceId={deleteTarget.workspaceId} + workspaceName={deleteTarget.workspaceName} + workspaceType={deleteTarget.workspaceType} + open={true} + onOpenChange={(open) => { + if (!open) setDeleteTarget(null); + }} + /> + )} </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts new file mode 100644 index 00000000000..0bcd171395b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "bun:test"; +import { resolveAgentConfigs } from "@superset/shared/agent-settings"; +import { + buildForkAgentLaunch, + buildLaunchSourcesFromPending, +} from "./buildForkAgentLaunch"; + +const PROJECT_ID = "proj-1"; + +function pendingBase( + overrides: Partial<Parameters<typeof buildLaunchSourcesFromPending>[0]> = {}, +): Parameters<typeof buildLaunchSourcesFromPending>[0] { + return { + projectId: PROJECT_ID, + prompt: "", + linkedIssues: [], + linkedPR: null, + agentId: null, + ...overrides, + }; +} + +describe("buildLaunchSourcesFromPending", () => { + test("returns [] when everything is empty", () => { + expect(buildLaunchSourcesFromPending(pendingBase(), undefined)).toEqual([]); + }); + + test("produces user-prompt source when prompt is non-empty", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ prompt: "refactor auth" }), + undefined, + ); + expect(sources).toEqual([ + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor auth" }], + }, + ]); + }); + + test("trims whitespace-only prompts out", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ prompt: " \n " }), + undefined, + ); + expect(sources.filter((s) => s.kind === "user-prompt")).toEqual([]); + }); + + test("orders sources: user-prompt, task, issue, pr, attachment", () => { + const sources = buildLaunchSourcesFromPending( + pendingBase({ + prompt: "fix", + linkedIssues: [ + { source: "internal", taskId: "T-1", slug: "s", title: "t" }, + { + source: "github", + url: "https://x/issues/9", + number: 9, + slug: "s", + title: "t", + state: "open", + }, + ], + linkedPR: { + prNumber: 1, + url: "https://x/pull/1", + title: "t", + state: "open", + }, + }), + [ + { + data: "data:text/plain;base64,AA==", + mediaType: "text/plain", + filename: "a.txt", + }, + ], + ); + expect(sources.map((s) => s.kind)).toEqual([ + "user-prompt", + "internal-task", + "github-issue", + "github-pr", + "attachment", + ]); + }); + + test("decodes base64 data URLs to Uint8Array", () => { + const sources = buildLaunchSourcesFromPending(pendingBase(), [ + { + data: "data:text/plain;base64,AQID", + mediaType: "text/plain", + filename: "logs.txt", + }, + ]); + expect(sources).toHaveLength(1); + const source = sources[0]; + if (source?.kind !== "attachment") throw new Error("wrong kind"); + expect(source.file.filename).toBe("logs.txt"); + expect(Array.from(source.file.data)).toEqual([1, 2, 3]); + }); +}); + +describe("buildForkAgentLaunch", () => { + const agentConfigs = resolveAgentConfigs({}); + + test("returns null when there are no sources", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase(), + attachments: undefined, + agentConfigs, + }); + expect(build).toBeNull(); + }); + + test("returns null when there are no enabled agents", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "hi" }), + attachments: undefined, + agentConfigs: [], + }); + expect(build).toBeNull(); + }); + + test("selected claude agent → terminal launch", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ + prompt: "refactor the auth middleware", + agentId: "claude", + }), + attachments: undefined, + agentConfigs, + }); + expect(build?.kind).toBe("terminal"); + if (build?.kind !== "terminal") throw new Error("wrong kind"); + expect(build.launch.name).toBe("Claude"); + expect(build.launch.command).toContain("claude"); + expect(build.launch.command).toContain("refactor the auth middleware"); + expect(build.launch.attachmentNames).toEqual([]); + expect(build.attachmentsToWrite).toEqual([]); + }); + + test("linked internal task renders into the command", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ + prompt: "do it", + agentId: "claude", + linkedIssues: [ + { + source: "internal", + taskId: "TASK-42", + slug: "refactor-auth", + title: "Refactor auth", + }, + ], + }), + attachments: undefined, + agentConfigs, + }); + if (build?.kind !== "terminal") throw new Error("wrong kind"); + expect(build.launch.command).toContain("Refactor auth"); + }); + + test("attachments produce disk-ready bytes + matching names", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "fix", agentId: "claude" }), + attachments: [ + { + data: "data:text/plain;base64,AQID", // [1,2,3] + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + agentConfigs, + }); + if (build?.kind !== "terminal") throw new Error("wrong kind"); + expect(build.attachmentsToWrite).toHaveLength(1); + expect(build.attachmentsToWrite[0]?.filename).toBe("logs.txt"); + expect(Array.from(build.attachmentsToWrite[0]?.data ?? [])).toEqual([ + 1, 2, 3, + ]); + expect(build.launch.attachmentNames).toEqual(["logs.txt"]); + }); + + test("chat agent → chat launch with initialPrompt + files", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ + prompt: "help me refactor", + agentId: "superset-chat", + }), + attachments: [ + { + data: "data:text/plain;base64,AQID", + mediaType: "text/plain", + filename: "logs.txt", + }, + ], + agentConfigs, + }); + expect(build?.kind).toBe("chat"); + if (build?.kind !== "chat") throw new Error("wrong kind"); + expect(build.launch.initialPrompt).toContain("help me refactor"); + expect(build.launch.initialFiles).toHaveLength(1); + expect(build.launch.initialFiles?.[0]?.data).toBe( + "data:text/plain;base64,AQID", + ); + expect(build.launch.initialFiles?.[0]?.filename).toBe("logs.txt"); + }); + + test("disabled agent → null", async () => { + const disabled = agentConfigs.map((c) => ({ ...c, enabled: false })); + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "hi", agentId: "claude" }), + attachments: undefined, + agentConfigs: disabled, + }); + expect(build).toBeNull(); + }); + + test("agentId null → null (no user selection, no launch)", async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "hi", agentId: null }), + attachments: undefined, + agentConfigs, + }); + expect(build).toBeNull(); + }); + + test('agentId "none" → null (explicit opt-out)', async () => { + const build = await buildForkAgentLaunch({ + pending: pendingBase({ prompt: "hi", agentId: "none" }), + attachments: undefined, + agentConfigs, + }); + expect(build).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts new file mode 100644 index 00000000000..ee67e8a95fb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildForkAgentLaunch.ts @@ -0,0 +1,556 @@ +import { + type AgentDefinitionId, + isTerminalAgentDefinition, +} from "@superset/shared/agent-catalog"; +import { + buildPromptCommandFromAgentConfig, + getCommandFromAgentConfig, + indexResolvedAgentConfigs, + type ResolvedAgentConfig, +} from "@superset/shared/agent-settings"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import type { + PendingChatLaunch, + PendingTerminalLaunch, + PendingWorkspaceRow, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { buildLaunchSpec } from "shared/context/buildLaunchSpec"; +import { buildLaunchContext } from "shared/context/composer"; +import { defaultContributorRegistry } from "shared/context/contributors"; +import type { + AgentLaunchSpec, + AttachmentFile, + ContentPart, + LaunchSource, + ResolveCtx, +} from "shared/context/types"; + +export interface LoadedAttachment { + data: string; // base64 data URL + mediaType: string; + filename: string; +} + +export interface ResolvedPrContent { + number: number; + title: string; + body: string; + url: string; + branch: string; +} + +export interface BuildForkAgentLaunchInputs { + pending: Pick< + PendingWorkspaceRow, + "projectId" | "prompt" | "linkedIssues" | "linkedPR" | "agentId" + >; + attachments: LoadedAttachment[] | undefined; + agentConfigs: ResolvedAgentConfig[]; + /** + * Host-service client for fetching issue/PR bodies. When provided, + * the resolvers call `getGitHubIssueContent` / `getGitHubPullRequestContent` + * for full bodies. When null, falls back to title-only from the pending row. + */ + hostServiceClient?: { + workspaceCreation: { + getGitHubIssueContent: { + query: (input: { projectId: string; issueNumber: number }) => Promise<{ + number: number; + title: string; + body: string; + url: string; + state: string; + author: string | null; + }>; + }; + getGitHubPullRequestContent: { + query: (input: { projectId: string; prNumber: number }) => Promise<{ + number: number; + title: string; + body: string; + url: string; + state: string; + branch: string; + baseBranch: string; + headRepositoryOwner: string | null; + isCrossRepository: boolean; + author: string | null; + }>; + }; + }; + }; + /** + * Pre-resolved PR content. Used by the pr-checkout flow to avoid a + * redundant `getGitHubPullRequestContent` call — the pending page + * already fetched this once to build the mutation payload, so we + * thread it through rather than re-fetching inside `fetchPullRequest`. + */ + resolvedPr?: ResolvedPrContent; +} + +/** + * The pending page writes one of these to the pending row after + * host-service.create resolves; the V2 workspace page consumes it on + * mount. See apps/desktop/docs/V2_LAUNCH_CONTEXT.md. + */ +export type PendingLaunchBuild = + | { + kind: "terminal"; + launch: PendingTerminalLaunch; + /** + * Binary payloads to write to `<worktree>/.superset/attachments/` + * via workspaceTrpc.filesystem before setting `row.terminalLaunch`. + * Already named with collision-safe filenames matching + * `launch.attachmentNames` and any inline refs in `launch.command`. + */ + attachmentsToWrite: Array<{ + filename: string; + mediaType: string; + data: Uint8Array; + }>; + } + | { kind: "chat"; launch: PendingChatLaunch }; + +/** + * Builds a PendingLaunchBuild record describing how the V2 workspace + * page should dispatch the agent once it mounts. The pending page owns + * applying this to the pending row (and writing terminal attachments + * to disk). Returns null for no-op launches (e.g. no sources, no agent + * enabled). + * + * When `hostServiceClient` is passed in, issues and PRs get full bodies + * fetched via host-service. Internal tasks get descriptions fetched via + * the cloud API (apiTrpcClient.task.byId). Either fetch failing + * degrades to title-only from the pending row — non-fatal. + */ +export async function buildForkAgentLaunch( + inputs: BuildForkAgentLaunchInputs, +): Promise<PendingLaunchBuild | null> { + const agentId = resolveAgentId(inputs.pending.agentId, inputs.agentConfigs); + if (!agentId) return null; + + const agentConfig = indexResolvedAgentConfigs(inputs.agentConfigs).get( + agentId, + ); + if (!agentConfig || !agentConfig.enabled) return null; + + const sources = buildLaunchSourcesFromPending( + inputs.pending, + inputs.attachments, + ); + if (sources.length === 0) return null; + + const ctx = await buildLaunchContext( + { + projectId: inputs.pending.projectId, + sources, + agent: { id: agentId }, + }, + { + contributors: defaultContributorRegistry, + resolveCtx: buildResolveCtxFromPending( + inputs.pending, + inputs.hostServiceClient, + inputs.resolvedPr, + ), + }, + ); + const spec = buildLaunchSpec(ctx, agentConfig); + if (!spec) return null; + + if (isTerminalAgentDefinition(agentConfig)) { + return buildTerminalLaunch(spec, agentConfig); + } + return buildChatLaunch(spec, agentConfig); +} + +function resolveAgentId( + selected: string | null, + configs: ResolvedAgentConfig[], +): AgentDefinitionId | null { + if (!selected || selected === "none") return null; + const match = indexResolvedAgentConfigs(configs).get( + selected as AgentDefinitionId, + ); + return match?.enabled ? match.id : null; +} + +// --------------------------------------------------------------------------- +// Terminal launch assembly +// --------------------------------------------------------------------------- + +function buildTerminalLaunch( + spec: AgentLaunchSpec, + agentConfig: Extract<ResolvedAgentConfig, { kind: "terminal" }>, +): PendingLaunchBuild | null { + const { attachmentsToWrite, inlineByIndex } = assignFilenamesAndCollect( + spec.user, + spec.attachments, + ); + const promptText = flattenUserContentForTerminal(spec.user, inlineByIndex); + + const command = promptText.trim() + ? buildPromptCommandFromAgentConfig({ + prompt: promptText, + randomId: crypto.randomUUID(), + config: agentConfig, + }) + : getCommandFromAgentConfig(agentConfig); + if (!command) return null; + + return { + kind: "terminal", + launch: { + command, + name: agentConfig.label, + attachmentNames: attachmentsToWrite.map((a) => a.filename), + }, + attachmentsToWrite, + }; +} + +function flattenUserContentForTerminal( + user: ContentPart[], + inlineByIndex: Map<number, string>, +): string { + const out: string[] = []; + user.forEach((part, index) => { + if (part.type === "text") { + out.push(part.text); + return; + } + const filename = inlineByIndex.get(index); + if (!filename) return; + out.push(`![${filename}](.superset/attachments/${filename})`); + }); + return out.join("").trim(); +} + +// --------------------------------------------------------------------------- +// Chat launch assembly +// --------------------------------------------------------------------------- + +function buildChatLaunch( + spec: AgentLaunchSpec, + agentConfig: Extract<ResolvedAgentConfig, { kind: "chat" }>, +): PendingLaunchBuild { + const initialPrompt = extractTextParts(spec.user).join("\n\n").trim(); + const binaries = [ + ...spec.user.filter((p) => p.type !== "text"), + ...spec.attachments.filter((p) => p.type !== "text"), + ]; + const initialFiles = binaries.length + ? binaries.map((part) => ({ + data: toBase64DataUrl(part), + mediaType: part.mediaType, + filename: part.type === "file" ? part.filename : undefined, + })) + : undefined; + + return { + kind: "chat", + launch: { + initialPrompt: initialPrompt || undefined, + initialFiles, + model: agentConfig.model, + taskSlug: spec.taskSlug, + }, + }; +} + +function extractTextParts(parts: ContentPart[]): string[] { + return parts + .filter( + (p): p is Extract<ContentPart, { type: "text" }> => p.type === "text", + ) + .map((p) => p.text); +} + +function toBase64DataUrl(part: Exclude<ContentPart, { type: "text" }>): string { + return `data:${part.mediaType};base64,${bytesToBase64(part.data)}`; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i] ?? 0); + } + return btoa(binary); +} + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +// --------------------------------------------------------------------------- +// Shared: collect binary parts into disk-ready attachments with stable names +// --------------------------------------------------------------------------- + +function assignFilenamesAndCollect( + user: ContentPart[], + attachments: ContentPart[], +): { + attachmentsToWrite: Array<{ + filename: string; + mediaType: string; + data: Uint8Array; + }>; + inlineByIndex: Map<number, string>; +} { + const used = new Set<string>(); + const out: Array<{ filename: string; mediaType: string; data: Uint8Array }> = + []; + const inlineByIndex = new Map<number, string>(); + + user.forEach((part, index) => { + if (part.type === "text") return; + const filename = nextUniqueName(part, used, out.length); + inlineByIndex.set(index, filename); + out.push({ filename, mediaType: part.mediaType, data: part.data }); + }); + + for (const part of attachments) { + if (part.type === "text") continue; + const filename = nextUniqueName(part, used, out.length); + out.push({ filename, mediaType: part.mediaType, data: part.data }); + } + + return { attachmentsToWrite: out, inlineByIndex }; +} + +function nextUniqueName( + part: Exclude<ContentPart, { type: "text" }>, + used: Set<string>, + fallbackIndex: number, +): string { + const raw = part.type === "file" ? part.filename : undefined; + const sanitized = raw ? sanitizeFilename(raw) : ""; + let name = sanitized; + if (!name) { + let counter = fallbackIndex + 1; + do { + name = `attachment_${counter}`; + counter++; + } while (used.has(name)); + } else if (used.has(name)) { + const segs = name.split("."); + const ext = segs.length > 1 ? segs.pop() : undefined; + const base = segs.join("."); + let counter = 1; + let candidate: string; + do { + candidate = ext ? `${base}_${counter}.${ext}` : `${name}_${counter}`; + counter++; + } while (used.has(candidate)); + name = candidate; + } + used.add(name); + return name; +} + +function sanitizeFilename(filename: string): string { + const cleaned = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + return cleaned.trim() ? cleaned : ""; +} + +// --------------------------------------------------------------------------- +// Source + ResolveCtx (unchanged from prior implementation) +// --------------------------------------------------------------------------- + +export function buildLaunchSourcesFromPending( + pending: BuildForkAgentLaunchInputs["pending"], + attachments: LoadedAttachment[] | undefined, +): LaunchSource[] { + const sources: LaunchSource[] = []; + + const prompt = pending.prompt?.trim(); + if (prompt) { + sources.push({ + kind: "user-prompt", + content: [{ type: "text", text: prompt }], + }); + } + + for (const issue of pending.linkedIssues) { + if (issue.source === "internal" && issue.taskId) { + sources.push({ kind: "internal-task", id: issue.taskId }); + } else if (issue.source === "github" && issue.url) { + sources.push({ kind: "github-issue", url: issue.url }); + } + } + + if (pending.linkedPR?.url) { + sources.push({ kind: "github-pr", url: pending.linkedPR.url }); + } + + for (const attachment of attachments ?? []) { + sources.push({ + kind: "attachment", + file: dataUrlAttachmentToBytes(attachment), + }); + } + + return sources; +} + +function dataUrlAttachmentToBytes(loaded: LoadedAttachment): AttachmentFile { + const match = loaded.data.match(/^data:[^;]+;base64,(.+)$/); + const base64 = match?.[1] ?? ""; + return { + data: base64ToBytes(base64), + mediaType: loaded.mediaType, + filename: loaded.filename, + }; +} + +function slugifyTitle(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 80); +} + +function buildResolveCtxFromPending( + pending: BuildForkAgentLaunchInputs["pending"], + client?: BuildForkAgentLaunchInputs["hostServiceClient"], + resolvedPr?: ResolvedPrContent, +): ResolveCtx { + return { + projectId: pending.projectId, + signal: new AbortController().signal, + + fetchIssue: async (url) => { + const match = pending.linkedIssues.find( + (i) => i.source === "github" && i.url === url, + ); + if (!match) { + throw Object.assign(new Error(`Issue not found: ${url}`), { + status: 404, + }); + } + + // Try host-service for full body; fall back to pending-row metadata. + if (client && match.number) { + try { + const data = + await client.workspaceCreation.getGitHubIssueContent.query({ + projectId: pending.projectId, + issueNumber: match.number, + }); + return { + number: data.number, + url: data.url, + title: data.title, + body: data.body, + slug: match.slug || slugifyTitle(data.title), + }; + } catch (err) { + console.warn( + `[v2-launch] getGitHubIssueContent failed for #${match.number}, using title-only`, + err, + ); + } + } + + return { + number: match.number ?? 0, + url: match.url ?? url, + title: match.title, + body: "", + slug: match.slug, + }; + }, + + fetchPullRequest: async (url) => { + if (!pending.linkedPR || pending.linkedPR.url !== url) { + throw Object.assign(new Error(`PR not found: ${url}`), { + status: 404, + }); + } + + // Pre-resolved from the pending page (pr-checkout path) — skip + // the redundant host-service call. The mutation payload already + // used the same `getGitHubPullRequestContent` response. + if (resolvedPr && resolvedPr.url === url) { + return { + number: resolvedPr.number, + url: resolvedPr.url, + title: resolvedPr.title, + body: resolvedPr.body, + branch: resolvedPr.branch, + }; + } + + // Try host-service for full body + branch; fall back to pending-row. + if (client) { + try { + const data = + await client.workspaceCreation.getGitHubPullRequestContent.query({ + projectId: pending.projectId, + prNumber: pending.linkedPR.prNumber, + }); + return { + number: data.number, + url: data.url, + title: data.title, + body: data.body, + branch: data.branch, + }; + } catch (err) { + console.warn( + `[v2-launch] getGitHubPullRequestContent failed for #${pending.linkedPR.prNumber}, using title-only`, + err, + ); + } + } + + return { + number: pending.linkedPR.prNumber, + url: pending.linkedPR.url, + title: pending.linkedPR.title, + body: "", + branch: "", + }; + }, + + fetchInternalTask: async (id) => { + const match = pending.linkedIssues.find( + (i) => i.source === "internal" && i.taskId === id, + ); + if (!match) { + throw Object.assign(new Error(`Task not found: ${id}`), { + status: 404, + }); + } + + // Fetch full task from Superset cloud API (same source as task view). + try { + const task = await apiTrpcClient.task.byId.query(id); + if (task) { + return { + id: task.id, + slug: match.slug || slugifyTitle(task.title), + title: task.title, + description: task.description ?? null, + }; + } + } catch (err) { + console.warn( + `[v2-launch] task.byId failed for ${id}, using title-only`, + err, + ); + } + + return { + id, + slug: match.slug, + title: match.title, + description: null, + }; + }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts new file mode 100644 index 00000000000..9d51cc26f84 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, test } from "bun:test"; +import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { + buildAdoptPayload, + buildCheckoutPayload, + buildForkPayload, + buildPrCheckoutPayload, + mapLinkedContextFromPending, +} from "./buildIntentPayload"; + +function makePending( + overrides: Partial<PendingWorkspaceRow> = {}, +): PendingWorkspaceRow { + return { + id: "11111111-1111-1111-1111-111111111111", + projectId: "22222222-2222-2222-2222-222222222222", + hostTarget: { kind: "local" }, + intent: "fork", + name: "my-workspace", + workspaceNameWasAutoGenerated: true, + branchName: "feature-foo", + status: "creating", + error: null, + workspaceId: null, + warnings: [], + terminals: [], + createdAt: new Date("2026-04-13T00:00:00Z"), + prompt: "", + baseBranch: null, + baseBranchSource: null, + linkedIssues: [], + linkedPR: null, + attachmentCount: 0, + runSetupScript: true, + terminalLaunch: null, + chatLaunch: null, + agentId: null, + ...overrides, + }; +} + +describe("mapLinkedContextFromPending", () => { + test("extracts internal task ids from linkedIssues", () => { + const mapped = mapLinkedContextFromPending({ + linkedIssues: [ + { slug: "SUP-1", title: "a", source: "internal", taskId: "t1" }, + { slug: "SUP-2", title: "b", source: "internal", taskId: "t2" }, + ], + linkedPR: null, + }); + expect(mapped.internalIssueIds).toEqual(["t1", "t2"]); + expect(mapped.githubIssueUrls).toBeUndefined(); + expect(mapped.linkedPrUrl).toBeUndefined(); + }); + + test("extracts github urls from linkedIssues", () => { + const mapped = mapLinkedContextFromPending({ + linkedIssues: [ + { + slug: "#1", + title: "a", + source: "github", + url: "https://github.com/o/r/issues/1", + }, + ], + linkedPR: null, + }); + expect(mapped.githubIssueUrls).toEqual(["https://github.com/o/r/issues/1"]); + expect(mapped.internalIssueIds).toBeUndefined(); + }); + + test("skips internal issues missing taskId and github issues missing url", () => { + const mapped = mapLinkedContextFromPending({ + linkedIssues: [ + { slug: "SUP-1", title: "no task id", source: "internal" }, + { slug: "#1", title: "no url", source: "github" }, + ], + linkedPR: null, + }); + expect(mapped.internalIssueIds).toBeUndefined(); + expect(mapped.githubIssueUrls).toBeUndefined(); + }); + + test("surfaces linkedPR.url", () => { + const mapped = mapLinkedContextFromPending({ + linkedIssues: [], + linkedPR: { + prNumber: 42, + title: "PR 42", + url: "https://github.com/o/r/pull/42", + state: "open", + }, + }); + expect(mapped.linkedPrUrl).toBe("https://github.com/o/r/pull/42"); + }); + + test("returns all undefined for empty input", () => { + const mapped = mapLinkedContextFromPending({ + linkedIssues: [], + linkedPR: null, + }); + expect(mapped).toEqual({ + internalIssueIds: undefined, + githubIssueUrls: undefined, + linkedPrUrl: undefined, + }); + }); +}); + +describe("buildForkPayload", () => { + test("passes fork-specific fields and linked context", () => { + const pending = makePending({ + intent: "fork", + prompt: "do the thing", + baseBranch: "main", + baseBranchSource: "local", + linkedIssues: [ + { slug: "SUP-1", title: "a", source: "internal", taskId: "t1" }, + ], + linkedPR: { + prNumber: 3, + title: "p", + url: "https://github.com/o/r/pull/3", + state: "open", + }, + }); + const payload = buildForkPayload("pid", pending, undefined); + expect(payload.pendingId).toBe("pid"); + expect(payload.projectId).toBe(pending.projectId); + expect(payload.hostTarget).toEqual({ kind: "local" }); + expect(payload.names).toEqual({ + workspaceName: "my-workspace", + branchName: "feature-foo", + workspaceNameWasAutoGenerated: true, + }); + expect(payload.composer.prompt).toBe("do the thing"); + expect(payload.composer.baseBranch).toBe("main"); + expect(payload.composer.baseBranchSource).toBe("local"); + expect(payload.linkedContext?.internalIssueIds).toEqual(["t1"]); + expect(payload.linkedContext?.linkedPrUrl).toBe( + "https://github.com/o/r/pull/3", + ); + }); + + test("empty prompt/baseBranch become undefined, not empty strings", () => { + const pending = makePending({ prompt: "", baseBranch: null }); + const payload = buildForkPayload("pid", pending, undefined); + expect(payload.composer.prompt).toBeUndefined(); + expect(payload.composer.baseBranch).toBeUndefined(); + }); + + test("attachments are plumbed through linkedContext", () => { + const pending = makePending(); + const payload = buildForkPayload("pid", pending, [ + { data: "b64", mediaType: "image/png", filename: "a.png" }, + ]); + expect(payload.linkedContext?.attachments).toHaveLength(1); + }); + + test("host-tracking hostTarget survives the map", () => { + const pending = makePending({ + hostTarget: { kind: "host", hostId: "h-1" }, + }); + const payload = buildForkPayload("pid", pending, undefined); + expect(payload.hostTarget).toEqual({ kind: "host", hostId: "h-1" }); + }); + + test("propagates workspaceNameWasAutoGenerated=false for user-typed names", () => { + const pending = makePending({ workspaceNameWasAutoGenerated: false }); + const payload = buildForkPayload("pid", pending, undefined); + expect(payload.names.workspaceNameWasAutoGenerated).toBe(false); + }); +}); + +describe("buildCheckoutPayload", () => { + test("sends branch + runSetupScript; no composer prompt/baseBranch", () => { + const pending = makePending({ + intent: "checkout", + branchName: "feature-foo", + runSetupScript: false, + }); + const payload = buildCheckoutPayload("pid", pending); + expect(payload.branch).toBe("feature-foo"); + expect(payload.workspaceName).toBe("my-workspace"); + expect(payload.composer).toEqual({ runSetupScript: false }); + }); +}); + +describe("buildPrCheckoutPayload", () => { + const prContent = { + number: 42, + url: "https://github.com/o/r/pull/42", + title: "Fix typo", + branch: "fix/typo", + baseBranch: "main", + headRepositoryOwner: "kietho", + isCrossRepository: true, + state: "open", + body: "body text", + }; + + test("maps PR content into the pr input with normalized state", () => { + const pending = makePending({ + intent: "pr-checkout", + prompt: "review this PR", + linkedPR: { + prNumber: 42, + title: "Fix typo", + url: "https://github.com/o/r/pull/42", + state: "open", + }, + }); + const payload = buildPrCheckoutPayload("pid", pending, prContent); + + expect(payload.pr).toEqual({ + number: 42, + url: "https://github.com/o/r/pull/42", + title: "Fix typo", + headRefName: "fix/typo", + baseRefName: "main", + headRepositoryOwner: "kietho", + isCrossRepository: true, + state: "open", + }); + expect(payload.branch).toBeUndefined(); + }); + + test("composer.baseBranch = PR's baseRefName (Changes-tab authority)", () => { + const pending = makePending({ intent: "pr-checkout" }); + const payload = buildPrCheckoutPayload("pid", pending, { + ...prContent, + baseBranch: "develop", + }); + expect(payload.composer.baseBranch).toBe("develop"); + }); + + test("preserves prompt and runSetupScript from pending row", () => { + const pending = makePending({ + intent: "pr-checkout", + prompt: "hey", + runSetupScript: false, + }); + const payload = buildPrCheckoutPayload("pid", pending, prContent); + expect(payload.composer.prompt).toBe("hey"); + expect(payload.composer.runSetupScript).toBe(false); + }); + + test("linkedPrUrl falls back to PR content URL when linkedPR-level missing", () => { + // linkedPR exists but for some reason url isn't in the linkedIssues map + // (shouldn't happen normally, but be resilient). + const pending = makePending({ + intent: "pr-checkout", + linkedPR: null, + }); + const payload = buildPrCheckoutPayload("pid", pending, prContent); + expect(payload.linkedContext?.linkedPrUrl).toBe( + "https://github.com/o/r/pull/42", + ); + }); + + test("closed state maps to closed", () => { + const pending = makePending({ intent: "pr-checkout" }); + const payload = buildPrCheckoutPayload("pid", pending, { + ...prContent, + state: "closed", + }); + expect(payload.pr?.state).toBe("closed"); + }); + + test("merged state maps to merged", () => { + const pending = makePending({ intent: "pr-checkout" }); + const payload = buildPrCheckoutPayload("pid", pending, { + ...prContent, + state: "merged", + }); + expect(payload.pr?.state).toBe("merged"); + }); + + test("unknown state falls back to open (safe default)", () => { + const pending = makePending({ intent: "pr-checkout" }); + const payload = buildPrCheckoutPayload("pid", pending, { + ...prContent, + state: "draft", + }); + expect(payload.pr?.state).toBe("open"); + }); + + test("throws clear error for cross-repo PR with deleted fork (null owner)", () => { + const pending = makePending({ intent: "pr-checkout" }); + expect(() => + buildPrCheckoutPayload("pid", pending, { + ...prContent, + headRepositoryOwner: null, + isCrossRepository: true, + }), + ).toThrow("head fork repository has been deleted"); + }); + + test("same-repo PR with null owner is fine (owner not needed)", () => { + const pending = makePending({ intent: "pr-checkout" }); + const payload = buildPrCheckoutPayload("pid", pending, { + ...prContent, + headRepositoryOwner: null, + isCrossRepository: false, + }); + expect(payload.pr?.headRepositoryOwner).toBe(""); + }); +}); + +describe("buildAdoptPayload", () => { + test("minimal payload: projectId + host + name + branch", () => { + const pending = makePending({ + intent: "adopt", + branchName: "agreeable-ermine", + }); + const payload = buildAdoptPayload(pending); + expect(payload).toEqual({ + projectId: pending.projectId, + hostTarget: { kind: "local" }, + workspaceName: "my-workspace", + branch: "agreeable-ermine", + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts new file mode 100644 index 00000000000..81c2f57df71 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildIntentPayload.ts @@ -0,0 +1,163 @@ +import type { AdoptWorktreeInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree"; +import type { CheckoutWorkspaceInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace"; +import type { CreateWorkspaceInput } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace"; +import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; + +/** + * Pure builders that translate a `PendingWorkspaceRow` into the input shape + * each host-service mutation expects. Kept pure (no React, no IO) so the + * dispatch logic in the pending page is testable in isolation. See + * `buildIntentPayload.test.ts` for the contract suite. + */ + +type Attachment = { data: string; mediaType: string; filename: string }; + +export function mapLinkedContextFromPending( + pending: Pick<PendingWorkspaceRow, "linkedIssues" | "linkedPR">, +): { + internalIssueIds: string[] | undefined; + githubIssueUrls: string[] | undefined; + linkedPrUrl: string | undefined; +} { + const internalIssueIds = pending.linkedIssues + .filter((i) => i.source === "internal" && i.taskId) + .map((i) => i.taskId as string); + const githubIssueUrls = pending.linkedIssues + .filter((i) => i.source === "github" && i.url) + .map((i) => i.url as string); + return { + internalIssueIds: + internalIssueIds.length > 0 ? internalIssueIds : undefined, + githubIssueUrls: githubIssueUrls.length > 0 ? githubIssueUrls : undefined, + linkedPrUrl: pending.linkedPR?.url, + }; +} + +export function buildForkPayload( + pendingId: string, + pending: PendingWorkspaceRow, + attachments: Attachment[] | undefined, +): CreateWorkspaceInput { + const linked = mapLinkedContextFromPending(pending); + return { + pendingId, + projectId: pending.projectId, + hostTarget: pending.hostTarget, + names: { + workspaceName: pending.name, + branchName: pending.branchName, + workspaceNameWasAutoGenerated: pending.workspaceNameWasAutoGenerated, + }, + composer: { + prompt: pending.prompt || undefined, + baseBranch: pending.baseBranch || undefined, + baseBranchSource: pending.baseBranchSource ?? undefined, + runSetupScript: pending.runSetupScript, + }, + linkedContext: { + internalIssueIds: linked.internalIssueIds, + githubIssueUrls: linked.githubIssueUrls, + linkedPrUrl: linked.linkedPrUrl, + attachments, + }, + }; +} + +export function buildCheckoutPayload( + pendingId: string, + pending: PendingWorkspaceRow, +): CheckoutWorkspaceInput { + return { + pendingId, + projectId: pending.projectId, + hostTarget: pending.hostTarget, + workspaceName: pending.name, + branch: pending.branchName, + composer: { + baseBranch: pending.baseBranch || undefined, + runSetupScript: pending.runSetupScript, + }, + }; +} + +/** + * Builds the `workspaceCreation.checkout` payload for PR mode. Requires the + * resolved PR content fetched at pending-page time (not persisted in the + * pending row itself — kept narrow on purpose). + * + * The server derives the real local branch name from `pr.headRefName` + + * `pr.isCrossRepository`; the pending row's `branchName` is only a display + * placeholder in PR mode. + */ +export function buildPrCheckoutPayload( + pendingId: string, + pending: PendingWorkspaceRow, + prContent: { + number: number; + url: string; + title: string; + branch: string; // headRefName + baseBranch: string; // baseRefName + headRepositoryOwner: string | null; + isCrossRepository: boolean; + state: string; + }, +): CheckoutWorkspaceInput { + // Null owner on a cross-repo PR means the head fork repo has been + // deleted. We can't derive `<owner>/<headRefName>` without it, and + // `gh pr checkout` wouldn't have a fork to configure push against. + // Fail early with a clear error rather than a cryptic server-side + // "headRepositoryOwner is required". + if (prContent.isCrossRepository && !prContent.headRepositoryOwner) { + throw new Error( + `Cannot check out PR #${prContent.number}: the head fork repository has been deleted.`, + ); + } + const linked = mapLinkedContextFromPending(pending); + const normalizedState: "open" | "closed" | "merged" = + prContent.state === "closed" + ? "closed" + : prContent.state === "merged" + ? "merged" + : "open"; + return { + pendingId, + projectId: pending.projectId, + hostTarget: pending.hostTarget, + workspaceName: pending.name, + pr: { + number: prContent.number, + url: prContent.url, + title: prContent.title, + headRefName: prContent.branch, + baseRefName: prContent.baseBranch, + // Same-repo PRs don't need an owner for branch derivation; pass an + // empty string rather than leaking null into the server input. + headRepositoryOwner: prContent.headRepositoryOwner ?? "", + isCrossRepository: prContent.isCrossRepository, + state: normalizedState, + }, + composer: { + prompt: pending.prompt || undefined, + // PR's base is authoritative for the Changes tab — see plan §3. + baseBranch: prContent.baseBranch, + runSetupScript: pending.runSetupScript, + }, + linkedContext: { + internalIssueIds: linked.internalIssueIds, + githubIssueUrls: linked.githubIssueUrls, + linkedPrUrl: linked.linkedPrUrl ?? prContent.url, + }, + }; +} + +export function buildAdoptPayload( + pending: PendingWorkspaceRow, +): AdoptWorktreeInput { + return { + projectId: pending.projectId, + hostTarget: pending.hostTarget, + workspaceName: pending.name, + branch: pending.branchName, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts new file mode 100644 index 00000000000..8221d5ff994 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/buildSetupPaneLayout.ts @@ -0,0 +1,39 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { + PaneViewerData, + TerminalPaneData, +} from "../../v2-workspace/$workspaceId/types"; + +/** + * Build a pane layout from terminal descriptors returned by workspace creation. + * Each terminal becomes its own tab. The renderer just attaches — sessions are + * already running on the host-service. + */ +export function buildSetupPaneLayout( + terminals: Array<{ id: string; role: string; label: string }>, +): WorkspaceState<PaneViewerData> { + const tabs = terminals.map((t) => { + const paneId = `pane-${crypto.randomUUID()}`; + const tabId = `tab-${crypto.randomUUID()}`; + return { + id: tabId, + createdAt: Date.now(), + activePaneId: paneId, + layout: { type: "pane" as const, paneId }, + panes: { + [paneId]: { + id: paneId, + kind: "terminal", + titleOverride: t.label, + data: { terminalId: t.id } as TerminalPaneData, + }, + }, + }; + }); + + return { + version: 1, + activeTabId: tabs[0]?.id ?? null, + tabs, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts new file mode 100644 index 00000000000..fe1a91848cc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/dispatchForkLaunch.ts @@ -0,0 +1,238 @@ +import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { toast } from "@superset/ui/sonner"; +import { env } from "renderer/env.renderer"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import type { + PendingChatLaunch, + PendingTerminalLaunch, + PendingWorkspaceRow, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { + buildForkAgentLaunch, + type LoadedAttachment, + type ResolvedPrContent, +} from "./buildForkAgentLaunch"; + +export interface DispatchForkLaunchInputs { + workspaceId: string; + pending: Pick< + PendingWorkspaceRow, + | "projectId" + | "prompt" + | "linkedIssues" + | "linkedPR" + | "hostTarget" + | "agentId" + >; + loadedAttachments: LoadedAttachment[] | undefined; + agentConfigs: ResolvedAgentConfig[]; + activeHostUrl: string | null; + activeOrganizationId: string | null; + /** + * Pre-resolved PR content from the pr-checkout flow. Threaded into + * `buildForkAgentLaunch` so the `fetchPullRequest` resolver skips a + * redundant `getGitHubPullRequestContent` call. + */ + resolvedPr?: ResolvedPrContent; + onApplyToRow: (patch: { + terminalLaunch?: PendingTerminalLaunch | null; + chatLaunch?: PendingChatLaunch | null; + }) => void; +} + +/** + * After host-service.create resolves, run the composer pipeline and + * stash the launch intent on the pending row. The V2 workspace page's + * useConsumePendingLaunch mount effect picks it up. + * + * For terminal launches we also write attachment bytes to + * `<worktree>/.superset/attachments/` now — the worktree exists and + * workspaceTrpc.filesystem is available. Chat launches carry their + * binaries as base64 data URLs inline (existing ChatLaunchConfig shape). + */ +export async function dispatchForkLaunch({ + workspaceId, + pending, + loadedAttachments, + agentConfigs, + activeHostUrl, + activeOrganizationId, + resolvedPr, + onApplyToRow, +}: DispatchForkLaunchInputs): Promise<void> { + console.log("[v2-launch] dispatchForkLaunch: start", { + workspaceId, + projectId: pending.projectId, + attachmentCount: loadedAttachments?.length ?? 0, + agentConfigCount: agentConfigs.length, + }); + + const hostUrl = resolveHostUrl( + pending.hostTarget, + activeHostUrl, + activeOrganizationId, + ); + const hostClient = hostUrl ? getHostServiceClientByUrl(hostUrl) : undefined; + + let build: Awaited<ReturnType<typeof buildForkAgentLaunch>>; + try { + build = await buildForkAgentLaunch({ + pending, + attachments: loadedAttachments, + agentConfigs, + hostServiceClient: hostClient, + resolvedPr, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[v2-launch] buildForkAgentLaunch failed:", err); + toast.error("Couldn't prepare agent launch", { description: msg }); + return; + } + + console.log("[v2-launch] dispatchForkLaunch: built", { + kind: build?.kind ?? null, + terminalCommand: + build?.kind === "terminal" + ? build.launch.command.slice(0, 120) + : undefined, + chatPrompt: + build?.kind === "chat" + ? build.launch.initialPrompt?.slice(0, 120) + : undefined, + attachmentsToWrite: + build?.kind === "terminal" ? build.attachmentsToWrite.length : 0, + }); + + if (!build) { + console.warn( + "[v2-launch] dispatchForkLaunch: buildForkAgentLaunch returned null — no launch", + ); + // Only warn if the user gave input worth launching on (prompt text, + // linked context, or attachments). An empty workspace-create with no + // agent enabled is a valid case and shouldn't surface a toast. + const userGaveInput = + (pending.prompt?.trim().length ?? 0) > 0 || + pending.linkedIssues.length > 0 || + !!pending.linkedPR || + (loadedAttachments?.length ?? 0) > 0; + if (userGaveInput) { + toast.warning("Workspace created but no agent launched", { + description: + "Enable an agent in Settings → Agents to auto-launch on new workspaces.", + }); + } + return; + } + + if (build.kind === "chat") { + onApplyToRow({ chatLaunch: build.launch }); + console.log("[v2-launch] dispatchForkLaunch: chatLaunch applied to row"); + return; + } + + if (!hostUrl) { + console.warn("[v2-launch] host-service URL not resolved; skip launch"); + toast.error("Couldn't reach host service", { + description: "Agent didn't launch. Check your host connection.", + }); + return; + } + + try { + if (build.attachmentsToWrite.length > 0) { + await writeAttachmentsToWorktree({ + hostUrl, + workspaceId, + attachments: build.attachmentsToWrite, + }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[v2-launch] failed to write attachments:", err); + toast.warning("Attachments didn't save to the workspace", { + description: `Agent will launch without files. ${msg}`, + }); + // keep going — terminal launch still useful even without files + } + + onApplyToRow({ terminalLaunch: build.launch }); + console.log("[v2-launch] dispatchForkLaunch: terminalLaunch applied to row", { + workspaceId, + }); +} + +function resolveHostUrl( + hostTarget: PendingWorkspaceRow["hostTarget"], + activeHostUrl: string | null, + activeOrganizationId: string | null, +): string | null { + if (hostTarget.kind === "local") return activeHostUrl; + if (!activeOrganizationId) return null; + const routingKey = buildHostRoutingKey( + activeOrganizationId, + hostTarget.hostId, + ); + return `${env.RELAY_URL}/hosts/${routingKey}`; +} + +async function writeAttachmentsToWorktree({ + hostUrl, + workspaceId, + attachments, +}: { + hostUrl: string; + workspaceId: string; + attachments: Array<{ + filename: string; + mediaType: string; + data: Uint8Array; + }>; +}): Promise<void> { + const client = getHostServiceClientByUrl(hostUrl); + const workspace = await client.workspace.get.query({ id: workspaceId }); + const worktreePath: string | undefined = ( + workspace as { worktreePath?: string } + ).worktreePath; + if (!worktreePath) { + console.warn( + "[v2-launch] workspace has no worktreePath; skipping attachments", + ); + throw new Error("Workspace has no worktreePath"); + } + + const dir = joinPath(worktreePath, ".superset/attachments"); + try { + await client.filesystem.createDirectory.mutate({ + workspaceId, + absolutePath: dir, + }); + } catch { + // directory may already exist; writeFile will fail loudly if it doesn't + } + + for (const attachment of attachments) { + await client.filesystem.writeFile.mutate({ + workspaceId, + absolutePath: joinPath(dir, attachment.filename), + content: { + kind: "base64", + data: bytesToBase64(attachment.data), + }, + }); + } +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i] ?? 0); + } + return btoa(binary); +} + +function joinPath(a: string, b: string): string { + if (a.endsWith("/")) return `${a}${b}`; + return `${a}/${b}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx new file mode 100644 index 00000000000..d144b347215 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pending/$pendingId/page.tsx @@ -0,0 +1,553 @@ +import { toast } from "@superset/ui/sonner"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { GoGitBranch } from "react-icons/go"; +import { HiCheck, HiExclamationTriangle } from "react-icons/hi2"; +import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + clearAttachments, + loadAttachments, +} from "renderer/lib/pending-attachment-store"; +import { useAdoptWorktree } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree"; +import { useCheckoutDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace"; +import { useCreateDashboardWorkspace } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import type { ResolvedPrContent } from "./buildForkAgentLaunch"; +import { + buildAdoptPayload, + buildCheckoutPayload, + buildForkPayload, + buildPrCheckoutPayload, +} from "./buildIntentPayload"; +import { buildSetupPaneLayout } from "./buildSetupPaneLayout"; +import { dispatchForkLaunch } from "./dispatchForkLaunch"; + +/** + * Pending workspace progress page. + * + * Lives at /_dashboard/pending/$pendingId (NOT under /v2-workspace/) because + * the v2-workspace layout wraps children in WorkspaceTrpcProvider. During route + * transitions away from a real workspace, the layout would strip the provider + * while the old workspace's TerminalPane is still mounted — causing a crash. + * Keeping this route outside v2-workspace avoids that entirely. + * + * The page is the single point of dispatch for all three workspace-creation + * intents (fork / checkout / adopt). The modal inserts a row tagged with + * `intent` and navigates here; this page calls the right host-service mutation + * on first mount and on retry. See `V2_WORKSPACE_CREATION.md` §3. + */ +export const Route = createFileRoute( + "/_authenticated/_dashboard/pending/$pendingId/", +)({ + component: PendingWorkspacePage, +}); + +function useFireIntent(pendingId: string, pending: PendingWorkspaceRow | null) { + const collections = useCollections(); + const createWorkspace = useCreateDashboardWorkspace(); + const checkoutWorkspace = useCheckoutDashboardWorkspace(); + const adoptWorktree = useAdoptWorktree(); + const trpcUtils = electronTrpc.useUtils(); + const { activeHostUrl } = useLocalHostService(); + const hostUrl = useHostTargetUrl(pending?.hostTarget ?? null); + const { data: session } = authClient.useSession(); + const activeOrganizationId = session?.session?.activeOrganizationId ?? null; + const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); + + const fire = useCallback(async () => { + if (!pending) return; + + collections.pendingWorkspaces.update(pendingId, (draft) => { + draft.status = "creating"; + draft.error = null; + }); + + try { + let result: { + workspace?: { id?: string } | null; + terminals?: Array<{ id: string; role: string; label: string }>; + warnings?: string[]; + }; + let loadedAttachments: + | Array<{ data: string; mediaType: string; filename: string }> + | undefined; + // Populated in the pr-checkout path; threaded into dispatchForkLaunch + // so the agent-launch resolver reuses the data instead of re-fetching. + let resolvedPr: ResolvedPrContent | undefined; + + switch (pending.intent) { + case "fork": { + if (pending.attachmentCount > 0) { + try { + loadedAttachments = await loadAttachments(pendingId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn("[v2-launch] loadAttachments failed:", err); + toast.warning("Couldn't load saved attachments", { + description: `Workspace will be created without files. ${msg}`, + }); + } + } + result = await createWorkspace( + buildForkPayload(pendingId, pending, loadedAttachments), + ); + break; + } + case "checkout": { + result = await checkoutWorkspace( + buildCheckoutPayload(pendingId, pending), + ); + break; + } + case "adopt": { + result = await adoptWorktree(buildAdoptPayload(pending)); + break; + } + case "pr-checkout": { + if (!pending.linkedPR) { + throw new Error("pr-checkout intent requires a linkedPR"); + } + if (!hostUrl) { + throw new Error("Host service not available"); + } + const hostClient = getHostServiceClientByUrl(hostUrl); + // Single fetch — reused by both the mutation payload and the + // agent-launch resolver (via resolvedPr). Zero net new fetches + // vs fork-with-PR, which fetches the same data at launch build. + const prContent = + await hostClient.workspaceCreation.getGitHubPullRequestContent.query( + { + projectId: pending.projectId, + prNumber: pending.linkedPR.prNumber, + }, + ); + resolvedPr = { + number: prContent.number, + url: prContent.url, + title: prContent.title, + body: prContent.body, + branch: prContent.branch, + }; + result = await checkoutWorkspace( + buildPrCheckoutPayload(pendingId, pending, prContent), + ); + break; + } + } + + // Register in the sidebar as soon as the workspace exists. The + // post-create navigate effect also calls this, but only fires while + // the user is still on the pending page and after workspace sync + // completes — calling it here guarantees the row appears even if the + // user has navigated away or sync is slow. + if (result.workspace?.id) { + ensureWorkspaceInSidebar(result.workspace.id, pending.projectId); + } + + // V2 dispatch: after host-service.create resolves, build the launch + // plan and stash it on the pending row. The V2 workspace page's + // useConsumePendingLaunch mount-effect picks it up and opens the + // pane. See apps/desktop/docs/V2_LAUNCH_CONTEXT.md. + // + // Fetch agent configs imperatively here rather than reading from + // a useQuery hook — a not-yet-resolved query would silently skip + // the dispatch, permanently losing the launch for a successful + // workspace create. + const needsLaunchDispatch = + (pending.intent === "fork" || pending.intent === "pr-checkout") && + !!result.workspace?.id; + if (needsLaunchDispatch && result.workspace?.id) { + const agentConfigs = await trpcUtils.settings.getAgentPresets.fetch(); + await dispatchForkLaunch({ + workspaceId: result.workspace.id, + pending, + loadedAttachments, + agentConfigs, + activeHostUrl, + activeOrganizationId, + resolvedPr, + onApplyToRow: (patch) => { + collections.pendingWorkspaces.update(pendingId, (draft) => { + if (patch.terminalLaunch !== undefined) { + draft.terminalLaunch = patch.terminalLaunch; + } + if (patch.chatLaunch !== undefined) { + draft.chatLaunch = patch.chatLaunch; + } + }); + }, + }); + } + + collections.pendingWorkspaces.update(pendingId, (draft) => { + draft.status = "succeeded"; + draft.workspaceId = result.workspace?.id ?? null; + draft.terminals = result.terminals ?? []; + draft.warnings = result.warnings ?? []; + }); + void clearAttachments(pendingId); + } catch (err) { + collections.pendingWorkspaces.update(pendingId, (draft) => { + draft.status = "failed"; + draft.error = + err instanceof Error ? err.message : "Failed to create workspace"; + }); + } + }, [ + collections, + createWorkspace, + checkoutWorkspace, + adoptWorktree, + ensureWorkspaceInSidebar, + pending, + pendingId, + trpcUtils, + activeHostUrl, + activeOrganizationId, + hostUrl, + ]); + + return fire; +} + +function PendingWorkspacePage() { + const { pendingId } = Route.useParams(); + const navigate = useNavigate(); + const collections = useCollections(); + const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); + const navigatedRef = useRef(false); + const firedRef = useRef(false); + + // Route params can change under a mounted component (user navigates from + // one pending page to another). Reset the fire/nav guards so the new + // pendingId actually dispatches — otherwise the second page sticks in + // "creating" forever. + const prevPendingIdRef = useRef(pendingId); + const [syncTimedOut, setSyncTimedOut] = useState(false); + if (prevPendingIdRef.current !== pendingId) { + prevPendingIdRef.current = pendingId; + firedRef.current = false; + navigatedRef.current = false; + setSyncTimedOut(false); + } + + const { data: pendingRows } = useLiveQuery( + (q) => + q + .from({ pw: collections.pendingWorkspaces }) + .where(({ pw }) => eq(pw.id, pendingId)) + .select(({ pw }) => ({ ...pw })), + [collections, pendingId], + ); + const pending: PendingWorkspaceRow | null = + (pendingRows?.[0] as PendingWorkspaceRow | undefined) ?? null; + const fireIntent = useFireIntent(pendingId, pending); + + // Wait for the cloud row to appear in the local collection before + // navigating. Fast-path intents (adopt) can beat Electric sync to the + // punch, landing us on the workspace route before the row is visible — + // which shows "workspace not found". Fork's slow path hides this race. + const { data: workspaceRowMatch } = useLiveQuery( + (q) => + q + .from({ w: collections.v2Workspaces }) + .where(({ w }) => eq(w.id, pending?.workspaceId ?? "")) + .select(({ w }) => ({ id: w.id })), + [collections, pending?.workspaceId], + ); + const workspaceSynced = (workspaceRowMatch?.length ?? 0) > 0; + + // Fire the mutation once on first mount. The modal stores draft state in + // the pending row and navigates here — page owns the actual call so all + // three intents share one dispatch + retry path. + useEffect(() => { + if (!pending || pending.status !== "creating" || firedRef.current) return; + firedRef.current = true; + void fireIntent(); + }, [pending, fireIntent]); + + // Poll host-service for step-by-step progress (fork + checkout only; + // adopt is fast and doesn't instrument progress). + const intentHasProgress = + pending?.intent === "fork" || pending?.intent === "checkout"; + const hostUrl = useHostTargetUrl(pending?.hostTarget ?? null); + + const { data: progress } = useQuery({ + queryKey: ["workspaceCreation", "getProgress", pendingId, hostUrl], + queryFn: async () => { + if (!hostUrl) return null; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.getProgress.query({ + pendingId, + }); + }, + refetchInterval: 500, + enabled: pending?.status === "creating" && !!hostUrl && intentHasProgress, + }); + + const steps = progress?.steps ?? []; + + const STALE_THRESHOLD_MS = 2 * 60 * 1000; + const [now, setNow] = useState(Date.now()); + useEffect(() => { + if (pending?.status !== "creating") return; + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, [pending?.status]); + + const createdAtMs = pending?.createdAt + ? new Date(pending.createdAt).getTime() + : now; + const elapsedMs = Math.max(0, now - createdAtMs); + const elapsedLabel = formatRelativeTime(createdAtMs); + const isStale = + pending?.status === "creating" && elapsedMs > STALE_THRESHOLD_MS; + + // If sync stalls past this, swap the spinner for a recoverable stall UI + // rather than silently navigating into "Workspace not found". syncTimedOut + // must stay in the deps + guard below so "Keep waiting" (which flips it + // false) re-arms a fresh timer instead of leaving the user stranded. + const SYNC_TIMEOUT_MS = 10_000; + useEffect(() => { + if ( + pending?.status !== "succeeded" || + !pending.workspaceId || + workspaceSynced || + syncTimedOut || + navigatedRef.current + ) { + return; + } + const timer = setTimeout(() => setSyncTimedOut(true), SYNC_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [pending?.status, pending?.workspaceId, workspaceSynced, syncTimedOut]); + + const doNavigate = useCallback(() => { + if (!pending?.workspaceId || navigatedRef.current) return; + navigatedRef.current = true; + ensureWorkspaceInSidebar(pending.workspaceId, pending.projectId); + + if (pending.terminals.length > 0) { + const paneLayout = buildSetupPaneLayout(pending.terminals); + collections.v2WorkspaceLocalState.update(pending.workspaceId, (draft) => { + draft.paneLayout = paneLayout; + }); + } + + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: pending.workspaceId }, + }); + setTimeout(() => { + collections.pendingWorkspaces.delete(pendingId); + }, 1000); + }, [collections, ensureWorkspaceInSidebar, navigate, pending, pendingId]); + + useEffect(() => { + if ( + pending?.status === "succeeded" && + pending.workspaceId && + workspaceSynced + ) { + doNavigate(); + } + }, [pending?.status, pending?.workspaceId, workspaceSynced, doNavigate]); + + if (!pending) { + return ( + <div className="flex h-full items-center justify-center text-muted-foreground"> + Workspace not found + </div> + ); + } + + const creatingLabel = + pending.intent === "adopt" + ? "Adopting worktree..." + : pending.intent === "checkout" + ? "Checking out branch..." + : "Creating workspace..."; + + return ( + <div className="flex h-full w-full flex-1 justify-center pt-24"> + <div className="w-full max-w-sm space-y-5 p-8"> + <div className="space-y-1"> + <h2 className="text-lg font-semibold">{pending.name}</h2> + <div className="flex items-center gap-1.5 text-sm text-muted-foreground"> + <GoGitBranch className="size-3.5" /> + <span className="font-mono">{pending.branchName}</span> + </div> + </div> + + {pending.status === "creating" && ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <p + className={`text-sm ${isStale ? "text-amber-500" : "text-muted-foreground"}`} + > + {isStale + ? "This is taking longer than expected..." + : creatingLabel} + </p> + <span className="text-xs tabular-nums text-muted-foreground/50"> + {elapsedLabel} + </span> + </div> + {intentHasProgress && steps.length > 0 ? ( + <div className="space-y-2"> + {steps.map((step) => ( + <div + key={step.id} + className="flex items-center gap-2.5 text-sm" + > + {step.status === "done" ? ( + <HiCheck className="size-4 text-emerald-500" /> + ) : step.status === "active" ? ( + <div className="size-4 flex items-center justify-center"> + <div className="size-2.5 rounded-full bg-foreground animate-pulse" /> + </div> + ) : ( + <div className="size-4 flex items-center justify-center"> + <div className="size-2 rounded-full bg-muted-foreground/30" /> + </div> + )} + <span + className={ + step.status === "done" || step.status === "active" + ? "text-foreground" + : "text-muted-foreground/50" + } + > + {step.label} + </span> + </div> + ))} + </div> + ) : ( + // Adopt has no host-side progress steps — show a generic spinner. + <div className="flex items-center gap-2.5 text-sm text-muted-foreground"> + <div className="size-4 flex items-center justify-center"> + <div className="size-2.5 rounded-full bg-foreground animate-pulse" /> + </div> + </div> + )} + <div className="flex gap-2 pt-1"> + <button + type="button" + className="rounded-md border px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent" + onClick={() => { + collections.pendingWorkspaces.delete(pendingId); + void clearAttachments(pendingId); + void navigate({ to: "/" }); + }} + > + Dismiss + </button> + </div> + </div> + )} + + {pending.status === "succeeded" && + (syncTimedOut && !workspaceSynced ? ( + <div className="space-y-4"> + <div className="flex items-start gap-2 text-sm text-amber-500"> + <HiExclamationTriangle className="size-4 mt-0.5 shrink-0" /> + <span> + Workspace was created but hasn't synced to this device yet. + Check your connection. + </span> + </div> + <div className="flex gap-2"> + <button + type="button" + className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90" + onClick={() => setSyncTimedOut(false)} + > + Keep waiting + </button> + <button + type="button" + className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent" + onClick={doNavigate} + > + Open anyway + </button> + <button + type="button" + className="rounded-md border px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent" + onClick={() => { + collections.pendingWorkspaces.delete(pendingId); + void clearAttachments(pendingId); + void navigate({ to: "/" }); + }} + > + Dismiss + </button> + </div> + </div> + ) : ( + <div className="space-y-2"> + <div className="flex items-center gap-2 text-sm text-emerald-500"> + <HiCheck className="size-4" /> + <span>Workspace ready — opening...</span> + </div> + {pending.warnings.length > 0 && ( + <ul className="space-y-1 text-xs text-amber-500"> + {pending.warnings.map((w) => ( + <li key={w} className="flex items-start gap-1.5"> + <HiExclamationTriangle className="size-3.5 mt-0.5 shrink-0" /> + <span>{w}</span> + </li> + ))} + </ul> + )} + </div> + ))} + + {pending.status === "failed" && ( + <div className="space-y-4"> + <div className="flex items-start gap-2 text-sm text-destructive"> + <HiExclamationTriangle className="size-4 mt-0.5 shrink-0" /> + <span className="select-text cursor-text break-words"> + {pending.error ?? "Failed to create workspace"} + </span> + </div> + <div className="flex gap-2"> + <button + type="button" + className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90" + onClick={() => { + firedRef.current = true; // prevent the mount-effect from racing + void fireIntent(); + }} + > + Retry + </button> + <button + type="button" + className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent" + onClick={() => { + collections.pendingWorkspaces.delete(pendingId); + void clearAttachments(pendingId); + void navigate({ to: "/" }); + }} + > + Dismiss + </button> + </div> + </div> + )} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx index ef1836a6768..799b4d563dd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/project/$projectId/page.tsx @@ -1,3 +1,4 @@ +import { sanitizeSegment } from "@superset/shared/workspace-launch"; import { Button } from "@superset/ui/button"; import { Checkbox } from "@superset/ui/checkbox"; import { @@ -36,7 +37,6 @@ import { resolveEffectiveWorkspaceBaseBranch } from "renderer/lib/workspaceBaseB import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { NotFound } from "renderer/routes/not-found"; import type { SetupAction } from "shared/types/config"; -import { sanitizeSegment } from "shared/utils/branch"; import { ExternalWorktreesBanner } from "./components/ExternalWorktreesBanner"; export const Route = createFileRoute( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/CommentInput/CommentInput.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/CommentInput/CommentInput.tsx deleted file mode 100644 index 719e789651f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/CommentInput/CommentInput.tsx +++ /dev/null @@ -1,13 +0,0 @@ -interface CommentInputProps { - placeholder?: string; -} - -export function CommentInput({ - placeholder = "Leave a comment...", -}: CommentInputProps) { - return ( - <div className="border border-border rounded-lg p-3 text-sm text-muted-foreground cursor-text hover:border-muted-foreground/50 transition-colors"> - {placeholder} - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/CommentInput/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/CommentInput/index.ts deleted file mode 100644 index 10729e12405..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/CommentInput/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CommentInput } from "./CommentInput"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/AssigneeProperty/AssigneeProperty.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/AssigneeProperty/AssigneeProperty.tsx index e9c5ac0d6b5..90706ba0dc5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/AssigneeProperty/AssigneeProperty.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/AssigneeProperty/AssigneeProperty.tsx @@ -8,6 +8,7 @@ import { import { useLiveQuery } from "@tanstack/react-db"; import { useMemo, useState } from "react"; import { HiOutlineUserCircle } from "react-icons/hi2"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { TaskWithStatus } from "../../../../../components/TasksView/hooks/useTasksTable"; @@ -17,6 +18,7 @@ interface AssigneePropertyProps { export function AssigneeProperty({ task }: AssigneePropertyProps) { const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const { data: allUsers } = useLiveQuery( @@ -32,14 +34,10 @@ export function AssigneeProperty({ task }: AssigneePropertyProps) { return; } - setOpen(false); - - collections.tasks.update(task.id, (draft) => { - draft.assigneeId = userId; - draft.assigneeExternalId = null; - draft.assigneeDisplayName = null; - draft.assigneeAvatarUrl = null; - }); + const transaction = taskActions.updateAssignee(task.id, userId); + if (transaction) { + setOpen(false); + } }; return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspace/OpenInWorkspace.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspace/OpenInWorkspace.tsx index e07de3735d5..305e69a34d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspace/OpenInWorkspace.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/OpenInWorkspace/OpenInWorkspace.tsx @@ -1,4 +1,11 @@ import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; +import { buildTaskAgentLaunchRequest } from "@superset/shared/agent-launch-request"; +import { + type AgentDefinitionId, + getEnabledAgentConfigs, + getFallbackAgentId, + indexResolvedAgentConfigs, +} from "@superset/shared/agent-settings"; import { Button } from "@superset/ui/button"; import { DropdownMenu, @@ -17,13 +24,6 @@ import { launchAgentSession } from "renderer/lib/agent-session-orchestrator"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { ProjectThumbnail } from "renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail"; -import { buildTaskAgentLaunchRequest } from "shared/utils/agent-launch-request"; -import { - type AgentDefinitionId, - getEnabledAgentConfigs, - getFallbackAgentId, - indexResolvedAgentConfigs, -} from "shared/utils/agent-settings"; import type { TaskWithStatus } from "../../../../../components/TasksView/hooks/useTasksTable"; import { deriveBranchName } from "../../../../utils/deriveBranchName"; @@ -121,7 +121,7 @@ export function OpenInWorkspace({ task }: OpenInWorkspaceProps) { const result = await createWorkspace.mutateAsyncWithPendingSetup( { projectId, - name: task.slug, + name: task.title, branchName, }, { agentLaunchRequest: launchRequestTemplate ?? undefined }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/PriorityProperty/PriorityProperty.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/PriorityProperty/PriorityProperty.tsx index 645eb240a54..3f3de904ff4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/PriorityProperty/PriorityProperty.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/PriorityProperty/PriorityProperty.tsx @@ -6,7 +6,7 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { useState } from "react"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { PriorityIcon } from "../../../../../components/TasksView/components/shared/PriorityIcon"; import type { TaskWithStatus } from "../../../../../components/TasksView/hooks/useTasksTable"; import { ALL_PRIORITIES } from "../../../../../components/TasksView/utils/sorting"; @@ -24,7 +24,7 @@ interface PriorityPropertyProps { } export function PriorityProperty({ task }: PriorityPropertyProps) { - const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const currentPriority = task.priority; @@ -36,13 +36,9 @@ export function PriorityProperty({ task }: PriorityPropertyProps) { return; } - try { - collections.tasks.update(task.id, (draft) => { - draft.priority = newPriority; - }); + const transaction = taskActions.updatePriority(task.id, newPriority); + if (transaction) { setOpen(false); - } catch (error) { - console.error("[PriorityProperty] Failed to update priority:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx index 448d83c2d76..abf102a0e36 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/PropertiesSidebar/components/StatusProperty/StatusProperty.tsx @@ -7,6 +7,7 @@ import { } from "@superset/ui/dropdown-menu"; import { useLiveQuery } from "@tanstack/react-db"; import { useMemo, useState } from "react"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { StatusIcon, @@ -22,6 +23,7 @@ interface StatusPropertyProps { export function StatusProperty({ task }: StatusPropertyProps) { const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const { data: allStatuses } = useLiveQuery( @@ -42,13 +44,9 @@ export function StatusProperty({ task }: StatusPropertyProps) { return; } - try { - collections.tasks.update(task.id, (draft) => { - draft.statusId = newStatus.id; - }); + const transaction = taskActions.updateStatus(task.id, newStatus.id); + if (transaction) { setOpen(false); - } catch (error) { - console.error("[StatusProperty] Failed to update status:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskActionMenu/TaskActionMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskActionMenu/TaskActionMenu.tsx index ca37d3f5b7c..1a56a7c2ccc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskActionMenu/TaskActionMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskActionMenu/TaskActionMenu.tsx @@ -13,7 +13,7 @@ import { HiOutlineTrash, } from "react-icons/hi2"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import type { TaskWithStatus } from "../../../components/TasksView/hooks/useTasksTable"; interface TaskActionMenuProps { @@ -22,7 +22,7 @@ interface TaskActionMenuProps { } export function TaskActionMenu({ task, onDelete }: TaskActionMenuProps) { - const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const { copyToClipboard } = useCopyToClipboard(); @@ -37,13 +37,11 @@ export function TaskActionMenu({ task, onDelete }: TaskActionMenuProps) { setOpen(false); }; - const handleDelete = async () => { - try { - await collections.tasks.delete(task.id); + const handleDelete = () => { + const transaction = taskActions.deleteTask(task.id); + if (transaction) { setOpen(false); onDelete?.(); - } catch (error) { - console.error("[TaskActionMenu] Failed to delete task:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx deleted file mode 100644 index 235ee01ddb0..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/TaskMarkdownRenderer.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import "highlight.js/styles/github-dark.css"; -import "./task-markdown.css"; - -import { cn } from "@superset/ui/utils"; -import { Extension } from "@tiptap/core"; -import { Blockquote } from "@tiptap/extension-blockquote"; -import { Bold } from "@tiptap/extension-bold"; -import { BulletList } from "@tiptap/extension-bullet-list"; -import { Code } from "@tiptap/extension-code"; -import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; -import { Document } from "@tiptap/extension-document"; -import { HardBreak } from "@tiptap/extension-hard-break"; -import { Heading } from "@tiptap/extension-heading"; -import { History } from "@tiptap/extension-history"; -import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; -import Image from "@tiptap/extension-image"; -import { Italic } from "@tiptap/extension-italic"; -import Link from "@tiptap/extension-link"; -import { ListItem } from "@tiptap/extension-list-item"; -import { OrderedList } from "@tiptap/extension-ordered-list"; -import { Paragraph } from "@tiptap/extension-paragraph"; -import Placeholder from "@tiptap/extension-placeholder"; -import { Strike } from "@tiptap/extension-strike"; -import TaskItem from "@tiptap/extension-task-item"; -import TaskList from "@tiptap/extension-task-list"; -import { Text } from "@tiptap/extension-text"; -import { Underline } from "@tiptap/extension-underline"; -import { - type Editor, - EditorContent, - ReactNodeViewRenderer, - useEditor, -} from "@tiptap/react"; -import { BubbleMenu } from "@tiptap/react/menus"; -import { common, createLowlight } from "lowlight"; -import { useEffect } from "react"; -import { BubbleMenuToolbar } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer/components/BubbleMenuToolbar"; -import { env } from "renderer/env.renderer"; -import { Markdown } from "tiptap-markdown"; -import { CodeBlockView } from "./components/CodeBlockView"; -import { SlashCommand } from "./components/SlashCommand"; - -const lowlight = createLowlight(common); - -const LINEAR_IMAGE_HOST = "uploads.linear.app"; - -function isLinearImageUrl(src: string): boolean { - try { - const url = new URL(src); - return url.host === LINEAR_IMAGE_HOST; - } catch { - return false; - } -} - -function getLinearProxyUrl(linearUrl: string): string { - const proxyUrl = new URL(`${env.NEXT_PUBLIC_API_URL}/api/proxy/linear-image`); - proxyUrl.searchParams.set("url", linearUrl); - return proxyUrl.toString(); -} - -const LinearImage = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - src: { - default: null, - parseHTML: (element) => element.getAttribute("src"), - renderHTML: (attributes) => { - const src = attributes.src; - if (!src) return { src: null }; - const proxiedSrc = isLinearImageUrl(src) - ? getLinearProxyUrl(src) - : src; - return { - src: proxiedSrc, - crossorigin: isLinearImageUrl(src) ? "use-credentials" : undefined, - }; - }, - }, - }; - }, -}); - -const HEADING_CLASSES: Record<number, string> = { - 1: "text-3xl font-bold leading-tight mt-0 mb-3", - 2: "text-2xl font-semibold leading-snug mt-6 mb-2", - 3: "text-xl font-semibold leading-snug mt-5 mb-2", - 4: "text-base font-semibold leading-normal mt-4 mb-2", - 5: "text-base font-semibold leading-normal mt-4 mb-2", - 6: "text-base font-semibold leading-normal mt-4 mb-2", -}; - -const StyledHeading = Heading.extend({ - renderHTML({ node, HTMLAttributes }) { - const level = node.attrs.level as number; - const classes = HEADING_CLASSES[level] || HEADING_CLASSES[1]; - return [`h${level}`, { ...HTMLAttributes, class: classes }, 0]; - }, -}); - -const KeyboardHandler = Extension.create({ - name: "keyboardHandler", - addKeyboardShortcuts() { - return { - Tab: ({ editor }) => { - if (editor.commands.sinkListItem("listItem")) return true; - if (editor.commands.sinkListItem("taskItem")) return true; - // Not in a list - consume event to prevent browser focus navigation - return true; - }, - "Shift-Tab": ({ editor }) => { - if (editor.commands.liftListItem("listItem")) return true; - if (editor.commands.liftListItem("taskItem")) return true; - return true; - }, - Escape: ({ editor }) => { - editor.commands.blur(); - return true; - }, - }; - }, -}); - -interface TaskMarkdownRendererProps { - content: string; - onSave?: (markdown: string) => void; - onChange?: (markdown: string) => void; - placeholder?: string; - autoFocus?: boolean; - className?: string; - editorClassName?: string; - onModEnter?: () => void; -} - -function getMarkdown(editor: Editor | null): string { - const storage = editor?.storage as - | Record<string, { getMarkdown?: () => string }> - | undefined; - return storage?.markdown?.getMarkdown?.() ?? ""; -} - -export function TaskMarkdownRenderer({ - content, - onSave, - onChange, - placeholder = "Add description...", - autoFocus = false, - className, - editorClassName, - onModEnter, -}: TaskMarkdownRendererProps) { - const editor = useEditor({ - autofocus: autoFocus ? "end" : false, - extensions: [ - Document, - Text, - Paragraph.configure({ - HTMLAttributes: { class: "mt-0 mb-3 leading-relaxed" }, - }), - StyledHeading.configure({ levels: [1, 2, 3, 4, 5, 6] }), - Bold.configure({ - HTMLAttributes: { class: "font-semibold" }, - }), - Italic.configure({ - HTMLAttributes: { class: "italic" }, - }), - Strike.configure({ - HTMLAttributes: { class: "line-through" }, - }), - Underline.configure({ - HTMLAttributes: { class: "underline" }, - }), - Code.configure({ - HTMLAttributes: { - class: "font-mono text-sm px-1 py-0.5 rounded bg-muted", - }, - }), - CodeBlockLowlight.extend({ - addNodeView() { - return ReactNodeViewRenderer(CodeBlockView); - }, - }).configure({ - lowlight, - HTMLAttributes: { - class: - "my-3 p-3 rounded-md bg-muted overflow-x-auto font-mono text-sm", - }, - }), - BulletList.configure({ - HTMLAttributes: { - class: "task-markdown-list mt-0 pl-6", - }, - }), - OrderedList.configure({ - HTMLAttributes: { class: "mt-0 mb-3 pl-6 list-decimal" }, - }), - ListItem.configure({ - HTMLAttributes: {}, - }), - TaskList.configure({ - HTMLAttributes: { class: "mt-0 mb-3 pl-0 list-none" }, - }), - TaskItem.configure({ - HTMLAttributes: { class: "flex items-start gap-2 mb-1" }, - nested: true, - }), - Blockquote.configure({ - HTMLAttributes: { - class: "my-3 pl-4 border-l-2 border-border text-muted-foreground", - }, - }), - HorizontalRule.configure({ - HTMLAttributes: { class: "my-6 border-none border-t border-border" }, - }), - HardBreak, - History, - Link.configure({ - openOnClick: false, - HTMLAttributes: { class: "text-primary underline" }, - }), - LinearImage.configure({ - HTMLAttributes: { class: "max-w-full h-auto rounded-md my-3" }, - }), - Placeholder.configure({ - placeholder: ({ node }) => { - if (node.type.name === "paragraph") { - return placeholder; - } - return ""; - }, - showOnlyCurrent: false, - emptyNodeClass: - "first:before:text-muted-foreground first:before:float-left first:before:h-0 first:before:pointer-events-none first:before:content-[attr(data-placeholder)]", - }), - Markdown.configure({ - html: true, - transformPastedText: true, - transformCopiedText: true, - }), - SlashCommand, - KeyboardHandler, - ], - content, - editorProps: { - attributes: { - class: cn("focus:outline-none min-h-[100px]", editorClassName), - }, - handleKeyDown: (_, event) => { - if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { - onModEnter?.(); - return true; - } - return false; - }, - }, - onUpdate: ({ editor }) => { - onChange?.(getMarkdown(editor)); - }, - onBlur: ({ editor }) => { - onSave?.(getMarkdown(editor)); - }, - }); - - useEffect(() => { - if (!editor || editor.isFocused) return; - - const currentMarkdown = getMarkdown(editor); - if (currentMarkdown === content) return; - - editor.commands.setContent(content, { emitUpdate: false }); - }, [content, editor]); - - return ( - <div className={cn("w-full", className)}> - {editor && ( - <BubbleMenu - editor={editor} - options={{ - placement: "top", - offset: { mainAxis: 8 }, - }} - shouldShow={({ editor: e, from, to }) => { - if (from === to) return false; - if (e.isActive("codeBlock")) return false; - return true; - }} - > - <BubbleMenuToolbar editor={editor} /> - </BubbleMenu> - )} - <EditorContent editor={editor} className="w-full" /> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/index.ts deleted file mode 100644 index a7b6aa66fe0..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TaskMarkdownRenderer } from "./TaskMarkdownRenderer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/task-markdown.css b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/task-markdown.css deleted file mode 100644 index bdb50063efe..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer/task-markdown.css +++ /dev/null @@ -1,87 +0,0 @@ -/* Alternating bullet styles - works at any nesting depth */ -.task-markdown-list { - list-style-type: disc; -} - -.task-markdown-list ul { - list-style-type: circle; - margin: 0; - padding-left: 1.5rem; -} - -.task-markdown-list ul ul { - list-style-type: disc; -} - -.task-markdown-list ul ul ul { - list-style-type: circle; -} - -.task-markdown-list ul ul ul ul { - list-style-type: disc; -} - -.task-markdown-list ul ul ul ul ul { - list-style-type: circle; -} - -/* Tighter spacing for list items */ -.task-markdown-list li { - margin: 0; -} - -/* Task list checkbox styling */ -ul[data-type="taskList"] li[data-type="taskItem"] { - display: flex; - align-items: flex-start; - gap: 0.5rem; -} - -ul[data-type="taskList"] li[data-type="taskItem"] > label { - display: flex; - align-items: center; - margin-top: 0.125rem; -} - -ul[data-type="taskList"] - li[data-type="taskItem"] - > label - > input[type="checkbox"] { - appearance: none; - width: 1rem; - height: 1rem; - border: 1.5px solid hsl(var(--border)); - border-radius: 0.25rem; - background-color: transparent; - cursor: pointer; - transition: all 0.15s ease; -} - -ul[data-type="taskList"] - li[data-type="taskItem"] - > label - > input[type="checkbox"]:hover { - border-color: hsl(var(--primary)); -} - -ul[data-type="taskList"] - li[data-type="taskItem"] - > label - > input[type="checkbox"]:checked { - background-color: hsl(var(--primary)); - border-color: hsl(var(--primary)); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); - background-size: 0.75rem; - background-position: center; - background-repeat: no-repeat; -} - -ul[data-type="taskList"] - li[data-type="taskItem"] - > label - > input[type="checkbox"]:focus { - outline: none; - box-shadow: - 0 0 0 2px hsl(var(--background)), - 0 0 0 4px hsl(var(--ring)); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.test.tsx deleted file mode 100644 index 39c216caa7d..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test } from "bun:test"; -// biome-ignore lint/style/noRestrictedImports: test file needs fs/path for source verification -import { readFileSync } from "node:fs"; -// biome-ignore lint/style/noRestrictedImports: test file needs fs/path for source verification -import { join } from "node:path"; - -const TASK_DETAIL_DIR = __dirname; - -function readComponent(relativePath: string): string { - return readFileSync(join(TASK_DETAIL_DIR, relativePath), "utf-8"); -} - -describe("Task detail action menu", () => { - test("page renders the task action menu in the header", () => { - const source = readComponent("page.tsx"); - const headerSource = readComponent( - "components/TaskDetailHeader/TaskDetailHeader.tsx", - ); - - expect(source).toContain( - 'import { TaskDetailHeader } from "./components/TaskDetailHeader";', - ); - expect(source).toContain("<TaskDetailHeader"); - expect(source).toContain("onBack={handleBack}"); - expect(source).toContain("onDelete={handleDelete}"); - expect(headerSource).toContain('aria-label="Back to tasks"'); - }); - - test("task action menu mirrors destructive and copy actions", () => { - const source = readComponent( - "components/TaskActionMenu/TaskActionMenu.tsx", - ); - - expect(source).toContain("await collections.tasks.delete(task.id)"); - expect(source).toContain( - 'console.error("[TaskActionMenu] Failed to delete task:", error)', - ); - expect(source).toContain("copyToClipboard(task.slug)"); - expect(source).toContain("copyToClipboard(task.title)"); - expect(source).not.toContain("<span>Status</span>"); - expect(source).not.toContain("<span>Assignee</span>"); - expect(source).not.toContain("<span>Priority</span>"); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx index aba93a4f504..c28b9c33fd4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/page.tsx @@ -1,4 +1,8 @@ -import type { SelectUser } from "@superset/db/schema"; +import type { + SelectTask, + SelectTaskStatus, + SelectUser, +} from "@superset/db/schema"; import { ScrollArea } from "@superset/ui/scroll-area"; import { Separator } from "@superset/ui/separator"; import { eq, or } from "@tanstack/db"; @@ -6,16 +10,15 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMemo } from "react"; +import { MarkdownEditor } from "renderer/components/MarkdownEditor"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import type { TaskWithStatus } from "../components/TasksView/hooks/useTasksTable"; import { Route as TasksLayoutRoute } from "../layout"; import { ActivitySection } from "./components/ActivitySection"; -import { CommentInput } from "./components/CommentInput"; import { EditableTitle } from "./components/EditableTitle"; import { PropertiesSidebar } from "./components/PropertiesSidebar"; import { TaskDetailHeader } from "./components/TaskDetailHeader"; -import { TaskMarkdownRenderer } from "./components/TaskMarkdownRenderer"; import { useEscapeToNavigate } from "./hooks/useEscapeToNavigate"; export const Route = createFileRoute( @@ -24,11 +27,18 @@ export const Route = createFileRoute( component: TaskDetailPage, }); +type TaskDetailRecord = SelectTask & { + status: SelectTaskStatus; + assignee: SelectUser | null; + creator: SelectUser | null; +}; + function TaskDetailPage() { const { taskId } = Route.useParams(); const { tab, assignee, search } = TasksLayoutRoute.useSearch(); const navigate = useNavigate(); const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const isUuidTaskId = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( taskId, @@ -54,16 +64,20 @@ function TaskDetailPage() { .leftJoin({ assignee: collections.users }, ({ tasks, assignee }) => eq(tasks.assigneeId, assignee.id), ) - .select(({ tasks, status, assignee }) => ({ + .leftJoin({ creator: collections.users }, ({ tasks, creator }) => + eq(tasks.creatorId, creator.id), + ) + .select(({ tasks, status, assignee, creator }) => ({ ...tasks, status, assignee: assignee ?? null, + creator: creator ?? null, })) .where(({ tasks }) => or(eq(tasks.id, taskId), eq(tasks.slug, taskId))), [collections, taskId], ); - const task: TaskWithStatus | null = useMemo(() => { + const task: TaskDetailRecord | null = useMemo(() => { if (!taskData || taskData.length === 0) return null; const task = taskData[0]; return { @@ -72,6 +86,10 @@ function TaskDetailPage() { typeof task.assignee?.id === "string" ? (task.assignee as SelectUser) : null, + creator: + typeof task.creator?.id === "string" + ? (task.creator as SelectUser) + : null, }; }, [taskData]); const taskFallbackQuery = useQuery({ @@ -92,21 +110,18 @@ function TaskDetailPage() { const handleSaveTitle = (title: string) => { if (!task) return; - collections.tasks.update(task.id, (draft) => { - draft.title = title; - }); + taskActions.updateTitle(task.id, title); }; const handleSaveDescription = (markdown: string) => { if (!task) return; - collections.tasks.update(task.id, (draft) => { - draft.description = markdown; - }); + taskActions.updateDescription(task.id, markdown); }; const handleDelete = () => { navigate({ to: "/tasks", search: backSearch }); }; + const creatorName = task?.creator?.name?.trim() ? task.creator.name : null; if (!task) { if (isTaskLoading || isTaskSyncing) { @@ -139,24 +154,24 @@ function TaskDetailPage() { <div className="px-6 py-6 max-w-4xl"> <EditableTitle value={task.title} onSave={handleSaveTitle} /> - <TaskMarkdownRenderer + <MarkdownEditor content={task.description ?? ""} onSave={handleSaveDescription} /> - <Separator className="my-8" /> + {creatorName ? ( + <> + <Separator className="my-8" /> - <h2 className="text-lg font-semibold mb-4">Activity</h2> - - <ActivitySection - createdAt={new Date(task.createdAt)} - creatorName={task.assignee?.name ?? "Someone"} - creatorAvatarUrl={task.assignee?.image} - /> + <h2 className="text-lg font-semibold mb-4">Activity</h2> - <div className="mt-6"> - <CommentInput /> - </div> + <ActivitySection + createdAt={new Date(task.createdAt)} + creatorName={creatorName} + creatorAvatarUrl={task.creator?.image} + /> + </> + ) : null} </div> </ScrollArea> </div> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/utils/deriveBranchName.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/utils/deriveBranchName.ts index 2c92d31b807..845b66a2c4f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/utils/deriveBranchName.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/$taskId/utils/deriveBranchName.ts @@ -1,4 +1,4 @@ -import { sanitizeSegment } from "shared/utils/branch"; +import { sanitizeSegment } from "@superset/shared/workspace-launch"; export function deriveBranchName({ slug, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx index 5062a4599fd..9611eef13ef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/TasksView.tsx @@ -1,4 +1,3 @@ -import { Spinner } from "@superset/ui/spinner"; import { useLiveQuery } from "@tanstack/react-db"; import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -78,7 +77,7 @@ export function TasksView({ storeSetSearch(searchQuery); }, [searchQuery, storeSetSearch]); - const { data: integrations, isLoading: isCheckingLinear } = useLiveQuery( + const { data: integrations } = useLiveQuery( (q) => q .from({ integrationConnections: collections.integrationConnections }) @@ -134,7 +133,7 @@ export function TasksView({ }); }; - const showLinearCTA = !isCheckingLinear && !isLinearConnected; + const showLinearCTA = integrations !== undefined && !isLinearConnected; return ( <div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden"> @@ -153,11 +152,7 @@ export function TasksView({ /> )} - {isCheckingLinear ? ( - <div className="flex-1 flex items-center justify-center"> - <Spinner className="size-5" /> - </div> - ) : showLinearCTA ? ( + {showLinearCTA ? ( <LinearCTA /> ) : viewMode === "board" ? ( <BoardContent diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/BoardContent/BoardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/BoardContent/BoardContent.tsx index 1bd982c332e..91d8097e329 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/BoardContent/BoardContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/BoardContent/BoardContent.tsx @@ -1,4 +1,3 @@ -import { Spinner } from "@superset/ui/spinner"; import { HiCheckCircle } from "react-icons/hi2"; import type { TaskWithStatus } from "../../hooks/useTasksData"; import { useTasksData } from "../../hooks/useTasksData"; @@ -18,20 +17,12 @@ export function BoardContent({ assigneeFilter, onTaskClick, }: BoardContentProps) { - const { data, allStatuses, isLoading } = useTasksData({ + const { data, allStatuses } = useTasksData({ filterTab, searchQuery, assigneeFilter, }); - if (isLoading) { - return ( - <div className="flex-1 flex items-center justify-center"> - <Spinner className="size-5" /> - </div> - ); - } - if (data.length === 0) { return ( <div className="flex-1 flex items-center justify-center"> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TableContent/TableContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TableContent/TableContent.tsx index f2425da20b1..b49262bc15e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TableContent/TableContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TableContent/TableContent.tsx @@ -1,4 +1,3 @@ -import { Spinner } from "@superset/ui/spinner"; import { useCallback, useEffect, useMemo } from "react"; import { HiCheckCircle } from "react-icons/hi2"; import type { TaskWithStatus } from "../../hooks/useTasksData"; @@ -25,7 +24,7 @@ export function TableContent({ onTaskClick, onSelectionChange, }: TableContentProps) { - const { table, isLoading, slugColumnWidth, rowSelection, setRowSelection } = + const { table, slugColumnWidth, rowSelection, setRowSelection } = useTasksTable({ filterTab, searchQuery, @@ -44,14 +43,6 @@ export function TableContent({ onSelectionChange?.(selectedTasks, clearSelection); }, [selectedTasks, clearSelection, onSelectionChange]); - if (isLoading) { - return ( - <div className="flex-1 flex items-center justify-center"> - <Spinner className="size-5" /> - </div> - ); - } - if (table.getRowModel().rows.length === 0) { return ( <div className="flex-1 flex items-center justify-center"> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksBoardView/TasksBoardView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksBoardView/TasksBoardView.tsx index 4965ac63ba3..ec08f8c9dbc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksBoardView/TasksBoardView.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksBoardView/TasksBoardView.tsx @@ -12,7 +12,7 @@ import { import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; import type { SelectTaskStatus } from "@superset/db/schema"; import { useCallback, useMemo, useState } from "react"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import type { TaskWithStatus } from "../../hooks/useTasksData"; import { compareStatusesForDropdown } from "../../utils/sorting"; import { KanbanCard } from "./components/KanbanCard"; @@ -29,7 +29,7 @@ export function TasksBoardView({ allStatuses, onTaskClick, }: TasksBoardViewProps) { - const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [activeTask, setActiveTask] = useState<TaskWithStatus | null>(null); const sensors = useSensors( @@ -95,11 +95,9 @@ export function TasksBoardView({ const task = data.find((t) => t.id === taskId); if (!task || task.statusId === targetStatusId) return; - collections.tasks.update(taskId, (draft) => { - draft.statusId = targetStatusId; - }); + taskActions.updateStatus(taskId, targetStatusId); }, - [data, collections], + [data, taskActions], ); const handleDragCancel = useCallback(() => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/TasksTableView.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/TasksTableView.test.ts index 0c5fd6b7ff5..209c0cf7614 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/TasksTableView.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/TasksTableView.test.ts @@ -11,12 +11,13 @@ function readComponent(relativePath: string): string { } describe("Tasks table delete wiring", () => { - test("TaskContextMenu deletes tasks through the collections API", () => { + test("TaskContextMenu deletes tasks through optimistic task actions", () => { const source = readComponent( "components/TaskContextMenu/TaskContextMenu.tsx", ); - expect(source).toContain("collections.tasks.delete(task.id)"); + expect(source).toContain("useOptimisticCollectionActions"); + expect(source).toContain("taskActions.deleteTask(task.id)"); expect(source).toContain("onSelect={handleDelete}"); }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/components/TaskContextMenu/TaskContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/components/TaskContextMenu/TaskContextMenu.tsx index 651f9d79135..2e86c76cbee 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/components/TaskContextMenu/TaskContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTableView/components/TaskContextMenu/TaskContextMenu.tsx @@ -1,3 +1,4 @@ +import type { SelectTaskStatus } from "@superset/db/schema"; import { ContextMenu, ContextMenuContent, @@ -16,6 +17,7 @@ import { HiOutlineUserCircle, } from "react-icons/hi2"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { TaskWithStatus } from "../../../../hooks/useTasksTable"; import { compareStatusesForDropdown } from "../../../../utils/sorting"; @@ -37,6 +39,7 @@ export function TaskContextMenu({ onDelete, }: TaskContextMenuProps) { const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const { data: allStatuses } = useLiveQuery( (q) => q.from({ taskStatuses: collections.taskStatuses }), @@ -55,37 +58,16 @@ export function TaskContextMenu({ const users = useMemo(() => allUsers || [], [allUsers]); - const handleStatusChange = (status: (typeof allStatuses)[0]) => { - try { - collections.tasks.update(task.id, (draft) => { - draft.statusId = status.id; - }); - } catch (error) { - console.error("[TaskContextMenu] Failed to update status:", error); - } + const handleStatusChange = (status: SelectTaskStatus) => { + taskActions.updateStatus(task.id, status.id); }; const handleAssigneeChange = (userId: string | null) => { - try { - collections.tasks.update(task.id, (draft) => { - draft.assigneeId = userId; - draft.assigneeExternalId = null; - draft.assigneeDisplayName = null; - draft.assigneeAvatarUrl = null; - }); - } catch (error) { - console.error("[TaskContextMenu] Failed to update assignee:", error); - } + taskActions.updateAssignee(task.id, userId); }; const handlePriorityChange = (priority: typeof task.priority) => { - try { - collections.tasks.update(task.id, (draft) => { - draft.priority = priority; - }); - } catch (error) { - console.error("[TaskContextMenu] Failed to update priority:", error); - } + taskActions.updatePriority(task.id, priority); }; const { copyToClipboard } = useCopyToClipboard(); @@ -99,11 +81,9 @@ export function TaskContextMenu({ }; const handleDelete = () => { - try { - collections.tasks.delete(task.id); + const transaction = taskActions.deleteTask(task.id); + if (transaction) { onDelete?.(); - } catch (error) { - console.error("[TaskContextMenu] Failed to delete task:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx index 4b6585151c3..656b9dcc299 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/TasksTopBar.tsx @@ -10,7 +10,7 @@ import { HiOutlineViewColumns, HiXMark, } from "react-icons/hi2"; -import { useAppHotkey } from "renderer/stores/hotkeys"; +import { useHotkey } from "renderer/hotkeys"; import type { ViewMode } from "../../../../stores/tasks-filter-state"; import type { TaskWithStatus } from "../../hooks/useTasksData"; import { ActiveIcon } from "../shared/icons/ActiveIcon"; @@ -69,7 +69,7 @@ export function TasksTopBar({ const searchInputRef = useRef<HTMLInputElement>(null); const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); - useAppHotkey( + useHotkey( "FOCUS_TASK_SEARCH", () => { searchInputRef.current?.focus(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx index e7b38af2f36..a3e8e0435c9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/CreateTaskDialog/CreateTaskDialog.tsx @@ -16,10 +16,10 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { HiChevronRight, HiOutlinePaperClip, HiXMark } from "react-icons/hi2"; +import { MarkdownEditor } from "renderer/components/MarkdownEditor"; +import { PLATFORM } from "renderer/hotkeys"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; -import { TaskMarkdownRenderer } from "renderer/routes/_authenticated/_dashboard/tasks/$taskId/components/TaskMarkdownRenderer"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useHotkeysStore } from "renderer/stores/hotkeys/store"; import { compareStatusesForDropdown } from "../../../../utils/sorting"; import type { TabValue } from "../../TasksTopBar"; import { CreateTaskAssigneePicker } from "./components/CreateTaskAssigneePicker"; @@ -44,8 +44,7 @@ export function CreateTaskDialog({ const collections = useCollections(); const { data: session } = authClient.useSession(); const navigate = useNavigate(); - const platform = useHotkeysStore((state) => state.platform); - const modKey = platform === "darwin" ? "⌘" : "Ctrl"; + const modKey = PLATFORM === "mac" ? "⌘" : "Ctrl"; const titleInputRef = useRef<HTMLInputElement>(null); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); @@ -126,7 +125,7 @@ export function CreateTaskDialog({ setIsCreating(true); try { - const result = await apiTrpcClient.task.createFromUi.mutate({ + const result = await apiTrpcClient.task.create.mutate({ title: title.trim(), description: description.trim() || null, statusId, @@ -213,7 +212,7 @@ export function CreateTaskDialog({ /> <div className="mt-5 flex-1"> - <TaskMarkdownRenderer + <MarkdownEditor content={description} onChange={setDescription} placeholder="Add description..." diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopover/RunInWorkspacePopover.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopover/RunInWorkspacePopover.tsx index e052219c782..201d2f9ddc5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopover/RunInWorkspacePopover.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/TasksTopBar/components/RunInWorkspacePopover/RunInWorkspacePopover.tsx @@ -1,4 +1,11 @@ import type { AgentLaunchRequest } from "@superset/shared/agent-launch"; +import { buildTaskAgentLaunchRequest } from "@superset/shared/agent-launch-request"; +import { + type AgentDefinitionId, + getEnabledAgentConfigs, + getFallbackAgentId, + indexResolvedAgentConfigs, +} from "@superset/shared/agent-settings"; import { Button } from "@superset/ui/button"; import { DropdownMenu, @@ -21,13 +28,6 @@ import { launchAgentSession } from "renderer/lib/agent-session-orchestrator"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { ProjectThumbnail } from "renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail"; -import { buildTaskAgentLaunchRequest } from "shared/utils/agent-launch-request"; -import { - type AgentDefinitionId, - getEnabledAgentConfigs, - getFallbackAgentId, - indexResolvedAgentConfigs, -} from "shared/utils/agent-settings"; import { deriveBranchName } from "../../../../../../$taskId/utils/deriveBranchName"; import type { TaskWithStatus } from "../../../../hooks/useTasksTable"; @@ -174,7 +174,7 @@ export function RunInWorkspacePopover({ const result = await createWorkspace.mutateAsyncWithPendingSetup( { projectId: effectiveProjectId, - name: task.slug, + name: task.title, branchName, }, { agentLaunchRequest: launchRequestTemplate ?? undefined }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx index 8c632d1fe64..e70ae23cdf9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksData/useTasksData.tsx @@ -29,11 +29,10 @@ export function useTasksData({ }: UseTasksDataParams): { data: TaskWithStatus[]; allStatuses: SelectTaskStatus[]; - isLoading: boolean; } { const collections = useCollections(); - const { data: allData, isLoading } = useLiveQuery( + const { data: allData } = useLiveQuery( (q) => q .from({ tasks: collections.tasks }) @@ -52,7 +51,7 @@ export function useTasksData({ [collections], ); - const { data: statusData, isLoading: isStatusesLoading } = useLiveQuery( + const { data: statusData } = useLiveQuery( (q) => q .from({ taskStatuses: collections.taskStatuses }) @@ -119,6 +118,5 @@ export function useTasksData({ return { data: filteredData, allStatuses, - isLoading: isLoading || isStatusesLoading, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx index 368d922e484..a2926a4d961 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/AssigneeCell/AssigneeCell.tsx @@ -9,6 +9,7 @@ import { useLiveQuery } from "@tanstack/react-db"; import type { CellContext } from "@tanstack/react-table"; import { useMemo, useState } from "react"; import { HiOutlineUserCircle } from "react-icons/hi2"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { TaskWithStatus } from "../../useTasksTable"; @@ -18,6 +19,7 @@ interface AssigneeCellProps { export function AssigneeCell({ info }: AssigneeCellProps) { const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const task = info.row.original; @@ -36,14 +38,10 @@ export function AssigneeCell({ info }: AssigneeCellProps) { return; } - setOpen(false); - - collections.tasks.update(task.id, (draft) => { - draft.assigneeId = userId; - draft.assigneeExternalId = null; - draft.assigneeDisplayName = null; - draft.assigneeAvatarUrl = null; - }); + const transaction = taskActions.updateAssignee(task.id, userId); + if (transaction) { + setOpen(false); + } }; return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/PriorityCell/PriorityCell.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/PriorityCell/PriorityCell.tsx index b605da46445..d2f0703ad41 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/PriorityCell/PriorityCell.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/PriorityCell/PriorityCell.tsx @@ -7,7 +7,7 @@ import { } from "@superset/ui/dropdown-menu"; import type { CellContext } from "@tanstack/react-table"; import { useState } from "react"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { PriorityIcon } from "../../../../components/shared/PriorityIcon"; import { ALL_PRIORITIES } from "../../../../utils/sorting"; import type { TaskWithStatus } from "../../useTasksTable"; @@ -25,7 +25,7 @@ const PRIORITY_LABELS: Record<TaskPriority, string> = { }; export function PriorityCell({ info }: PriorityCellProps) { - const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const task = info.row.original; @@ -38,13 +38,9 @@ export function PriorityCell({ info }: PriorityCellProps) { return; } - try { - collections.tasks.update(task.id, (draft) => { - draft.priority = newPriority; - }); + const transaction = taskActions.updatePriority(task.id, newPriority); + if (transaction) { setOpen(false); - } catch (error) { - console.error("[PriorityCell] Failed to update priority:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/StatusCell/StatusCell.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/StatusCell/StatusCell.tsx index 7d9981b7a0a..44b886868cc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/StatusCell/StatusCell.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/components/StatusCell/StatusCell.tsx @@ -7,6 +7,7 @@ import { } from "@superset/ui/dropdown-menu"; import { useLiveQuery } from "@tanstack/react-db"; import { useMemo, useState } from "react"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { StatusIcon, @@ -22,6 +23,7 @@ interface StatusCellProps { export function StatusCell({ taskWithStatus }: StatusCellProps) { const collections = useCollections(); + const { tasks: taskActions } = useOptimisticCollectionActions(); const [open, setOpen] = useState(false); const { data: allStatuses } = useLiveQuery( @@ -42,13 +44,12 @@ export function StatusCell({ taskWithStatus }: StatusCellProps) { return; } - try { - collections.tasks.update(taskWithStatus.id, (draft) => { - draft.statusId = newStatus.id; - }); + const transaction = taskActions.updateStatus( + taskWithStatus.id, + newStatus.id, + ); + if (transaction) { setOpen(false); - } catch (error) { - console.error("[StatusCell] Failed to update status:", error); } }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx index 8831e44b173..2cb776f481a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useTasksTable/useTasksTable.tsx @@ -71,7 +71,6 @@ export function useTasksTable({ assigneeFilter, }: UseTasksTableParams): { table: Table<TaskWithStatus>; - isLoading: boolean; slugColumnWidth: string; rowSelection: RowSelectionState; setRowSelection: ( @@ -87,7 +86,7 @@ export function useTasksTable({ const rowSelection = useRowSelectionStore((s) => s.rowSelection); const setRowSelection = useRowSelectionStore((s) => s.setRowSelection); - const { data: allData, isLoading } = useLiveQuery( + const { data: allData } = useLiveQuery( (q) => q .from({ tasks: collections.tasks }) @@ -348,5 +347,5 @@ export function useTasksTable({ autoResetExpanded: false, }); - return { table, isLoading, slugColumnWidth, rowSelection, setRowSelection }; + return { table, slugColumnWidth, rowSelection, setRowSelection }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts index bf3bbb68267..a807561923b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -8,6 +8,15 @@ export interface WorkspaceSearchParams { paneId?: string; } +export interface V2WorkspaceSearchParams { + terminalId?: string; + chatSessionId?: string; + focusRequestId?: string; + openUrl?: string; + openUrlTarget?: "current-tab" | "new-tab"; + openUrlRequestId?: string; +} + /** * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. * This ensures the workspace will be restored when the app is reopened. @@ -39,9 +48,15 @@ export function navigateToWorkspace( export function navigateToV2Workspace( workspaceId: string, navigate: UseNavigateResult<string>, + options?: Omit<NavigateOptions, "to" | "params" | "search"> & { + search?: V2WorkspaceSearchParams; + }, ): Promise<void> { + const { search, ...rest } = options ?? {}; return navigate({ to: "/v2-workspace/$workspaceId", params: { workspaceId }, + search: search ?? {}, + ...rest, }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/AddTabMenu/AddTabMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/AddTabMenu/AddTabMenu.tsx new file mode 100644 index 00000000000..949822d8472 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/AddTabMenu/AddTabMenu.tsx @@ -0,0 +1,36 @@ +import { DropdownMenuItem } from "@superset/ui/dropdown-menu"; +import { BsTerminalPlus } from "react-icons/bs"; +import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; +import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; + +interface AddTabMenuProps { + onAddTerminal: () => void; + onAddChat: () => void; + onAddBrowser: () => void; +} + +export function AddTabMenu({ + onAddTerminal, + onAddChat, + onAddBrowser, +}: AddTabMenuProps) { + return ( + <> + <DropdownMenuItem className="gap-2" onClick={onAddTerminal}> + <BsTerminalPlus className="size-4" /> + <span>Terminal</span> + <HotkeyMenuShortcut hotkeyId="NEW_GROUP" /> + </DropdownMenuItem> + <DropdownMenuItem className="gap-2" onClick={onAddChat}> + <TbMessageCirclePlus className="size-4" /> + <span>Chat</span> + <HotkeyMenuShortcut hotkeyId="NEW_CHAT" /> + </DropdownMenuItem> + <DropdownMenuItem className="gap-2" onClick={onAddBrowser}> + <TbWorld className="size-4" /> + <span>Browser</span> + <HotkeyMenuShortcut hotkeyId="NEW_BROWSER" /> + </DropdownMenuItem> + </> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/AddTabMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/AddTabMenu/index.ts new file mode 100644 index 00000000000..212f4c1e26b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/AddTabMenu/index.ts @@ -0,0 +1 @@ +export { AddTabMenu } from "./AddTabMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx deleted file mode 100644 index ba1d4d82cca..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import { - createPaneRoot, - type PaneRegistry, - PaneWorkspace, -} from "@superset/pane-layout"; -import { - DropdownMenuCheckboxItem, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@superset/ui/dropdown-menu"; -import { useNavigate } from "@tanstack/react-router"; -import { FileCode2, Globe, MessageSquare, TerminalSquare } from "lucide-react"; -import { useCallback, useMemo } from "react"; -import { BsTerminalPlus } from "react-icons/bs"; -import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; -import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { WorkspaceChat } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat"; -import { WorkspaceFilePreview } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/WorkspaceFilePreview"; -import { WorkspaceTerminal } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal"; -import { - CommandPalette, - useCommandPalette, -} from "renderer/screens/main/components/CommandPalette"; -import { PresetsBar } from "renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar"; -import { useAppHotkey } from "renderer/stores/hotkeys"; -import { DEFAULT_SHOW_PRESETS_BAR } from "shared/constants"; -import { PaneViewerEmptyState } from "./components/PaneViewerEmptyState"; -import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; -import { - type BrowserPaneData, - type ChatPaneData, - createBrowserPane, - createChatPane, - createFilePane, - createTerminalPane, - type DevtoolsPaneData, - type FilePaneData, - type PaneViewerData, - type TerminalPaneData, -} from "./pane-viewer.model"; - -interface PaneViewerProps { - projectId: string; - workspaceId: string; - workspaceName: string; -} - -function getFileTitle(filePath: string): string { - return filePath.split("/").pop() ?? filePath; -} - -export function PaneViewer({ - projectId, - workspaceId, - workspaceName, -}: PaneViewerProps) { - const navigate = useNavigate(); - const { store } = useV2WorkspacePaneLayout({ - projectId, - workspaceId, - }); - const utils = electronTrpc.useUtils(); - const { data: showPresetsBar } = - electronTrpc.settings.getShowPresetsBar.useQuery(); - const setShowPresetsBar = electronTrpc.settings.setShowPresetsBar.useMutation( - { - onMutate: async ({ enabled }) => { - await utils.settings.getShowPresetsBar.cancel(); - const previous = utils.settings.getShowPresetsBar.getData(); - utils.settings.getShowPresetsBar.setData(undefined, enabled); - return { previous }; - }, - onError: (_error, _variables, context) => { - if (context?.previous !== undefined) { - utils.settings.getShowPresetsBar.setData(undefined, context.previous); - } - }, - onSettled: () => { - utils.settings.getShowPresetsBar.invalidate(); - }, - }, - ); - - const openFilePane = useCallback( - (filePath: string) => { - const pane = createFilePane({ - title: getFileTitle(filePath), - filePath, - mode: "editor", - hasChanges: false, - }); - const activePane = store.getState().getActivePane(); - - if (activePane) { - store.getState().addPaneToGroup({ - rootId: activePane.rootId, - groupId: activePane.groupId, - pane, - replaceUnpinned: true, - select: true, - }); - return; - } - - store.getState().addRoot( - createPaneRoot({ - titleOverride: "Files", - panes: [pane], - }), - ); - }, - [store], - ); - - const addTerminalRoot = useCallback(() => { - store.getState().addRoot( - createPaneRoot({ - titleOverride: "Terminal", - panes: [ - createTerminalPane({ - title: "Terminal", - sessionKey: `${workspaceId}:${crypto.randomUUID()}`, - cwd: `/workspace/${workspaceName}`, - launchMode: "workspace-shell", - }), - ], - }), - ); - }, [store, workspaceId, workspaceName]); - - const addChatRoot = useCallback(() => { - store.getState().addRoot( - createPaneRoot({ - titleOverride: "Chat", - panes: [ - createChatPane({ - title: "Chat", - sessionId: null, - }), - ], - }), - ); - }, [store]); - - const addBrowserRoot = useCallback(() => { - store.getState().addRoot( - createPaneRoot({ - titleOverride: "Browser", - panes: [ - createBrowserPane({ - title: "Browser", - url: "http://localhost:3000", - mode: "preview", - }), - ], - }), - ); - }, [store]); - - const commandPalette = useCommandPalette({ - workspaceId, - navigate, - onSelectFile: ({ close, filePath, targetWorkspaceId }) => { - close(); - - if (targetWorkspaceId !== workspaceId) { - void navigate({ - to: "/v2-workspace/$workspaceId", - params: { workspaceId: targetWorkspaceId }, - }); - return; - } - - openFilePane(filePath); - }, - }); - - const handleQuickOpen = useCallback(() => { - commandPalette.toggle(); - }, [commandPalette]); - const setPaneData = store.getState().setPaneData; - - const paneRegistry = useMemo<PaneRegistry<PaneViewerData>>( - () => ({ - file: { - getIcon: () => <FileCode2 className="size-4" />, - renderPane: ({ pane }) => { - const data = pane.data as FilePaneData; - - return ( - <WorkspaceFilePreview - selectedFilePath={data.filePath} - workspaceId={workspaceId} - /> - ); - }, - }, - terminal: { - getIcon: () => <TerminalSquare className="size-4" />, - renderPane: ({ pane }) => { - const _data = pane.data as TerminalPaneData; - return <WorkspaceTerminal workspaceId={workspaceId} />; - }, - }, - browser: { - getIcon: () => <Globe className="size-4" />, - renderPane: ({ pane }) => { - const data = pane.data as BrowserPaneData; - - return ( - <iframe - className="h-full w-full border-0 bg-background" - src={data.url} - title={pane.titleOverride ?? "Browser"} - /> - ); - }, - }, - chat: { - getIcon: () => <MessageSquare className="size-4" />, - renderPane: ({ pane }) => { - const data = pane.data as ChatPaneData; - return ( - <WorkspaceChat - onSessionIdChange={(sessionId) => - setPaneData({ - paneId: pane.id, - data: { sessionId }, - }) - } - sessionId={data.sessionId} - workspaceId={workspaceId} - /> - ); - }, - }, - devtools: { - renderPane: ({ pane }) => { - const data = pane.data as DevtoolsPaneData; - - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - Inspecting {data.targetTitle} - </div> - ); - }, - }, - }), - [setPaneData, workspaceId], - ); - - useAppHotkey("NEW_GROUP", addTerminalRoot, undefined, [addTerminalRoot]); - useAppHotkey("NEW_CHAT", addChatRoot, undefined, [addChatRoot]); - useAppHotkey("NEW_BROWSER", addBrowserRoot, undefined, [addBrowserRoot]); - useAppHotkey("QUICK_OPEN", handleQuickOpen, undefined, [handleQuickOpen]); - - return ( - <> - <div - className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" - data-workspace-id={workspaceId} - > - {(showPresetsBar ?? DEFAULT_SHOW_PRESETS_BAR) ? <PresetsBar /> : null} - <PaneWorkspace - className="rounded-none border-0" - onAddRoot={addTerminalRoot} - registry={paneRegistry} - renderAddRootMenu={() => ( - <> - <DropdownMenuItem className="gap-2" onClick={addTerminalRoot}> - <BsTerminalPlus className="size-4" /> - <span>Terminal</span> - <HotkeyMenuShortcut hotkeyId="NEW_GROUP" /> - </DropdownMenuItem> - <DropdownMenuItem className="gap-2" onClick={addChatRoot}> - <TbMessageCirclePlus className="size-4" /> - <span>Chat</span> - <HotkeyMenuShortcut hotkeyId="NEW_CHAT" /> - </DropdownMenuItem> - <DropdownMenuItem className="gap-2" onClick={addBrowserRoot}> - <TbWorld className="size-4" /> - <span>Browser</span> - <HotkeyMenuShortcut hotkeyId="NEW_BROWSER" /> - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuCheckboxItem - checked={showPresetsBar ?? DEFAULT_SHOW_PRESETS_BAR} - onCheckedChange={(checked) => - setShowPresetsBar.mutate({ enabled: checked === true }) - } - onSelect={(event) => event.preventDefault()} - > - Show Preset Bar - </DropdownMenuCheckboxItem> - </> - )} - renderEmptyState={() => ( - <PaneViewerEmptyState - onOpenBrowser={addBrowserRoot} - onOpenChat={addChatRoot} - onOpenQuickOpen={handleQuickOpen} - onOpenTerminal={addTerminalRoot} - /> - )} - store={store} - /> - </div> - <CommandPalette - excludePattern={commandPalette.excludePattern} - filtersOpen={commandPalette.filtersOpen} - includePattern={commandPalette.includePattern} - isLoading={commandPalette.isFetching} - onExcludePatternChange={commandPalette.setExcludePattern} - onFiltersOpenChange={commandPalette.setFiltersOpen} - onIncludePatternChange={commandPalette.setIncludePattern} - onOpenChange={commandPalette.handleOpenChange} - onQueryChange={commandPalette.setQuery} - onScopeChange={commandPalette.setScope} - onSelectFile={commandPalette.selectFile} - open={commandPalette.open} - query={commandPalette.query} - scope={commandPalette.scope} - searchResults={commandPalette.searchResults} - workspaceName={workspaceName} - /> - </> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/components/PaneViewerEmptyState/PaneViewerEmptyState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/components/PaneViewerEmptyState/PaneViewerEmptyState.tsx deleted file mode 100644 index d3105511b67..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/components/PaneViewerEmptyState/PaneViewerEmptyState.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useMemo } from "react"; -import type { IconType } from "react-icons"; -import { BsTerminalPlus } from "react-icons/bs"; -import { LuSearch } from "react-icons/lu"; -import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; -import supersetEmptyStateWordmark from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/assets/superset-empty-state-wordmark.svg"; -import { EmptyTabActionButton } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/components/EmptyTabActionButton"; -import { useHotkeyDisplay } from "renderer/stores/hotkeys"; -import { useTheme } from "renderer/stores/theme"; - -interface PaneViewerEmptyStateProps { - onOpenBrowser: () => void; - onOpenChat: () => void; - onOpenQuickOpen: () => void; - onOpenTerminal: () => void; -} - -interface PaneViewerEmptyStateAction { - display: string[]; - icon: IconType; - id: string; - label: string; - onClick: () => void; -} - -export function PaneViewerEmptyState({ - onOpenBrowser, - onOpenChat, - onOpenQuickOpen, - onOpenTerminal, -}: PaneViewerEmptyStateProps) { - const activeTheme = useTheme(); - const newGroupDisplay = useHotkeyDisplay("NEW_GROUP"); - const newChatDisplay = useHotkeyDisplay("NEW_CHAT"); - const newBrowserDisplay = useHotkeyDisplay("NEW_BROWSER"); - const quickOpenDisplay = useHotkeyDisplay("QUICK_OPEN"); - - const actions = useMemo<Array<PaneViewerEmptyStateAction>>( - () => [ - { - id: "terminal", - label: "Open Terminal", - display: newGroupDisplay, - icon: BsTerminalPlus, - onClick: onOpenTerminal, - }, - { - id: "chat", - label: "Open Chat", - display: newChatDisplay, - icon: TbMessageCirclePlus, - onClick: onOpenChat, - }, - { - id: "browser", - label: "Open Browser", - display: newBrowserDisplay, - icon: TbWorld, - onClick: onOpenBrowser, - }, - { - id: "search-files", - label: "Search Files", - display: quickOpenDisplay, - icon: LuSearch, - onClick: onOpenQuickOpen, - }, - ], - [ - newBrowserDisplay, - newChatDisplay, - newGroupDisplay, - onOpenBrowser, - onOpenChat, - onOpenQuickOpen, - onOpenTerminal, - quickOpenDisplay, - ], - ); - - return ( - <div className="flex h-full flex-1 items-center justify-center px-6 py-10"> - <div className="w-full max-w-xl"> - <div className="mb-7 flex items-center justify-center py-3"> - <img - alt="Superset" - className={`h-8 w-auto select-none ${ - activeTheme?.type === "dark" - ? "opacity-85" - : "brightness-0 opacity-75" - }`} - draggable={false} - src={supersetEmptyStateWordmark} - /> - </div> - <div className="mx-auto grid w-full max-w-md gap-0.5"> - {actions.map((action) => ( - <EmptyTabActionButton - key={action.id} - display={action.display} - icon={action.icon} - label={action.label} - onClick={action.onClick} - /> - ))} - </div> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/components/PaneViewerEmptyState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/components/PaneViewerEmptyState/index.ts deleted file mode 100644 index c04838161da..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/components/PaneViewerEmptyState/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneViewerEmptyState } from "./PaneViewerEmptyState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts deleted file mode 100644 index 65d0cd2b365..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - createPaneWorkspaceState, - createPaneWorkspaceStore, - type PaneWorkspaceState, -} from "@superset/pane-layout"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import type { PaneViewerData } from "../../pane-viewer.model"; - -const EMPTY_PANE_LAYOUT = createPaneWorkspaceState<PaneViewerData>({ - roots: [], -}); - -function getPaneLayoutSnapshot( - state: PaneWorkspaceState<PaneViewerData>, -): string { - return JSON.stringify(state); -} - -interface UseV2WorkspacePaneLayoutParams { - projectId: string; - workspaceId: string; -} - -export function useV2WorkspacePaneLayout({ - projectId, - workspaceId, -}: UseV2WorkspacePaneLayoutParams) { - const collections = useCollections(); - const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); - const [store] = useState(() => - createPaneWorkspaceStore<PaneViewerData>({ - initialState: EMPTY_PANE_LAYOUT, - }), - ); - const lastSyncedSnapshotRef = useRef( - getPaneLayoutSnapshot(EMPTY_PANE_LAYOUT), - ); - - const { data: localWorkspaceRows = [] } = useLiveQuery( - (query) => - query - .from({ v2WorkspaceLocalState: collections.v2WorkspaceLocalState }) - .where(({ v2WorkspaceLocalState }) => - eq(v2WorkspaceLocalState.workspaceId, workspaceId), - ), - [collections, workspaceId], - ); - const localWorkspaceState = localWorkspaceRows[0] ?? null; - const persistedPaneLayout = useMemo( - () => - (localWorkspaceState?.paneLayout as - | PaneWorkspaceState<PaneViewerData> - | undefined) ?? EMPTY_PANE_LAYOUT, - [localWorkspaceState], - ); - - useEffect(() => { - ensureWorkspaceInSidebar(workspaceId, projectId); - }, [ensureWorkspaceInSidebar, projectId, workspaceId]); - - useEffect(() => { - const nextSnapshot = getPaneLayoutSnapshot(persistedPaneLayout); - if (nextSnapshot === lastSyncedSnapshotRef.current) { - return; - } - - lastSyncedSnapshotRef.current = nextSnapshot; - store.getState().replaceState(persistedPaneLayout); - }, [persistedPaneLayout, store]); - - useEffect(() => { - const unsubscribe = store.subscribe((nextStore) => { - const nextSnapshot = getPaneLayoutSnapshot(nextStore.state); - if (nextSnapshot === lastSyncedSnapshotRef.current) { - return; - } - - ensureWorkspaceInSidebar(workspaceId, projectId); - if (!collections.v2WorkspaceLocalState.get(workspaceId)) { - return; - } - - collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { - draft.paneLayout = nextStore.state; - }); - lastSyncedSnapshotRef.current = nextSnapshot; - }); - - return () => { - unsubscribe(); - }; - }, [collections, ensureWorkspaceInSidebar, projectId, store, workspaceId]); - - return { - localWorkspaceState, - store, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/index.ts deleted file mode 100644 index 0bf32100f61..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneViewer } from "./PaneViewer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/pane-viewer.model.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/pane-viewer.model.ts deleted file mode 100644 index b5584f900b2..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/pane-viewer.model.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { createPane, type PaneState } from "@superset/pane-layout"; - -export interface FilePaneData { - filePath: string; - mode: "editor" | "diff" | "preview"; - hasChanges: boolean; - language?: string; -} - -export interface TerminalPaneData { - sessionKey: string; - cwd: string; - launchMode: "workspace-shell" | "command" | "agent"; - command?: string; -} - -export interface ChatPaneData { - sessionId: string | null; -} - -export interface BrowserPaneData { - url: string; - mode: "docs" | "preview" | "generic"; -} - -export interface DevtoolsPaneData { - targetPaneId: string; - targetTitle: string; -} - -export type PaneViewerData = - | FilePaneData - | TerminalPaneData - | ChatPaneData - | BrowserPaneData - | DevtoolsPaneData; - -export function createFilePane({ - title, - filePath, - mode, - hasChanges, - language, - pinned, -}: { - title: string; - filePath: string; - mode: FilePaneData["mode"]; - hasChanges: boolean; - language?: string; - pinned?: boolean; -}): PaneState<PaneViewerData> { - return createPane({ - kind: "file", - titleOverride: title, - pinned, - data: { - filePath, - mode, - hasChanges, - language, - }, - }); -} - -export function createTerminalPane({ - title, - sessionKey, - cwd, - launchMode, - command, - pinned = true, -}: { - title: string; - sessionKey: string; - cwd: string; - launchMode: TerminalPaneData["launchMode"]; - command?: string; - pinned?: boolean; -}): PaneState<PaneViewerData> { - return createPane({ - kind: "terminal", - titleOverride: title, - pinned, - data: { - sessionKey, - cwd, - launchMode, - command, - }, - }); -} - -export function createBrowserPane({ - title, - url, - mode, - pinned = true, -}: { - title: string; - url: string; - mode: BrowserPaneData["mode"]; - pinned?: boolean; -}): PaneState<PaneViewerData> { - return createPane({ - kind: "browser", - titleOverride: title, - pinned, - data: { - url, - mode, - }, - }); -} - -export function createChatPane({ - title, - sessionId, - pinned = true, -}: { - title: string; - sessionId: string | null; - pinned?: boolean; -}): PaneState<PaneViewerData> { - return createPane({ - kind: "chat", - titleOverride: title, - pinned, - data: { - sessionId, - }, - }); -} - -export function createDevtoolsPane({ - title, - targetPaneId, - targetTitle, - pinned = true, -}: { - title: string; - targetPaneId: string; - targetTitle: string; - pinned?: boolean; -}): PaneState<PaneViewerData> { - return createPane({ - kind: "devtools", - titleOverride: title, - pinned, - data: { - targetPaneId, - targetTitle, - }, - }); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator/StatusIndicator.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator/StatusIndicator.tsx new file mode 100644 index 00000000000..70666c3c549 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator/StatusIndicator.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { + VscCopy, + VscDiffAdded, + VscDiffModified, + VscDiffRemoved, + VscDiffRenamed, +} from "react-icons/vsc"; + +export type FileStatus = + | "added" + | "copied" + | "changed" + | "deleted" + | "modified" + | "renamed" + | "untracked"; + +const STATUS_COLORS: Record<FileStatus, string> = { + added: "text-green-700 dark:text-green-400", + copied: "text-purple-700 dark:text-purple-400", + changed: "text-yellow-600 dark:text-yellow-400", + deleted: "text-red-700 dark:text-red-500", + modified: "text-yellow-600 dark:text-yellow-400", + renamed: "text-blue-600 dark:text-blue-400", + untracked: "text-green-700 dark:text-green-400", +}; + +function getStatusIcon(status: FileStatus, iconClass: string): ReactNode { + switch (status) { + case "added": + case "untracked": + return <VscDiffAdded className={iconClass} />; + case "modified": + case "changed": + return <VscDiffModified className={iconClass} />; + case "deleted": + return <VscDiffRemoved className={iconClass} />; + case "renamed": + return <VscDiffRenamed className={iconClass} />; + case "copied": + return <VscCopy className={iconClass} />; + default: + return null; + } +} + +export function StatusIndicator({ + status, + className, + iconClassName = "w-3 h-3", +}: { + status: string; + className?: string; + iconClassName?: string; +}) { + return ( + <span + className={`flex shrink-0 items-center ${STATUS_COLORS[status as FileStatus] ?? ""} ${className ?? ""}`} + > + {getStatusIcon(status as FileStatus, iconClassName)} + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator/index.ts new file mode 100644 index 00000000000..072b5d0a83b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator/index.ts @@ -0,0 +1 @@ +export { type FileStatus, StatusIndicator } from "./StatusIndicator"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx new file mode 100644 index 00000000000..55aa9879e9b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/V2NotificationStatusIndicator.tsx @@ -0,0 +1,21 @@ +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import { + useV2SourcesNotificationStatus, + type V2NotificationSourceInput, +} from "renderer/stores/v2-notifications"; + +interface V2NotificationStatusIndicatorProps { + workspaceId: string; + sources: Iterable<V2NotificationSourceInput>; + className?: string; +} + +export function V2NotificationStatusIndicator({ + workspaceId, + sources, + className, +}: V2NotificationStatusIndicatorProps) { + const status = useV2SourcesNotificationStatus(workspaceId, sources); + if (!status) return null; + return <StatusIndicator status={status} className={className} />; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/index.ts new file mode 100644 index 00000000000..8f354b0d419 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2NotificationStatusIndicator/index.ts @@ -0,0 +1 @@ +export { V2NotificationStatusIndicator } from "./V2NotificationStatusIndicator"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx new file mode 100644 index 00000000000..4f6a763070c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/V2PresetsBar.tsx @@ -0,0 +1,275 @@ +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useNavigate } from "@tanstack/react-router"; +import { Eye, EyeOff, Settings } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { HiMiniCommandLine } from "react-icons/hi2"; +import { + getPresetIcon, + useIsDarkTheme, +} from "renderer/assets/app-icons/preset-icons"; +import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; +import type { HotkeyId } from "renderer/hotkeys"; +import { useMigrateV1PresetsToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { V2PresetBarItem } from "./components/V2PresetBarItem"; + +interface V2PresetsBarProps { + matchedPresets: V2TerminalPresetRow[]; + executePreset: (preset: V2TerminalPresetRow) => void; +} + +// Co-located to keep v2 self-contained. Mirrors the v1 array in +// renderer/hotkeys/registry.ts; order matches the registry OPEN_PRESET_{n} +// definitions so PRESET_HOTKEY_IDS[i] targets the i-th visible preset. +const PRESET_HOTKEY_IDS: HotkeyId[] = [ + "OPEN_PRESET_1", + "OPEN_PRESET_2", + "OPEN_PRESET_3", + "OPEN_PRESET_4", + "OPEN_PRESET_5", + "OPEN_PRESET_6", + "OPEN_PRESET_7", + "OPEN_PRESET_8", + "OPEN_PRESET_9", +]; + +function isPresetVisibleInBar(pinnedToBar: boolean | undefined): boolean { + // The persisted field is legacy "pinned" wording; the v2 UI treats it as + // show/hide visibility. Undefined defaults to visible for compatibility. + return pinnedToBar !== false; +} + +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} + +function getVisiblePresetOrder( + presets: ReadonlyArray<{ id: string; pinnedToBar?: boolean }>, +): string[] { + return presets.flatMap((preset) => + isPresetVisibleInBar(preset.pinnedToBar) ? [preset.id] : [], + ); +} + +export function V2PresetsBar({ + matchedPresets, + executePreset, +}: V2PresetsBarProps) { + const navigate = useNavigate(); + const isDark = useIsDarkTheme(); + const collections = useCollections(); + useMigrateV1PresetsToV2(); + + const [localVisiblePresetIds, setLocalVisiblePresetIds] = useState<string[]>( + () => getVisiblePresetOrder(matchedPresets), + ); + + useEffect(() => { + const serverVisiblePresetIds = getVisiblePresetOrder(matchedPresets); + setLocalVisiblePresetIds((current) => + areStringArraysEqual(current, serverVisiblePresetIds) + ? current + : serverVisiblePresetIds, + ); + }, [matchedPresets]); + + const visiblePresets = useMemo(() => { + const presetById = new Map( + matchedPresets.map((preset, index) => [preset.id, { preset, index }]), + ); + const orderedVisiblePresets: Array<{ + preset: V2TerminalPresetRow; + index: number; + }> = []; + const seenIds = new Set<string>(); + + for (const presetId of localVisiblePresetIds) { + const item = presetById.get(presetId); + if (!item) continue; + if (!isPresetVisibleInBar(item.preset.pinnedToBar)) continue; + orderedVisiblePresets.push(item); + seenIds.add(presetId); + } + + for (const [index, preset] of matchedPresets.entries()) { + if (!isPresetVisibleInBar(preset.pinnedToBar)) continue; + if (seenIds.has(preset.id)) continue; + orderedVisiblePresets.push({ preset, index }); + } + + return orderedVisiblePresets; + }, [matchedPresets, localVisiblePresetIds]); + + const visiblePresetIndexById = useMemo( + () => + new Map( + visiblePresets.map(({ preset }, visibleIndex) => [ + preset.id, + visibleIndex, + ]), + ), + [visiblePresets], + ); + + const handleEditPreset = useCallback( + (presetId: string) => { + navigate({ + to: "/settings/terminal", + search: { editPresetId: presetId }, + }); + }, + [navigate], + ); + + const handleLocalVisibleReorder = useCallback( + (fromIndex: number, toIndex: number) => { + setLocalVisiblePresetIds((current) => { + if ( + fromIndex < 0 || + fromIndex >= current.length || + toIndex < 0 || + toIndex >= current.length + ) { + return current; + } + const next = [...current]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + return next; + }); + }, + [], + ); + + const handlePersistVisibleReorder = useCallback( + (presetId: string, targetVisibleIndex: number) => { + const reorderedVisiblePresetIds = [...localVisiblePresetIds]; + const currentVisibleIndex = reorderedVisiblePresetIds.indexOf(presetId); + if (currentVisibleIndex === -1) return; + const [moved] = reorderedVisiblePresetIds.splice(currentVisibleIndex, 1); + reorderedVisiblePresetIds.splice(targetVisibleIndex, 0, moved); + + const visibleSet = new Set(reorderedVisiblePresetIds); + const hidden = matchedPresets + .filter((preset) => !visibleSet.has(preset.id)) + .map((preset) => preset.id); + const finalOrder = [...reorderedVisiblePresetIds, ...hidden]; + const currentTabOrderById = new Map( + matchedPresets.map((preset) => [preset.id, preset.tabOrder]), + ); + + for (const [index, id] of finalOrder.entries()) { + if (currentTabOrderById.get(id) === index) continue; + collections.v2TerminalPresets.update(id, (draft) => { + draft.tabOrder = index; + }); + } + }, + [collections.v2TerminalPresets, localVisiblePresetIds, matchedPresets], + ); + + const handleTogglePresetVisibility = useCallback( + (presetId: string, nextVisible: boolean) => { + collections.v2TerminalPresets.update(presetId, (draft) => { + draft.pinnedToBar = nextVisible; + }); + }, + [collections.v2TerminalPresets], + ); + + return ( + <div + className="flex h-8 min-w-0 shrink-0 items-center gap-0.5 overflow-x-auto overflow-y-hidden border-b border-border bg-background px-2" + style={{ scrollbarWidth: "none" }} + > + <DropdownMenu> + <Tooltip> + <TooltipTrigger asChild> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon" className="size-6 shrink-0"> + <Settings className="size-3.5" /> + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <TooltipContent side="bottom" sideOffset={4}> + Manage Presets + </TooltipContent> + </Tooltip> + <DropdownMenuContent align="end" className="w-56"> + {matchedPresets.map((preset) => { + const icon = getPresetIcon(preset.name, isDark); + const isVisible = isPresetVisibleInBar(preset.pinnedToBar); + const visibleIndex = visiblePresetIndexById.get(preset.id); + const hotkeyId = + typeof visibleIndex === "number" + ? PRESET_HOTKEY_IDS[visibleIndex] + : undefined; + return ( + <DropdownMenuItem + key={preset.id} + className="gap-2" + onSelect={(event) => { + event.preventDefault(); + handleTogglePresetVisibility(preset.id, !isVisible); + }} + > + {icon ? ( + <img src={icon} alt="" className="size-4 object-contain" /> + ) : ( + <HiMiniCommandLine className="size-4" /> + )} + <span className="min-w-0 flex-1 truncate"> + {preset.name || "default"} + </span> + <div className="ml-auto flex items-center gap-2"> + {isVisible && hotkeyId ? ( + <HotkeyMenuShortcut hotkeyId={hotkeyId} /> + ) : null} + {isVisible ? ( + <Eye className="size-3.5 text-foreground" /> + ) : ( + <EyeOff className="size-3.5 text-muted-foreground/60" /> + )} + </div> + </DropdownMenuItem> + ); + })} + <DropdownMenuSeparator /> + <DropdownMenuItem + className="gap-2" + onClick={() => navigate({ to: "/settings/terminal" })} + > + <Settings className="size-4" /> + <span>Manage Presets</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + {visiblePresets.map(({ preset }, visibleIndex) => { + const hotkeyId = PRESET_HOTKEY_IDS[visibleIndex]; + return ( + <V2PresetBarItem + key={preset.id} + preset={preset} + visibleIndex={visibleIndex} + hotkeyId={hotkeyId} + isDark={isDark} + onExecutePreset={executePreset} + onEdit={(presetToEdit) => handleEditPreset(presetToEdit.id)} + onLocalReorder={handleLocalVisibleReorder} + onPersistReorder={handlePersistVisibleReorder} + /> + ); + })} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx new file mode 100644 index 00000000000..ec7374bad4c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/V2PresetBarItem.tsx @@ -0,0 +1,126 @@ +import { Button } from "@superset/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useEffect, useRef } from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { HiMiniCommandLine } from "react-icons/hi2"; +import { getPresetIcon } from "renderer/assets/app-icons/preset-icons"; +import type { HotkeyId } from "renderer/hotkeys"; +import { HotkeyLabel } from "renderer/hotkeys"; +import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; + +const V2_PRESET_BAR_ITEM_TYPE = "V2_PRESET_BAR_ITEM"; + +interface V2PresetBarItemProps { + preset: V2TerminalPresetRow; + visibleIndex: number; + hotkeyId?: HotkeyId; + isDark: boolean; + onExecutePreset: (preset: V2TerminalPresetRow) => void; + onEdit: (preset: V2TerminalPresetRow) => void; + onLocalReorder: (fromIndex: number, toIndex: number) => void; + onPersistReorder: (presetId: string, targetVisibleIndex: number) => void; +} + +export function V2PresetBarItem({ + preset, + visibleIndex, + hotkeyId, + isDark, + onExecutePreset, + onEdit, + onLocalReorder, + onPersistReorder, +}: V2PresetBarItemProps) { + const containerRef = useRef<HTMLDivElement>(null); + const icon = getPresetIcon(preset.name, isDark); + const label = preset.description || preset.name || "default"; + + const [{ isDragging }, drag] = useDrag( + () => ({ + type: V2_PRESET_BAR_ITEM_TYPE, + item: { + id: preset.id, + index: visibleIndex, + originalIndex: visibleIndex, + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [preset.id, visibleIndex], + ); + + const [, drop] = useDrop({ + accept: V2_PRESET_BAR_ITEM_TYPE, + hover: (item: { id: string; index: number; originalIndex: number }) => { + if (item.index !== visibleIndex) { + onLocalReorder(item.index, visibleIndex); + item.index = visibleIndex; + } + }, + drop: (item: { id: string; index: number; originalIndex: number }) => { + if (item.originalIndex !== item.index) { + onPersistReorder(item.id, item.index); + } + }, + }); + + useEffect(() => { + drag(drop(containerRef)); + }, [drag, drop]); + + return ( + <ContextMenu> + <ContextMenuTrigger asChild> + <div + ref={containerRef} + className={isDragging ? "opacity-40" : undefined} + style={{ cursor: isDragging ? "grabbing" : "grab" }} + > + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-6 max-w-36 min-w-0 shrink-0 gap-1.5 px-2 text-xs" + onClick={() => onExecutePreset(preset)} + > + {icon ? ( + <img + src={icon} + alt="" + className="size-3.5 shrink-0 object-contain" + /> + ) : ( + <HiMiniCommandLine className="size-3.5 shrink-0" /> + )} + <span className="min-w-0 truncate"> + {preset.name || "default"} + </span> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom" sideOffset={4}> + <HotkeyLabel label={label} id={hotkeyId} /> + </TooltipContent> + </Tooltip> + </div> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem onSelect={() => onExecutePreset(preset)}> + Run preset + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem onSelect={() => onEdit(preset)}> + Edit preset + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/index.ts new file mode 100644 index 00000000000..21204af63e1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/components/V2PresetBarItem/index.ts @@ -0,0 +1 @@ +export { V2PresetBarItem } from "./V2PresetBarItem"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/index.ts new file mode 100644 index 00000000000..cfdf73e8de9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/V2PresetsBar/index.ts @@ -0,0 +1 @@ +export { V2PresetsBar } from "./V2PresetsBar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/WorkspaceChat.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/WorkspaceChat.tsx deleted file mode 100644 index fe9e0c2ea33..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/WorkspaceChat.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { SessionSelector } from "./components/SessionSelector"; -import { ChatPaneInterface as WorkspaceChatInterface } from "./components/WorkspaceChatInterface"; -import { useWorkspaceChatController } from "./hooks/useWorkspaceChatController"; - -export function WorkspaceChat({ - onSessionIdChange, - sessionId, - workspaceId, -}: { - onSessionIdChange: (sessionId: string | null) => void; - sessionId: string | null; - workspaceId: string; -}) { - const { - organizationId, - workspacePath, - sessionItems, - handleSelectSession, - handleNewChat, - handleDeleteSession, - getOrCreateSession, - } = useWorkspaceChatController({ - onSessionIdChange, - sessionId, - workspaceId, - }); - - return ( - <div className="flex h-full w-full min-h-0 flex-col"> - <div className="border-b border-border px-4 py-3"> - <SessionSelector - currentSessionId={sessionId} - sessions={sessionItems} - fallbackTitle="New Chat" - onSelectSession={handleSelectSession} - onNewChat={handleNewChat} - onDeleteSession={handleDeleteSession} - /> - </div> - - <div className="min-h-0 flex-1"> - <WorkspaceChatInterface - getOrCreateSession={getOrCreateSession} - initialLaunchConfig={null} - isFocused - onResetSession={handleNewChat} - sessionId={sessionId} - workspaceId={workspaceId} - organizationId={organizationId} - cwd={workspacePath} - /> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx deleted file mode 100644 index e6b00f916cf..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { - PromptInput, - PromptInputAttachment, - PromptInputAttachments, - type PromptInputMessage, - PromptInputTextarea, -} from "@superset/ui/ai-elements/prompt-input"; -import type { ThinkingLevel } from "@superset/ui/ai-elements/thinking-toggle"; -import type { ChatStatus, FileUIPart } from "ai"; -import type React from "react"; -import type { ReactNode } from "react"; -import { useCallback, useRef, useState } from "react"; -import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; -import { SlashCommandInput } from "renderer/components/Chat/ChatInterface/components/SlashCommandInput"; -import type { SlashCommand } from "renderer/components/Chat/ChatInterface/hooks/useSlashCommands"; -import type { - ModelOption, - PermissionMode, -} from "renderer/components/Chat/ChatInterface/types"; -import { useHotkeyText } from "renderer/stores/hotkeys"; -import { MentionAnchor, MentionProvider } from "../MentionPopover"; -import { ChatComposerControls } from "./components/ChatComposerControls"; -import { ChatInputDropZone } from "./components/ChatInputDropZone"; -import { ChatShortcuts } from "./components/ChatShortcuts"; -import { FileDropOverlay } from "./components/FileDropOverlay"; -import { LinkedIssues } from "./components/LinkedIssues"; -import { SlashCommandPreview } from "./components/SlashCommandPreview"; -import type { LinkedIssue } from "./types"; -import { getErrorMessage } from "./utils/getErrorMessage"; - -interface ChatInputFooterProps { - sessionId: string | null; - workspaceId: string; - cwd: string; - isFocused: boolean; - error: unknown; - canAbort: boolean; - submitStatus?: ChatStatus; - availableModels: ModelOption[]; - selectedModel: ModelOption | null; - setSelectedModel: React.Dispatch<React.SetStateAction<ModelOption | null>>; - modelSelectorOpen: boolean; - setModelSelectorOpen: React.Dispatch<React.SetStateAction<boolean>>; - permissionMode: PermissionMode; - setPermissionMode: React.Dispatch<React.SetStateAction<PermissionMode>>; - thinkingLevel: ThinkingLevel; - setThinkingLevel: (level: ThinkingLevel) => void; - slashCommands: SlashCommand[]; - submitDisabled?: boolean; - renderAttachment?: (file: FileUIPart & { id: string }) => ReactNode; - onSubmitStart?: () => void; - onSubmitEnd?: () => void; - onSend: (message: PromptInputMessage) => Promise<void> | void; - onStop: (e: React.MouseEvent) => void; - onSlashCommandSend: (command: SlashCommand) => void; -} - -export function ChatInputFooter({ - sessionId, - workspaceId, - cwd, - isFocused, - error, - canAbort, - submitStatus, - availableModels, - selectedModel, - setSelectedModel, - modelSelectorOpen, - setModelSelectorOpen, - permissionMode, - setPermissionMode, - thinkingLevel, - setThinkingLevel, - slashCommands, - submitDisabled, - renderAttachment, - onSubmitStart, - onSubmitEnd, - onSend, - onStop, - onSlashCommandSend, -}: ChatInputFooterProps) { - const [issueLinkOpen, setIssueLinkOpen] = useState(false); - const [linkedIssues, setLinkedIssues] = useState<LinkedIssue[]>([]); - const inputRootRef = useRef<HTMLDivElement>(null); - const errorMessage = getErrorMessage(error); - const focusShortcutText = useHotkeyText("FOCUS_CHAT_INPUT"); - const showFocusHint = focusShortcutText !== "Unassigned"; - - const addLinkedIssue = useCallback( - (slug: string, title: string, taskId: string | undefined, url?: string) => { - setLinkedIssues((prev) => { - if (prev.some((issue) => issue.slug === slug)) return prev; - return [...prev, { slug, title, taskId, url }]; - }); - }, - [], - ); - - const removeLinkedIssue = useCallback((slug: string) => { - setLinkedIssues((prev) => prev.filter((issue) => issue.slug !== slug)); - }, []); - - const handleSend = useCallback( - (message: PromptInputMessage) => { - if (linkedIssues.length === 0) return onSend(message); - - const prefix = linkedIssues - .map((issue) => `@task:${issue.slug}`) - .join(" "); - const modifiedMessage: PromptInputMessage = { - ...message, - text: `${prefix} ${message.text}`, - }; - setLinkedIssues([]); - return onSend(modifiedMessage); - }, - [linkedIssues, onSend], - ); - - return ( - <ChatInputDropZone className="bg-background px-4 py-3"> - {(dragType) => ( - <div className="mx-auto w-full max-w-[680px]"> - {errorMessage && ( - <p - role="alert" - className="mb-3 select-text rounded-md border border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive" - > - {errorMessage} - </p> - )} - <SlashCommandInput - onCommandSend={onSlashCommandSend} - commands={slashCommands} - > - <MentionProvider cwd={cwd}> - <MentionAnchor> - <div - ref={inputRootRef} - className={ - dragType === "path" - ? "relative opacity-50 transition-opacity" - : "relative" - } - > - {showFocusHint && ( - <span className="pointer-events-none absolute top-3 right-3 z-10 text-xs text-muted-foreground/50 [:focus-within>&]:hidden"> - {focusShortcutText} to focus - </span> - )} - <PromptInput - className="[&>[data-slot=input-group]]:rounded-[13px] [&>[data-slot=input-group]]:border-[0.5px] [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:bg-foreground/[0.02]" - onSubmitStart={onSubmitStart} - onSubmitEnd={onSubmitEnd} - onSubmit={handleSend} - multiple - maxFiles={5} - maxFileSize={10 * 1024 * 1024} - globalDrop - > - <ChatShortcuts - isFocused={isFocused} - setIssueLinkOpen={setIssueLinkOpen} - /> - <IssueLinkCommand - open={issueLinkOpen} - onOpenChange={setIssueLinkOpen} - onSelect={addLinkedIssue} - /> - <FileDropOverlay visible={dragType === "files"} /> - <PromptInputAttachments> - {renderAttachment ?? - ((file) => <PromptInputAttachment data={file} />)} - </PromptInputAttachments> - <LinkedIssues - issues={linkedIssues} - onRemove={removeLinkedIssue} - /> - <SlashCommandPreview - sessionId={sessionId} - workspaceId={workspaceId} - slashCommands={slashCommands} - /> - <PromptInputTextarea - placeholder="Ask to make changes, @mention files, run /commands" - className="min-h-10" - /> - <ChatComposerControls - availableModels={availableModels} - selectedModel={selectedModel} - setSelectedModel={setSelectedModel} - modelSelectorOpen={modelSelectorOpen} - setModelSelectorOpen={setModelSelectorOpen} - permissionMode={permissionMode} - setPermissionMode={setPermissionMode} - thinkingLevel={thinkingLevel} - setThinkingLevel={setThinkingLevel} - canAbort={canAbort} - submitStatus={submitStatus} - submitDisabled={submitDisabled} - onStop={onStop} - onLinkIssue={() => setIssueLinkOpen(true)} - /> - </PromptInput> - </div> - </MentionAnchor> - </MentionProvider> - </SlashCommandInput> - <div className="py-1.5" /> - </div> - )} - </ChatInputDropZone> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx deleted file mode 100644 index c00313c1f54..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { - usePromptInputAttachments, - usePromptInputController, -} from "@superset/ui/ai-elements/prompt-input"; -import type React from "react"; -import { useAppHotkey } from "renderer/stores/hotkeys"; - -interface ChatShortcutsProps { - isFocused: boolean; - setIssueLinkOpen: React.Dispatch<React.SetStateAction<boolean>>; -} - -export function ChatShortcuts({ - isFocused, - setIssueLinkOpen, -}: ChatShortcutsProps) { - const attachments = usePromptInputAttachments(); - const { textInput } = usePromptInputController(); - - useAppHotkey( - "CHAT_ADD_ATTACHMENT", - () => { - attachments.openFileDialog(); - }, - { enabled: isFocused, preventDefault: true }, - ); - - useAppHotkey( - "CHAT_LINK_ISSUE", - () => { - setIssueLinkOpen((prev) => !prev); - }, - { enabled: isFocused, preventDefault: true }, - ); - - useAppHotkey( - "FOCUS_CHAT_INPUT", - () => { - textInput.focus(); - }, - { enabled: isFocused, preventDefault: true }, - ); - - return null; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.test.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.test.tsx deleted file mode 100644 index b1398f16adb..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import { forwardRef } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -mock.module("@superset/ui/ai-elements/conversation", () => ({ - Conversation: ({ children }: { children: React.ReactNode }) => ( - <div>{children}</div> - ), - ConversationContent: forwardRef< - HTMLDivElement, - { children: React.ReactNode } - >(({ children }, ref) => <div ref={ref}>{children}</div>), - ConversationLoadingState: ({ label }: { label?: string }) => ( - <div>{label ?? "Loading conversation..."}</div> - ), - ConversationEmptyState: ({ title }: { title?: string }) => ( - <div>{title ?? "Empty"}</div> - ), - ConversationScrollButton: () => null, -})); - -mock.module("@superset/ui/ai-elements/message", () => ({ - Message: ({ children }: { children: React.ReactNode }) => ( - <div>{children}</div> - ), - MessageContent: ({ children }: { children: React.ReactNode }) => ( - <div>{children}</div> - ), -})); - -mock.module("@superset/ui/ai-elements/shimmer-label", () => ({ - ShimmerLabel: ({ children }: { children: React.ReactNode }) => ( - <span>{children}</span> - ), -})); - -mock.module( - "renderer/components/Chat/ChatInterface/components/ToolCallBlock", - () => ({ - ToolCallBlock: () => null, - }), -); - -mock.module("./components/AssistantMessage", () => ({ - AssistantMessage: ({ - message, - footer, - }: { - message: { - id: string; - content: Array<{ type: string; text?: string }>; - }; - footer?: React.ReactNode; - }) => ( - <div data-assistant-id={message.id}> - {message.content - .filter((part) => part.type === "text") - .map((part, index) => ( - <span key={`${message.id}-${index}`}>{part.text}</span> - ))} - {footer} - </div> - ), -})); - -mock.module("./components/UserMessage", () => ({ - UserMessage: ({ - message, - }: { - message: { - id: string; - content: Array<{ type: string; text?: string }>; - }; - }) => ( - <div data-user-id={message.id}> - {message.content - .filter((part) => part.type === "text") - .map((part, index) => ( - <span key={`${message.id}-${index}`}>{part.text}</span> - ))} - </div> - ), -})); - -mock.module("./components/MessageScrollbackRail", () => ({ - MessageScrollbackRail: ({ - messages, - }: { - messages: Array<{ id: string }>; - }) => <div data-rail-count={messages.length} />, -})); - -mock.module("./components/SubagentExecutionMessage", () => ({ - SubagentExecutionMessage: () => <div>SUBAGENT_EXECUTION_MESSAGE</div>, -})); - -mock.module("./components/PendingApprovalMessage", () => ({ - PendingApprovalMessage: () => null, -})); - -mock.module("./components/PendingPlanApprovalMessage", () => ({ - PendingPlanApprovalMessage: () => <div>PENDING_PLAN_APPROVAL_MESSAGE</div>, -})); - -mock.module("./components/PendingQuestionMessage", () => ({ - PendingQuestionMessage: () => null, -})); - -mock.module("./components/ToolPreviewMessage", () => ({ - ToolPreviewMessage: ({ - pendingPlanToolCallId, - }: { - pendingPlanToolCallId?: string | null; - }) => ( - <div data-pending-plan-tool-call-id={pendingPlanToolCallId ?? ""}> - TOOL_PREVIEW_MESSAGE - </div> - ), -})); - -mock.module("./hooks/useChatMessageSearch", () => ({ - useChatMessageSearch: () => ({ - isSearchOpen: false, - query: "", - caseSensitive: false, - matchCount: 0, - activeMatchIndex: 0, - setQuery: () => {}, - setCaseSensitive: () => {}, - findNext: () => {}, - findPrevious: () => {}, - closeSearch: () => {}, - }), -})); - -const { ChatMessageList } = await import("./ChatMessageList"); -type ChatMessageListProps = Parameters<typeof ChatMessageList>[0]; - -type TestMessage = { - id: string; - role: "user" | "assistant"; - content: Array<{ type: "text"; text: string }>; - createdAt: Date; -}; - -function testMessage( - id: string, - role: TestMessage["role"], - text: string, - createdAt: string, -): TestMessage { - return { - id, - role, - content: [{ type: "text", text }], - createdAt: new Date(createdAt), - }; -} - -function createBaseProps( - overrides: Partial<ChatMessageListProps> = {}, -): ChatMessageListProps { - return { - messages: [] as never, - isFocused: true, - isRunning: false, - isConversationLoading: false, - isAwaitingAssistant: false, - currentMessage: null, - interruptedMessage: null, - workspaceId: "workspace-1", - sessionId: "session-1", - organizationId: "org-1", - workspaceCwd: "/repo", - activeTools: undefined, - toolInputBuffers: undefined, - activeSubagents: undefined, - pendingApproval: null, - isApprovalSubmitting: false, - onApprovalRespond: async () => {}, - pendingPlanApproval: null, - isPlanSubmitting: false, - onPlanRespond: async () => {}, - pendingQuestion: null, - isQuestionSubmitting: false, - onQuestionRespond: async () => {}, - editingUserMessageId: null, - isEditSubmitting: false, - onStartEditUserMessage: () => {}, - onCancelEditUserMessage: () => {}, - onSubmitEditedUserMessage: async () => {}, - onRestartUserMessage: async () => {}, - ...overrides, - }; -} - -function renderListHtml(overrides: Partial<ChatMessageListProps> = {}): string { - return renderToStaticMarkup( - <ChatMessageList {...createBaseProps(overrides)} />, - ); -} - -describe("ChatMessageList", () => { - it("shows loading state while conversation history is loading", () => { - const html = renderListHtml({ - isConversationLoading: true, - }); - - expect(html).toContain("Loading conversation..."); - expect(html).not.toContain("Start a conversation"); - }); - - it("shows interrupted preview content after stop and hides the source assistant message", () => { - const html = renderListHtml({ - messages: [ - testMessage( - "user-1", - "user", - "first user prompt", - "2026-03-03T00:00:00.000Z", - ), - testMessage( - "assistant-1", - "assistant", - "persisted assistant text", - "2026-03-03T00:00:01.000Z", - ), - ] as never, - interruptedMessage: { - id: "interrupted:assistant-1", - sourceMessageId: "assistant-1", - content: [{ type: "text", text: "interrupted snapshot text" }], - } as never, - }); - - expect(html).toContain("first user prompt"); - expect(html).toContain("interrupted snapshot text"); - expect(html).toContain("Interrupted"); - expect(html).toContain("Response stopped"); - expect(html).not.toContain("persisted assistant text"); - }); - - it("does not show interrupted preview while a response is still running", () => { - const html = renderListHtml({ - messages: [ - testMessage( - "user-1", - "user", - "first user prompt", - "2026-03-03T00:00:00.000Z", - ), - testMessage( - "assistant-1", - "assistant", - "persisted assistant text", - "2026-03-03T00:00:01.000Z", - ), - ] as never, - isRunning: true, - isAwaitingAssistant: true, - currentMessage: testMessage( - "assistant-current", - "assistant", - "streaming assistant text", - "2026-03-03T00:00:02.000Z", - ) as never, - interruptedMessage: { - id: "interrupted:assistant-1", - sourceMessageId: "assistant-1", - content: [{ type: "text", text: "interrupted snapshot text" }], - } as never, - }); - - expect(html).toContain("streaming assistant text"); - expect(html).not.toContain("interrupted snapshot text"); - expect(html).not.toContain("Interrupted"); - expect(html).not.toContain("Response stopped"); - }); - - it("renders subagent activity while keeping anchored pending plan inline", () => { - const html = renderListHtml({ - messages: [ - { - id: "assistant-plan-1", - role: "assistant", - content: [ - { - type: "tool_call", - id: "tool-call-1", - name: "submit_plan", - args: {}, - }, - ], - createdAt: new Date("2026-03-03T00:00:01.000Z"), - }, - ] as never, - activeSubagents: new Map([ - [ - "tool-call-1", - { - status: "running", - task: "Run tests", - }, - ], - ]) as never, - pendingPlanApproval: { - planId: "tool-call-1", - title: "Implementation plan", - plan: "Do the thing", - } as never, - }); - - expect(html).toContain("SUBAGENT_EXECUTION_MESSAGE"); - expect(html).not.toContain("PENDING_PLAN_APPROVAL_MESSAGE"); - }); - - it("shows tool preview while awaiting assistant when pending plan is anchored", () => { - const html = renderListHtml({ - isAwaitingAssistant: true, - activeTools: new Map([ - [ - "tool-call-1", - { - name: "submit_plan", - status: "streaming_input", - }, - ], - ]) as never, - pendingPlanApproval: { - toolCallId: "tool-call-1", - title: "Implementation plan", - plan: "Do the thing", - } as never, - }); - - expect(html).toContain("TOOL_PREVIEW_MESSAGE"); - expect(html).not.toContain("PENDING_PLAN_APPROVAL_MESSAGE"); - }); - - it("does not render standalone pending plan when anchored from interrupted preview", () => { - const html = renderListHtml({ - messages: [ - { - id: "assistant-1", - role: "assistant", - content: [ - { - type: "tool_call", - id: "tool-call-interrupted", - name: "submit_plan", - args: {}, - }, - ], - createdAt: new Date("2026-03-03T00:00:01.000Z"), - }, - ] as never, - interruptedMessage: { - id: "interrupted:assistant-1", - sourceMessageId: "assistant-1", - content: [ - { - type: "tool_call", - id: "tool-call-interrupted", - name: "submit_plan", - args: {}, - }, - ], - } as never, - pendingPlanApproval: { - title: "Implementation plan", - plan: "Do the thing", - } as never, - }); - - expect(html).not.toContain("PENDING_PLAN_APPROVAL_MESSAGE"); - }); -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx deleted file mode 100644 index 11573df272a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Message, MessageContent } from "@superset/ui/ai-elements/message"; -import { cn } from "@superset/ui/lib/utils"; -import { useState } from "react"; -import { MarkdownToggleContent } from "renderer/components/Chat/components/MarkdownToggleContent"; -import { - type SubagentEntries, - toSubagentViewModels, -} from "./utils/toSubagentViewModels"; - -interface SubagentExecutionMessageProps { - subagents: SubagentEntries; - inline?: boolean; -} - -function getStatusLabel(status: "running" | "completed" | "error"): string { - if (status === "running") return "Running"; - if (status === "completed") return "Completed"; - return "Failed"; -} - -function getStatusClassName(status: "running" | "completed" | "error"): string { - if (status === "running") { - return "border-primary/40 bg-primary/10 text-primary"; - } - if (status === "completed") { - return "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"; - } - return "border-destructive/40 bg-destructive/10 text-destructive"; -} - -export function SubagentExecutionMessage({ - subagents, - inline = false, -}: SubagentExecutionMessageProps) { - const [markdownBySubagent, setMarkdownBySubagent] = useState< - Record<string, boolean> - >({}); - if (subagents.length === 0) return null; - const viewModels = toSubagentViewModels(subagents); - - const content = ( - <div className="w-full max-w-none space-y-3 rounded-xl border bg-card/95 p-3"> - <div className="text-sm font-medium text-foreground"> - Subagent activity - </div> - <div className="space-y-3"> - {viewModels.map((subagent) => ( - <div - key={subagent.toolCallId} - className="space-y-2 rounded-md border bg-muted/20 p-3" - > - <div className="flex flex-wrap items-center justify-between gap-2"> - <div className="text-sm font-medium text-foreground"> - {subagent.task} - </div> - <span - className={cn( - "rounded-full border px-2 py-0.5 text-xs font-medium", - getStatusClassName(subagent.status), - )} - > - {getStatusLabel(subagent.status)} - </span> - </div> - <div className="text-xs text-muted-foreground"> - {subagent.agentType} - {subagent.modelId ? ` • ${subagent.modelId}` : ""} - {subagent.durationMs !== undefined - ? ` • ${Math.round(subagent.durationMs)} ms` - : ""} - </div> - {subagent.text ? ( - <MarkdownToggleContent - toggleId={`subagent-markdown-${subagent.toolCallId}`} - checked={markdownBySubagent[subagent.toolCallId] ?? true} - onCheckedChange={(checked) => - setMarkdownBySubagent((previous) => ({ - ...previous, - [subagent.toolCallId]: checked, - })) - } - content={subagent.text} - labelClassName="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground" - markdownContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2" - plainContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2 text-xs whitespace-pre-wrap break-words" - /> - ) : null} - {subagent.toolCalls.length > 0 ? ( - <div className="flex flex-wrap items-center gap-1.5"> - {subagent.toolCalls.map((tool, index) => ( - <span - key={`${subagent.toolCallId}-${tool.name}-${index}`} - className={cn( - "rounded-full border px-2 py-0.5 text-xs", - tool.isError - ? "border-destructive/40 bg-destructive/10 text-destructive" - : "border-muted-foreground/30 bg-background/80 text-muted-foreground", - )} - > - {tool.name} - </span> - ))} - </div> - ) : null} - </div> - ))} - </div> - </div> - ); - - if (inline) return content; - - return ( - <Message from="assistant"> - <MessageContent>{content}</MessageContent> - </Message> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/types.ts deleted file mode 100644 index f17e0dd93c1..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; - -export type ChatMessage = NonNullable<UseChatDisplayReturn["messages"]>[number]; - -export type ChatMessagePart = ChatMessage["content"][number]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/ModelPicker.tsx deleted file mode 100644 index 31bea858f62..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/ModelPicker.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { - ModelSelector, - ModelSelectorContent, - ModelSelectorEmpty, - ModelSelectorInput, - ModelSelectorList, - ModelSelectorLogo, - ModelSelectorTrigger, -} from "@superset/ui/ai-elements/model-selector"; -import { PromptInputButton } from "@superset/ui/ai-elements/prompt-input"; -import { claudeIcon } from "@superset/ui/icons/preset-icons"; -import { useNavigate } from "@tanstack/react-router"; -import { ChevronDownIcon } from "lucide-react"; -import { useMemo } from "react"; -import { PILL_BUTTON_CLASS } from "renderer/components/Chat/ChatInterface/styles"; -import type { ModelOption } from "renderer/components/Chat/ChatInterface/types"; -import { ModelProviderGroup } from "./components/ModelProviderGroup"; -import { groupModelsByProvider } from "./utils/groupModelsByProvider"; -import { - ANTHROPIC_LOGO_PROVIDER, - providerToLogo, -} from "./utils/providerToLogo"; - -interface ModelPickerProps { - models: ModelOption[]; - selectedModel: ModelOption | null; - onSelectModel: (model: ModelOption) => void; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function ModelPicker({ - models, - selectedModel, - onSelectModel, - open, - onOpenChange, -}: ModelPickerProps) { - const navigate = useNavigate(); - const groupedModels = useMemo(() => groupModelsByProvider(models), [models]); - const selectedLogo = selectedModel - ? providerToLogo(selectedModel.provider) - : null; - - const openModelsSettings = () => { - onOpenChange(false); - void navigate({ to: "/settings/models" }); - }; - - return ( - <ModelSelector open={open} onOpenChange={onOpenChange}> - <ModelSelectorTrigger asChild> - <PromptInputButton - className={`${PILL_BUTTON_CLASS} px-2 gap-1.5 text-xs text-foreground`} - > - {selectedLogo === ANTHROPIC_LOGO_PROVIDER ? ( - <img alt="Claude" className="size-3" src={claudeIcon} /> - ) : selectedLogo ? ( - <ModelSelectorLogo provider={selectedLogo} /> - ) : null} - <span>{selectedModel?.name ?? "Model"}</span> - <ChevronDownIcon className="size-2.5 opacity-50" /> - </PromptInputButton> - </ModelSelectorTrigger> - <ModelSelectorContent title="Select Model"> - <ModelSelectorInput placeholder="Search models..." /> - <ModelSelectorList> - <ModelSelectorEmpty>No models found.</ModelSelectorEmpty> - {groupedModels.map(([provider, providerModels]) => ( - <ModelProviderGroup - key={provider} - provider={provider} - models={providerModels} - isAnthropicAuthenticated={true} - isAnthropicOAuthPending={false} - isAnthropicApiKeyPending={false} - onOpenAnthropicAuthModal={openModelsSettings} - isOpenAIAuthenticated={true} - isOpenAIOAuthPending={false} - isOpenAIApiKeyPending={false} - onOpenOpenAIAuthModal={openModelsSettings} - onSelectModel={onSelectModel} - onCloseModelSelector={() => { - onOpenChange(false); - }} - /> - ))} - </ModelSelectorList> - </ModelSelectorContent> - </ModelSelector> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/types.ts deleted file mode 100644 index cf771fad95f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; -import type { ChatLaunchConfig } from "shared/tabs-types"; - -export interface ChatRawSnapshot { - sessionId: string | null; - isRunning: boolean; - currentMessage: UseChatDisplayReturn["currentMessage"] | null; - messages: UseChatDisplayReturn["messages"]; - error: unknown; -} - -export interface ChatPaneInterfaceProps { - sessionId: string | null; - initialLaunchConfig: ChatLaunchConfig | null; - workspaceId: string; - organizationId: string | null; - cwd: string; - isFocused: boolean; - getOrCreateSession: () => Promise<string>; - onResetSession: () => Promise<void>; - onUserMessageSubmitted?: (message: string) => void; - onRawSnapshotChange?: (snapshot: ChatRawSnapshot) => void; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatController/useWorkspaceChatController.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatController/useWorkspaceChatController.ts deleted file mode 100644 index 155d9bd9801..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatController/useWorkspaceChatController.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { workspaceTrpc } from "@superset/workspace-client"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useCallback, useMemo } from "react"; -import { apiTrpcClient } from "renderer/lib/api-trpc-client"; -import { authClient } from "renderer/lib/auth-client"; -import { posthog } from "renderer/lib/posthog"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; - -interface SessionSelectorItem { - sessionId: string; - title: string; - updatedAt: Date; -} - -function toSessionSelectorItem(session: { - id: string; - title: string | null; - lastActiveAt: Date | string | null; - createdAt: Date | string; -}): SessionSelectorItem { - return { - sessionId: session.id, - title: session.title ?? "", - updatedAt: - session.lastActiveAt instanceof Date - ? session.lastActiveAt - : session.lastActiveAt - ? new Date(session.lastActiveAt) - : session.createdAt instanceof Date - ? session.createdAt - : new Date(session.createdAt), - }; -} - -async function createSessionRecord(input: { - sessionId: string; - v2WorkspaceId: string; -}): Promise<void> { - await apiTrpcClient.chat.createSession.mutate({ - sessionId: input.sessionId, - v2WorkspaceId: input.v2WorkspaceId, - }); -} - -async function deleteSessionRecord(sessionId: string): Promise<void> { - const result = await apiTrpcClient.chat.deleteSession.mutate({ - sessionId, - }); - if (!result.deleted) { - throw new Error(`Failed to delete session ${sessionId}`); - } -} - -export function useWorkspaceChatController({ - sessionId, - onSessionIdChange, - workspaceId, -}: { - sessionId: string | null; - onSessionIdChange: (sessionId: string | null) => void; - workspaceId: string; -}) { - const { data: session } = authClient.useSession(); - const organizationId = session?.session?.activeOrganizationId ?? null; - const collections = useCollections(); - - const { data: workspace } = workspaceTrpc.workspace.get.useQuery( - { id: workspaceId }, - { enabled: Boolean(workspaceId) }, - ); - - const { data: allSessionsData } = useLiveQuery( - (q) => - q - .from({ chatSessions: collections.chatSessions }) - .where(({ chatSessions }) => - eq(chatSessions.v2WorkspaceId, workspaceId), - ) - .orderBy(({ chatSessions }) => chatSessions.lastActiveAt, "desc") - .select(({ chatSessions }) => ({ ...chatSessions })), - [collections.chatSessions, workspaceId], - ); - const sessions = allSessionsData ?? []; - - const handleSelectSession = useCallback( - (nextSessionId: string) => { - onSessionIdChange(nextSessionId); - }, - [onSessionIdChange], - ); - - const handleNewChat = useCallback(async () => { - onSessionIdChange(null); - }, [onSessionIdChange]); - - const handleDeleteSession = useCallback( - async (sessionIdToDelete: string) => { - await deleteSessionRecord(sessionIdToDelete); - posthog.capture("chat_session_deleted", { - workspace_id: workspaceId, - session_id: sessionIdToDelete, - organization_id: organizationId, - }); - if (sessionIdToDelete === sessionId) { - onSessionIdChange(null); - } - }, - [onSessionIdChange, organizationId, sessionId, workspaceId], - ); - - const getOrCreateSession = useCallback(async (): Promise<string> => { - if (!organizationId) { - throw new Error("No active organization selected"); - } - - if (sessionId) { - if (sessions.some((item) => item.id === sessionId)) { - return sessionId; - } - - await createSessionRecord({ - sessionId, - v2WorkspaceId: workspaceId, - }); - return sessionId; - } - - const nextSessionId = crypto.randomUUID(); - await createSessionRecord({ - sessionId: nextSessionId, - v2WorkspaceId: workspaceId, - }); - onSessionIdChange(nextSessionId); - posthog.capture("chat_session_created", { - workspace_id: workspaceId, - session_id: nextSessionId, - organization_id: organizationId, - }); - return nextSessionId; - }, [onSessionIdChange, organizationId, sessionId, sessions, workspaceId]); - - const sessionItems = useMemo( - () => sessions.map((item) => toSessionSelectorItem(item)), - [sessions], - ); - - return { - sessionId, - organizationId, - workspacePath: workspace?.worktreePath ?? "", - sessionItems, - handleSelectSession, - handleNewChat, - handleDeleteSession, - getOrCreateSession, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/index.ts deleted file mode 100644 index a1993ea0215..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceChat } from "./WorkspaceChat"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceEmptyState/WorkspaceEmptyState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceEmptyState/WorkspaceEmptyState.tsx new file mode 100644 index 00000000000..5f4a4177ecc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceEmptyState/WorkspaceEmptyState.tsx @@ -0,0 +1,110 @@ +import { useMemo } from "react"; +import type { IconType } from "react-icons"; +import { BsTerminalPlus } from "react-icons/bs"; +import { LuSearch } from "react-icons/lu"; +import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; +import { useHotkeyDisplay } from "renderer/hotkeys"; +import supersetEmptyStateWordmark from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/assets/superset-empty-state-wordmark.svg"; +import { EmptyTabActionButton } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/components/EmptyTabActionButton"; +import { useTheme } from "renderer/stores/theme"; + +interface WorkspaceEmptyStateProps { + onOpenBrowser: () => void; + onOpenChat: () => void; + onOpenQuickOpen: () => void; + onOpenTerminal: () => void; +} + +interface WorkspaceEmptyStateAction { + display: string[]; + icon: IconType; + id: string; + label: string; + onClick: () => void; +} + +export function WorkspaceEmptyState({ + onOpenBrowser, + onOpenChat, + onOpenQuickOpen, + onOpenTerminal, +}: WorkspaceEmptyStateProps) { + const activeTheme = useTheme(); + const { keys: newGroupDisplay } = useHotkeyDisplay("NEW_GROUP"); + const { keys: newChatDisplay } = useHotkeyDisplay("NEW_CHAT"); + const { keys: newBrowserDisplay } = useHotkeyDisplay("NEW_BROWSER"); + const { keys: quickOpenDisplay } = useHotkeyDisplay("QUICK_OPEN"); + + const actions = useMemo<Array<WorkspaceEmptyStateAction>>( + () => [ + { + id: "terminal", + label: "Open Terminal", + display: newGroupDisplay, + icon: BsTerminalPlus, + onClick: onOpenTerminal, + }, + { + id: "chat", + label: "Open Chat", + display: newChatDisplay, + icon: TbMessageCirclePlus, + onClick: onOpenChat, + }, + { + id: "browser", + label: "Open Browser", + display: newBrowserDisplay, + icon: TbWorld, + onClick: onOpenBrowser, + }, + { + id: "search-files", + label: "Search Files", + display: quickOpenDisplay, + icon: LuSearch, + onClick: onOpenQuickOpen, + }, + ], + [ + newBrowserDisplay, + newChatDisplay, + newGroupDisplay, + onOpenBrowser, + onOpenChat, + onOpenQuickOpen, + onOpenTerminal, + quickOpenDisplay, + ], + ); + + return ( + <div className="flex h-full flex-1 items-center justify-center px-6 py-10"> + <div className="w-full max-w-xl"> + <div className="mb-7 flex items-center justify-center py-3"> + <img + alt="Superset" + className={`h-8 w-auto select-none ${ + activeTheme?.type === "dark" + ? "opacity-85" + : "brightness-0 opacity-75" + }`} + draggable={false} + src={supersetEmptyStateWordmark} + /> + </div> + <div className="mx-auto grid w-full max-w-md gap-0.5"> + {actions.map((action) => ( + <EmptyTabActionButton + key={action.id} + display={action.display} + icon={action.icon} + label={action.label} + onClick={action.onClick} + /> + ))} + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceEmptyState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceEmptyState/index.ts new file mode 100644 index 00000000000..d35a8872ac8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceEmptyState/index.ts @@ -0,0 +1 @@ +export { WorkspaceEmptyState } from "./WorkspaceEmptyState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/WorkspaceFiles.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/WorkspaceFiles.tsx deleted file mode 100644 index fde17821d59..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/WorkspaceFiles.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - useFileTree, - useWorkspaceFsEventBridge, - useWorkspaceFsEvents, - workspaceTrpc, -} from "@superset/workspace-client"; -import { useCallback, useMemo, useState } from "react"; -import { - ROW_HEIGHT, - TREE_INDENT, -} from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; -import { WorkspaceFilePreview } from "./components/WorkspaceFilePreview"; -import { WorkspaceFilesSearchResultItem } from "./components/WorkspaceFilesSearchResultItem"; -import { WorkspaceFilesToolbar } from "./components/WorkspaceFilesToolbar"; -import { WorkspaceFilesTreeItem } from "./components/WorkspaceFilesTreeItem"; -import { useWorkspaceFileSearch } from "./hooks/useWorkspaceFileSearch"; - -interface WorkspaceFilesProps { - onSelectFile: (absolutePath: string) => void; - selectedFilePath?: string; - workspaceId: string; -} - -export function WorkspaceFiles({ - onSelectFile, - selectedFilePath, - workspaceId, -}: WorkspaceFilesProps) { - const [isRefreshing, setIsRefreshing] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const utils = workspaceTrpc.useUtils(); - const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ - id: workspaceId, - }); - const rootPath = workspaceQuery.data?.worktreePath ?? ""; - - useWorkspaceFsEventBridge( - workspaceId, - Boolean(workspaceId && workspaceQuery.data?.worktreePath), - ); - - const fileTree = useFileTree({ - workspaceId, - rootPath, - }); - const { - hasQuery, - isFetching: isFetchingSearch, - searchResults, - } = useWorkspaceFileSearch({ - searchTerm, - workspaceId, - }); - - useWorkspaceFsEvents( - workspaceId, - () => { - if (searchTerm.trim().length === 0) { - return; - } - - void utils.filesystem.searchFiles.invalidate(); - }, - Boolean(workspaceId && searchTerm.trim().length > 0), - ); - - const flattenedTreeEntries = useMemo(() => { - const entries: Array<{ - depth: number; - node: (typeof fileTree.rootEntries)[number]; - }> = []; - - const visitNodes = ( - nodes: typeof fileTree.rootEntries, - depth: number, - ): void => { - for (const node of nodes) { - entries.push({ node, depth }); - if (node.isExpanded && node.children.length > 0) { - visitNodes(node.children, depth + 1); - } - } - }; - - visitNodes(fileTree.rootEntries, 0); - return entries; - }, [fileTree.rootEntries]); - - const handleRefresh = useCallback(async () => { - setIsRefreshing(true); - try { - await fileTree.refreshAll(); - } finally { - setIsRefreshing(false); - } - }, [fileTree]); - - if (workspaceQuery.isPending) { - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - Loading workspace files... - </div> - ); - } - - if (!workspaceQuery.data?.worktreePath) { - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - Workspace worktree not available - </div> - ); - } - - return ( - <div className="flex h-full min-h-0 overflow-hidden"> - <div className="flex w-80 min-w-80 flex-col border-r border-border"> - <WorkspaceFilesToolbar - isRefreshing={isRefreshing} - onCollapseAll={fileTree.collapseAll} - onNewFile={() => {}} - onNewFolder={() => {}} - onRefresh={() => void handleRefresh()} - onSearchChange={setSearchTerm} - searchTerm={searchTerm} - /> - <div className="min-h-0 flex-1 overflow-y-auto p-2"> - {hasQuery ? ( - searchResults.length === 0 ? ( - <div className="px-2 py-3 text-sm text-muted-foreground"> - {isFetchingSearch ? "Searching files..." : "No matches found"} - </div> - ) : ( - <div className="flex flex-col"> - {searchResults.map((entry) => ( - <WorkspaceFilesSearchResultItem - entry={entry} - key={entry.absolutePath} - onActivate={onSelectFile} - selectedFilePath={selectedFilePath} - /> - ))} - </div> - ) - ) : fileTree.isLoadingRoot && fileTree.rootEntries.length === 0 ? ( - <div className="px-2 py-3 text-sm text-muted-foreground"> - Loading files... - </div> - ) : fileTree.rootEntries.length === 0 ? ( - <div className="px-2 py-3 text-sm text-muted-foreground"> - No files found - </div> - ) : ( - <div className="flex flex-col"> - {flattenedTreeEntries.map(({ depth, node }) => ( - <WorkspaceFilesTreeItem - depth={depth} - indent={TREE_INDENT} - key={node.absolutePath} - node={node} - onSelectFile={onSelectFile} - onToggleDirectory={(absolutePath) => - void fileTree.toggle(absolutePath) - } - rowHeight={ROW_HEIGHT} - selectedFilePath={selectedFilePath} - /> - ))} - </div> - )} - </div> - </div> - <div className="min-h-0 flex-1"> - <WorkspaceFilePreview - selectedFilePath={selectedFilePath} - workspaceId={workspaceId} - /> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/WorkspaceFilePreview.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/WorkspaceFilePreview.tsx deleted file mode 100644 index b34d7787ce8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/WorkspaceFilePreview.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { WorkspaceFilePreviewContent } from "./components/WorkspaceFilePreviewContent"; - -interface WorkspaceFilePreviewProps { - selectedFilePath?: string; - workspaceId: string; -} - -export function WorkspaceFilePreview({ - selectedFilePath, - workspaceId, -}: WorkspaceFilePreviewProps) { - if (!selectedFilePath) { - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - Select a file to preview it - </div> - ); - } - - return ( - <WorkspaceFilePreviewContent - selectedFilePath={selectedFilePath} - workspaceId={workspaceId} - /> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx deleted file mode 100644 index e293635b91a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/WorkspaceFilePreviewContent.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useFileDocument } from "@superset/workspace-client"; - -interface WorkspaceFilePreviewContentProps { - selectedFilePath: string; - workspaceId: string; -} - -export function WorkspaceFilePreviewContent({ - selectedFilePath, - workspaceId, -}: WorkspaceFilePreviewContentProps) { - const document = useFileDocument({ - workspaceId, - absolutePath: selectedFilePath, - mode: "auto", - }); - - if (document.state.kind === "loading") { - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - Loading file... - </div> - ); - } - - if (document.state.kind === "not-found") { - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - File not found - </div> - ); - } - - if (document.state.kind === "binary") { - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - Binary files are not previewed yet - </div> - ); - } - - if (document.state.kind === "too-large") { - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - File is too large to preview - </div> - ); - } - - if (document.state.kind === "bytes") { - return ( - <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> - Byte previews are not implemented yet - </div> - ); - } - - return ( - <div className="flex h-full min-h-0 flex-col"> - <div className="border-b border-border px-4 py-3"> - <div className="flex items-center justify-between gap-4"> - <div className="min-w-0"> - <h2 className="truncate text-sm font-medium"> - {document.absolutePath} - </h2> - <p className="text-xs text-muted-foreground"> - Revision {document.state.revision} - </p> - </div> - <button - className="text-xs text-muted-foreground transition hover:text-foreground" - onClick={() => void document.reload()} - type="button" - > - Reload - </button> - </div> - {document.hasExternalChange ? ( - <p className="mt-2 text-xs text-amber-600"> - File changed on disk. Reload to sync with the workspace. - </p> - ) : null} - </div> - <pre className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4 text-xs leading-6 text-foreground"> - {document.state.content} - </pre> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/index.ts deleted file mode 100644 index 5a8ef005640..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/components/WorkspaceFilePreviewContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceFilePreviewContent } from "./WorkspaceFilePreviewContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/index.ts deleted file mode 100644 index 41335072140..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceFilePreview } from "./WorkspaceFilePreview"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesSearchResultItem/WorkspaceFilesSearchResultItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesSearchResultItem/WorkspaceFilesSearchResultItem.tsx deleted file mode 100644 index 41385035705..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesSearchResultItem/WorkspaceFilesSearchResultItem.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { cn } from "@superset/ui/utils"; -import { SEARCH_RESULT_ROW_HEIGHT } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; - -interface WorkspaceFilesSearchResultEntry { - absolutePath: string; - isDirectory: boolean; - name: string; - relativePath: string; -} - -interface WorkspaceFilesSearchResultItemProps { - entry: WorkspaceFilesSearchResultEntry; - onActivate: (absolutePath: string) => void; - selectedFilePath?: string; -} - -const PATH_LABEL_MAX_CHARS = 48; - -function getFolderLabel(relativePath: string): string { - const normalizedPath = relativePath.replace(/\\/g, "/"); - const lastSlashIndex = normalizedPath.lastIndexOf("/"); - if (lastSlashIndex <= 0) { - return "root"; - } - - return normalizedPath.slice(0, lastSlashIndex); -} - -function truncatePathStart(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - - const sliceLength = Math.max(1, maxLength - 3); - return `...${value.slice(value.length - sliceLength)}`; -} - -export function WorkspaceFilesSearchResultItem({ - entry, - onActivate, - selectedFilePath, -}: WorkspaceFilesSearchResultItemProps) { - const folderLabel = getFolderLabel(entry.relativePath); - const folderLabelDisplay = truncatePathStart( - folderLabel, - PATH_LABEL_MAX_CHARS, - ); - const isSelected = selectedFilePath === entry.absolutePath; - - return ( - <button - className={cn( - "flex w-full cursor-pointer select-none items-center gap-1 px-1 text-left transition-colors hover:bg-accent/50", - isSelected && "bg-accent", - )} - onClick={() => { - if (!entry.isDirectory) { - onActivate(entry.absolutePath); - } - }} - style={{ height: SEARCH_RESULT_ROW_HEIGHT }} - type="button" - > - <span className="flex h-4 w-4 shrink-0 items-center justify-center" /> - <div className="flex min-w-0 flex-1 flex-col gap-0.5"> - <span - className="truncate text-[10px] text-muted-foreground" - title={entry.relativePath} - > - {folderLabelDisplay} - </span> - <div className="flex min-w-0 items-center gap-1"> - <FileIcon - className="size-4 shrink-0" - fileName={entry.name} - isDirectory={entry.isDirectory} - /> - <span className="min-w-0 flex-1 truncate text-xs">{entry.name}</span> - </div> - </div> - </button> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesSearchResultItem/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesSearchResultItem/index.ts deleted file mode 100644 index 726c9b9ab0a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesSearchResultItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceFilesSearchResultItem } from "./WorkspaceFilesSearchResultItem"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesToolbar/WorkspaceFilesToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesToolbar/WorkspaceFilesToolbar.tsx deleted file mode 100644 index 43a63f4d16c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesToolbar/WorkspaceFilesToolbar.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { - LuChevronsDownUp, - LuFilePlus, - LuFolderPlus, - LuRefreshCw, - LuX, -} from "react-icons/lu"; -import { SEARCH_DEBOUNCE_MS } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; - -interface WorkspaceFilesToolbarProps { - searchTerm: string; - onSearchChange: (term: string) => void; - onNewFile: () => void; - onNewFolder: () => void; - onCollapseAll: () => void; - onRefresh: () => void; - isRefreshing?: boolean; -} - -export function WorkspaceFilesToolbar({ - searchTerm, - onSearchChange, - onNewFile, - onNewFolder, - onCollapseAll, - onRefresh, - isRefreshing = false, -}: WorkspaceFilesToolbarProps) { - const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm); - const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); - - useEffect(() => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - debounceTimeoutRef.current = null; - } - setLocalSearchTerm(searchTerm); - }, [searchTerm]); - - useEffect(() => { - return () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - }; - }, []); - - const handleSearchChange = useCallback( - (event: React.ChangeEvent<HTMLInputElement>) => { - const nextValue = event.target.value; - setLocalSearchTerm(nextValue); - - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - - debounceTimeoutRef.current = setTimeout(() => { - onSearchChange(nextValue); - debounceTimeoutRef.current = null; - }, SEARCH_DEBOUNCE_MS); - }, - [onSearchChange], - ); - - const handleClearSearch = useCallback(() => { - setLocalSearchTerm(""); - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - debounceTimeoutRef.current = null; - } - onSearchChange(""); - }, [onSearchChange]); - - return ( - <div className="flex flex-col gap-1 border-b border-border px-2 py-1.5"> - <div className="relative"> - <Input - className="h-7 pr-7 text-xs" - onChange={handleSearchChange} - placeholder="Search files..." - type="text" - value={localSearchTerm} - /> - {localSearchTerm ? ( - <button - className="absolute top-1/2 right-1 -translate-y-1/2 rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground" - onClick={handleClearSearch} - type="button" - > - <LuX className="size-3.5" /> - </button> - ) : null} - </div> - - <div className="flex items-center gap-0.5"> - <Tooltip> - <TooltipTrigger asChild> - <Button - className="size-6" - disabled - onClick={onNewFile} - size="icon" - variant="ghost" - > - <LuFilePlus className="size-3.5" /> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom">New File (coming soon)</TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Button - className="size-6" - disabled - onClick={onNewFolder} - size="icon" - variant="ghost" - > - <LuFolderPlus className="size-3.5" /> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom"> - New Folder (coming soon) - </TooltipContent> - </Tooltip> - - <div className="flex-1" /> - - <Tooltip> - <TooltipTrigger asChild> - <Button - className="size-6" - onClick={onCollapseAll} - size="icon" - variant="ghost" - > - <LuChevronsDownUp className="size-3.5" /> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom">Collapse All</TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Button - className="size-6" - disabled={isRefreshing} - onClick={onRefresh} - size="icon" - variant="ghost" - > - <LuRefreshCw - className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`} - /> - </Button> - </TooltipTrigger> - <TooltipContent side="bottom">Refresh</TooltipContent> - </Tooltip> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesToolbar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesToolbar/index.ts deleted file mode 100644 index f5a1915ee0e..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesToolbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceFilesToolbar } from "./WorkspaceFilesToolbar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx deleted file mode 100644 index 0e4b85c709e..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { cn } from "@superset/ui/utils"; -import type { FileTreeNode } from "@superset/workspace-client"; -import { LuChevronDown, LuChevronRight } from "react-icons/lu"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; - -interface WorkspaceFilesTreeItemProps { - node: FileTreeNode; - depth: number; - rowHeight: number; - indent: number; - selectedFilePath?: string; - onSelectFile: (absolutePath: string) => void; - onToggleDirectory: (absolutePath: string) => void; -} - -export function WorkspaceFilesTreeItem({ - node, - depth, - rowHeight, - indent, - selectedFilePath, - onSelectFile, - onToggleDirectory, -}: WorkspaceFilesTreeItemProps) { - const isFolder = node.kind === "directory"; - const isSelected = selectedFilePath === node.absolutePath; - - return ( - <button - aria-expanded={isFolder ? node.isExpanded : undefined} - className={cn( - "flex w-full cursor-pointer select-none items-center gap-1 px-1 text-left transition-colors hover:bg-accent/50", - isSelected && "bg-accent", - )} - onClick={() => - isFolder - ? onToggleDirectory(node.absolutePath) - : onSelectFile(node.absolutePath) - } - style={{ - height: rowHeight, - paddingLeft: depth * indent, - }} - type="button" - > - <span className="flex h-4 w-4 shrink-0 items-center justify-center"> - {isFolder ? ( - node.isExpanded ? ( - <LuChevronDown className="size-3.5 text-muted-foreground" /> - ) : ( - <LuChevronRight className="size-3.5 text-muted-foreground" /> - ) - ) : null} - </span> - - <FileIcon - className="size-4 shrink-0" - fileName={node.name} - isDirectory={isFolder} - isOpen={node.isExpanded} - /> - - <span className="min-w-0 flex-1 truncate text-xs">{node.name}</span> - </button> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/hooks/useWorkspaceFileSearch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/hooks/useWorkspaceFileSearch/index.ts deleted file mode 100644 index 22e7ab3276c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/hooks/useWorkspaceFileSearch/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useWorkspaceFileSearch } from "./useWorkspaceFileSearch"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/hooks/useWorkspaceFileSearch/useWorkspaceFileSearch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/hooks/useWorkspaceFileSearch/useWorkspaceFileSearch.ts deleted file mode 100644 index 9c42d9353bb..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/hooks/useWorkspaceFileSearch/useWorkspaceFileSearch.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { workspaceTrpc } from "@superset/workspace-client"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { SEARCH_RESULT_LIMIT } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; - -interface UseWorkspaceFileSearchParams { - workspaceId: string; - searchTerm: string; - limit?: number; -} - -export function useWorkspaceFileSearch({ - workspaceId, - searchTerm, - limit = SEARCH_RESULT_LIMIT, -}: UseWorkspaceFileSearchParams) { - const trimmedQuery = searchTerm.trim(); - const debouncedQuery = useDebouncedValue(trimmedQuery, 150); - const isDebouncing = - trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery; - - const { data: searchResults, isFetching } = - workspaceTrpc.filesystem.searchFiles.useQuery( - { - workspaceId, - query: debouncedQuery, - limit, - }, - { - enabled: debouncedQuery.length > 0, - placeholderData: (previous) => previous ?? { matches: [] }, - staleTime: 1000, - }, - ); - - return { - searchResults: - searchResults?.matches.map((match) => ({ - absolutePath: match.absolutePath, - isDirectory: match.kind === "directory", - name: match.name, - relativePath: match.relativePath, - })) ?? [], - isFetching: isFetching || isDebouncing, - hasQuery: trimmedQuery.length > 0, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/index.ts deleted file mode 100644 index 86b9c7506b7..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceFiles } from "./WorkspaceFiles"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx new file mode 100644 index 00000000000..239776f590f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -0,0 +1,175 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { Search } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { LuFile, LuGitCompareArrows } from "react-icons/lu"; +import { useGitStatus } from "renderer/hooks/host-service/useGitStatus"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { CommentPaneData } from "../../types"; +import { FilesTab } from "./components/FilesTab"; +import { PRActionHeader } from "./components/PRActionHeader"; +import { SidebarHeader } from "./components/SidebarHeader"; +import { useChangesTab } from "./hooks/useChangesTab"; +import { type OpenChatFn, usePRFlowDispatch } from "./hooks/usePRFlowDispatch"; +import { usePRFlowState } from "./hooks/usePRFlowState"; +import { useReviewTab } from "./hooks/useReviewTab"; +import type { SidebarTabDefinition } from "./types"; + +// Gates the "Create PR" button only — the chat-driven create flow doesn't +// exist in v2 yet. The PR status group (link + merge dropdown for an open PR) +// always renders so users can see PR state and merge once a PR exists. +const CREATE_PR_BUTTON_ENABLED = false; + +type SidebarTabId = "changes" | "files" | "review"; + +const VALID_TAB_IDS: readonly SidebarTabId[] = ["changes", "files", "review"]; + +function isSidebarTabId(tab: string): tab is SidebarTabId { + return (VALID_TAB_IDS as readonly string[]).includes(tab); +} + +export interface PendingReveal { + path: string; + isDirectory: boolean; +} + +interface WorkspaceSidebarProps { + onSelectFile: (absolutePath: string, openInNewTab?: boolean) => void; + onSelectDiffFile?: (path: string, openInNewTab?: boolean) => void; + onOpenComment?: (comment: CommentPaneData) => void; + onOpenChat?: OpenChatFn; + onSearch?: () => void; + selectedFilePath?: string; + pendingReveal?: PendingReveal | null; + workspaceId: string; +} + +function IconButton({ + icon: Icon, + tooltip, + onClick, +}: { + icon: React.ComponentType<{ className?: string }>; + tooltip: string; + onClick?: () => void; +}) { + return ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-6" + onClick={onClick} + > + <Icon className="size-3.5" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom">{tooltip}</TooltipContent> + </Tooltip> + ); +} + +export function WorkspaceSidebar({ + onSelectFile, + onSelectDiffFile, + onOpenComment, + onOpenChat, + onSearch, + selectedFilePath, + pendingReveal, + workspaceId, +}: WorkspaceSidebarProps) { + const collections = useCollections(); + const localState = collections.v2WorkspaceLocalState.get(workspaceId); + const activeTab: SidebarTabId = + (localState?.sidebarState?.activeTab as SidebarTabId | undefined) ?? + "changes"; + + function setActiveTab(tab: string) { + if (!isSidebarTabId(tab)) return; + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.activeTab = tab; + }); + } + + const containerRef = useRef<HTMLDivElement>(null); + const [compact, setCompact] = useState(false); + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + if (!entry) return; + const width = entry.contentRect.width; + // Hysteresis: expand back to labels only once we're clearly past + // the breakpoint, so the labels don't jitter on the edge. + setCompact((prev) => (prev ? width < 280 : width < 260)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const gitStatus = useGitStatus(workspaceId); + + const changesTabDef = useChangesTab({ + workspaceId, + gitStatus, + onSelectFile: onSelectDiffFile, + onOpenFile: onSelectFile, + }); + const changesTab: SidebarTabDefinition = { + ...changesTabDef, + icon: LuGitCompareArrows, + }; + + const reviewTab = useReviewTab({ workspaceId, onOpenComment }); + + const { flowState, onRetry } = usePRFlowState(workspaceId); + const dispatch = usePRFlowDispatch({ + onOpenChat: onOpenChat ?? (() => {}), + }); + + const filesTab: SidebarTabDefinition = { + id: "files", + label: "Files", + icon: LuFile, + actions: <IconButton icon={Search} tooltip="Search" onClick={onSearch} />, + content: ( + <FilesTab + onSelectFile={onSelectFile} + selectedFilePath={selectedFilePath} + pendingReveal={pendingReveal} + workspaceId={workspaceId} + gitStatus={gitStatus.data} + /> + ), + }; + + const tabs: SidebarTabDefinition[] = [filesTab, changesTab, reviewTab]; + const activeTabDef = tabs.find((t) => t.id === activeTab); + + return ( + <div + ref={containerRef} + className="isolate flex h-full w-full min-h-0 flex-col overflow-hidden bg-background" + > + <PRActionHeader + workspaceId={workspaceId} + state={flowState} + dispatch={dispatch} + onRetry={onRetry} + createPREnabled={CREATE_PR_BUTTON_ENABLED} + /> + <SidebarHeader + tabs={tabs} + activeTab={activeTab} + onTabChange={setActiveTab} + compact={compact} + /> + <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> + {activeTabDef?.content} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx new file mode 100644 index 00000000000..5cb23450489 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -0,0 +1,648 @@ +import type { AppRouter } from "@superset/host-service"; +import { alert } from "@superset/ui/atoms/Alert"; +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { workspaceTrpc } from "@superset/workspace-client"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + FilePlus, + FolderPlus, + FoldVertical, + Loader2, + RefreshCw, +} from "lucide-react"; +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; +import { + type FileTreeNode, + useFileTree, +} from "renderer/hooks/host-service/useFileTree"; +import { + type FileStatus, + useGitStatusMap, +} from "renderer/hooks/host-service/useGitStatusMap"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; +import { + ROW_HEIGHT, + TREE_INDENT, +} from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; +import { NewItemInput } from "./components/NewItemInput"; +import { WorkspaceFilesTreeItem } from "./components/WorkspaceFilesTreeItem"; + +type GitStatusData = inferRouterOutputs<AppRouter>["git"]["getStatus"]; + +type InlineEditState = + | { kind: "create"; mode: "file" | "folder"; parentPath: string } + | { kind: "rename"; absolutePath: string; name: string; isDirectory: boolean } + | null; + +interface FilesTabProps { + onSelectFile: (absolutePath: string, openInNewTab?: boolean) => void; + selectedFilePath?: string; + pendingReveal?: { + path: string; + isDirectory: boolean; + } | null; + workspaceId: string; + gitStatus: GitStatusData | undefined; +} + +function toPosix(path: string): string { + return path.replace(/\\/g, "/"); +} + +function TreeNode({ + node, + depth, + indent, + rowHeight, + selectedFilePath, + hoveredPath, + inlineEdit, + isMuted, + fileStatusByPath, + folderStatusByPath, + ignoredPaths, + onSelectFile, + onOpenInEditor, + onToggleDirectory, + onInlineEditSubmit, + onInlineEditCancel, + onNewFile, + onNewFolder, + onRename, + onDelete, +}: { + node: FileTreeNode; + depth: number; + indent: number; + rowHeight: number; + selectedFilePath?: string; + hoveredPath?: string | null; + inlineEdit: InlineEditState; + isMuted: boolean; + fileStatusByPath: Map<string, FileStatus>; + folderStatusByPath: Map<string, FileStatus>; + ignoredPaths: Set<string>; + onSelectFile: (absolutePath: string, openInNewTab?: boolean) => void; + onOpenInEditor: (absolutePath: string) => void; + onToggleDirectory: (absolutePath: string) => void; + onInlineEditSubmit: (name: string) => void; + onInlineEditCancel: () => void; + onNewFile: (parentPath: string) => void; + onNewFolder: (parentPath: string) => void; + onRename: (absolutePath: string, name: string, isDirectory: boolean) => void; + onDelete: (absolutePath: string, name: string, isDirectory: boolean) => void; +}) { + const isCreating = inlineEdit?.kind === "create"; + const isCreatingHere = + isCreating && inlineEdit.parentPath === node.absolutePath; + const isCreatingFile = isCreatingHere && inlineEdit.mode === "file"; + const isRenaming = + inlineEdit?.kind === "rename" && + inlineEdit.absolutePath === node.absolutePath; + const lastFolderIndex = node.children.findLastIndex( + (n) => n.kind === "directory", + ); + + // Resolve decoration once per node. Muted wins over change status so + // gitignored paths stay quiet even in the `git add -f` edge case. + const posixRelativePath = toPosix(node.relativePath); + const isFolder = node.kind === "directory"; + const fileStatus = !isFolder + ? fileStatusByPath.get(posixRelativePath) + : undefined; + const folderStatus = isFolder + ? folderStatusByPath.get(posixRelativePath) + : undefined; + const decoration = isMuted ? undefined : (fileStatus ?? folderStatus); + + return ( + <div> + {isRenaming ? ( + <NewItemInput + mode={node.kind === "directory" ? "folder" : "file"} + depth={depth} + initialValue={inlineEdit.name} + onSubmit={onInlineEditSubmit} + onCancel={onInlineEditCancel} + /> + ) : ( + <WorkspaceFilesTreeItem + node={node} + depth={depth} + indent={indent} + rowHeight={rowHeight} + selectedFilePath={selectedFilePath} + isHovered={hoveredPath === node.absolutePath} + decoration={decoration} + isMuted={isMuted} + onSelectFile={onSelectFile} + onOpenInEditor={onOpenInEditor} + onToggleDirectory={onToggleDirectory} + onNewFile={onNewFile} + onNewFolder={onNewFolder} + onRename={onRename} + onDelete={onDelete} + /> + )} + {node.kind === "directory" && node.isExpanded && ( + <> + {isCreatingHere && inlineEdit.mode === "folder" && ( + <NewItemInput + mode="folder" + depth={depth + 1} + onSubmit={onInlineEditSubmit} + onCancel={onInlineEditCancel} + /> + )} + {node.children.map((child, index) => { + const childIsMuted = + isMuted || ignoredPaths.has(toPosix(child.relativePath)); + return ( + <Fragment key={child.absolutePath}> + <TreeNode + node={child} + depth={depth + 1} + indent={indent} + rowHeight={rowHeight} + selectedFilePath={selectedFilePath} + hoveredPath={hoveredPath} + inlineEdit={inlineEdit} + isMuted={childIsMuted} + fileStatusByPath={fileStatusByPath} + folderStatusByPath={folderStatusByPath} + ignoredPaths={ignoredPaths} + onSelectFile={onSelectFile} + onOpenInEditor={onOpenInEditor} + onToggleDirectory={onToggleDirectory} + onInlineEditSubmit={onInlineEditSubmit} + onInlineEditCancel={onInlineEditCancel} + onNewFile={onNewFile} + onNewFolder={onNewFolder} + onRename={onRename} + onDelete={onDelete} + /> + {isCreatingFile && index === lastFolderIndex && ( + <NewItemInput + mode="file" + depth={depth + 1} + onSubmit={onInlineEditSubmit} + onCancel={onInlineEditCancel} + /> + )} + </Fragment> + ); + })} + {isCreatingFile && lastFolderIndex === -1 && ( + <NewItemInput + mode="file" + depth={depth + 1} + onSubmit={onInlineEditSubmit} + onCancel={onInlineEditCancel} + /> + )} + </> + )} + </div> + ); +} + +export function FilesTab({ + onSelectFile, + selectedFilePath, + pendingReveal, + workspaceId, + gitStatus, +}: FilesTabProps) { + const [_isRefreshing, setIsRefreshing] = useState(false); + const [hoveredPath, setHoveredPath] = useState<string | null>(null); + const [inlineEdit, setInlineEdit] = useState<InlineEditState>(null); + const utils = workspaceTrpc.useUtils(); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const rootPath = workspaceQuery.data?.worktreePath ?? ""; + + const openInExternalEditor = useOpenInExternalEditor(workspaceId); + + const handleOpenInEditor = useCallback( + (absolutePath: string) => { + openInExternalEditor(absolutePath); + }, + [openInExternalEditor], + ); + + const writeFile = workspaceTrpc.filesystem.writeFile.useMutation(); + const createDirectory = + workspaceTrpc.filesystem.createDirectory.useMutation(); + const movePath = workspaceTrpc.filesystem.movePath.useMutation(); + + const fileTree = useFileTree({ workspaceId, rootPath }); + + const { fileStatusByPath, folderStatusByPath, ignoredPaths } = + useGitStatusMap(gitStatus); + + useWorkspaceEvent( + "fs:events", + workspaceId, + () => void utils.filesystem.searchFiles.invalidate(), + Boolean(workspaceId), + ); + + const scrollContainerRef = useRef<HTMLDivElement>(null); + const lastMousePos = useRef<{ x: number; y: number } | null>(null); + + const updateHoverFromPoint = useCallback((x: number, y: number) => { + const el = document.elementFromPoint(x, y)?.closest("[data-filepath]"); + setHoveredPath(el?.getAttribute("data-filepath") ?? null); + }, []); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + lastMousePos.current = { x: e.clientX, y: e.clientY }; + updateHoverFromPoint(e.clientX, e.clientY); + }, + [updateHoverFromPoint], + ); + + const handleScroll = useCallback(() => { + if (lastMousePos.current) + updateHoverFromPoint(lastMousePos.current.x, lastMousePos.current.y); + }, [updateHoverFromPoint]); + + const handleMouseLeave = useCallback(() => { + lastMousePos.current = null; + setHoveredPath(null); + }, []); + + // Every reveal request from the parent is a fresh `pendingReveal` object, + // so depending on its identity re-runs this effect for repeat reveals of + // the same path too. fileTree is intentionally omitted — its identity + // changes every render and would loop; the closure reads the latest state + // via useFileTree's internal refs. `cancelled` guards against a stale + // reveal scrolling the sidebar back to an outdated path if a newer + // request lands mid-flight. + // biome-ignore lint/correctness/useExhaustiveDependencies: fileTree intentionally omitted + useEffect(() => { + if (!pendingReveal || !rootPath) return; + let cancelled = false; + const { path, isDirectory } = pendingReveal; + void fileTree.reveal(path, { isDirectory }).then(() => { + if (cancelled) return; + requestAnimationFrame(() => { + if (cancelled) return; + scrollContainerRef.current + ?.querySelector(`[data-filepath="${CSS.escape(path)}"]`) + ?.scrollIntoView({ block: "center" }); + }); + }); + return () => { + cancelled = true; + }; + }, [pendingReveal, rootPath]); + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + try { + await fileTree.refreshAll(); + } finally { + setIsRefreshing(false); + } + }, [fileTree]); + + const getParentForCreation = useCallback((): string => { + if (!selectedFilePath || !rootPath) return rootPath; + // Walk tree to check if selected is a directory + function isDirectory(nodes: FileTreeNode[]): boolean { + for (const n of nodes) { + if (n.absolutePath === selectedFilePath) return n.kind === "directory"; + if (n.children.length > 0 && isDirectory(n.children)) return true; + } + return false; + } + if (isDirectory(fileTree.rootEntries)) return selectedFilePath; + const lastSlash = selectedFilePath.lastIndexOf("/"); + return lastSlash > 0 ? selectedFilePath.slice(0, lastSlash) : rootPath; + }, [selectedFilePath, rootPath, fileTree.rootEntries]); + + const startCreating = useCallback( + async (mode: "file" | "folder", targetPath?: string) => { + const parentPath = targetPath ?? getParentForCreation(); + if (parentPath !== rootPath) await fileTree.expand(parentPath); + setInlineEdit({ kind: "create", mode, parentPath }); + + scrollContainerRef.current + ?.querySelector("[data-new-item-input]") + ?.scrollIntoView({ block: "nearest" }); + setTimeout(() => { + scrollContainerRef.current + ?.querySelector<HTMLInputElement>("[data-new-item-input] input") + ?.focus(); + }, 200); + }, + [getParentForCreation, rootPath, fileTree], + ); + + const startRenaming = useCallback( + (absolutePath: string, name: string, isDirectory: boolean) => { + setInlineEdit({ kind: "rename", absolutePath, name, isDirectory }); + setTimeout(() => { + scrollContainerRef.current + ?.querySelector<HTMLInputElement>("[data-new-item-input] input") + ?.focus(); + }, 200); + }, + [], + ); + + const handleInlineEditSubmit = useCallback( + async (name: string) => { + if (!inlineEdit || !rootPath) return; + + try { + if (inlineEdit.kind === "create") { + const { mode, parentPath } = inlineEdit; + const segments = name.split("/").filter(Boolean); + if (segments.length === 0) return; + + const absolutePath = `${parentPath}/${name}`; + + if (mode === "folder") { + await createDirectory.mutateAsync({ + workspaceId, + absolutePath, + recursive: true, + }); + } else { + if (segments.length > 1) { + const dirPath = `${parentPath}/${segments.slice(0, -1).join("/")}`; + await createDirectory.mutateAsync({ + workspaceId, + absolutePath: dirPath, + recursive: true, + }); + } + await writeFile.mutateAsync({ + workspaceId, + absolutePath, + content: "", + options: { create: true, overwrite: false }, + }); + onSelectFile(absolutePath); + } + } else { + const { absolutePath } = inlineEdit; + const parentDir = absolutePath.slice( + 0, + absolutePath.lastIndexOf("/"), + ); + const destinationPath = `${parentDir}/${name}`; + await movePath.mutateAsync({ + workspaceId, + sourceAbsolutePath: absolutePath, + destinationAbsolutePath: destinationPath, + }); + } + } catch (error) { + toast.error( + inlineEdit.kind === "create" + ? "Failed to create item" + : "Failed to rename", + { + description: error instanceof Error ? error.message : undefined, + }, + ); + } + setInlineEdit(null); + }, + [ + inlineEdit, + rootPath, + workspaceId, + writeFile, + createDirectory, + movePath, + onSelectFile, + ], + ); + + const handleInlineEditCancel = useCallback(() => setInlineEdit(null), []); + + const deletePath = workspaceTrpc.filesystem.deletePath.useMutation(); + + const handleDelete = useCallback( + (absolutePath: string, name: string, isDirectory: boolean) => { + const itemType = isDirectory ? "folder" : "file"; + alert({ + title: `Delete ${name}?`, + description: `Are you sure you want to delete this ${itemType}? This action cannot be undone.`, + actions: [ + { + label: "Delete", + variant: "destructive", + onClick: () => { + toast.promise( + deletePath.mutateAsync({ + workspaceId, + absolutePath, + }), + { + loading: `Deleting ${name}...`, + success: `Deleted ${name}`, + error: `Failed to delete ${name}`, + }, + ); + }, + }, + { + label: "Cancel", + variant: "ghost", + }, + ], + }); + }, + [workspaceId, deletePath], + ); + + if (!rootPath) { + return ( + <div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"> + {workspaceQuery.isLoading ? ( + <> + <Loader2 className="size-3.5 animate-spin" /> + <span>Loading files...</span> + </> + ) : ( + "Workspace worktree not available" + )} + </div> + ); + } + + const isCreatingAtRoot = + inlineEdit?.kind === "create" && inlineEdit.parentPath === rootPath; + const isCreatingFileAtRoot = + isCreatingAtRoot && + inlineEdit?.kind === "create" && + inlineEdit.mode === "file"; + const isCreatingFolderAtRoot = + isCreatingAtRoot && + inlineEdit?.kind === "create" && + inlineEdit.mode === "folder"; + const rootLastFolderIndex = fileTree.rootEntries.findLastIndex( + (n) => n.kind === "directory", + ); + + return ( + <div className="flex h-full min-h-0 flex-col overflow-hidden"> + {/* biome-ignore lint/a11y/noStaticElementInteractions: mouse tracking for hover state */} + <div + ref={scrollContainerRef} + className="min-h-0 flex-1 overflow-y-auto" + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} + onScroll={handleScroll} + > + <div + className="group flex items-center justify-between bg-background px-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground" + style={{ + height: ROW_HEIGHT, + position: "sticky", + top: 0, + zIndex: 20, + }} + > + <span className="truncate">Explorer</span> + <div className="flex items-center gap-0.5"> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5" + onClick={() => void startCreating("file")} + > + <FilePlus className="size-3" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom">New File</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5" + onClick={() => void startCreating("folder")} + > + <FolderPlus className="size-3" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom">New Folder</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5" + onClick={() => void handleRefresh()} + > + <RefreshCw className="size-3" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom">Refresh</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5" + onClick={fileTree.collapseAll} + > + <FoldVertical className="size-3" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom">Collapse All</TooltipContent> + </Tooltip> + </div> + </div> + + {fileTree.rootEntries.length === 0 && + !fileTree.isLoadingRoot && + !isCreatingAtRoot ? ( + <div className="px-2 py-3 text-sm text-muted-foreground"> + No files found + </div> + ) : ( + <> + {isCreatingFolderAtRoot && ( + <NewItemInput + mode="folder" + depth={1} + onSubmit={handleInlineEditSubmit} + onCancel={handleInlineEditCancel} + /> + )} + {fileTree.rootEntries.map((node, index) => { + const nodeIsMuted = ignoredPaths.has(toPosix(node.relativePath)); + return ( + <Fragment key={node.absolutePath}> + <TreeNode + node={node} + depth={1} + indent={TREE_INDENT} + rowHeight={ROW_HEIGHT} + selectedFilePath={selectedFilePath} + hoveredPath={hoveredPath} + inlineEdit={inlineEdit} + isMuted={nodeIsMuted} + fileStatusByPath={fileStatusByPath} + folderStatusByPath={folderStatusByPath} + ignoredPaths={ignoredPaths} + onSelectFile={onSelectFile} + onOpenInEditor={handleOpenInEditor} + onToggleDirectory={(absolutePath) => + void fileTree.toggle(absolutePath) + } + onInlineEditSubmit={handleInlineEditSubmit} + onInlineEditCancel={handleInlineEditCancel} + onNewFile={(parentPath) => + void startCreating("file", parentPath) + } + onNewFolder={(parentPath) => + void startCreating("folder", parentPath) + } + onRename={(absolutePath, name, isDirectory) => + startRenaming(absolutePath, name, isDirectory) + } + onDelete={handleDelete} + /> + {isCreatingFileAtRoot && index === rootLastFolderIndex && ( + <NewItemInput + mode="file" + depth={1} + onSubmit={handleInlineEditSubmit} + onCancel={handleInlineEditCancel} + /> + )} + </Fragment> + ); + })} + {isCreatingFileAtRoot && rootLastFolderIndex === -1 && ( + <NewItemInput + mode="file" + depth={1} + onSubmit={handleInlineEditSubmit} + onCancel={handleInlineEditCancel} + /> + )} + </> + )} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/NewItemInput/NewItemInput.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/NewItemInput/NewItemInput.tsx new file mode 100644 index 00000000000..bf0c65a9e02 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/NewItemInput/NewItemInput.tsx @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from "react"; +import { LuChevronDown } from "react-icons/lu"; +import { + ROW_HEIGHT, + TREE_INDENT, +} from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; + +interface NewItemInputProps { + mode: "file" | "folder"; + depth: number; + initialValue?: string; + onSubmit: (name: string) => void; + onCancel: () => void; +} + +export function NewItemInput({ + mode, + depth, + initialValue = "", + onSubmit, + onCancel, +}: NewItemInputProps) { + const [value, setValue] = useState(initialValue); + const inputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + const input = inputRef.current; + if (!input) return; + // For rename: select the name up to the extension so user can type to replace + if (initialValue) { + const dotIndex = initialValue.lastIndexOf("."); + if (dotIndex > 0 && mode === "file") { + input.setSelectionRange(0, dotIndex); + } else { + input.select(); + } + } + }, [initialValue, mode]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + const trimmed = value.trim(); + if (trimmed && trimmed !== initialValue) onSubmit(trimmed); + else onCancel(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + + const displayName = value.includes("/") + ? (value.split("/").pop() ?? "") + : value; + + return ( + <div + data-new-item-input + className="flex w-full items-center gap-1 px-1" + style={{ + height: ROW_HEIGHT, + paddingLeft: 4 + depth * TREE_INDENT, + }} + > + <span className="flex h-4 w-4 shrink-0 items-center justify-center"> + {mode === "folder" ? ( + <LuChevronDown className="size-3.5 text-muted-foreground" /> + ) : null} + </span> + + <FileIcon + className="size-4 shrink-0" + fileName={displayName || (mode === "folder" ? "folder" : "file")} + isDirectory={mode === "folder"} + isOpen={false} + /> + + <input + ref={inputRef} + value={value} + onChange={(e) => setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={onCancel} + className="min-w-0 flex-1 bg-transparent text-xs outline-none ring-1 ring-ring rounded-sm px-1" + style={{ height: ROW_HEIGHT - 4 }} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/NewItemInput/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/NewItemInput/index.ts new file mode 100644 index 00000000000..23e9c65a4c7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/NewItemInput/index.ts @@ -0,0 +1 @@ +export { NewItemInput } from "./NewItemInput"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx new file mode 100644 index 00000000000..38fb17472ad --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/WorkspaceFilesTreeItem.tsx @@ -0,0 +1,191 @@ +import { ContextMenu, ContextMenuTrigger } from "@superset/ui/context-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { memo } from "react"; +import { LuChevronDown, LuChevronRight, LuCircle } from "react-icons/lu"; +import type { FileTreeNode } from "renderer/hooks/host-service/useFileTree"; +import type { FileStatus } from "renderer/hooks/host-service/useGitStatusMap"; +import { CLICK_HINT_TOOLTIP } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels"; +import { getSidebarClickIntent } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; + +import { FileContextMenu } from "./components/FileContextMenu"; +import { FolderContextMenu } from "./components/FolderContextMenu"; + +const STATUS_TEXT_CLASS: Record<FileStatus, string> = { + added: "text-green-700 dark:text-green-400", + copied: "text-purple-700 dark:text-purple-400", + changed: "text-yellow-600 dark:text-yellow-400", + deleted: "text-red-700 dark:text-red-500", + modified: "text-yellow-600 dark:text-yellow-400", + renamed: "text-blue-600 dark:text-blue-400", + untracked: "text-green-700 dark:text-green-400", +}; + +// Single-letter badge shown on the right of changed file rows, VS Code style. +const STATUS_LETTER: Record<FileStatus, string> = { + added: "A", + copied: "C", + changed: "M", + deleted: "D", + modified: "M", + renamed: "R", + untracked: "U", +}; + +interface WorkspaceFilesTreeItemProps { + node: FileTreeNode; + depth: number; + rowHeight: number; + indent: number; + selectedFilePath?: string; + isHovered?: boolean; + decoration?: FileStatus; + isMuted?: boolean; + onSelectFile: (absolutePath: string, openInNewTab?: boolean) => void; + onOpenInEditor: (absolutePath: string) => void; + onToggleDirectory: (absolutePath: string) => void; + onNewFile: (parentPath: string) => void; + onNewFolder: (parentPath: string) => void; + onRename: (absolutePath: string, name: string, isDirectory: boolean) => void; + onDelete: (absolutePath: string, name: string, isDirectory: boolean) => void; +} + +function WorkspaceFilesTreeItemComponent({ + node, + depth, + rowHeight, + indent, + selectedFilePath, + isHovered, + decoration, + isMuted, + onSelectFile, + onOpenInEditor, + onToggleDirectory, + onNewFile, + onNewFolder, + onRename, + onDelete, +}: WorkspaceFilesTreeItemProps) { + const isFolder = node.kind === "directory"; + const isSelected = selectedFilePath === node.absolutePath; + + const nameColorClass = isMuted + ? "text-muted-foreground" + : decoration + ? STATUS_TEXT_CLASS[decoration] + : undefined; + + const rowButton = ( + <button + data-filepath={node.absolutePath} + aria-expanded={isFolder ? node.isExpanded : undefined} + className={cn( + "flex w-full cursor-pointer select-none items-center gap-1 pr-4 text-left transition-colors", + isFolder ? "bg-background" : undefined, + isHovered && !isSelected + ? isFolder + ? "!bg-muted" + : "!bg-accent/50" + : undefined, + isSelected ? "!bg-accent" : undefined, + )} + onClick={(e) => { + const intent = getSidebarClickIntent(e); + if (isFolder) { + onToggleDirectory(node.absolutePath); + } else if (intent === "openInEditor") { + onOpenInEditor(node.absolutePath); + } else { + onSelectFile(node.absolutePath, intent === "openInNewTab"); + } + }} + style={{ + height: rowHeight, + paddingLeft: 8 + (depth - 1) * indent, + ...(isFolder + ? { + position: "sticky" as const, + top: (depth - 1) * rowHeight, + zIndex: Math.max(1, 50 - depth), + } + : {}), + }} + type="button" + > + <span className="flex h-4 w-4 shrink-0 items-center justify-center"> + {isFolder ? ( + node.isExpanded ? ( + <LuChevronDown className="size-3.5 text-muted-foreground" /> + ) : ( + <LuChevronRight className="size-3.5 text-muted-foreground" /> + ) + ) : null} + </span> + + <FileIcon + className="size-4 shrink-0" + fileName={node.name} + isDirectory={isFolder} + isOpen={node.isExpanded} + /> + + <span className={cn("min-w-0 flex-1 truncate text-xs", nameColorClass)}> + {node.name} + </span> + + {decoration && !isMuted && ( + <span + className={cn( + "ml-auto shrink-0 text-[10px] font-semibold leading-none", + STATUS_TEXT_CLASS[decoration], + )} + > + {isFolder ? ( + <LuCircle className="size-2 fill-current opacity-50" /> + ) : ( + STATUS_LETTER[decoration] + )} + </span> + )} + </button> + ); + + return ( + <ContextMenu> + {isFolder ? ( + <ContextMenuTrigger asChild>{rowButton}</ContextMenuTrigger> + ) : ( + <Tooltip> + <ContextMenuTrigger asChild> + <TooltipTrigger asChild>{rowButton}</TooltipTrigger> + </ContextMenuTrigger> + <TooltipContent side="right">{CLICK_HINT_TOOLTIP}</TooltipContent> + </Tooltip> + )} + {isFolder ? ( + <FolderContextMenu + absolutePath={node.absolutePath} + relativePath={node.relativePath} + onNewFile={() => onNewFile(node.absolutePath)} + onNewFolder={() => onNewFolder(node.absolutePath)} + onRename={() => onRename(node.absolutePath, node.name, true)} + onDelete={() => onDelete(node.absolutePath, node.name, true)} + /> + ) : ( + <FileContextMenu + absolutePath={node.absolutePath} + relativePath={node.relativePath} + onOpen={() => onSelectFile(node.absolutePath)} + onOpenInNewTab={() => onSelectFile(node.absolutePath, true)} + onOpenInEditor={() => onOpenInEditor(node.absolutePath)} + onRename={() => onRename(node.absolutePath, node.name, false)} + onDelete={() => onDelete(node.absolutePath, node.name, false)} + /> + )} + </ContextMenu> + ); +} + +export const WorkspaceFilesTreeItem = memo(WorkspaceFilesTreeItemComponent); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx new file mode 100644 index 00000000000..568272e6583 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/FileContextMenu.tsx @@ -0,0 +1,57 @@ +import { + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, +} from "@superset/ui/context-menu"; +import { + MOD_CLICK_LABEL, + SHIFT_CLICK_LABEL, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels"; +import { PathActionsMenuItems } from "../PathActionsMenuItems"; + +interface FileContextMenuProps { + absolutePath: string; + relativePath?: string; + onOpen: () => void; + onOpenInNewTab: () => void; + onOpenInEditor: () => void; + onRename: () => void; + onDelete: () => void; +} + +export function FileContextMenu({ + absolutePath, + relativePath, + onOpen, + onOpenInNewTab, + onOpenInEditor, + onRename, + onDelete, +}: FileContextMenuProps) { + return ( + <ContextMenuContent className="w-56"> + <ContextMenuItem onSelect={onOpen}>Open</ContextMenuItem> + <ContextMenuItem onSelect={onOpenInNewTab}> + Open in New Tab + <ContextMenuShortcut>{SHIFT_CLICK_LABEL}</ContextMenuShortcut> + </ContextMenuItem> + <ContextMenuItem onSelect={onOpenInEditor}> + Open in Editor + <ContextMenuShortcut>{MOD_CLICK_LABEL}</ContextMenuShortcut> + </ContextMenuItem> + <ContextMenuSeparator /> + <PathActionsMenuItems + absolutePath={absolutePath} + relativePath={relativePath} + /> + <ContextMenuSeparator /> + <ContextMenuItem onSelect={() => setTimeout(onRename, 0)}> + Rename... + </ContextMenuItem> + <ContextMenuItem variant="destructive" onSelect={onDelete}> + Delete + </ContextMenuItem> + </ContextMenuContent> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/index.ts new file mode 100644 index 00000000000..f2a47473bc6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FileContextMenu/index.ts @@ -0,0 +1 @@ +export { FileContextMenu } from "./FileContextMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx new file mode 100644 index 00000000000..119e2eb84fe --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/FolderContextMenu.tsx @@ -0,0 +1,47 @@ +import { + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, +} from "@superset/ui/context-menu"; +import { PathActionsMenuItems } from "../PathActionsMenuItems"; + +interface FolderContextMenuProps { + absolutePath: string; + relativePath?: string; + onNewFile: () => void; + onNewFolder: () => void; + onRename: () => void; + onDelete: () => void; +} + +export function FolderContextMenu({ + absolutePath, + relativePath, + onNewFile, + onNewFolder, + onRename, + onDelete, +}: FolderContextMenuProps) { + return ( + <ContextMenuContent className="w-56"> + <ContextMenuItem onSelect={() => setTimeout(onNewFile, 0)}> + New File... + </ContextMenuItem> + <ContextMenuItem onSelect={() => setTimeout(onNewFolder, 0)}> + New Folder... + </ContextMenuItem> + <ContextMenuSeparator /> + <PathActionsMenuItems + absolutePath={absolutePath} + relativePath={relativePath} + /> + <ContextMenuSeparator /> + <ContextMenuItem onSelect={() => setTimeout(onRename, 0)}> + Rename... + </ContextMenuItem> + <ContextMenuItem variant="destructive" onSelect={onDelete}> + Delete + </ContextMenuItem> + </ContextMenuContent> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/index.ts new file mode 100644 index 00000000000..1d35e4d09a4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/FolderContextMenu/index.ts @@ -0,0 +1 @@ +export { FolderContextMenu } from "./FolderContextMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx new file mode 100644 index 00000000000..9b5d66bf614 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/PathActionsMenuItems.tsx @@ -0,0 +1,56 @@ +import { + ContextMenuItem, + ContextMenuSeparator, +} from "@superset/ui/context-menu"; +import { toast } from "@superset/ui/sonner"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +interface PathActionsMenuItemsProps { + absolutePath: string; + relativePath?: string; +} + +export function PathActionsMenuItems({ + absolutePath, + relativePath, +}: PathActionsMenuItemsProps) { + const { copyToClipboard } = useCopyToClipboard(); + + const handleCopy = (path: string, successMessage: string) => { + toast.promise(copyToClipboard(path), { + success: successMessage, + error: (err: unknown) => + `Failed to copy path: ${err instanceof Error ? err.message : "Unknown error"}`, + }); + }; + + const handleRevealInFinder = async () => { + try { + await electronTrpcClient.external.openInFinder.mutate(absolutePath); + } catch (error) { + toast.error( + `Failed to reveal in Finder: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }; + + return ( + <> + <ContextMenuItem onSelect={handleRevealInFinder}> + Reveal in Finder + </ContextMenuItem> + <ContextMenuSeparator /> + <ContextMenuItem onSelect={() => handleCopy(absolutePath, "Path copied")}> + Copy Path + </ContextMenuItem> + {relativePath && ( + <ContextMenuItem + onSelect={() => handleCopy(relativePath, "Relative path copied")} + > + Copy Relative Path + </ContextMenuItem> + )} + </> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts new file mode 100644 index 00000000000..2f4345f1fcd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems/index.ts @@ -0,0 +1 @@ +export { PathActionsMenuItems } from "./PathActionsMenuItems"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesTreeItem/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilesTreeItem/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/index.ts new file mode 100644 index 00000000000..63d1ea6004d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/index.ts @@ -0,0 +1 @@ +export { FilesTab } from "./FilesTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/PRActionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/PRActionHeader.tsx new file mode 100644 index 00000000000..13a8a4d2639 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/PRActionHeader.tsx @@ -0,0 +1,177 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { VscGitPullRequest, VscLoading } from "react-icons/vsc"; +import type { PRFlowDispatch } from "../../hooks/usePRFlowDispatch"; +import { PRStatusGroup } from "./components/PRStatusGroup"; +import { + type PRFlowState, + selectActionButton, + type UnavailableReason, +} from "./utils/getPRFlowState"; + +interface PRActionHeaderProps { + workspaceId: string; + state: PRFlowState; + dispatch: PRFlowDispatch; + onRetry?: () => void; + /** + * Gates the "Create PR" entry point. When false, the no-PR state renders + * a muted icon with a tooltip instead of a clickable create button. + * Will flip to true once the chat-driven create flow lands in v2. + */ + createPREnabled?: boolean; +} + +export function PRActionHeader({ + workspaceId, + state, + dispatch, + onRetry, + createPREnabled = true, +}: PRActionHeaderProps) { + const action = selectActionButton(state); + + return ( + <div className="flex h-12 shrink-0 items-center gap-2 border-b border-border bg-muted/45 px-2 dark:bg-muted/35"> + <div className="ml-auto flex items-center"> + <ActionSlot + variant={action} + state={state} + dispatch={dispatch} + onRetry={onRetry} + createPREnabled={createPREnabled} + workspaceId={workspaceId} + /> + </div> + </div> + ); +} + +/** + * Mirrors v1's PRButton state machine using just icons. PR-state, CI/review + * detail, and copy all live in the hover card surfaced from PRStatusGroup — + * the bar itself stays quiet at rest. + */ +function ActionSlot({ + variant, + state, + dispatch, + onRetry, + createPREnabled, + workspaceId, +}: { + variant: ReturnType<typeof selectActionButton>; + state: PRFlowState; + dispatch: PRFlowDispatch; + onRetry?: () => void; + createPREnabled: boolean; + workspaceId: string; +}) { + switch (variant.kind) { + case "hidden": + // `pr-exists` lands here — render the link + indicators + dropdown. + return ( + <PRStatusGroup + state={state} + workspaceId={workspaceId} + onRefresh={onRetry} + /> + ); + + case "disabled-tooltip": + return <UnavailableIcon reason={variant.reasonKind} />; + + case "create-pr-dropdown": + if (!createPREnabled) { + return ( + <UnavailableIcon + reason="create-disabled" + tooltip="Create PR coming soon" + /> + ); + } + return <CreatePRIconButton state={state} dispatch={dispatch} />; + + case "cancel-busy": + return ( + <> + <PRStatusGroup + state={state} + workspaceId={workspaceId} + onRefresh={onRetry} + /> + <VscLoading className="ml-1.5 size-4 animate-spin text-muted-foreground" /> + </> + ); + + case "retry": + return ( + <button + type="button" + onClick={onRetry} + aria-label="Retry loading pull request" + className="flex items-center text-muted-foreground/60 transition-colors hover:text-muted-foreground" + > + <VscGitPullRequest className="size-4" /> + </button> + ); + } +} + +function UnavailableIcon({ + reason, + tooltip, +}: { + reason: UnavailableReason | "create-disabled"; + tooltip?: string; +}) { + const tooltipText = tooltip ?? unavailableTooltip(reason); + return ( + <Tooltip> + <TooltipTrigger asChild> + <span className="flex items-center text-muted-foreground/40"> + <VscGitPullRequest className="size-4" /> + </span> + </TooltipTrigger> + <TooltipContent side="bottom">{tooltipText}</TooltipContent> + </Tooltip> + ); +} + +function unavailableTooltip( + reason: UnavailableReason | "create-disabled", +): string { + switch (reason) { + case "no-repo": + return "No GitHub repository connected"; + case "default-branch": + return "Switch to a feature branch to create a pull request"; + case "detached-head": + return "Checkout a branch to create a pull request"; + case "create-disabled": + return "Create PR coming soon"; + } +} + +function CreatePRIconButton({ + state, + dispatch, +}: { + state: PRFlowState; + dispatch: PRFlowDispatch; +}) { + return ( + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => dispatch({ state, draft: false })} + aria-label="Create pull request" + className="flex items-center text-muted-foreground transition-colors hover:text-foreground" + > + <VscGitPullRequest className="size-4" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom">Create Pull Request</TooltipContent> + </Tooltip> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/PRStatusGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/PRStatusGroup.tsx new file mode 100644 index 00000000000..e7a2914de25 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/PRStatusGroup.tsx @@ -0,0 +1,243 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@superset/ui/hover-card"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { useMemo } from "react"; +import { VscChevronDown, VscGitMerge, VscLoading } from "react-icons/vsc"; +import { PRIcon, type PRState } from "renderer/screens/main/components/PRIcon"; +import { computeChecksRollup } from "../../utils/computeChecksStatus"; +import type { PRFlowState } from "../../utils/getPRFlowState"; +import { PRDetailCard } from "./components/PRDetailCard"; +import { PRStatusIndicators } from "./components/PRStatusIndicators"; + +interface PRStatusGroupProps { + state: PRFlowState; + workspaceId: string; + onRefresh?: () => void; +} + +/** + * v1-style PR badge sitting on the right of the action header — link to the + * PR with status icon, compact CI/review indicators next to the number, plus + * a merge dropdown when the PR is open and not a draft. Hovering the link + * surfaces a rich detail popover (title, branches, CI summary, review status, + * last activity). + * + * Closed/merged/draft PRs render the link without the merge dropdown. + * Indicators are suppressed past `open`/`draft` since post-merge CI/review + * state is historical noise. + */ +export function PRStatusGroup({ + state, + workspaceId, + onRefresh, +}: PRStatusGroupProps) { + const pr = + state.kind === "pr-exists" + ? state.pr + : state.kind === "busy" || state.kind === "error" + ? state.pr + : null; + + // Triggers a GitHub→host-service-DB sync for this workspace's PR. Without + // this, post-merge UI state lags by up to ~30s waiting for the next + // background sync tick. Called after a successful merge before refetching + // the local query. + const refreshPRMutation = + workspaceTrpc.pullRequests.refreshByWorkspaces.useMutation(); + + const mergePRMutation = workspaceTrpc.github.mergePR.useMutation({ + onMutate: () => { + const toastId = toast.loading("Merging PR..."); + return { toastId }; + }, + onSuccess: async (_data, _variables, context) => { + toast.success("PR merged", { id: context?.toastId }); + try { + await refreshPRMutation.mutateAsync({ workspaceIds: [workspaceId] }); + } catch (error) { + console.warn("Failed to refresh PR state after merge", error); + toast.warning( + "Merged, but couldn't refresh PR state — try again in a moment", + ); + } finally { + onRefresh?.(); + } + }, + onError: (error, _variables, context) => { + toast.error(`Merge failed: ${error.message}`, { id: context?.toastId }); + }, + }); + + const checks = useMemo( + () => (pr ? computeChecksRollup(pr.checks) : null), + [pr], + ); + + if (!pr || !checks) return null; + + const linkState = pr.isDraft + ? "draft" + : pr.state === "merged" + ? "merged" + : pr.state === "closed" + ? "closed" + : "open"; + const canMerge = pr.state === "open" && !pr.isDraft; + const showIndicators = pr.state === "open"; // includes draft + + const handleMerge = (mergeMethod: "merge" | "squash" | "rebase") => { + mergePRMutation.mutate({ + owner: pr.repoOwner, + repo: pr.repoName, + pullNumber: pr.number, + mergeMethod, + }); + }; + + const tint = stateTintClasses(linkState); + + return ( + <div + className={cn( + "flex items-center overflow-hidden rounded border", + tint.container, + )} + aria-busy={mergePRMutation.isPending} + > + <HoverCard openDelay={150} closeDelay={120}> + <HoverCardTrigger asChild> + <a + href={pr.url} + target="_blank" + rel="noopener noreferrer" + className={cn( + "flex items-center gap-1 px-1.5 py-0.5 outline-none transition-colors", + tint.hover, + )} + > + <PRIcon state={linkState} className="size-4" /> + <span className="font-mono text-xs text-muted-foreground"> + #{pr.number} + </span> + {showIndicators && <PRStatusIndicators checks={checks} />} + </a> + </HoverCardTrigger> + <HoverCardContent + align="end" + sideOffset={8} + className="w-80 overflow-hidden p-0" + > + <PRDetailCard pr={pr} checks={checks} linkState={linkState} /> + </HoverCardContent> + </HoverCard> + + {canMerge && ( + <> + <div className={cn("h-full w-px", tint.divider)} /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + className={cn( + "flex items-center px-1 py-0.5 outline-none transition-colors", + tint.hover, + )} + disabled={mergePRMutation.isPending} + aria-label={ + mergePRMutation.isPending + ? "Merging pull request" + : "Open merge options" + } + > + {mergePRMutation.isPending ? ( + <VscLoading className="size-3 animate-spin text-muted-foreground" /> + ) : ( + <VscChevronDown className="size-3 text-muted-foreground" /> + )} + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-44"> + <DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> + Merge + </DropdownMenuLabel> + <DropdownMenuItem + onClick={() => handleMerge("squash")} + className="text-xs" + disabled={mergePRMutation.isPending} + > + <VscGitMerge className="size-3.5" /> + Squash and merge + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => handleMerge("merge")} + className="text-xs" + disabled={mergePRMutation.isPending} + > + <VscGitMerge className="size-3.5" /> + Create merge commit + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => handleMerge("rebase")} + className="text-xs" + disabled={mergePRMutation.isPending} + > + <VscGitMerge className="size-3.5" /> + Rebase and merge + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </> + )} + </div> + ); +} + +/** + * State-tinted styling for the PR badge bordered group. Mirrors the PRIcon + * color palette so the whole group reads as "open"/"draft"/etc. at a glance, + * not just the icon. + */ +function stateTintClasses(state: PRState): { + container: string; + hover: string; + divider: string; +} { + switch (state) { + case "open": + return { + container: "border-emerald-500/30 bg-emerald-500/10", + hover: "hover:bg-emerald-500/15 focus-visible:bg-emerald-500/15", + divider: "bg-emerald-500/30", + }; + case "merged": + return { + container: "border-violet-500/30 bg-violet-500/10", + hover: "hover:bg-violet-500/15 focus-visible:bg-violet-500/15", + divider: "bg-violet-500/30", + }; + case "closed": + return { + container: "border-rose-500/30 bg-rose-500/10", + hover: "hover:bg-rose-500/15 focus-visible:bg-rose-500/15", + divider: "bg-rose-500/30", + }; + case "draft": + return { + container: "border-border bg-muted/40", + hover: "hover:bg-muted/60 focus-visible:bg-muted/60", + divider: "bg-border", + }; + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRDetailCard/PRDetailCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRDetailCard/PRDetailCard.tsx new file mode 100644 index 00000000000..e585b32d673 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRDetailCard/PRDetailCard.tsx @@ -0,0 +1,189 @@ +import { cn } from "@superset/ui/utils"; +import { formatDistanceToNow } from "date-fns"; +import { + LuArrowUpRight, + LuCircleCheck, + LuCircleDashed, + LuCircleX, + LuGitBranch, +} from "react-icons/lu"; +import { PRIcon, type PRState } from "renderer/screens/main/components/PRIcon"; +import type { ChecksRollup } from "../../../../utils/computeChecksStatus"; +import type { PullRequest } from "../../../../utils/getPRFlowState"; + +interface PRDetailCardProps { + pr: PullRequest; + checks: ChecksRollup; + linkState: PRState; +} + +/** + * Rich popover that opens on hover/focus of the PR link. Surfaces the title, + * branch info, CI/review summary, and last activity — everything that matters + * about the PR without leaving the workspace. Wide enough (320px) to fit a + * reasonable PR title on two lines. + */ +export function PRDetailCard({ pr, checks, linkState }: PRDetailCardProps) { + const stateLabel = pr.isDraft + ? "Draft" + : pr.state === "merged" + ? "Merged" + : pr.state === "closed" + ? "Closed" + : "Open"; + const statePillClass = stateLabelToPillClass(linkState); + + const updatedRelative = pr.updatedAt + ? formatDistanceToNow(new Date(pr.updatedAt), { addSuffix: true }) + : null; + + return ( + <div className="flex flex-col"> + <div className="flex items-start gap-2 px-3 pt-3 pb-2"> + <PRIcon state={linkState} className="mt-0.5 size-4 shrink-0" /> + <div className="min-w-0 flex-1"> + <p className="line-clamp-2 text-sm font-medium leading-snug text-foreground"> + {pr.title} + </p> + <div className="mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground"> + <span className="font-mono">#{pr.number}</span> + <span aria-hidden="true">·</span> + <span + className={cn( + "rounded-sm px-1 py-px text-[10px] font-medium", + statePillClass, + )} + > + {stateLabel} + </span> + </div> + </div> + </div> + + {pr.headRefName && ( + <div className="flex items-center gap-1.5 px-3 pb-2 text-[11px] text-muted-foreground"> + <LuGitBranch + aria-hidden="true" + className="size-3 shrink-0 text-muted-foreground/70" + /> + <span className="truncate font-mono" title={pr.headRefName}> + {pr.headRefName} + </span> + </div> + )} + + <div className="flex flex-col gap-1.5 border-t border-border/60 px-3 py-2.5"> + <ChecksLine checks={checks} /> + </div> + + {updatedRelative && ( + <div className="border-t border-border/60 px-3 py-2 text-[11px] text-muted-foreground"> + Updated {updatedRelative} + </div> + )} + + <a + href={pr.url} + target="_blank" + rel="noopener noreferrer" + className="group flex items-center justify-between border-t border-border/60 px-3 py-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + > + <span>View on GitHub</span> + <LuArrowUpRight + aria-hidden="true" + className="size-3.5 text-muted-foreground/70 transition-transform group-hover:translate-x-px group-hover:-translate-y-px" + /> + </a> + </div> + ); +} + +function ChecksLine({ checks }: { checks: ChecksRollup }) { + if (checks.overall === "none") { + return <DetailLine icon={null} muted text="No checks reported" />; + } + const total = checks.relevantCount; + if (checks.overall === "success") { + return ( + <DetailLine + icon={ + <LuCircleCheck + aria-hidden="true" + className="size-3.5 shrink-0 text-emerald-500" + /> + } + text={`All ${total} ${total === 1 ? "check" : "checks"} passed`} + /> + ); + } + if (checks.overall === "failure") { + const failing = checks.failureCount; + return ( + <DetailLine + icon={ + <LuCircleX + aria-hidden="true" + className="size-3.5 shrink-0 text-rose-500" + /> + } + text={`${failing} of ${total} ${total === 1 ? "check" : "checks"} failing`} + accent="failure" + /> + ); + } + const pending = checks.pendingCount; + return ( + <DetailLine + icon={ + <LuCircleDashed + aria-hidden="true" + className="size-3.5 shrink-0 text-amber-500" + /> + } + text={`${pending} of ${total} ${total === 1 ? "check" : "checks"} running`} + accent="pending" + /> + ); +} + +function DetailLine({ + icon, + text, + muted, + accent, +}: { + icon: React.ReactNode; + text: string; + muted?: boolean; + accent?: "failure" | "pending"; +}) { + return ( + <div className="flex items-center gap-1.5 text-xs"> + {icon ?? <span className="size-3.5 shrink-0" aria-hidden="true" />} + <span + className={cn( + "truncate", + muted && "text-muted-foreground/60", + !muted && !accent && "text-foreground", + accent === "failure" && "text-rose-600 dark:text-rose-400", + accent === "pending" && "text-amber-600 dark:text-amber-400", + )} + > + {text} + </span> + </div> + ); +} + +function stateLabelToPillClass(state: PRState): string { + switch (state) { + case "open": + return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"; + case "merged": + return "bg-violet-500/10 text-violet-600 dark:text-violet-400"; + case "closed": + return "bg-rose-500/10 text-rose-600 dark:text-rose-400"; + case "draft": + return "bg-muted text-muted-foreground"; + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRDetailCard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRDetailCard/index.ts new file mode 100644 index 00000000000..71af2ecc70a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRDetailCard/index.ts @@ -0,0 +1 @@ +export { PRDetailCard } from "./PRDetailCard"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRStatusIndicators/PRStatusIndicators.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRStatusIndicators/PRStatusIndicators.tsx new file mode 100644 index 00000000000..8ebff09d39a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRStatusIndicators/PRStatusIndicators.tsx @@ -0,0 +1,46 @@ +import { cn } from "@superset/ui/utils"; +import { LuCircleCheck, LuCircleDashed, LuCircleX } from "react-icons/lu"; +import type { ChecksRollup } from "../../../../utils/computeChecksStatus"; + +interface PRStatusIndicatorsProps { + checks: ChecksRollup; +} + +/** + * Compact CI status dot next to the PR number. Suppressed when no checks are + * reported so the row stays quiet for trivial PRs. + */ +export function PRStatusIndicators({ checks }: PRStatusIndicatorsProps) { + if (checks.overall === "none") return null; + + return ( + <span className="ml-0.5 flex items-center"> + <ChecksDot status={checks.overall} /> + </span> + ); +} + +function ChecksDot({ status }: { status: ChecksRollup["overall"] }) { + if (status === "success") { + return ( + <LuCircleCheck + aria-hidden="true" + className={cn("size-3 shrink-0", "text-emerald-500")} + /> + ); + } + if (status === "failure") { + return ( + <LuCircleX + aria-hidden="true" + className={cn("size-3 shrink-0", "text-rose-500")} + /> + ); + } + return ( + <LuCircleDashed + aria-hidden="true" + className={cn("size-3 shrink-0", "text-amber-500")} + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRStatusIndicators/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRStatusIndicators/index.ts new file mode 100644 index 00000000000..8939476b1a3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/components/PRStatusIndicators/index.ts @@ -0,0 +1 @@ +export { PRStatusIndicators } from "./PRStatusIndicators"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/index.ts new file mode 100644 index 00000000000..9baa05b688e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRStatusGroup/index.ts @@ -0,0 +1 @@ +export { PRStatusGroup } from "./PRStatusGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/index.ts new file mode 100644 index 00000000000..0974561bfdd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/index.ts @@ -0,0 +1 @@ +export { PRActionHeader } from "./PRActionHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/buildPRContext.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/buildPRContext.test.ts new file mode 100644 index 00000000000..5b728675844 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/buildPRContext.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import type { BranchSyncStatus, PRFlowState } from "../getPRFlowState"; +import { buildPRContext } from "./buildPRContext"; + +const sync = (overrides: Partial<BranchSyncStatus> = {}): BranchSyncStatus => ({ + hasRepo: true, + hasUpstream: true, + pushCount: 0, + pullCount: 0, + isDefaultBranch: false, + isDetached: false, + hasUncommitted: false, + currentBranch: "feature-x", + defaultBranch: "main", + ...overrides, +}); + +const noPrState = (overrides: Partial<BranchSyncStatus> = {}): PRFlowState => ({ + kind: "no-pr", + sync: sync(overrides), +}); + +describe("buildPRContext (no-pr)", () => { + test("includes branch, base, and publish status", () => { + const md = buildPRContext(noPrState()); + expect(md).toContain("Current: `feature-x`"); + expect(md).toContain("Base: `main`"); + expect(md).toContain("Published: yes"); + }); + + test("flags unpublished branches with publish precondition", () => { + const md = buildPRContext(noPrState({ hasUpstream: false })); + expect(md).toContain("Published: no"); + expect(md).toContain("Publish the branch"); + }); + + test("flags uncommitted changes", () => { + const md = buildPRContext(noPrState({ hasUncommitted: true })); + expect(md).toContain("Uncommitted changes: yes"); + expect(md).toContain("Commit or stash uncommitted changes"); + }); + + test("flags unpushed commits when branch has upstream", () => { + const md = buildPRContext(noPrState({ pushCount: 3 })); + expect(md).toContain("Commits ahead of upstream: 3"); + expect(md).toContain("Push unpushed commits"); + }); + + test("warns when branch is behind upstream", () => { + const md = buildPRContext(noPrState({ pullCount: 2 })); + expect(md).toContain("Commits behind upstream: 2"); + expect(md).toContain("behind upstream"); + }); + + test("mentions --draft arg handling", () => { + const md = buildPRContext(noPrState()); + expect(md).toContain("`--draft`"); + }); + + test("uses defaultBranch in suggested gh pr create command", () => { + const md = buildPRContext(noPrState({ defaultBranch: "develop" })); + expect(md).toContain("gh pr create --base develop"); + }); +}); + +describe("buildPRContext (other states)", () => { + test("returns stub for non-no-pr states", () => { + const md = buildPRContext({ kind: "loading" }); + expect(md).toContain("# PR context (loading)"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/buildPRContext.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/buildPRContext.ts new file mode 100644 index 00000000000..13e68a7343d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/buildPRContext.ts @@ -0,0 +1,82 @@ +import type { BranchSyncStatus, PRFlowState } from "../getPRFlowState"; + +/** + * Builds the markdown attachment that is passed to the agent when the + * PR action button is clicked. The skill reads this file to decide + * whether to commit, publish, or push before calling `gh pr create`. + */ +export function buildPRContext(state: PRFlowState): string { + switch (state.kind) { + case "no-pr": + return renderNoPR(state.sync); + default: + return renderStub(state.kind); + } +} + +function renderNoPR(sync: BranchSyncStatus): string { + const lines: string[] = []; + lines.push("# PR context"); + lines.push(""); + lines.push( + "You are about to create a pull request. Use this snapshot to", + "decide what steps to run before calling `gh pr create`.", + ); + lines.push(""); + + lines.push("## Branch"); + lines.push(`- Current: \`${sync.currentBranch ?? "(detached)"}\``); + lines.push(`- Base: \`${sync.defaultBranch ?? "(unknown)"}\``); + lines.push(`- Published: ${sync.hasUpstream ? "yes" : "no"}`); + lines.push(""); + + lines.push("## Sync"); + lines.push( + `- Commits ahead of upstream: ${sync.hasUpstream ? sync.pushCount : "n/a"}`, + ); + lines.push( + `- Commits behind upstream: ${sync.hasUpstream ? sync.pullCount : "n/a"}`, + ); + lines.push(`- Uncommitted changes: ${sync.hasUncommitted ? "yes" : "no"}`); + lines.push(""); + + lines.push("## Required preconditions"); + if (sync.hasUncommitted) { + lines.push("- Commit or stash uncommitted changes."); + } + if (!sync.hasUpstream) { + lines.push("- Publish the branch (`git push -u origin <branch>`)."); + } else if (sync.pushCount > 0) { + lines.push("- Push unpushed commits."); + } + if (sync.hasUpstream && sync.pullCount > 0) { + lines.push( + "- Branch is behind upstream; pull/rebase before creating the PR,", + " or stop and ask the user to resolve.", + ); + } + lines.push(""); + + lines.push("## Creating the PR"); + if (sync.defaultBranch) { + lines.push( + `- Run \`gh pr create --base ${sync.defaultBranch} --title "..." --body "..."\`.`, + ); + } else { + lines.push( + "- Resolve the base branch first (e.g. `gh repo view --json defaultBranchRef`),", + ' then run `gh pr create --base <resolved-branch> --title "..." --body "..."`.', + ); + } + lines.push( + "- If the prompt includes `--draft`, add `--draft` to the `gh` call.", + ); + lines.push("- Print the PR URL at the end."); + lines.push(""); + + return lines.join("\n"); +} + +function renderStub(kind: PRFlowState["kind"]): string { + return `# PR context (${kind})\n\nNo additional context is available for this state yet.\n`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/index.ts new file mode 100644 index 00000000000..189d5f94a05 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/buildPRContext/index.ts @@ -0,0 +1 @@ +export { buildPRContext } from "./buildPRContext"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/computeChecksStatus/computeChecksStatus.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/computeChecksStatus/computeChecksStatus.ts new file mode 100644 index 00000000000..8aafc0821e8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/computeChecksStatus/computeChecksStatus.ts @@ -0,0 +1,76 @@ +import type { PullRequest } from "../getPRFlowState"; + +type CheckRun = PullRequest["checks"][number]; + +/** Effective per-check status after collapsing GitHub's status × conclusion grid. */ +export type EffectiveCheckStatus = + | "success" + | "failure" + | "pending" + | "skipped" + | "cancelled"; + +const KNOWN_EFFECTIVE_STATUSES = new Set<string>([ + "success", + "failure", + "pending", + "skipped", + "cancelled", +]); + +/** + * Resolves a check's effective status. The host-service DB stores the already- + * resolved effective status (e.g. "success") in `status`, but the tRPC router + * types `status` as `CheckStatusState` ("completed"/"in_progress"/etc.) and + * leaves `conclusion` null. So we first try to read `status` as effective; if + * it isn't one of those, fall back to status+conclusion logic for raw GitHub + * data. + */ +export function coerceCheckStatus( + status: CheckRun["status"] | string, + conclusion: CheckRun["conclusion"], +): EffectiveCheckStatus { + if (KNOWN_EFFECTIVE_STATUSES.has(status)) + return status as EffectiveCheckStatus; + if (status !== "completed") return "pending"; + if (!conclusion) return "pending"; + if (conclusion === "success" || conclusion === "neutral") return "success"; + if (conclusion === "skipped") return "skipped"; + if (conclusion === "cancelled") return "cancelled"; + return "failure"; +} + +export type ChecksRollup = { + overall: "success" | "failure" | "pending" | "none"; + successCount: number; + failureCount: number; + pendingCount: number; + relevantCount: number; +}; + +/** Roll up an array of check runs into a single overall status + counts. */ +export function computeChecksRollup(checks: CheckRun[]): ChecksRollup { + let successCount = 0; + let failureCount = 0; + let pendingCount = 0; + for (const c of checks) { + const s = coerceCheckStatus(c.status, c.conclusion); + if (s === "skipped" || s === "cancelled") continue; + if (s === "success") successCount++; + else if (s === "failure") failureCount++; + else pendingCount++; + } + const relevantCount = successCount + failureCount + pendingCount; + let overall: ChecksRollup["overall"]; + if (relevantCount === 0) overall = "none"; + else if (failureCount > 0) overall = "failure"; + else if (pendingCount > 0) overall = "pending"; + else overall = "success"; + return { + overall, + successCount, + failureCount, + pendingCount, + relevantCount, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/computeChecksStatus/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/computeChecksStatus/index.ts new file mode 100644 index 00000000000..24b802e78c3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/computeChecksStatus/index.ts @@ -0,0 +1,6 @@ +export { + type ChecksRollup, + coerceCheckStatus, + computeChecksRollup, + type EffectiveCheckStatus, +} from "./computeChecksStatus"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/getPRFlowState.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/getPRFlowState.test.ts new file mode 100644 index 00000000000..94913a80075 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/getPRFlowState.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "bun:test"; +import { + type BranchSyncStatus, + getPRFlowState, + type PullRequest, + selectActionButton, + selectPRLink, + selectStatusBadge, +} from "./getPRFlowState"; + +const sync = (overrides: Partial<BranchSyncStatus> = {}): BranchSyncStatus => ({ + hasRepo: true, + hasUpstream: true, + pushCount: 0, + pullCount: 0, + isDefaultBranch: false, + isDetached: false, + hasUncommitted: false, + currentBranch: "feature-x", + defaultBranch: "main", + ...overrides, +}); + +const pr = (overrides: Partial<PullRequest> = {}): PullRequest => ({ + number: 42, + url: "https://github.com/org/repo/pull/42", + title: "Feature X", + body: null, + state: "open", + isDraft: false, + reviewDecision: null, + mergeable: "unknown", + headRefName: "feature-x", + updatedAt: "", + checks: [], + repoOwner: "org", + repoName: "repo", + ...overrides, +}); + +describe("getPRFlowState", () => { + test("error when load failed and no data", () => { + const state = getPRFlowState({ + pr: null, + sync: null, + isLoading: false, + isAgentRunning: false, + loadError: new Error("boom"), + }); + expect(state).toEqual({ kind: "error", pr: null, message: "boom" }); + }); + + test("loading when first fetch hasn't returned", () => { + const state = getPRFlowState({ + pr: null, + sync: null, + isLoading: true, + isAgentRunning: false, + loadError: null, + }); + expect(state.kind).toBe("loading"); + }); + + test("busy overrides actionable states when agent is running", () => { + const state = getPRFlowState({ + pr: null, + sync: sync(), + isLoading: false, + isAgentRunning: true, + loadError: null, + }); + expect(state.kind).toBe("busy"); + }); + + test("unavailable: no-repo when hasRepo is false", () => { + const state = getPRFlowState({ + pr: null, + sync: sync({ hasRepo: false }), + isLoading: false, + isAgentRunning: false, + loadError: null, + }); + expect(state).toEqual({ kind: "unavailable", reason: "no-repo" }); + }); + + test("unavailable: detached-head", () => { + const state = getPRFlowState({ + pr: null, + sync: sync({ isDetached: true, currentBranch: null }), + isLoading: false, + isAgentRunning: false, + loadError: null, + }); + expect(state).toEqual({ kind: "unavailable", reason: "detached-head" }); + }); + + test("unavailable: default-branch", () => { + const state = getPRFlowState({ + pr: null, + sync: sync({ isDefaultBranch: true, currentBranch: "main" }), + isLoading: false, + isAgentRunning: false, + loadError: null, + }); + expect(state).toEqual({ kind: "unavailable", reason: "default-branch" }); + }); + + test("no-pr when on feature branch without a PR", () => { + const s = sync({ pushCount: 2 }); + const state = getPRFlowState({ + pr: null, + sync: s, + isLoading: false, + isAgentRunning: false, + loadError: null, + }); + expect(state).toEqual({ kind: "no-pr", sync: s }); + }); + + test("pr-exists when a PR is present", () => { + const p = pr(); + const state = getPRFlowState({ + pr: p, + sync: sync(), + isLoading: false, + isAgentRunning: false, + loadError: null, + }); + expect(state.kind).toBe("pr-exists"); + if (state.kind === "pr-exists") expect(state.pr).toBe(p); + }); +}); + +describe("selectActionButton", () => { + test("no-pr → create-pr-dropdown", () => { + expect(selectActionButton({ kind: "no-pr", sync: sync() })).toEqual({ + kind: "create-pr-dropdown", + }); + }); + test("busy → cancel-busy", () => { + expect(selectActionButton({ kind: "busy", pr: null })).toEqual({ + kind: "cancel-busy", + }); + }); + test("error → retry", () => { + expect( + selectActionButton({ kind: "error", pr: null, message: "x" }), + ).toEqual({ kind: "retry" }); + }); + test("loading → hidden", () => { + expect(selectActionButton({ kind: "loading" })).toEqual({ kind: "hidden" }); + }); + test("pr-exists → hidden (post-PR actions land later)", () => { + expect( + selectActionButton({ kind: "pr-exists", pr: pr(), sync: sync() }), + ).toEqual({ kind: "hidden" }); + }); + test("unavailable → disabled-tooltip with reason", () => { + expect( + selectActionButton({ kind: "unavailable", reason: "default-branch" }), + ).toMatchObject({ kind: "disabled-tooltip" }); + }); +}); + +describe("selectPRLink", () => { + test("none when no PR", () => { + expect(selectPRLink({ kind: "no-pr", sync: sync() })).toEqual({ + kind: "none", + }); + }); + test("open PR link", () => { + const p = pr({ number: 9, state: "open", isDraft: false }); + expect(selectPRLink({ kind: "pr-exists", pr: p, sync: null })).toEqual({ + kind: "pr-link", + state: "open", + number: 9, + url: p.url, + }); + }); + test("draft PR link takes priority over state", () => { + const p = pr({ isDraft: true, state: "open" }); + expect( + selectPRLink({ kind: "pr-exists", pr: p, sync: null }), + ).toMatchObject({ state: "draft" }); + }); + test("merged / closed PR links", () => { + expect( + selectPRLink({ + kind: "pr-exists", + pr: pr({ state: "merged" }), + sync: null, + }), + ).toMatchObject({ state: "merged" }); + expect( + selectPRLink({ + kind: "pr-exists", + pr: pr({ state: "closed" }), + sync: null, + }), + ).toMatchObject({ state: "closed" }); + }); + test("PR link still visible during busy/error when PR known", () => { + const p = pr(); + expect(selectPRLink({ kind: "busy", pr: p })).toMatchObject({ + kind: "pr-link", + }); + }); +}); + +describe("selectStatusBadge (no-pr variants)", () => { + test("'Not published' when no upstream", () => { + expect( + selectStatusBadge({ + kind: "no-pr", + sync: sync({ hasUpstream: false }), + }), + ).toBe("Not published"); + }); + test("'Diverged' when both push and pull", () => { + expect( + selectStatusBadge({ + kind: "no-pr", + sync: sync({ pushCount: 1, pullCount: 1 }), + }), + ).toBe("Diverged"); + }); + test("'N commits to push' with singular/plural", () => { + expect( + selectStatusBadge({ kind: "no-pr", sync: sync({ pushCount: 1 }) }), + ).toBe("1 commit to push"); + expect( + selectStatusBadge({ kind: "no-pr", sync: sync({ pushCount: 3 }) }), + ).toBe("3 commits to push"); + }); + test("'Uncommitted changes' when dirty and no pending push/pull", () => { + expect( + selectStatusBadge({ + kind: "no-pr", + sync: sync({ hasUncommitted: true }), + }), + ).toBe("Uncommitted changes"); + }); + test("'Ready' when clean and in-sync", () => { + expect(selectStatusBadge({ kind: "no-pr", sync: sync() })).toBe("Ready"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/getPRFlowState.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/getPRFlowState.ts new file mode 100644 index 00000000000..d73628e664d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/getPRFlowState.ts @@ -0,0 +1,169 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterOutputs } from "@trpc/server"; + +type RouterOutputs = inferRouterOutputs<AppRouter>; + +export type BranchSyncStatus = RouterOutputs["git"]["getBranchSyncStatus"]; +export type PullRequest = NonNullable<RouterOutputs["git"]["getPullRequest"]>; + +export type UnavailableReason = "no-repo" | "default-branch" | "detached-head"; + +export type PRFlowState = + | { kind: "loading" } + | { kind: "unavailable"; reason: UnavailableReason } + | { kind: "no-pr"; sync: BranchSyncStatus } + | { kind: "pr-exists"; pr: PullRequest; sync: BranchSyncStatus | null } + | { kind: "busy"; pr: PullRequest | null } + | { kind: "error"; pr: PullRequest | null; message: string }; + +export interface GetPRFlowStateInput { + pr: PullRequest | null; + sync: BranchSyncStatus | null; + isLoading: boolean; + isAgentRunning: boolean; + loadError: Error | null; +} + +export function getPRFlowState(input: GetPRFlowStateInput): PRFlowState { + const { pr, sync, isLoading, isAgentRunning, loadError } = input; + + if (loadError && !sync && !pr) { + return { kind: "error", pr: null, message: loadError.message }; + } + + if (isLoading && !sync) { + return { kind: "loading" }; + } + + if (isAgentRunning) { + return { kind: "busy", pr }; + } + + if (!sync || !sync.hasRepo) { + return { kind: "unavailable", reason: "no-repo" }; + } + if (sync.isDetached) { + return { kind: "unavailable", reason: "detached-head" }; + } + if (sync.isDefaultBranch) { + return { kind: "unavailable", reason: "default-branch" }; + } + + if (pr) { + return { kind: "pr-exists", pr, sync }; + } + + return { kind: "no-pr", sync }; +} + +// --------------------------------------------------------------------------- +// Selectors: derive header UI pieces from the flow state. +// Kept in this file because all three fork on the same `kind` discriminant. +// --------------------------------------------------------------------------- + +export type ActionButtonVariant = + | { kind: "hidden" } + | { kind: "disabled-tooltip"; reasonKind: UnavailableReason } + | { kind: "create-pr-dropdown" } + | { kind: "cancel-busy" } + | { kind: "retry" }; + +export function selectActionButton(state: PRFlowState): ActionButtonVariant { + switch (state.kind) { + case "loading": + return { kind: "hidden" }; + case "unavailable": + return { kind: "disabled-tooltip", reasonKind: state.reason }; + case "no-pr": + return { kind: "create-pr-dropdown" }; + case "pr-exists": + // Post-PR actions land in a later phase; for now the button hides + // once a PR exists. The PR link button remains visible on the left. + return { kind: "hidden" }; + case "busy": + return { kind: "cancel-busy" }; + case "error": + return { kind: "retry" }; + } +} + +export type PRLinkVariant = + | { kind: "none" } + | { + kind: "pr-link"; + state: "open" | "draft" | "merged" | "closed"; + number: number; + url: string; + }; + +export function selectPRLink(state: PRFlowState): PRLinkVariant { + const pr = getPRFromState(state); + if (!pr) return { kind: "none" }; + const linkState = pr.isDraft + ? "draft" + : pr.state === "merged" + ? "merged" + : pr.state === "closed" + ? "closed" + : "open"; + return { + kind: "pr-link", + state: linkState, + number: pr.number, + url: pr.url, + }; +} + +export function selectStatusBadge(state: PRFlowState): string | null { + switch (state.kind) { + case "loading": + return null; + case "unavailable": + return unavailableBadge(state.reason); + case "no-pr": + return syncBadgeText(state.sync); + case "pr-exists": + if (state.pr.isDraft) return "Draft"; + if (state.pr.state === "merged") return "Merged"; + if (state.pr.state === "closed") return "Closed"; + return "Open"; + case "busy": + return "Agent working…"; + case "error": + return "Failed to refresh — retry"; + } +} + +function getPRFromState(state: PRFlowState): PullRequest | null { + switch (state.kind) { + case "pr-exists": + return state.pr; + case "busy": + case "error": + return state.pr; + default: + return null; + } +} + +function unavailableBadge(reason: UnavailableReason): string { + switch (reason) { + case "no-repo": + return "No GitHub repo"; + case "default-branch": + return "On default branch"; + case "detached-head": + return "Detached HEAD"; + } +} + +function syncBadgeText(sync: BranchSyncStatus): string { + if (!sync.hasUpstream) return "Not published"; + if (sync.pushCount > 0 && sync.pullCount > 0) return "Diverged"; + if (sync.pushCount > 0) + return `${sync.pushCount} commit${sync.pushCount === 1 ? "" : "s"} to push`; + if (sync.pullCount > 0) + return `${sync.pullCount} commit${sync.pullCount === 1 ? "" : "s"} to pull`; + if (sync.hasUncommitted) return "Uncommitted changes"; + return "Ready"; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/index.ts new file mode 100644 index 00000000000..7d9f627657d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/index.ts @@ -0,0 +1,15 @@ +export type { + ActionButtonVariant, + BranchSyncStatus, + GetPRFlowStateInput, + PRFlowState, + PRLinkVariant, + PullRequest, + UnavailableReason, +} from "./getPRFlowState"; +export { + getPRFlowState, + selectActionButton, + selectPRLink, + selectStatusBadge, +} from "./getPRFlowState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx new file mode 100644 index 00000000000..b73ce99b654 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/SidebarHeader.tsx @@ -0,0 +1,71 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { getSidebarHeaderTabButtonClassName } from "renderer/screens/main/components/WorkspaceView/RightSidebar/headerTabStyles"; +import type { SidebarTabDefinition } from "../../types"; + +interface SidebarHeaderProps { + tabs: SidebarTabDefinition[]; + activeTab: string; + onTabChange: (id: string) => void; + compact?: boolean; +} + +export function SidebarHeader({ + tabs, + activeTab, + onTabChange, + compact, +}: SidebarHeaderProps) { + const actions = tabs.find((t) => t.id === activeTab)?.actions; + + return ( + <div className="flex h-10 shrink-0 items-stretch border-b border-border"> + <div className="flex min-w-0 items-center h-full overflow-hidden"> + {tabs.map((tab) => { + const isActive = activeTab === tab.id; + const btn = ( + <button + type="button" + onClick={() => onTabChange(tab.id)} + className={getSidebarHeaderTabButtonClassName({ + isActive, + compact, + })} + > + {tab.icon && <tab.icon className="size-3" />} + {!compact && tab.label} + </button> + ); + + if (compact) { + return ( + <Tooltip key={tab.id}> + <TooltipTrigger asChild>{btn}</TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + {tab.label} + </TooltipContent> + </Tooltip> + ); + } + + return ( + <button + key={tab.id} + type="button" + onClick={() => onTabChange(tab.id)} + className={getSidebarHeaderTabButtonClassName({ isActive })} + > + {tab.icon && <tab.icon className="size-3" />} + {tab.label} + </button> + ); + })} + </div> + <div className="flex-1" /> + {actions && ( + <div className="flex shrink-0 items-center h-10 pr-2 gap-0.5"> + {actions} + </div> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/index.ts new file mode 100644 index 00000000000..bdc4db6ef46 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/SidebarHeader/index.ts @@ -0,0 +1 @@ +export { SidebarHeader } from "./SidebarHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx new file mode 100644 index 00000000000..2b63dc65804 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/BaseBranchSelector.tsx @@ -0,0 +1,83 @@ +import type { AppRouter } from "@superset/host-service"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import type { inferRouterOutputs } from "@trpc/server"; +import { Check, ChevronDown } from "lucide-react"; +import { useMemo, useState } from "react"; + +type Branch = + inferRouterOutputs<AppRouter>["git"]["listBranches"]["branches"][number]; + +interface BaseBranchSelectorProps { + branches: Branch[]; + currentValue: string; + onChange: (branchName: string) => void; +} + +export function BaseBranchSelector({ + branches, + currentValue, + onChange, +}: BaseBranchSelectorProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const filtered = useMemo(() => { + if (!search) return branches; + const lower = search.toLowerCase(); + return branches.filter((b) => b.name.toLowerCase().includes(lower)); + }, [branches, search]); + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <button + type="button" + className="inline-flex items-center gap-0.5 font-medium text-foreground hover:underline" + > + {currentValue} + <ChevronDown className="size-3" /> + </button> + </PopoverTrigger> + <PopoverContent + className="flex w-64 max-h-96 flex-col p-0 overflow-hidden" + align="start" + > + <div className="border-b px-3 py-2"> + <input + placeholder="Search branches..." + value={search} + onChange={(e) => setSearch(e.target.value)} + className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + </div> + <ScrollArea className="flex-1 overflow-y-auto"> + <div className="p-1"> + {filtered.map((branch) => ( + <button + key={branch.name} + type="button" + className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-accent" + onClick={() => { + onChange(branch.name); + setOpen(false); + setSearch(""); + }} + > + <span className="truncate">{branch.name}</span> + {branch.name === currentValue && ( + <Check className="size-3.5 shrink-0" /> + )} + </button> + ))} + {filtered.length === 0 && ( + <div className="px-2 py-3 text-center text-sm text-muted-foreground"> + No branches found + </div> + )} + </div> + </ScrollArea> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/index.ts new file mode 100644 index 00000000000..7f9bd68a01a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/BaseBranchSelector/index.ts @@ -0,0 +1 @@ +export { BaseBranchSelector } from "./BaseBranchSelector"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx new file mode 100644 index 00000000000..8a622d02e91 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx @@ -0,0 +1,52 @@ +import { memo } from "react"; +import type { ChangesetFile } from "../../../../../../hooks/useChangeset"; +import { FileRow } from "./components/FileRow"; + +interface ChangesFileListProps { + files: ChangesetFile[]; + isLoading?: boolean; + worktreePath?: string; + onSelectFile?: (path: string, openInNewTab?: boolean) => void; + onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; + onOpenInEditor?: (path: string) => void; +} + +export const ChangesFileList = memo(function ChangesFileList({ + files, + isLoading, + worktreePath, + onSelectFile, + onOpenFile, + onOpenInEditor, +}: ChangesFileListProps) { + if (isLoading) { + return ( + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + Loading... + </div> + ); + } + + if (files.length === 0) { + return ( + <div className="px-3 py-6 text-center text-sm text-muted-foreground"> + No changes + </div> + ); + } + + return ( + <div className="min-h-0 flex-1 overflow-y-auto"> + {files.map((file) => ( + <FileRow + key={`${file.source.kind}:${file.path}`} + file={file} + worktreePath={worktreePath} + onSelect={onSelectFile} + onOpenFile={onOpenFile} + onOpenInEditor={onOpenInEditor} + /> + ))} + </div> + ); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx new file mode 100644 index 00000000000..a1d90d2bbd0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx @@ -0,0 +1,198 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { ChevronDown } from "lucide-react"; +import { memo } from "react"; +import { StatusIndicator } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; +import { PathActionsMenuItems } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/components/WorkspaceFilesTreeItem/components/PathActionsMenuItems"; +import type { ChangesetFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { + CLICK_HINT_TOOLTIP, + MOD_CLICK_LABEL, + SHIFT_CLICK_LABEL, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels"; +import { getSidebarClickIntent } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; + +function splitPath(path: string): { dir: string; basename: string } { + const lastSlash = path.lastIndexOf("/"); + if (lastSlash < 0) return { dir: "", basename: path }; + return { + dir: `${path.slice(0, lastSlash)}/`, + basename: path.slice(lastSlash + 1), + }; +} + +interface FileRowProps { + file: ChangesetFile; + worktreePath?: string; + onSelect?: (path: string, openInNewTab?: boolean) => void; + onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; + onOpenInEditor?: (path: string) => void; +} + +export const FileRow = memo(function FileRow({ + file, + worktreePath, + onSelect, + onOpenFile, + onOpenInEditor, +}: FileRowProps) { + const { dir, basename } = splitPath(file.path); + const oldBasename = + file.oldPath && (file.status === "renamed" || file.status === "copied") + ? splitPath(file.oldPath).basename + : null; + const absolutePath = worktreePath + ? toAbsoluteWorkspacePath(worktreePath, file.path) + : undefined; + + const rowButton = ( + <div className="group relative"> + <button + type="button" + className="flex w-full items-center gap-1.5 py-1 pr-3 pl-3 text-left text-xs hover:bg-accent/50" + onClick={(e) => { + const intent = getSidebarClickIntent(e); + if (intent === "openInEditor") { + onOpenInEditor?.(file.path); + } else { + onSelect?.(file.path, intent === "openInNewTab"); + } + }} + > + <FileIcon fileName={basename} className="size-3.5 shrink-0" /> + <span className="flex min-w-0 flex-1 items-baseline overflow-hidden"> + {dir && <span className="truncate text-muted-foreground">{dir}</span>} + {oldBasename && ( + <span className="truncate text-muted-foreground"> + {oldBasename} + <span className="px-1">→</span> + </span> + )} + <span className="min-w-[120px] truncate font-medium text-foreground"> + {basename} + </span> + </span> + <span className="ml-auto flex shrink-0 items-center gap-1.5 group-hover:invisible"> + {(file.additions > 0 || file.deletions > 0) && ( + <span className="text-[10px] text-muted-foreground"> + {file.additions > 0 && ( + <span className="text-green-400">+{file.additions}</span> + )} + {file.additions > 0 && file.deletions > 0 && " "} + {file.deletions > 0 && ( + <span className="text-red-400">-{file.deletions}</span> + )} + </span> + )} + <StatusIndicator status={file.status} /> + </span> + </button> + <div className="pointer-events-none absolute inset-y-0 right-2 flex items-center gap-0.5 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 has-[[data-state=open]]:pointer-events-auto has-[[data-state=open]]:opacity-100"> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + aria-label="More actions" + className="flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground data-[state=open]:bg-accent data-[state=open]:text-foreground" + onClick={(e) => e.stopPropagation()} + > + <ChevronDown className="size-3.5" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuItem onSelect={() => onSelect?.(file.path)}> + Open Diff + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => onSelect?.(file.path, true)}> + Open Diff in New Tab + <DropdownMenuShortcut>{SHIFT_CLICK_LABEL}</DropdownMenuShortcut> + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => absolutePath && onOpenFile?.(absolutePath)} + disabled={!onOpenFile || !absolutePath} + > + Open File + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => absolutePath && onOpenFile?.(absolutePath, true)} + disabled={!onOpenFile || !absolutePath} + > + Open File in New Tab + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => onOpenInEditor?.(file.path)} + disabled={!onOpenInEditor} + > + Open in Editor + <DropdownMenuShortcut>{MOD_CLICK_LABEL}</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + ); + + return ( + <ContextMenu> + <Tooltip> + <ContextMenuTrigger asChild> + <TooltipTrigger asChild>{rowButton}</TooltipTrigger> + </ContextMenuTrigger> + <TooltipContent side="right">{CLICK_HINT_TOOLTIP}</TooltipContent> + </Tooltip> + <ContextMenuContent className="w-56"> + <ContextMenuItem onSelect={() => onSelect?.(file.path)}> + Open Diff + </ContextMenuItem> + <ContextMenuItem onSelect={() => onSelect?.(file.path, true)}> + Open Diff in New Tab + <ContextMenuShortcut>{SHIFT_CLICK_LABEL}</ContextMenuShortcut> + </ContextMenuItem> + <ContextMenuItem + onSelect={() => absolutePath && onOpenFile?.(absolutePath)} + disabled={!onOpenFile || !absolutePath} + > + Open File + </ContextMenuItem> + <ContextMenuItem + onSelect={() => absolutePath && onOpenFile?.(absolutePath, true)} + disabled={!onOpenFile || !absolutePath} + > + Open File in New Tab + </ContextMenuItem> + <ContextMenuItem + onSelect={() => onOpenInEditor?.(file.path)} + disabled={!onOpenInEditor} + > + Open in Editor + <ContextMenuShortcut>{MOD_CLICK_LABEL}</ContextMenuShortcut> + </ContextMenuItem> + {absolutePath && ( + <> + <ContextMenuSeparator /> + <PathActionsMenuItems + absolutePath={absolutePath} + relativePath={file.path} + /> + </> + )} + </ContextMenuContent> + </ContextMenu> + ); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/index.ts new file mode 100644 index 00000000000..81ba5923974 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/index.ts @@ -0,0 +1 @@ +export { FileRow } from "./FileRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts new file mode 100644 index 00000000000..937e4423214 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/index.ts @@ -0,0 +1 @@ +export { ChangesFileList } from "./ChangesFileList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx new file mode 100644 index 00000000000..ae7dc18e485 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/ChangesHeader.tsx @@ -0,0 +1,136 @@ +import { GitBranch, Pencil } from "lucide-react"; +import { useRef, useState } from "react"; +import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { Branch, Commit } from "../../types"; +import { BaseBranchSelector } from "../BaseBranchSelector"; +import { CommitFilterDropdown } from "../CommitFilterDropdown"; + +interface ChangesHeaderProps { + currentBranch: { name: string; aheadCount: number; behindCount: number }; + defaultBranchName: string; + baseBranch: string | null; + totalFiles: number; + totalAdditions: number; + totalDeletions: number; + filter: ChangesFilter; + onFilterChange: (filter: ChangesFilter) => void; + commits: Commit[]; + uncommittedCount: number; + branches: Branch[]; + onBaseBranchChange: (branchName: string) => void; + onRenameBranch: (newName: string) => void; + canRename: boolean; +} + +export function ChangesHeader({ + currentBranch, + defaultBranchName, + baseBranch, + totalFiles, + totalAdditions, + totalDeletions, + onRenameBranch, + canRename, + filter, + onFilterChange, + commits, + uncommittedCount, + branches, + onBaseBranchChange, +}: ChangesHeaderProps) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(currentBranch.name); + const inputRef = useRef<HTMLInputElement>(null); + const skipBlurRef = useRef(false); + + const startEditing = () => { + setEditValue(currentBranch.name); + setIsEditing(true); + skipBlurRef.current = false; + requestAnimationFrame(() => inputRef.current?.select()); + }; + + const handleSubmit = () => { + const trimmed = editValue.trim(); + if (trimmed && trimmed !== currentBranch.name) { + onRenameBranch(trimmed); + } + setIsEditing(false); + }; + + return ( + <div className="space-y-1 border-b border-border bg-muted/30 px-3 py-2"> + <div className="group flex items-center gap-1.5 text-xs"> + <GitBranch className="size-3 shrink-0 text-muted-foreground" /> + {isEditing ? ( + <input + ref={inputRef} + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + skipBlurRef.current = true; + handleSubmit(); + } + if (e.key === "Escape") { + skipBlurRef.current = true; + setIsEditing(false); + } + }} + onBlur={() => { + if (skipBlurRef.current) return; + handleSubmit(); + }} + className="min-w-0 flex-1 truncate rounded-sm bg-transparent px-1 font-medium outline-none ring-1 ring-ring" + /> + ) : ( + <> + <span className="min-w-0 truncate font-medium"> + {currentBranch.name} + </span> + {canRename && ( + <button + type="button" + onClick={startEditing} + className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100" + > + <Pencil className="size-3" /> + </button> + )} + <span className="shrink-0 text-muted-foreground/60">from</span> + <BaseBranchSelector + branches={branches} + currentValue={baseBranch ?? defaultBranchName} + onChange={onBaseBranchChange} + /> + </> + )} + </div> + + <div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground"> + <CommitFilterDropdown + filter={filter} + onFilterChange={onFilterChange} + commits={commits} + uncommittedCount={uncommittedCount} + /> + <div className="flex shrink-0 items-center gap-1.5"> + <span> + {totalFiles} {totalFiles === 1 ? "file" : "files"} + </span> + {(totalAdditions > 0 || totalDeletions > 0) && ( + <span> + {totalAdditions > 0 && ( + <span className="text-green-400">+{totalAdditions}</span> + )} + {totalAdditions > 0 && totalDeletions > 0 && " "} + {totalDeletions > 0 && ( + <span className="text-red-400">-{totalDeletions}</span> + )} + </span> + )} + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/index.ts new file mode 100644 index 00000000000..2d44c6bf794 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesHeader/index.ts @@ -0,0 +1 @@ +export { ChangesHeader } from "./ChangesHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx new file mode 100644 index 00000000000..211fb8500cb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/ChangesTabContent.tsx @@ -0,0 +1,103 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterOutputs } from "@trpc/server"; +import { memo } from "react"; +import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { ChangesetFile } from "../../../../../../hooks/useChangeset"; +import { ChangesFileList } from "../ChangesFileList"; +import { ChangesHeader } from "../ChangesHeader"; + +type RouterOutputs = inferRouterOutputs<AppRouter>; + +interface ChangesTabContentProps { + status: { + data: RouterOutputs["git"]["getStatus"] | undefined; + isLoading: boolean; + }; + commits: { data: RouterOutputs["git"]["listCommits"] | undefined }; + branches: { data: RouterOutputs["git"]["listBranches"] | undefined }; + filter: ChangesFilter; + baseBranch: string | null; + files: ChangesetFile[]; + isLoading: boolean; + totalChanges: number; + totalAdditions: number; + totalDeletions: number; + worktreePath?: string; + onSelectFile?: (path: string, openInNewTab?: boolean) => void; + onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; + onOpenInEditor?: (path: string) => void; + onFilterChange: (filter: ChangesFilter) => void; + onBaseBranchChange: (branchName: string) => void; + onRenameBranch: (newName: string) => void; + canRenameBranch: boolean; +} + +export const ChangesTabContent = memo(function ChangesTabContent({ + status, + commits, + branches, + filter, + baseBranch, + files, + isLoading, + totalChanges, + totalAdditions, + totalDeletions, + worktreePath, + onSelectFile, + onOpenFile, + onOpenInEditor, + onFilterChange, + onBaseBranchChange, + onRenameBranch, + canRenameBranch, +}: ChangesTabContentProps) { + if (status.isLoading) { + return ( + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + Loading changes... + </div> + ); + } + + if (!status.data) { + return ( + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + Unable to load git status + </div> + ); + } + + return ( + <div className="flex h-full min-h-0 flex-col"> + <ChangesHeader + currentBranch={status.data.currentBranch} + defaultBranchName={status.data.defaultBranch.name} + baseBranch={baseBranch} + totalFiles={totalChanges} + totalAdditions={totalAdditions} + totalDeletions={totalDeletions} + filter={filter} + onFilterChange={onFilterChange} + commits={commits.data?.commits ?? []} + uncommittedCount={ + status.data.staged.length + status.data.unstaged.length + } + branches={branches.data?.branches ?? []} + onBaseBranchChange={onBaseBranchChange} + onRenameBranch={onRenameBranch} + canRename={canRenameBranch} + /> + <div className="min-h-0 flex-1 overflow-y-auto"> + <ChangesFileList + files={files} + isLoading={isLoading} + worktreePath={worktreePath} + onSelectFile={onSelectFile} + onOpenFile={onOpenFile} + onOpenInEditor={onOpenInEditor} + /> + </div> + </div> + ); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/index.ts new file mode 100644 index 00000000000..a8468c8187b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesTabContent/index.ts @@ -0,0 +1 @@ +export { ChangesTabContent } from "./ChangesTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx new file mode 100644 index 00000000000..aa08e9f0280 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx @@ -0,0 +1,132 @@ +import type { AppRouter } from "@superset/host-service"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import type { inferRouterOutputs } from "@trpc/server"; +import { Check, ChevronDown, ListFilter } from "lucide-react"; +import { useState } from "react"; +import type { ChangesFilter } from "../../useChangesTab"; +import { CommitRow } from "./components/CommitRow"; +import { RangeModal } from "./components/RangeModal"; + +type Commit = + inferRouterOutputs<AppRouter>["git"]["listCommits"]["commits"][number]; + +function getFilterLabel(filter: ChangesFilter, commits: Commit[]): string { + if (filter.kind === "all") return "All changes"; + if (filter.kind === "uncommitted") return "Uncommitted"; + if (filter.kind === "range") { + const from = commits.find((c) => c.hash === filter.fromHash); + const to = commits.find((c) => c.hash === filter.toHash); + return `${from?.shortHash ?? filter.fromHash.slice(0, 7)}..${to?.shortHash ?? filter.toHash.slice(0, 7)}`; + } + const commit = commits.find((c) => c.hash === filter.hash); + return commit?.shortHash ?? filter.hash.slice(0, 7); +} + +interface CommitFilterDropdownProps { + filter: ChangesFilter; + onFilterChange: (filter: ChangesFilter) => void; + commits: Commit[]; + uncommittedCount?: number; +} + +export function CommitFilterDropdown({ + filter, + onFilterChange, + commits, + uncommittedCount, +}: CommitFilterDropdownProps) { + const [rangeModalOpen, setRangeModalOpen] = useState(false); + + return ( + <> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" + > + <span className="max-w-[140px] truncate"> + {getFilterLabel(filter, commits)} + </span> + <ChevronDown className="size-3" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-72"> + <DropdownMenuItem onSelect={() => onFilterChange({ kind: "all" })}> + <div className="flex flex-1 items-center justify-between"> + <span>All changes</span> + {filter.kind === "all" && <Check className="size-3.5" />} + </div> + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => onFilterChange({ kind: "uncommitted" })} + > + <div className="flex flex-1 items-center justify-between"> + <div> + <div>Uncommitted changes</div> + {uncommittedCount != null && ( + <div className="text-[10px] text-muted-foreground"> + {uncommittedCount} files changed + </div> + )} + </div> + {filter.kind === "uncommitted" && <Check className="size-3.5" />} + </div> + </DropdownMenuItem> + + {commits.length > 1 && ( + <DropdownMenuItem onSelect={() => setRangeModalOpen(true)}> + <div className="flex flex-1 items-center justify-between"> + <div className="flex items-center gap-2"> + <ListFilter className="size-3.5 text-muted-foreground" /> + <span>Select range...</span> + </div> + {filter.kind === "range" && <Check className="size-3.5" />} + </div> + </DropdownMenuItem> + )} + + {commits.length > 0 && ( + <> + <DropdownMenuSeparator /> + {commits.map((commit) => ( + <DropdownMenuItem + key={commit.hash} + onSelect={() => + onFilterChange({ + kind: "commit", + hash: commit.hash, + }) + } + > + <CommitRow + commit={commit} + isSelected={ + filter.kind === "commit" && filter.hash === commit.hash + } + /> + </DropdownMenuItem> + ))} + </> + )} + </DropdownMenuContent> + </DropdownMenu> + + <RangeModal + open={rangeModalOpen} + onOpenChange={setRangeModalOpen} + commits={commits} + onSelect={(fromHash, toHash) => + onFilterChange({ kind: "range", fromHash, toHash }) + } + /> + </> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/CommitRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/CommitRow.tsx new file mode 100644 index 00000000000..66d00b04bd8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/CommitRow.tsx @@ -0,0 +1,36 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterOutputs } from "@trpc/server"; +import { Check } from "lucide-react"; + +type Commit = + inferRouterOutputs<AppRouter>["git"]["listCommits"]["commits"][number]; + +function timeAgo(date: string): string { + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +interface CommitRowProps { + commit: Commit; + isSelected?: boolean; +} + +export function CommitRow({ commit, isSelected }: CommitRowProps) { + return ( + <div className="flex flex-1 items-center justify-between"> + <div className="min-w-0"> + <div className="truncate text-sm">{commit.message}</div> + <div className="text-xs text-muted-foreground"> + {commit.shortHash} · {commit.author} · {timeAgo(commit.date)} + </div> + </div> + {isSelected && <Check className="size-3.5 shrink-0" />} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/index.ts new file mode 100644 index 00000000000..0aadae49b46 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/CommitRow/index.ts @@ -0,0 +1 @@ +export { CommitRow } from "./CommitRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/RangeModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/RangeModal.tsx new file mode 100644 index 00000000000..7c7547f35b8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/RangeModal.tsx @@ -0,0 +1,116 @@ +import type { AppRouter } from "@superset/host-service"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import type { inferRouterOutputs } from "@trpc/server"; +import { useEffect, useState } from "react"; +import { CommitRow } from "../CommitRow"; + +type Commit = + inferRouterOutputs<AppRouter>["git"]["listCommits"]["commits"][number]; + +interface RangeModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + commits: Commit[]; + onSelect: (fromHash: string, toHash: string) => void; +} + +export function RangeModal({ + open, + onOpenChange, + commits, + onSelect, +}: RangeModalProps) { + const [fromIdx, setFromIdx] = useState<number | null>(null); + const [toIdx, setToIdx] = useState<number | null>(null); + + // Reset selection when modal opens/closes + useEffect(() => { + if (!open) { + setFromIdx(null); + setToIdx(null); + } + }, [open]); + + const handleClick = (idx: number) => { + if (fromIdx === null) { + setFromIdx(idx); + setToIdx(idx); + } else if (toIdx === fromIdx) { + setToIdx(idx); + } else { + setFromIdx(idx); + setToIdx(idx); + } + }; + + const minIdx = + fromIdx !== null && toIdx !== null ? Math.min(fromIdx, toIdx) : -1; + const maxIdx = + fromIdx !== null && toIdx !== null ? Math.max(fromIdx, toIdx) : -1; + const hasRange = minIdx !== maxIdx && minIdx >= 0; + + const handleApply = () => { + if (!hasRange) return; + const from = commits[maxIdx]; + const to = commits[minIdx]; + if (from && to) { + onSelect(from.hash, to.hash); + onOpenChange(false); + setFromIdx(null); + setToIdx(null); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange} modal> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Select commit range</DialogTitle> + <DialogDescription> + Click two commits to define the range. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-[300px]"> + <div className="space-y-0.5"> + {commits.map((commit, idx) => { + const inRange = idx >= minIdx && idx <= maxIdx; + return ( + <button + key={commit.hash} + type="button" + onClick={() => handleClick(idx)} + className={`flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm ${ + inRange + ? "bg-accent text-accent-foreground" + : "hover:bg-accent/50" + }`} + > + <CommitRow commit={commit} /> + </button> + ); + })} + </div> + </ScrollArea> + + <DialogFooter> + <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button size="sm" disabled={!hasRange} onClick={handleApply}> + Apply + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/index.ts new file mode 100644 index 00000000000..2f02b98518e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/components/RangeModal/index.ts @@ -0,0 +1 @@ +export { RangeModal } from "./RangeModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/index.ts new file mode 100644 index 00000000000..9261ff2f668 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/index.ts @@ -0,0 +1 @@ +export { CommitFilterDropdown } from "./CommitFilterDropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/index.ts new file mode 100644 index 00000000000..1c4af3e44a0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/index.ts @@ -0,0 +1 @@ +export { type ChangesFilter, useChangesTab } from "./useChangesTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/types.ts new file mode 100644 index 00000000000..727d7505698 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/types.ts @@ -0,0 +1,9 @@ +import type { AppRouter } from "@superset/host-service"; +import type { inferRouterOutputs } from "@trpc/server"; + +type RouterOutputs = inferRouterOutputs<AppRouter>; + +export type Commit = RouterOutputs["git"]["listCommits"]["commits"][number]; +export type Branch = RouterOutputs["git"]["listBranches"]["branches"][number]; +export type ChangedFile = + RouterOutputs["git"]["getStatus"]["againstBase"][number]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx new file mode 100644 index 00000000000..89f81bbe616 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/useChangesTab.tsx @@ -0,0 +1,199 @@ +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { RefreshCw } from "lucide-react"; +import { useCallback, useState } from "react"; +import type { useGitStatus } from "renderer/hooks/host-service/useGitStatus"; +import { useChangeset } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset"; +import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; +import { useSidebarDiffRef } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { ChangesFilter } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; +import type { SidebarTabDefinition } from "../../types"; +import { ChangesTabContent } from "./components/ChangesTabContent"; + +export type { ChangesFilter }; + +interface UseChangesTabParams { + workspaceId: string; + gitStatus: ReturnType<typeof useGitStatus>; + onSelectFile?: (path: string, openInNewTab?: boolean) => void; + onOpenFile?: (absolutePath: string, openInNewTab?: boolean) => void; +} + +export function useChangesTab({ + workspaceId, + gitStatus: status, + onSelectFile, + onOpenFile, +}: UseChangesTabParams): SidebarTabDefinition { + const collections = useCollections(); + const utils = workspaceTrpc.useUtils(); + const localState = collections.v2WorkspaceLocalState.get(workspaceId); + const filter: ChangesFilter = localState?.sidebarState?.changesFilter ?? { + kind: "all", + }; + + const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( + { workspaceId }, + { staleTime: Number.POSITIVE_INFINITY }, + ); + const baseBranch = baseBranchQuery.data?.baseBranch ?? null; + + const ref = useSidebarDiffRef(workspaceId); + const { files, isLoading } = useChangeset({ workspaceId, ref }); + + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const worktreePath = workspaceQuery.data?.worktreePath; + const openInExternalEditor = useOpenInExternalEditor(workspaceId); + + const handleOpenInEditor = useCallback( + (relativePath: string) => { + if (!worktreePath) return; + openInExternalEditor(toAbsoluteWorkspacePath(worktreePath, relativePath)); + }, + [worktreePath, openInExternalEditor], + ); + + const setFilter = useCallback( + (next: ChangesFilter) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.changesFilter = next; + }); + }, + [collections, workspaceId], + ); + + const setBaseBranchMutation = workspaceTrpc.git.setBaseBranch.useMutation({ + onSuccess: () => { + void utils.git.getBaseBranch.invalidate({ workspaceId }); + void utils.git.getStatus.invalidate({ workspaceId }); + void utils.git.listCommits.invalidate({ workspaceId }); + void utils.git.getDiff.invalidate({ workspaceId }); + }, + }); + + const setBaseBranch = useCallback( + (branchName: string) => { + setBaseBranchMutation.mutate({ workspaceId, baseBranch: branchName }); + }, + [setBaseBranchMutation, workspaceId], + ); + + const commits = workspaceTrpc.git.listCommits.useQuery( + { workspaceId, baseBranch: baseBranch ?? undefined }, + { refetchOnWindowFocus: true }, + ); + + const branches = workspaceTrpc.git.listBranches.useQuery( + { workspaceId }, + { refetchInterval: 30_000, refetchOnWindowFocus: true }, + ); + + const renameBranchMutation = workspaceTrpc.git.renameBranch.useMutation(); + + const handleRenameBranch = useCallback( + (newName: string) => { + const currentName = status.data?.currentBranch.name; + if (!currentName) return; + toast.promise( + renameBranchMutation.mutateAsync({ + workspaceId, + oldName: currentName, + newName, + }), + { + loading: `Renaming branch to ${newName}...`, + success: `Branch renamed to ${newName}`, + error: (err) => + err instanceof Error ? err.message : "Failed to rename branch", + }, + ); + }, + [workspaceId, status.data?.currentBranch.name, renameBranchMutation], + ); + + const canRenameBranch = !status.data?.currentBranch.upstream; + + const totalChanges = files.length; + const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0); + const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0); + + const [isRefreshing, setIsRefreshing] = useState(false); + const handleRefresh = useCallback(async () => { + if (isRefreshing) return; + setIsRefreshing(true); + try { + await Promise.all([ + utils.git.getStatus.invalidate({ workspaceId }), + utils.git.getDiff.invalidate({ workspaceId }), + utils.git.listCommits.invalidate({ workspaceId }), + utils.git.listBranches.invalidate({ workspaceId }), + utils.git.getBaseBranch.invalidate({ workspaceId }), + ]); + } catch (error) { + console.warn("Failed to refresh changes tab", error); + toast.error( + error instanceof Error ? error.message : "Failed to refresh changes", + ); + } finally { + setIsRefreshing(false); + } + }, [utils, workspaceId, isRefreshing]); + + const actions = ( + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-6" + onClick={() => void handleRefresh()} + disabled={isRefreshing} + > + <RefreshCw + className={cn("size-3.5", isRefreshing && "animate-spin")} + /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom">Refresh changes</TooltipContent> + </Tooltip> + ); + + const content = ( + <ChangesTabContent + status={status} + commits={commits} + branches={branches} + filter={filter} + baseBranch={baseBranch} + files={files} + isLoading={isLoading} + totalChanges={totalChanges} + totalAdditions={totalAdditions} + totalDeletions={totalDeletions} + worktreePath={worktreePath} + onSelectFile={onSelectFile} + onOpenFile={onOpenFile} + onOpenInEditor={handleOpenInEditor} + onFilterChange={setFilter} + onBaseBranchChange={setBaseBranch} + onRenameBranch={handleRenameBranch} + canRenameBranch={canRenameBranch} + /> + ); + + return { + id: "changes", + label: "Changes", + badge: totalChanges > 0 ? totalChanges : undefined, + actions, + content, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/index.ts new file mode 100644 index 00000000000..8d13bf3d0c4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/index.ts @@ -0,0 +1,6 @@ +export type { + OpenChatFn, + PRFlowDispatch, + PRFlowDispatchArgs, +} from "./usePRFlowDispatch"; +export { planDispatch, usePRFlowDispatch } from "./usePRFlowDispatch"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/usePRFlowDispatch.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/usePRFlowDispatch.test.ts new file mode 100644 index 00000000000..cd78a1a8996 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/usePRFlowDispatch.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import type { + BranchSyncStatus, + PRFlowState, +} from "../../components/PRActionHeader/utils/getPRFlowState"; +import { planDispatch } from "./usePRFlowDispatch"; + +const sync: BranchSyncStatus = { + hasRepo: true, + hasUpstream: true, + pushCount: 1, + pullCount: 0, + isDefaultBranch: false, + isDetached: false, + hasUncommitted: false, + currentBranch: "feature-x", + defaultBranch: "main", +}; + +const noPrState: PRFlowState = { kind: "no-pr", sync }; + +describe("planDispatch", () => { + test("no-pr without draft → /pr/create-pr prompt", () => { + const plan = planDispatch(noPrState, { draft: false }); + expect(plan).not.toBeNull(); + expect(plan?.prompt).toBe("/pr/create-pr"); + }); + + test("no-pr with draft → /pr/create-pr --draft", () => { + const plan = planDispatch(noPrState, { draft: true }); + expect(plan?.prompt).toBe("/pr/create-pr --draft"); + }); + + test("attaches pr-context.md as base64 data URL", () => { + const plan = planDispatch(noPrState, { draft: false }); + expect(plan?.attachment.filename).toBe("pr-context.md"); + expect(plan?.attachment.mediaType).toBe("text/markdown"); + expect(plan?.attachment.data.startsWith("data:text/markdown;base64,")).toBe( + true, + ); + + const base64 = plan?.attachment.data.replace( + "data:text/markdown;base64,", + "", + ); + const decoded = Buffer.from(base64 ?? "", "base64").toString("utf-8"); + expect(decoded).toContain("# PR context"); + expect(decoded).toContain("Current: `feature-x`"); + }); + + test("returns null for states outside MVP scope", () => { + expect(planDispatch({ kind: "loading" }, { draft: false })).toBeNull(); + expect( + planDispatch({ kind: "busy", pr: null }, { draft: false }), + ).toBeNull(); + expect( + planDispatch( + { kind: "unavailable", reason: "default-branch" }, + { draft: false }, + ), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/usePRFlowDispatch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/usePRFlowDispatch.ts new file mode 100644 index 00000000000..7af0bf2e67f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowDispatch/usePRFlowDispatch.ts @@ -0,0 +1,87 @@ +import { useCallback } from "react"; +import type { ChatPaneData } from "../../../../types"; +import { buildPRContext } from "../../components/PRActionHeader/utils/buildPRContext"; +import type { PRFlowState } from "../../components/PRActionHeader/utils/getPRFlowState"; + +/** + * Opens a chat pane (or reuses one later — see plan phase 7) pre-populated + * with a slash command and a synthesized `pr-context.md` attachment. + * + * For the MVP, `onOpenChat` always creates a new chat tab. The V2 workspace + * page wires this up by calling `store.getState().addTab({ kind: "chat", ... })`. + */ +export type OpenChatFn = (launchConfig: ChatPaneData["launchConfig"]) => void; + +export interface PRFlowDispatchArgs { + state: PRFlowState; + draft?: boolean; +} + +export type PRFlowDispatch = (args: PRFlowDispatchArgs) => void; + +interface UsePRFlowDispatchOptions { + onOpenChat: OpenChatFn; +} + +export function usePRFlowDispatch({ + onOpenChat, +}: UsePRFlowDispatchOptions): PRFlowDispatch { + return useCallback( + ({ state, draft }: PRFlowDispatchArgs) => { + const plan = planDispatch(state, { draft: draft === true }); + if (!plan) return; + + onOpenChat({ + initialPrompt: plan.prompt, + initialFiles: [plan.attachment], + }); + }, + [onOpenChat], + ); +} + +interface DispatchPlan { + prompt: string; + attachment: { + data: string; + mediaType: string; + filename: string; + }; +} + +export function planDispatch( + state: PRFlowState, + options: { draft: boolean }, +): DispatchPlan | null { + switch (state.kind) { + case "no-pr": { + const prompt = options.draft ? "/pr/create-pr --draft" : "/pr/create-pr"; + const markdown = buildPRContext(state); + return { + prompt, + attachment: { + data: encodeAsDataUrl(markdown, "text/markdown"), + mediaType: "text/markdown", + filename: "pr-context.md", + }, + }; + } + // MVP scope: other states don't dispatch yet. + default: + return null; + } +} + +function encodeAsDataUrl(content: string, mediaType: string): string { + // `unescape` is removed from WHATWG; use TextEncoder for UTF-8 → base64. + // Branch names + commit messages can carry non-ASCII characters. + const base64 = + typeof btoa === "function" + ? btoa( + Array.from(new TextEncoder().encode(content), (b) => + String.fromCharCode(b), + ).join(""), + ) + : Buffer.from(content, "utf-8").toString("base64"); + return `data:${mediaType};base64,${base64}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowState/index.ts new file mode 100644 index 00000000000..96446e16010 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowState/index.ts @@ -0,0 +1 @@ +export { usePRFlowState } from "./usePRFlowState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowState/usePRFlowState.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowState/usePRFlowState.ts new file mode 100644 index 00000000000..d07dd907128 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/usePRFlowState/usePRFlowState.ts @@ -0,0 +1,64 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +import { useMemo } from "react"; +import { + type PullRequest as FlowPullRequest, + getPRFlowState, + type PRFlowState, +} from "../../components/PRActionHeader/utils/getPRFlowState"; + +interface UsePRFlowStateResult { + flowState: PRFlowState; + onRetry: () => void; +} + +export function usePRFlowState(workspaceId: string): UsePRFlowStateResult { + const prQuery = workspaceTrpc.git.getPullRequest.useQuery( + { workspaceId }, + { + enabled: !!workspaceId, + refetchInterval: 10_000, + refetchOnWindowFocus: true, + staleTime: 10_000, + }, + ); + + const syncQuery = workspaceTrpc.git.getBranchSyncStatus.useQuery( + { workspaceId }, + { + enabled: !!workspaceId, + refetchInterval: 10_000, + refetchOnWindowFocus: true, + staleTime: 5_000, + }, + ); + + const flowState = useMemo( + () => + getPRFlowState({ + pr: (prQuery.data as FlowPullRequest | null) ?? null, + sync: syncQuery.data ?? null, + isLoading: prQuery.isLoading || syncQuery.isLoading, + isAgentRunning: false, + loadError: + (prQuery.error as Error | null) ?? + (syncQuery.error as Error | null) ?? + null, + }), + [ + prQuery.data, + prQuery.error, + prQuery.isLoading, + syncQuery.data, + syncQuery.error, + syncQuery.isLoading, + ], + ); + + return { + flowState, + onRetry: () => { + void prQuery.refetch(); + void syncQuery.refetch(); + }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx new file mode 100644 index 00000000000..365a7c4721a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/ChecksSection.tsx @@ -0,0 +1,175 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { useMemo, useState } from "react"; +import { + LuArrowUpRight, + LuCheck, + LuLoaderCircle, + LuMinus, + LuX, +} from "react-icons/lu"; +import { VscChevronRight } from "react-icons/vsc"; +import type { NormalizedCheck, NormalizedPR } from "../../types"; + +const checkIconConfig = { + success: { + icon: LuCheck, + className: "text-emerald-600 dark:text-emerald-400", + }, + failure: { icon: LuX, className: "text-red-600 dark:text-red-400" }, + pending: { + icon: LuLoaderCircle, + className: "text-amber-600 dark:text-amber-400", + }, + skipped: { icon: LuMinus, className: "text-muted-foreground" }, + cancelled: { icon: LuMinus, className: "text-muted-foreground" }, +} as const; + +const checkSummaryIconConfig = { + success: checkIconConfig.success, + failure: checkIconConfig.failure, + pending: checkIconConfig.pending, + none: { icon: LuMinus, className: "text-muted-foreground" }, +} as const; + +interface ChecksSectionProps { + checks: NormalizedCheck[]; + checksStatus: NormalizedPR["checksStatus"]; + prUrl: string; +} + +export function ChecksSection({ + checks, + checksStatus, + prUrl, +}: ChecksSectionProps) { + const [open, setOpen] = useState(true); + + const relevantChecks = useMemo( + () => + checks.filter( + (check) => check.status !== "skipped" && check.status !== "cancelled", + ), + [checks], + ); + + const passingChecks = relevantChecks.filter( + (check) => check.status === "success", + ).length; + const checksSummary = + relevantChecks.length > 0 + ? `${passingChecks}/${relevantChecks.length} checks passing` + : "No checks reported"; + const checksStatusConfig = checkSummaryIconConfig[checksStatus]; + const ChecksStatusIcon = checksStatusConfig.icon; + + return ( + <Collapsible open={open} onOpenChange={setOpen}> + <CollapsibleTrigger + className={cn( + "flex w-full min-w-0 items-center justify-between gap-2 px-2 py-1.5 text-left", + "cursor-pointer transition-colors hover:bg-accent/30", + )} + > + <div className="flex min-w-0 items-center gap-1.5"> + <VscChevronRight + className={cn( + "size-3 shrink-0 text-muted-foreground transition-transform duration-150", + open && "rotate-90", + )} + /> + <span className="truncate text-xs font-medium">Checks</span> + <span className="shrink-0 text-[10px] text-muted-foreground"> + {relevantChecks.length} + </span> + </div> + <div + className={cn( + "flex shrink-0 items-center gap-1", + checksStatusConfig.className, + )} + > + <ChecksStatusIcon + className={cn( + "size-3.5 shrink-0", + checksStatus === "pending" && "animate-spin", + )} + /> + <span className="max-w-[140px] truncate text-[10px] normal-case"> + {checksSummary} + </span> + </div> + </CollapsibleTrigger> + <CollapsibleContent className="min-w-0 overflow-hidden px-0.5 pb-1"> + {relevantChecks.length === 0 ? ( + <div className="px-1.5 py-1 text-xs text-muted-foreground"> + No checks reported. + </div> + ) : ( + relevantChecks.map((check, index) => ( + <CheckRow + key={`${check.name}-${index}`} + check={check} + prUrl={prUrl} + /> + )) + )} + </CollapsibleContent> + </Collapsible> + ); +} + +function resolveCheckUrl( + check: NormalizedCheck, + prUrl: string, +): string | undefined { + if (check.url) return check.url; + const name = check.name.trim().toLowerCase(); + if (name.includes("coderabbit") || name.includes("code rabbit")) return prUrl; + return undefined; +} + +function CheckRow({ check, prUrl }: { check: NormalizedCheck; prUrl: string }) { + const { icon: CheckIcon, className } = checkIconConfig[check.status]; + const checkUrl = resolveCheckUrl(check, prUrl); + + const inner = ( + <div className="flex min-w-0 items-center gap-1 rounded-sm px-1.5 py-1 text-xs transition-colors hover:bg-accent/50"> + <CheckIcon + className={cn( + "size-3 shrink-0", + className, + check.status === "pending" && "animate-spin", + )} + /> + <div className="flex min-w-0 flex-1 items-center gap-1"> + <span className="min-w-0 truncate">{check.name}</span> + {checkUrl && ( + <LuArrowUpRight className="size-3.5 shrink-0 text-muted-foreground/70 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" /> + )} + </div> + {check.durationText && ( + <span className="shrink-0 text-[10px] text-muted-foreground"> + {check.durationText} + </span> + )} + </div> + ); + + return checkUrl ? ( + <a + href={checkUrl} + target="_blank" + rel="noopener noreferrer" + className="group block" + > + {inner} + </a> + ) : ( + inner + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/index.ts new file mode 100644 index 00000000000..093db2ed3bf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ChecksSection/index.ts @@ -0,0 +1 @@ +export { ChecksSection } from "./ChecksSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx new file mode 100644 index 00000000000..e907c55ab39 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/CommentsSection.tsx @@ -0,0 +1,354 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { Skeleton } from "@superset/ui/skeleton"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuArrowUpRight, LuCheck, LuCopy } from "react-icons/lu"; +import { VscChevronRight } from "react-icons/vsc"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { CommentPaneData } from "../../../../../../types"; +import type { NormalizedComment } from "../../types"; + +interface CommentsSectionProps { + comments: NormalizedComment[]; + isLoading: boolean; + onOpenComment?: (comment: CommentPaneData) => void; +} + +export function CommentsSection({ + comments, + isLoading, + onOpenComment, +}: CommentsSectionProps) { + const [commentsOpen, setCommentsOpen] = useState(true); + const [resolvedOpen, setResolvedOpen] = useState(false); + const [copiedActionKey, setCopiedActionKey] = useState<string | null>(null); + const copiedResetRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const isMountedRef = useRef(true); + + const copyToClipboard = useCallback( + (text: string) => electronTrpcClient.external.copyText.mutate(text), + [], + ); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (copiedResetRef.current) clearTimeout(copiedResetRef.current); + }; + }, []); + + const activeComments = useMemo( + () => comments.filter((c) => !c.isResolved), + [comments], + ); + const resolvedComments = useMemo( + () => comments.filter((c) => c.isResolved), + [comments], + ); + + const markCopied = useCallback((key: string) => { + if (!isMountedRef.current) return; + if (copiedResetRef.current) clearTimeout(copiedResetRef.current); + setCopiedActionKey(key); + copiedResetRef.current = setTimeout(() => { + if (!isMountedRef.current) return; + setCopiedActionKey(null); + copiedResetRef.current = null; + }, 1500); + }, []); + + const handleCopySingle = useCallback( + (comment: NormalizedComment) => { + void copyToClipboard(comment.body.trim() || "No comment body") + .then(() => { + markCopied(`comment:${comment.id}`); + }) + .catch((err) => { + console.warn("Failed to copy comment", err); + }); + }, + [copyToClipboard, markCopied], + ); + + const handleCopyAll = useCallback(() => { + const text = activeComments + .map((c) => { + const location = c.path + ? c.line + ? `${c.path}:${c.line}` + : c.path + : c.kind === "conversation" + ? "Conversation" + : null; + const meta = [ + c.authorLogin, + c.kind === "review" ? "Review" : "Comment", + location, + ] + .filter(Boolean) + .join(" \u2022 "); + return [meta, c.body.trim() || "No comment body"] + .filter(Boolean) + .join("\n"); + }) + .join("\n\n---\n\n"); + void copyToClipboard(text) + .then(() => { + markCopied("comments:all"); + }) + .catch((err) => { + console.warn("Failed to copy comments", err); + }); + }, [copyToClipboard, activeComments, markCopied]); + + const commentsCountLabel = isLoading ? "..." : comments.length; + const copyAllLabel = + copiedActionKey === "comments:all" ? "Copied" : "Copy all"; + + return ( + <> + <Collapsible + open={commentsOpen} + onOpenChange={setCommentsOpen} + className="min-w-0" + > + <div className="flex min-w-0 items-center"> + <CollapsibleTrigger + className={cn( + "flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1.5 text-left", + "cursor-pointer transition-colors hover:bg-accent/30", + )} + > + <VscChevronRight + className={cn( + "size-3 shrink-0 text-muted-foreground transition-transform duration-150", + commentsOpen && "rotate-90", + )} + /> + <span className="truncate text-xs font-medium">Comments</span> + <span className="shrink-0 text-[10px] text-muted-foreground"> + {commentsCountLabel} + </span> + </CollapsibleTrigger> + {activeComments.length > 0 && ( + <div className="mr-1.5 flex items-center gap-1"> + <button + type="button" + className="flex shrink-0 items-center gap-1 rounded-sm px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent/30 hover:text-foreground" + onClick={handleCopyAll} + > + {copiedActionKey === "comments:all" ? ( + <LuCheck className="size-3" /> + ) : ( + <LuCopy className="size-3" /> + )} + <span>{copyAllLabel}</span> + </button> + </div> + )} + </div> + <CollapsibleContent className="min-w-0 overflow-hidden px-0.5 pb-1"> + {isLoading ? ( + <div className="space-y-1 px-1"> + <Skeleton className="h-11 w-full rounded-sm" /> + <Skeleton className="h-11 w-full rounded-sm" /> + <Skeleton className="h-11 w-full rounded-sm" /> + </div> + ) : comments.length === 0 ? ( + <div className="px-1.5 py-1 text-xs text-muted-foreground"> + No comments yet. + </div> + ) : ( + activeComments.map((comment) => ( + <CommentRow + key={comment.id} + comment={comment} + copiedActionKey={copiedActionKey} + onCopy={handleCopySingle} + onOpen={onOpenComment} + /> + )) + )} + </CollapsibleContent> + </Collapsible> + + {resolvedComments.length > 0 && ( + <Collapsible + open={resolvedOpen} + onOpenChange={setResolvedOpen} + className="min-w-0" + > + <CollapsibleTrigger + className={cn( + "flex w-full min-w-0 items-center gap-1.5 px-2 py-1.5 text-left", + "cursor-pointer transition-colors hover:bg-accent/30", + )} + > + <VscChevronRight + className={cn( + "size-3 shrink-0 text-muted-foreground transition-transform duration-150", + resolvedOpen && "rotate-90", + )} + /> + <span className="truncate text-xs font-medium">Resolved</span> + <span className="shrink-0 text-[10px] text-muted-foreground"> + {resolvedComments.length} + </span> + </CollapsibleTrigger> + <CollapsibleContent className="min-w-0 overflow-hidden px-0.5 pb-1"> + {resolvedComments.map((comment) => ( + <CommentRow + key={comment.id} + comment={comment} + copiedActionKey={copiedActionKey} + onCopy={handleCopySingle} + onOpen={onOpenComment} + /> + ))} + </CollapsibleContent> + </Collapsible> + )} + </> + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatShortAge(isoDate?: string): string | null { + if (!isoDate) return null; + const ms = Date.now() - new Date(isoDate).getTime(); + if (Number.isNaN(ms) || ms < 0) return null; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${Math.max(1, seconds)}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + +function getPreviewText(body: string): string { + return ( + body + .replace(/<!--[\s\S]*?-->/g, "\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) + ?.replace(/^[-*+>]\s*/, "") + ?.replace(/\s+/g, " ") ?? "No preview available" + ); +} + +// --------------------------------------------------------------------------- +// CommentRow +// --------------------------------------------------------------------------- + +interface CommentRowProps { + comment: NormalizedComment; + copiedActionKey: string | null; + onCopy: (comment: NormalizedComment) => void; + onOpen?: (comment: CommentPaneData) => void; +} + +function CommentRow({ + comment, + copiedActionKey, + onCopy, + onOpen, +}: CommentRowProps) { + const age = formatShortAge(comment.createdAt); + const isCopied = copiedActionKey === `comment:${comment.id}`; + + const handleClick = () => { + onOpen?.({ + commentId: comment.id, + authorLogin: comment.authorLogin, + avatarUrl: comment.avatarUrl, + body: comment.body, + url: comment.url, + path: comment.path, + line: comment.line, + }); + }; + + const content = ( + <> + <Avatar className="mt-0.5 size-4 shrink-0"> + {comment.avatarUrl ? ( + <AvatarImage src={comment.avatarUrl} alt={comment.authorLogin} /> + ) : null} + <AvatarFallback className="text-[10px] font-medium"> + {comment.authorLogin.slice(0, 2).toUpperCase()} + </AvatarFallback> + </Avatar> + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-1.5"> + <span className="truncate text-xs font-medium text-foreground"> + {comment.authorLogin} + </span> + <span className="shrink-0 rounded border border-border/70 bg-muted/35 px-1 py-0 text-[9px] uppercase tracking-wide text-muted-foreground"> + {comment.kind === "review" ? "Review" : "Comment"} + </span> + <span className="flex-1" /> + {age ? ( + <span className="shrink-0 text-[10px] text-muted-foreground"> + {age} + </span> + ) : null} + </div> + <p className="mt-0.5 line-clamp-1 text-xs leading-4 text-muted-foreground"> + {getPreviewText(comment.body)} + </p> + </div> + </> + ); + + return ( + <div className="group relative flex items-start gap-1 rounded-sm px-1.5 py-1 transition-colors hover:bg-accent/50"> + <button + type="button" + onClick={handleClick} + className="flex min-w-0 flex-1 items-start gap-2 text-left" + aria-label={`View comment by ${comment.authorLogin}`} + > + {content} + </button> + <div className="absolute right-0.5 top-0.5 flex items-center gap-0.5 rounded-sm bg-background/90 px-0.5 py-0.5 shadow-sm opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"> + <button + type="button" + className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onCopy(comment); + }} + aria-label={isCopied ? "Copied comment" : "Copy comment"} + > + {isCopied ? ( + <LuCheck className="size-3" /> + ) : ( + <LuCopy className="size-3" /> + )} + </button> + {comment.url ? ( + <a + href={comment.url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + aria-label="Open comment on GitHub" + > + <LuArrowUpRight className="size-3" /> + </a> + ) : null} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/index.ts new file mode 100644 index 00000000000..2a154c68e68 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/CommentsSection/index.ts @@ -0,0 +1 @@ +export { CommentsSection } from "./CommentsSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/PRHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/PRHeader.tsx new file mode 100644 index 00000000000..1a48e0f154c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/PRHeader.tsx @@ -0,0 +1,61 @@ +import { cn } from "@superset/ui/utils"; +import { LuArrowUpRight } from "react-icons/lu"; +import { PRIcon } from "renderer/screens/main/components/PRIcon"; +import type { NormalizedPR } from "../../types"; + +const reviewDecisionConfig = { + approved: { + label: "Approved", + className: + "border border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + }, + changes_requested: { + label: "Changes requested", + className: + "border border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300", + }, + pending: { + label: "Review pending", + className: + "border border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300", + }, +} as const; + +interface PRHeaderProps { + pr: NormalizedPR; +} + +export function PRHeader({ pr }: PRHeaderProps) { + return ( + <div className="space-y-1.5 px-2 py-2"> + <a + href={pr.url} + target="_blank" + rel="noopener noreferrer" + className="group flex items-center gap-1.5 cursor-pointer" + > + <PRIcon state={pr.state} className="size-4 shrink-0" /> + <span + className="min-w-0 flex-1 truncate text-xs font-medium text-foreground" + title={pr.title} + > + {pr.title} + </span> + <LuArrowUpRight + aria-hidden="true" + className="size-3.5 shrink-0 text-muted-foreground/70 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" + /> + </a> + <div className="flex items-center gap-1.5"> + <span + className={cn( + "shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] font-medium", + reviewDecisionConfig[pr.reviewDecision].className, + )} + > + {reviewDecisionConfig[pr.reviewDecision].label} + </span> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/index.ts new file mode 100644 index 00000000000..d374e0690ed --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/PRHeader/index.ts @@ -0,0 +1 @@ +export { PRHeader } from "./PRHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx new file mode 100644 index 00000000000..70beca5832e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx @@ -0,0 +1,70 @@ +import { memo } from "react"; +import type { CommentPaneData } from "../../../../../../types"; +import type { NormalizedComment, NormalizedPR } from "../../types"; +import { ChecksSection } from "../ChecksSection"; +import { CommentsSection } from "../CommentsSection"; +import { PRHeader } from "../PRHeader"; + +interface ReviewTabContentProps { + pr: NormalizedPR | null; + comments: NormalizedComment[]; + isLoading: boolean; + isError: boolean; + isCommentsLoading: boolean; + onOpenComment?: (comment: CommentPaneData) => void; +} + +export const ReviewTabContent = memo(function ReviewTabContent({ + pr, + comments, + isLoading, + isError, + isCommentsLoading, + onOpenComment, +}: ReviewTabContentProps) { + if (isError) { + return ( + <div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground"> + Unable to load review status + </div> + ); + } + + if (isLoading && !pr) { + return ( + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + Loading review... + </div> + ); + } + + if (!pr) { + return ( + <div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground"> + Open a pull request to view review status, checks, and comments. + </div> + ); + } + + return ( + <div className="flex h-full min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto"> + <PRHeader pr={pr} /> + + <div className="my-1 border-b border-border/70" /> + + <ChecksSection + checks={pr.checks} + checksStatus={pr.checksStatus} + prUrl={pr.url} + /> + + <div className="my-1 border-b border-border/70" /> + + <CommentsSection + comments={comments} + isLoading={isCommentsLoading} + onOpenComment={onOpenComment} + /> + </div> + ); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/index.ts new file mode 100644 index 00000000000..398e2cda815 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/index.ts @@ -0,0 +1 @@ +export { ReviewTabContent } from "./ReviewTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/index.ts new file mode 100644 index 00000000000..019d568ed51 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/index.ts @@ -0,0 +1 @@ +export { useReviewTab } from "./useReviewTab"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/types.ts new file mode 100644 index 00000000000..890df920c3d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/types.ts @@ -0,0 +1,32 @@ +/** Normalized PR shape used by review tab UI components. */ +export interface NormalizedPR { + number: number; + url: string; + title: string; + state: "open" | "closed" | "merged" | "draft"; + reviewDecision: "approved" | "changes_requested" | "pending"; + checksStatus: "success" | "failure" | "pending" | "none"; + checks: NormalizedCheck[]; +} + +export interface NormalizedCheck { + name: string; + status: "success" | "failure" | "pending" | "skipped" | "cancelled"; + url?: string; + durationText?: string; +} + +/** Normalized comment shape, flattened from review threads + conversation comments. */ +export interface NormalizedComment { + id: string; + authorLogin: string; + avatarUrl?: string; + body: string; + createdAt?: string; + url?: string; + kind: "review" | "conversation"; + path?: string; + line?: number; + isResolved: boolean; + threadId?: string; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx new file mode 100644 index 00000000000..deb38f7bc92 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/useReviewTab.tsx @@ -0,0 +1,164 @@ +import type { AppRouter } from "@superset/host-service"; +import { workspaceTrpc } from "@superset/workspace-client"; +import type { inferRouterOutputs } from "@trpc/server"; +import { useMemo } from "react"; +import { LuMessageSquare } from "react-icons/lu"; +import type { CommentPaneData } from "../../../../types"; +import { + coerceCheckStatus, + computeChecksRollup, +} from "../../components/PRActionHeader/utils/computeChecksStatus"; +import type { SidebarTabDefinition } from "../../types"; +import { ReviewTabContent } from "./components/ReviewTabContent"; +import type { NormalizedComment, NormalizedPR } from "./types"; + +type RouterOutputs = inferRouterOutputs<AppRouter>; +type V2ThreadsData = RouterOutputs["git"]["getPullRequestThreads"]; + +interface UseReviewTabParams { + workspaceId: string; + onOpenComment?: (comment: CommentPaneData) => void; +} + +export function useReviewTab({ + workspaceId, + onOpenComment, +}: UseReviewTabParams): SidebarTabDefinition { + const prQuery = workspaceTrpc.git.getPullRequest.useQuery( + { workspaceId }, + { + enabled: !!workspaceId, + refetchInterval: 10_000, + refetchOnWindowFocus: true, + staleTime: 10_000, + }, + ); + + const hasPR = prQuery.isSuccess && prQuery.data != null; + const threadsQuery = workspaceTrpc.git.getPullRequestThreads.useQuery( + { workspaceId }, + { + enabled: !!workspaceId && hasPR, + refetchInterval: 30_000, + refetchOnWindowFocus: true, + }, + ); + + const pr = useMemo<NormalizedPR | null>(() => { + const raw = prQuery.data; + if (!raw) return null; + return { + number: raw.number, + url: raw.url, + title: raw.title, + state: raw.isDraft ? "draft" : raw.state, + reviewDecision: normalizeReviewDecision(raw.reviewDecision), + checksStatus: computeChecksRollup(raw.checks).overall, + checks: raw.checks.map((c) => ({ + name: c.name, + // The DB stores the already-resolved effective status (success/failure/ + // pending/skipped/cancelled) in the `status` field, even though the + // tRPC type calls it CheckStatusState. Fall back to coercing it. + status: coerceCheckStatus(c.status, c.conclusion), + url: c.detailsUrl ?? undefined, + durationText: computeDurationText(c.startedAt, c.completedAt), + })), + }; + }, [prQuery.data]); + + const comments = useMemo<NormalizedComment[]>(() => { + const data = threadsQuery.data; + if (!data) return []; + return normalizeThreadsToComments(data); + }, [threadsQuery.data]); + + const openCommentCount = comments.filter((c) => !c.isResolved).length; + + const content = ( + <ReviewTabContent + pr={pr} + comments={comments} + isLoading={prQuery.isLoading} + isError={prQuery.isError} + isCommentsLoading={threadsQuery.isLoading} + onOpenComment={onOpenComment} + /> + ); + + return { + id: "review", + label: "Review", + icon: LuMessageSquare, + badge: openCommentCount, + content, + }; +} + +// --------------------------------------------------------------------------- +// Normalization helpers +// --------------------------------------------------------------------------- + +function normalizeReviewDecision( + decision: string | null, +): "approved" | "changes_requested" | "pending" { + if (decision === "approved") return "approved"; + if (decision === "changes_requested") return "changes_requested"; + return "pending"; +} + +function computeDurationText( + startedAt: string | null, + completedAt: string | null, +): string | undefined { + if (!startedAt || !completedAt) return undefined; + const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime(); + if (Number.isNaN(ms) || ms < 0) return undefined; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + return `${minutes}m`; +} + +function normalizeThreadsToComments(data: V2ThreadsData): NormalizedComment[] { + const comments: NormalizedComment[] = []; + + for (const thread of data.reviewThreads) { + const first = thread.comments[0]; + if (!first) continue; + comments.push({ + id: first.id, + authorLogin: first.author.login, + avatarUrl: first.author.avatarUrl || undefined, + body: first.body, + createdAt: first.createdAt, + url: undefined, + kind: "review", + path: thread.path || undefined, + line: thread.line ?? undefined, + isResolved: thread.isResolved, + threadId: thread.id, + }); + } + + for (const c of data.conversationComments) { + comments.push({ + id: String(c.id), + authorLogin: c.user.login, + avatarUrl: c.user.avatarUrl || undefined, + body: c.body, + createdAt: c.createdAt, + url: c.htmlUrl || undefined, + kind: "conversation", + isResolved: false, + threadId: undefined, + }); + } + + comments.sort((a, b) => { + const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return ta - tb; + }); + + return comments; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/index.ts new file mode 100644 index 00000000000..3f3f7c98ff2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/index.ts @@ -0,0 +1 @@ +export { WorkspaceSidebar } from "./WorkspaceSidebar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts new file mode 100644 index 00000000000..01f1ed5d73b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/types.ts @@ -0,0 +1,10 @@ +import type { ComponentType, ReactNode } from "react"; + +export interface SidebarTabDefinition { + id: string; + label: string; + icon?: ComponentType<{ className?: string }>; + badge?: number; + actions?: ReactNode; + content: ReactNode; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx deleted file mode 100644 index 4040e457cf3..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/WorkspaceTerminal.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { FitAddon } from "@xterm/addon-fit"; -import { Terminal as XTerm } from "@xterm/xterm"; -import "@xterm/xterm/css/xterm.css"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useWorkspaceHostUrl } from "../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; - -interface WorkspaceTerminalProps { - workspaceId: string; -} - -type TerminalServerMessage = - | { - type: "data"; - data: string; - } - | { - type: "error"; - message: string; - } - | { - type: "exit"; - exitCode: number; - signal: number; - }; - -export function WorkspaceTerminal({ workspaceId }: WorkspaceTerminalProps) { - const hostUrl = useWorkspaceHostUrl(); - const containerRef = useRef<HTMLDivElement | null>(null); - const [connectionState, setConnectionState] = useState< - "connecting" | "open" | "closed" - >("connecting"); - const [reconnectKey, setReconnectKey] = useState(0); - - const websocketUrl = useMemo(() => { - const url = new URL(`/terminal/${workspaceId}`, hostUrl); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - url.searchParams.set("reconnect", String(reconnectKey)); - return url.toString(); - }, [hostUrl, reconnectKey, workspaceId]); - - useEffect(() => { - const container = containerRef.current; - if (!container) { - return; - } - - const fitAddon = new FitAddon(); - const terminal = new XTerm({ - cursorBlink: true, - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 12, - theme: { - background: "#14100f", - foreground: "#f5efe9", - }, - }); - terminal.loadAddon(fitAddon); - terminal.open(container); - fitAddon.fit(); - terminal.focus(); - - setConnectionState("connecting"); - const socket = new WebSocket(websocketUrl); - - const sendResize = () => { - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - socket.send( - JSON.stringify({ - type: "resize", - cols: terminal.cols, - rows: terminal.rows, - }), - ); - }; - - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - sendResize(); - }); - resizeObserver.observe(container); - - const onTerminalDataDispose = terminal.onData((data) => { - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - socket.send( - JSON.stringify({ - type: "input", - data, - }), - ); - }); - - socket.addEventListener("open", () => { - setConnectionState("open"); - sendResize(); - }); - - socket.addEventListener("message", (event) => { - let message: TerminalServerMessage; - try { - message = JSON.parse(String(event.data)) as TerminalServerMessage; - } catch { - terminal.writeln("\r\n[terminal] invalid server payload"); - return; - } - - if (message.type === "data") { - terminal.write(message.data); - return; - } - - if (message.type === "error") { - terminal.writeln(`\r\n[terminal] ${message.message}`); - return; - } - - terminal.writeln( - `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, - ); - }); - - socket.addEventListener("close", () => { - setConnectionState("closed"); - }); - - socket.addEventListener("error", () => { - terminal.writeln("\r\n[terminal] websocket error"); - }); - - return () => { - resizeObserver.disconnect(); - onTerminalDataDispose.dispose(); - socket.close(); - terminal.dispose(); - }; - }, [websocketUrl]); - - return ( - <div className="w-full rounded-lg border border-border p-4"> - <div className="mb-3 flex items-center justify-between gap-3"> - <div> - <h2 className="text-sm font-medium">terminal</h2> - <p className="text-xs text-muted-foreground"> - {connectionState === "open" - ? "Connected" - : connectionState === "connecting" - ? "Connecting..." - : "Disconnected"} - </p> - </div> - <Button - size="sm" - variant="outline" - onClick={() => setReconnectKey((value) => value + 1)} - > - Reconnect - </Button> - </div> - <div - ref={containerRef} - className="h-[360px] overflow-hidden rounded-md border border-border bg-[#14100f] p-2" - /> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/index.ts deleted file mode 100644 index 0c06f554ca5..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceTerminal } from "./WorkspaceTerminal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/index.ts new file mode 100644 index 00000000000..d0fa34260cd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/index.ts @@ -0,0 +1 @@ +export { useBrowserShellInteractionPassthrough } from "./useBrowserShellInteractionPassthrough"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/useBrowserShellInteractionPassthrough.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/useBrowserShellInteractionPassthrough.ts new file mode 100644 index 00000000000..ca2fdb0c855 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useBrowserShellInteractionPassthrough/useBrowserShellInteractionPassthrough.ts @@ -0,0 +1,64 @@ +import type { WorkspaceInteractionState } from "@superset/panes"; +import { useCallback, useEffect, useRef } from "react"; +import { browserRuntimeRegistry } from "../usePaneRegistry/components/BrowserPane"; + +interface UseBrowserShellInteractionPassthroughArgs { + sidebarOpen: boolean; +} + +export function useBrowserShellInteractionPassthrough({ + sidebarOpen, +}: UseBrowserShellInteractionPassthroughArgs) { + const workspaceResizeActiveRef = useRef(false); + const sidebarResizeActiveRef = useRef(false); + + const syncBrowserShellInteractionPassthrough = useCallback(() => { + browserRuntimeRegistry.setShellInteractionPassthrough( + workspaceResizeActiveRef.current || sidebarResizeActiveRef.current, + ); + }, []); + + const onWorkspaceInteractionStateChange = useCallback( + (state: WorkspaceInteractionState) => { + workspaceResizeActiveRef.current = state.resizeActive; + syncBrowserShellInteractionPassthrough(); + }, + [syncBrowserShellInteractionPassthrough], + ); + + const onSidebarResizeDragging = useCallback( + (isDragging: boolean) => { + sidebarResizeActiveRef.current = isDragging; + syncBrowserShellInteractionPassthrough(); + }, + [syncBrowserShellInteractionPassthrough], + ); + + const clearBrowserShellInteractionPassthrough = useCallback(() => { + workspaceResizeActiveRef.current = false; + sidebarResizeActiveRef.current = false; + browserRuntimeRegistry.setShellInteractionPassthrough(false); + }, []); + + useEffect(() => { + window.addEventListener("blur", clearBrowserShellInteractionPassthrough); + return () => { + window.removeEventListener( + "blur", + clearBrowserShellInteractionPassthrough, + ); + clearBrowserShellInteractionPassthrough(); + }; + }, [clearBrowserShellInteractionPassthrough]); + + useEffect(() => { + if (sidebarOpen || !sidebarResizeActiveRef.current) return; + sidebarResizeActiveRef.current = false; + syncBrowserShellInteractionPassthrough(); + }, [sidebarOpen, syncBrowserShellInteractionPassthrough]); + + return { + onSidebarResizeDragging, + onWorkspaceInteractionStateChange, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/index.ts new file mode 100644 index 00000000000..6757d5f4d5a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/index.ts @@ -0,0 +1,2 @@ +export type { ChangesetFile, DiffFileSource, DiffRef } from "./types"; +export { useChangeset } from "./useChangeset"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts new file mode 100644 index 00000000000..69200b2e0a6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts @@ -0,0 +1,21 @@ +import type { FileStatus } from "../../components/StatusIndicator"; + +export type DiffRef = + | { kind: "against-base"; baseBranch: string | null } + | { kind: "uncommitted" } + | { kind: "commit"; commitHash: string; fromHash?: string }; + +export type DiffFileSource = + | { kind: "against-base"; baseBranch: string | null } + | { kind: "staged" } + | { kind: "unstaged" } + | { kind: "commit"; commitHash: string; fromHash?: string }; + +export interface ChangesetFile { + path: string; + oldPath?: string; + status: FileStatus; + additions: number; + deletions: number; + source: DiffFileSource; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts new file mode 100644 index 00000000000..bfb7e9f02b7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts @@ -0,0 +1,151 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +import { useMemo } from "react"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import type { FileStatus } from "../../components/StatusIndicator"; +import type { ChangesetFile, DiffRef } from "./types"; + +interface UseChangesetArgs { + workspaceId: string; + ref: DiffRef; +} + +interface UseChangesetResult { + files: ChangesetFile[]; + isLoading: boolean; + isError: boolean; + error: unknown; +} + +export function useChangeset({ + workspaceId, + ref, +}: UseChangesetArgs): UseChangesetResult { + const utils = workspaceTrpc.useUtils(); + + const needsStatus = ref.kind === "against-base" || ref.kind === "uncommitted"; + const statusQuery = workspaceTrpc.git.getStatus.useQuery( + { + workspaceId, + baseBranch: + ref.kind === "against-base" ? (ref.baseBranch ?? undefined) : undefined, + }, + { enabled: needsStatus, staleTime: Number.POSITIVE_INFINITY }, + ); + + const commitQuery = workspaceTrpc.git.getCommitFiles.useQuery( + ref.kind === "commit" + ? { + workspaceId, + commitHash: ref.commitHash, + fromHash: ref.fromHash, + } + : { workspaceId, commitHash: "" }, + { + enabled: ref.kind === "commit", + staleTime: Number.POSITIVE_INFINITY, + }, + ); + + useWorkspaceEvent( + "git:changed", + workspaceId, + (payload) => { + void utils.git.getStatus.invalidate({ workspaceId }); + if (payload.paths && payload.paths.length > 0) { + for (const path of payload.paths) { + void utils.git.getDiff.invalidate({ workspaceId, path }); + } + } else { + void utils.git.getDiff.invalidate({ workspaceId }); + } + }, + needsStatus, + ); + + const files = useMemo<ChangesetFile[]>(() => { + if (ref.kind === "commit") { + return (commitQuery.data?.files ?? []).map((file) => ({ + path: file.path, + oldPath: file.oldPath, + status: file.status as FileStatus, + additions: file.additions, + deletions: file.deletions, + source: { + kind: "commit", + commitHash: ref.commitHash, + fromHash: ref.fromHash, + }, + })); + } + + const status = statusQuery.data; + if (!status) return []; + + if (ref.kind === "uncommitted") { + return [ + ...status.staged.map<ChangesetFile>((file) => ({ + path: file.path, + oldPath: file.oldPath, + status: file.status as FileStatus, + additions: file.additions, + deletions: file.deletions, + source: { kind: "staged" }, + })), + ...status.unstaged.map<ChangesetFile>((file) => ({ + path: file.path, + oldPath: file.oldPath, + status: file.status as FileStatus, + additions: file.additions, + deletions: file.deletions, + source: { kind: "unstaged" }, + })), + ]; + } + + // against-base: merge committed + dirty, last-wins by path, each file + // tagged with its actual source so downstream getDiff fetches the right + // bucket (dirty files show their working-tree diff; purely committed + // files show the base-branch diff). + const seen = new Map<string, ChangesetFile>(); + for (const file of status.againstBase) { + seen.set(file.path, { + path: file.path, + oldPath: file.oldPath, + status: file.status as FileStatus, + additions: file.additions, + deletions: file.deletions, + source: { kind: "against-base", baseBranch: ref.baseBranch }, + }); + } + for (const file of status.staged) { + seen.set(file.path, { + path: file.path, + oldPath: file.oldPath, + status: file.status as FileStatus, + additions: file.additions, + deletions: file.deletions, + source: { kind: "staged" }, + }); + } + for (const file of status.unstaged) { + seen.set(file.path, { + path: file.path, + oldPath: file.oldPath, + status: file.status as FileStatus, + additions: file.additions, + deletions: file.deletions, + source: { kind: "unstaged" }, + }); + } + return Array.from(seen.values()); + }, [ref, statusQuery.data, commitQuery.data?.files]); + + const activeQuery = needsStatus ? statusQuery : commitQuery; + + return { + files, + isLoading: activeQuery.isLoading, + isError: activeQuery.isError, + error: activeQuery.error, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/index.ts new file mode 100644 index 00000000000..c9c5b0b213f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/index.ts @@ -0,0 +1 @@ +export { useClearActivePaneAttention } from "./useClearActivePaneAttention"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts new file mode 100644 index 00000000000..800b0d1eac0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useClearActivePaneAttention/useClearActivePaneAttention.ts @@ -0,0 +1,36 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useEffect } from "react"; +import { + getV2NotificationSourcesForPane, + useV2NotificationStore, + useV2PaneNotificationStatus, +} from "renderer/stores/v2-notifications"; +import { useStore } from "zustand"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData } from "../../types"; + +export function useClearActivePaneAttention({ + workspaceId, + store, +}: { + workspaceId: string; + store: StoreApi<WorkspaceStore<PaneViewerData>>; +}): void { + const activePane = useStore(store, (state) => { + const tab = state.tabs.find( + (candidate) => candidate.id === state.activeTabId, + ); + return tab?.activePaneId ? tab.panes[tab.activePaneId] : undefined; + }); + const activePaneStatus = useV2PaneNotificationStatus(workspaceId, activePane); + const clearSourceAttention = useV2NotificationStore( + (state) => state.clearSourceAttention, + ); + + useEffect(() => { + if (activePaneStatus !== "review") return; + for (const source of getV2NotificationSourcesForPane(activePane)) { + clearSourceAttention(source, workspaceId); + } + }, [activePane, activePaneStatus, clearSourceAttention, workspaceId]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/index.ts new file mode 100644 index 00000000000..804d7ed031c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/index.ts @@ -0,0 +1 @@ +export { useConsumeAutomationRunLink } from "./useConsumeAutomationRunLink"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.test.ts new file mode 100644 index 00000000000..c70f1918d56 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "bun:test"; +import { getAutomationRunLinkConsumeKey } from "./useConsumeAutomationRunLink"; + +describe("getAutomationRunLinkConsumeKey", () => { + it("dedupes plain automation links by source id", () => { + expect( + getAutomationRunLinkConsumeKey({ + type: "terminal", + id: "terminal-1", + focusRequestId: undefined, + }), + ).toBe("terminal:terminal-1"); + expect( + getAutomationRunLinkConsumeKey({ + type: "chat", + id: "chat-1", + focusRequestId: undefined, + }), + ).toBe("chat:chat-1"); + }); + + it("treats each notification focus request as a fresh command", () => { + expect( + getAutomationRunLinkConsumeKey({ + type: "terminal", + id: "terminal-1", + focusRequestId: "request-1", + }), + ).toBe("terminal:terminal-1:focus:request-1"); + expect( + getAutomationRunLinkConsumeKey({ + type: "terminal", + id: "terminal-1", + focusRequestId: "request-2", + }), + ).toBe("terminal:terminal-1:focus:request-2"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.ts new file mode 100644 index 00000000000..95c10e8455d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeAutomationRunLink/useConsumeAutomationRunLink.ts @@ -0,0 +1,120 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useEffect, useRef } from "react"; +import type { StoreApi } from "zustand/vanilla"; +import type { + ChatPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +interface UseConsumeAutomationRunLinkArgs { + store: StoreApi<WorkspaceStore<PaneViewerData>>; + terminalId: string | undefined; + chatSessionId: string | undefined; + focusRequestId: string | undefined; +} + +/** + * When the workspace is opened via a deep link from an automation run + * (`?terminalId=...` or `?chatSessionId=...`), ensure the corresponding pane + * is present and focused. The underlying session already exists on the + * host-service from the dispatcher — we just re-adopt it in the pane store. + */ +export function useConsumeAutomationRunLink({ + store, + terminalId, + chatSessionId, + focusRequestId, +}: UseConsumeAutomationRunLinkArgs): void { + const consumedRef = useRef<Set<string>>(new Set()); + + useEffect(() => { + if (!terminalId) return; + const key = getAutomationRunLinkConsumeKey({ + type: "terminal", + id: terminalId, + focusRequestId, + }); + if (consumedRef.current.has(key)) return; + consumedRef.current.add(key); + focusOrAddTerminalPane(store, terminalId); + }, [store, terminalId, focusRequestId]); + + useEffect(() => { + if (!chatSessionId) return; + const key = getAutomationRunLinkConsumeKey({ + type: "chat", + id: chatSessionId, + focusRequestId, + }); + if (consumedRef.current.has(key)) return; + consumedRef.current.add(key); + focusOrAddChatPane(store, chatSessionId); + }, [store, chatSessionId, focusRequestId]); +} + +export function getAutomationRunLinkConsumeKey({ + type, + id, + focusRequestId, +}: { + type: "terminal" | "chat"; + id: string; + focusRequestId: string | undefined; +}): string { + return focusRequestId + ? `${type}:${id}:focus:${focusRequestId}` + : `${type}:${id}`; +} + +function focusOrAddTerminalPane( + store: StoreApi<WorkspaceStore<PaneViewerData>>, + terminalId: string, +): void { + const state = store.getState(); + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "terminal") continue; + const data = pane.data as TerminalPaneData; + if (data.terminalId === terminalId) { + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + } + state.addTab({ + panes: [ + { + kind: "terminal", + data: { terminalId } as PaneViewerData, + }, + ], + }); +} + +function focusOrAddChatPane( + store: StoreApi<WorkspaceStore<PaneViewerData>>, + sessionId: string, +): void { + const state = store.getState(); + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "chat") continue; + const data = pane.data as ChatPaneData; + if (data.sessionId === sessionId) { + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + } + state.addTab({ + panes: [ + { + kind: "chat", + data: { sessionId } as PaneViewerData, + }, + ], + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/index.ts new file mode 100644 index 00000000000..96cf0e45780 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/index.ts @@ -0,0 +1,4 @@ +export { + getOpenUrlRequestConsumeKey, + useConsumeOpenUrlRequest, +} from "./useConsumeOpenUrlRequest"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.test.ts new file mode 100644 index 00000000000..f5570aa1aed --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { getOpenUrlRequestConsumeKey } from "./useConsumeOpenUrlRequest"; + +describe("getOpenUrlRequestConsumeKey", () => { + it("dedupes repeated URL open requests without a request id", () => { + expect( + getOpenUrlRequestConsumeKey({ + url: "http://localhost:3000", + target: "current-tab", + requestId: undefined, + }), + ).toBe("current-tab:http://localhost:3000"); + }); + + it("treats each request id as a fresh URL open command", () => { + expect( + getOpenUrlRequestConsumeKey({ + url: "http://localhost:3000", + target: "new-tab", + requestId: "request-1", + }), + ).toBe("new-tab:http://localhost:3000:request:request-1"); + expect( + getOpenUrlRequestConsumeKey({ + url: "http://localhost:3000", + target: "new-tab", + requestId: "request-2", + }), + ).toBe("new-tab:http://localhost:3000:request:request-2"); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts new file mode 100644 index 00000000000..a6bb4db30df --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumeOpenUrlRequest/useConsumeOpenUrlRequest.ts @@ -0,0 +1,51 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useEffect, useRef } from "react"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData } from "../../types"; +import { + openUrlInV2Workspace, + type V2WorkspaceUrlOpenTarget, +} from "../../utils/openUrlInV2Workspace"; + +interface UseConsumeOpenUrlRequestArgs { + store: StoreApi<WorkspaceStore<PaneViewerData>>; + url: string | undefined; + target: V2WorkspaceUrlOpenTarget | undefined; + requestId: string | undefined; +} + +export function useConsumeOpenUrlRequest({ + store, + url, + target, + requestId, +}: UseConsumeOpenUrlRequestArgs): void { + const consumedRef = useRef<Set<string>>(new Set()); + + useEffect(() => { + if (!url) return; + const resolvedTarget = target ?? "current-tab"; + const key = getOpenUrlRequestConsumeKey({ + url, + target: resolvedTarget, + requestId, + }); + if (consumedRef.current.has(key)) return; + consumedRef.current.add(key); + openUrlInV2Workspace({ store, target: resolvedTarget, url }); + }, [store, target, url, requestId]); +} + +export function getOpenUrlRequestConsumeKey({ + url, + target, + requestId, +}: { + url: string; + target: V2WorkspaceUrlOpenTarget; + requestId: string | undefined; +}): string { + return requestId + ? `${target}:${url}:request:${requestId}` + : `${target}:${url}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts new file mode 100644 index 00000000000..f12ad26d382 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/index.ts @@ -0,0 +1 @@ +export { useConsumePendingLaunch } from "./useConsumePendingLaunch"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts new file mode 100644 index 00000000000..41817df7650 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts @@ -0,0 +1,189 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { toast } from "@superset/ui/sonner"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useEffect, useRef } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { PendingWorkspaceRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { StoreApi } from "zustand/vanilla"; +import type { + ChatPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +interface UseConsumePendingLaunchArgs { + workspaceId: string; + store: StoreApi<WorkspaceStore<PaneViewerData>>; +} + +/** + * Consumes a pending row's `terminalLaunch` / `chatLaunch` stashed by + * the pending page after host-service.create resolved. Opens the + * corresponding pane in the V2 `@superset/panes` store, clears the + * field so subsequent mounts don't re-dispatch. + * + * Pattern mirrors useV2PresetExecution: live-query a record, open a + * pane with the store, call workspaceTrpc for any PTY side effects. + * See apps/desktop/docs/V2_LAUNCH_CONTEXT.md "Dispatch architecture". + */ +export function useConsumePendingLaunch({ + workspaceId, + store, +}: UseConsumePendingLaunchArgs): void { + const collections = useCollections(); + const consumedRef = useRef<Set<string>>(new Set()); + + const { data: matches } = useLiveQuery( + (q) => + q + .from({ pw: collections.pendingWorkspaces }) + .where(({ pw }) => eq(pw.workspaceId, workspaceId)) + .select(({ pw }) => ({ ...pw })), + [collections, workspaceId], + ); + + const pending: PendingWorkspaceRow | null = + (matches?.[0] as PendingWorkspaceRow | undefined) ?? null; + + const updateRow = useCallback( + (patch: Partial<PendingWorkspaceRow>) => { + if (!pending) return; + collections.pendingWorkspaces.update(pending.id, (draft) => { + Object.assign(draft, patch); + }); + }, + [collections, pending], + ); + + useEffect(() => { + if (!pending) { + return; + } + + const terminalKey = pending.terminalLaunch + ? `${pending.id}:terminal` + : null; + const chatKey = pending.chatLaunch ? `${pending.id}:chat` : null; + + console.log("[v2-launch] useConsumePendingLaunch: tick", { + workspaceId, + pendingId: pending.id, + status: pending.status, + hasTerminalLaunch: !!pending.terminalLaunch, + hasChatLaunch: !!pending.chatLaunch, + terminalConsumed: terminalKey + ? consumedRef.current.has(terminalKey) + : null, + chatConsumed: chatKey ? consumedRef.current.has(chatKey) : null, + }); + + if (terminalKey && !consumedRef.current.has(terminalKey)) { + consumedRef.current.add(terminalKey); + console.log("[v2-launch] useConsumePendingLaunch: consuming terminal", { + command: pending.terminalLaunch?.command.slice(0, 120), + }); + consumeTerminalLaunch({ + pending, + store, + clear: () => updateRow({ terminalLaunch: null }), + }); + } + + if (chatKey && !consumedRef.current.has(chatKey)) { + consumedRef.current.add(chatKey); + console.log("[v2-launch] useConsumePendingLaunch: consuming chat"); + consumeChatLaunch({ + pending, + store, + clear: () => updateRow({ chatLaunch: null }), + }); + } + }, [pending, store, updateRow, workspaceId]); +} + +function consumeTerminalLaunch({ + pending, + store, + clear, +}: { + pending: PendingWorkspaceRow; + store: StoreApi<WorkspaceStore<PaneViewerData>>; + clear: () => void; +}): void { + const launch = pending.terminalLaunch; + if (!launch || !pending.workspaceId) { + console.warn("[v2-launch] consumeTerminalLaunch: bailing", { + hasLaunch: !!launch, + hasWorkspaceId: !!pending.workspaceId, + }); + // Defensive — shouldn't happen if the caller checked terminalLaunch + // already. Worth a toast so we see it in practice. + toast.error("Couldn't open agent pane", { + description: + "Missing launch data — please retry from the workspace menu.", + }); + return; + } + + const terminalId = crypto.randomUUID(); + console.log("[v2-launch] consumeTerminalLaunch: addTab", { + terminalId, + workspaceId: pending.workspaceId, + commandPreview: launch.command.slice(0, 120), + }); + + const data: TerminalPaneData = { + terminalId, + initialCommand: launch.command, + }; + store.getState().addTab({ + panes: [ + { + kind: "terminal", + titleOverride: launch.name, + data: data as PaneViewerData, + }, + ], + }); + clear(); + console.log("[v2-launch] consumeTerminalLaunch: done + cleared"); +} + +function consumeChatLaunch({ + pending, + store, + clear, +}: { + pending: PendingWorkspaceRow; + store: StoreApi<WorkspaceStore<PaneViewerData>>; + clear: () => void; +}): void { + const launch = pending.chatLaunch; + if (!launch) return; + + const data: ChatPaneData = { + sessionId: null, + launchConfig: { + initialPrompt: launch.initialPrompt, + initialFiles: launch.initialFiles, + model: launch.model, + taskSlug: launch.taskSlug, + }, + }; + + console.log("[v2-launch] consumeChatLaunch: addTab", { + hasPrompt: !!launch.initialPrompt, + fileCount: launch.initialFiles?.length ?? 0, + }); + store.getState().addTab({ + panes: [ + { + kind: "chat", + data: data as PaneViewerData, + }, + ], + }); + clear(); + console.log("[v2-launch] consumeChatLaunch: done + cleared"); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/index.ts new file mode 100644 index 00000000000..895864788f3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/index.ts @@ -0,0 +1 @@ +export { useDefaultContextMenuActions } from "./useDefaultContextMenuActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx new file mode 100644 index 00000000000..43804012141 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultContextMenuActions/useDefaultContextMenuActions.tsx @@ -0,0 +1,167 @@ +import { + type ContextMenuActionConfig, + type PaneRegistry, + type RendererContext, + resolveTabTitle, +} from "@superset/panes"; +import { useMemo } from "react"; +import { + LuColumns2, + LuEqual, + LuGlobe, + LuMessageSquare, + LuMoveRight, + LuPlus, + LuRows2, + LuX, +} from "react-icons/lu"; +import { useHotkeyDisplay } from "renderer/hotkeys"; +import type { + BrowserPaneData, + ChatPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +export function useDefaultContextMenuActions( + paneRegistry: PaneRegistry<PaneViewerData>, +): ContextMenuActionConfig<PaneViewerData>[] { + const splitDownShortcut = useHotkeyDisplay("SPLIT_DOWN").text; + const splitRightShortcut = useHotkeyDisplay("SPLIT_RIGHT").text; + const splitWithChatShortcut = useHotkeyDisplay("SPLIT_WITH_CHAT").text; + const splitWithBrowserShortcut = useHotkeyDisplay("SPLIT_WITH_BROWSER").text; + const equalizePaneSplitsShortcut = useHotkeyDisplay( + "EQUALIZE_PANE_SPLITS", + ).text; + const closePaneShortcut = useHotkeyDisplay("CLOSE_PANE").text; + + return useMemo<ContextMenuActionConfig<PaneViewerData>[]>( + () => [ + { + key: "split-horizontal", + label: "Split Horizontally", + icon: <LuRows2 />, + shortcut: + splitDownShortcut !== "Unassigned" ? splitDownShortcut : undefined, + onSelect: (ctx) => { + ctx.actions.split("down", { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + } as TerminalPaneData, + }); + }, + }, + { + key: "split-vertical", + label: "Split Vertically", + icon: <LuColumns2 />, + shortcut: + splitRightShortcut !== "Unassigned" ? splitRightShortcut : undefined, + onSelect: (ctx) => { + ctx.actions.split("right", { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + } as TerminalPaneData, + }); + }, + }, + { + key: "split-with-chat", + label: "Split with New Chat", + icon: <LuMessageSquare />, + shortcut: + splitWithChatShortcut !== "Unassigned" + ? splitWithChatShortcut + : undefined, + onSelect: (ctx) => { + ctx.actions.split("right", { + kind: "chat", + data: { sessionId: null } as ChatPaneData, + }); + }, + }, + { + key: "split-with-browser", + label: "Split with New Browser", + icon: <LuGlobe />, + shortcut: + splitWithBrowserShortcut !== "Unassigned" + ? splitWithBrowserShortcut + : undefined, + onSelect: (ctx) => { + ctx.actions.split("right", { + kind: "browser", + data: { + url: "about:blank", + } as BrowserPaneData, + }); + }, + }, + { + key: "equalize-splits", + label: "Equalize Pane Splits", + icon: <LuEqual />, + shortcut: + equalizePaneSplitsShortcut !== "Unassigned" + ? equalizePaneSplitsShortcut + : undefined, + onSelect: (ctx) => { + ctx.store.getState().equalizeTab({ tabId: ctx.tab.id }); + }, + }, + { key: "sep-move", type: "separator" }, + { + key: "move-to-tab", + label: "Move to Tab", + icon: <LuMoveRight />, + children: (ctx: RendererContext<PaneViewerData>) => { + const tabs = ctx.store.getState().tabs; + const otherTabs = tabs.filter((t) => t.id !== ctx.tab.id); + const items: ContextMenuActionConfig<PaneViewerData>[] = + otherTabs.map((tab) => ({ + key: `move-to-${tab.id}`, + label: resolveTabTitle(tab, tabs, paneRegistry), + onSelect: () => { + ctx.store + .getState() + .movePaneToTab({ paneId: ctx.pane.id, targetTabId: tab.id }); + }, + })); + if (otherTabs.length > 0) { + items.push({ key: "sep-new-tab", type: "separator" }); + } + items.push({ + key: "move-to-new-tab", + label: "New Tab", + icon: <LuPlus />, + onSelect: () => { + ctx.store.getState().movePaneToNewTab({ paneId: ctx.pane.id }); + }, + }); + return items; + }, + }, + { key: "sep-close", type: "separator" }, + { + key: "close-pane", + label: "Close Pane", + icon: <LuX />, + variant: "destructive", + shortcut: + closePaneShortcut !== "Unassigned" ? closePaneShortcut : undefined, + onSelect: (ctx) => ctx.actions.close(), + }, + ], + [ + splitDownShortcut, + splitRightShortcut, + splitWithChatShortcut, + splitWithBrowserShortcut, + equalizePaneSplitsShortcut, + closePaneShortcut, + paneRegistry, + ], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/index.ts new file mode 100644 index 00000000000..bced12af430 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/index.ts @@ -0,0 +1 @@ +export { useDefaultPaneActions } from "./useDefaultPaneActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/useDefaultPaneActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/useDefaultPaneActions.tsx new file mode 100644 index 00000000000..5699b313ca8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDefaultPaneActions/useDefaultPaneActions.tsx @@ -0,0 +1,40 @@ +import type { PaneActionConfig } from "@superset/panes"; +import { useMemo } from "react"; +import { HiMiniXMark } from "react-icons/hi2"; +import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; +import { HotkeyLabel } from "renderer/hotkeys"; +import type { PaneViewerData, TerminalPaneData } from "../../types"; + +export function useDefaultPaneActions(): PaneActionConfig<PaneViewerData>[] { + return useMemo<PaneActionConfig<PaneViewerData>[]>( + () => [ + { + key: "split", + icon: (ctx) => + ctx.pane.parentDirection === "horizontal" ? ( + <TbLayoutRows className="size-3.5" /> + ) : ( + <TbLayoutColumns className="size-3.5" /> + ), + tooltip: <HotkeyLabel label="Split pane" id="SPLIT_AUTO" />, + onClick: (ctx) => { + const position = + ctx.pane.parentDirection === "horizontal" ? "down" : "right"; + ctx.actions.split(position, { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + } as TerminalPaneData, + }); + }, + }, + { + key: "close", + icon: <HiMiniXMark className="size-3.5" />, + tooltip: <HotkeyLabel label="Close pane" id="CLOSE_PANE" />, + onClick: (ctx) => ctx.actions.close(), + }, + ], + [], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/index.ts new file mode 100644 index 00000000000..2809604a41a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/index.ts @@ -0,0 +1 @@ +export { useDirtyTabCloseGuard } from "./useDirtyTabCloseGuard"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/useDirtyTabCloseGuard.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/useDirtyTabCloseGuard.ts new file mode 100644 index 00000000000..01ea5cc54e0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useDirtyTabCloseGuard/useDirtyTabCloseGuard.ts @@ -0,0 +1,76 @@ +import type { WorkspaceProps } from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; +import { useCallback } from "react"; +import { getBaseName } from "renderer/lib/pathBasename"; +import { getDocument } from "../../state/fileDocumentStore"; +import type { FilePaneData, PaneViewerData } from "../../types"; + +type OnBeforeCloseTab = NonNullable< + WorkspaceProps<PaneViewerData>["onBeforeCloseTab"] +>; + +export function useDirtyTabCloseGuard({ + workspaceId, +}: { + workspaceId: string; +}): OnBeforeCloseTab { + return useCallback<OnBeforeCloseTab>( + (tab) => { + const dirtyPanes = Object.values(tab.panes).filter((pane) => { + if (pane.kind !== "file") return false; + const filePath = (pane.data as FilePaneData).filePath; + return getDocument(workspaceId, filePath)?.dirty === true; + }); + const dirtyFileNames = dirtyPanes.map((pane) => + getBaseName((pane.data as FilePaneData).filePath), + ); + if (dirtyPanes.length === 0) return true; + const title = + dirtyPanes.length === 1 + ? `Do you want to save the changes you made to ${dirtyFileNames[0]}?` + : `Do you want to save changes to ${dirtyPanes.length} files?`; + return new Promise<boolean>((resolve) => { + alert({ + title, + description: "Your changes will be lost if you don't save them.", + actions: [ + { + label: "Save All", + onClick: async () => { + for (const pane of dirtyPanes) { + const filePath = (pane.data as FilePaneData).filePath; + const doc = getDocument(workspaceId, filePath); + if (!doc) continue; + const result = await doc.save(); + if (result.status !== "saved") { + resolve(false); + return; + } + } + resolve(true); + }, + }, + { + label: "Don't Save", + variant: "secondary", + onClick: async () => { + for (const pane of dirtyPanes) { + const filePath = (pane.data as FilePaneData).filePath; + const doc = getDocument(workspaceId, filePath); + if (doc) await doc.reload(); + } + resolve(true); + }, + }, + { + label: "Cancel", + variant: "ghost", + onClick: () => resolve(false), + }, + ], + }); + }); + }, + [workspaceId], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/index.ts new file mode 100644 index 00000000000..de169c7f90c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/index.ts @@ -0,0 +1,4 @@ +export { + type OpenInExternalEditorOptions, + useOpenInExternalEditor, +} from "./useOpenInExternalEditor"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts new file mode 100644 index 00000000000..f394b0a1a2b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor/useOpenInExternalEditor.ts @@ -0,0 +1,64 @@ +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useV2ProjectDefaultApp } from "renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +export interface OpenInExternalEditorOptions { + line?: number; + column?: number; +} + +export function useOpenInExternalEditor(workspaceId: string) { + const collections = useCollections(); + const { machineId } = useLocalHostService(); + const { data: workspaceRows = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .where(({ workspaces }) => eq(workspaces.id, workspaceId)) + .select(({ workspaces }) => ({ + hostId: workspaces.hostId, + projectId: workspaces.projectId ?? null, + })), + [collections, workspaceId], + ); + const workspaceRow = workspaceRows[0]; + const projectId = workspaceRow?.projectId ?? undefined; + + // Forward the v2 CMD+O choice as an explicit app override; the server + // can't look this up on its own (v2 projects aren't in the v1 localDb). + const { app: v2PreferredApp } = useV2ProjectDefaultApp(projectId); + + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const worktreePath = workspaceQuery.data?.worktreePath ?? undefined; + + return useCallback( + (path: string, opts?: OpenInExternalEditorOptions) => { + if (workspaceRow?.hostId !== machineId) { + toast.error("Can't open remote workspace paths in an external editor"); + return; + } + electronTrpcClient.external.openFileInEditor + .mutate({ + path, + line: opts?.line, + column: opts?.column, + worktreePath, + projectId, + app: v2PreferredApp, + }) + .catch((error) => { + console.error("Failed to open in external editor:", error); + toast.error("Failed to open in external editor"); + }); + }, + [workspaceRow, machineId, projectId, worktreePath, v2PreferredApp], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/BrowserPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/BrowserPane.tsx new file mode 100644 index 00000000000..5ec5e29553b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/BrowserPane.tsx @@ -0,0 +1,152 @@ +import type { RendererContext, Tab } from "@superset/panes"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { GlobeIcon } from "lucide-react"; +import { useCallback, useSyncExternalStore } from "react"; +import { TbDeviceDesktop } from "react-icons/tb"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { BrowserPaneData, PaneViewerData } from "../../../../types"; + +import { browserRuntimeRegistry } from "./browserRuntimeRegistry"; +import { BrowserErrorOverlay } from "./components/BrowserErrorOverlay"; +import { BrowserOverflowMenu } from "./components/BrowserOverflowMenu"; +import { BrowserToolbar } from "./components/BrowserToolbar"; +import { usePersistentWebview } from "./hooks/usePersistentWebview"; + +function getSingleBrowserPane( + tab: Tab<PaneViewerData>, +): { id: string; data: BrowserPaneData } | null { + const paneIds = Object.keys(tab.panes); + if (paneIds.length !== 1) return null; + const pane = tab.panes[paneIds[0]]; + if (pane.kind !== "browser") return null; + return { id: pane.id, data: pane.data as BrowserPaneData }; +} + +export function renderBrowserTabIcon(tab: Tab<PaneViewerData>) { + const browser = getSingleBrowserPane(tab); + if (!browser?.data.faviconUrl) return null; + return ( + <img src={browser.data.faviconUrl} alt="" className="size-3.5 shrink-0" /> + ); +} + +interface BrowserPaneProps { + ctx: RendererContext<PaneViewerData>; +} + +function useBrowserState(paneId: string) { + return useSyncExternalStore( + useCallback( + (cb) => browserRuntimeRegistry.onStateChange(paneId, cb), + [paneId], + ), + useCallback(() => browserRuntimeRegistry.getState(paneId), [paneId]), + ); +} + +export function BrowserPane({ ctx }: BrowserPaneProps) { + const paneId = ctx.pane.id; + const state = useBrowserState(paneId); + const { placeholderRef, reload } = usePersistentWebview({ paneId, ctx }); + + const isBlankPage = !state.currentUrl || state.currentUrl === "about:blank"; + + return ( + <div className="relative flex flex-1 h-full"> + <div ref={placeholderRef} className="w-full h-full" style={{ flex: 1 }} /> + {state.error && !state.isLoading && ( + <BrowserErrorOverlay error={state.error} onRetry={reload} /> + )} + {isBlankPage && !state.isLoading && !state.error && ( + <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background pointer-events-none"> + <GlobeIcon className="size-10 text-muted-foreground/30" /> + <div className="text-center"> + <p className="text-sm font-medium text-muted-foreground/50"> + Browser + </p> + <p className="mt-1 text-xs text-muted-foreground/30"> + Enter a URL above, or instruct an agent to navigate + <br /> + and use the browser + </p> + </div> + </div> + )} + </div> + ); +} + +interface BrowserPaneToolbarProps { + ctx: RendererContext<PaneViewerData>; +} + +export function BrowserPaneToolbar({ ctx }: BrowserPaneToolbarProps) { + const paneId = ctx.pane.id; + const state = useBrowserState(paneId); + + const handleOpenDevTools = useCallback(() => { + electronTrpcClient.browser.openDevTools.mutate({ paneId }).catch(() => {}); + }, [paneId]); + + const handleGoBack = useCallback(() => { + browserRuntimeRegistry.goBack(paneId); + }, [paneId]); + + const handleGoForward = useCallback(() => { + browserRuntimeRegistry.goForward(paneId); + }, [paneId]); + + const handleReload = useCallback(() => { + browserRuntimeRegistry.reload(paneId); + }, [paneId]); + + const handleNavigate = useCallback( + (url: string) => { + browserRuntimeRegistry.navigate(paneId, url); + }, + [paneId], + ); + + const isBlankPage = !state.currentUrl || state.currentUrl === "about:blank"; + const PaneHeaderActions = ctx.components.PaneHeaderActions; + + return ( + <div className="flex h-full w-full min-w-0 items-center justify-between"> + <BrowserToolbar + currentUrl={state.currentUrl} + pageTitle={state.pageTitle} + isLoading={state.isLoading} + canGoBack={state.canGoBack} + canGoForward={state.canGoForward} + onGoBack={handleGoBack} + onGoForward={handleGoForward} + onReload={handleReload} + onNavigate={handleNavigate} + /> + <div className="flex shrink-0 items-center pr-1"> + <div className="mx-1.5 h-3.5 w-px bg-muted-foreground/60" /> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={handleOpenDevTools} + className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-muted-foreground" + > + <TbDeviceDesktop className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + Open DevTools + </TooltipContent> + </Tooltip> + <BrowserOverflowMenu + paneId={paneId} + currentUrl={state.currentUrl} + hasPage={!isBlankPage} + /> + <div className="mx-1 h-3.5 w-px bg-muted-foreground/60" /> + <PaneHeaderActions /> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts new file mode 100644 index 00000000000..31ebb1cc4a6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry.ts @@ -0,0 +1,462 @@ +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { BrowserLoadError } from "shared/tabs-types"; +import { sanitizeUrl } from "./sanitizeUrl"; + +export interface BrowserRuntimeState { + currentUrl: string; + pageTitle: string; + faviconUrl: string | null; + isLoading: boolean; + error: BrowserLoadError | null; + canGoBack: boolean; + canGoForward: boolean; +} + +export interface PersistableBrowserState { + url: string; + pageTitle: string; + faviconUrl: string | null; +} + +interface RegistryEntry { + webview: Electron.WebviewTag; + state: BrowserRuntimeState; + onPersist: ((state: PersistableBrowserState) => void) | null; + webContentsId: number | null; + detachHandlers: () => void; + placeholder: HTMLElement | null; + resizeObserver: ResizeObserver | null; + visible: boolean; +} + +const EMPTY_STATE: BrowserRuntimeState = Object.freeze({ + currentUrl: "about:blank", + pageTitle: "", + faviconUrl: null, + isLoading: false, + error: null, + canGoBack: false, + canGoForward: false, +}); + +const ROOT_CONTAINER_ID = "browser-runtime-root"; + +class BrowserRuntimeRegistryImpl { + private entries = new Map<string, RegistryEntry>(); + private listenersByPaneId = new Map<string, Set<() => void>>(); + private rootContainer: HTMLDivElement | null = null; + private globalListenersInstalled = false; + private windowDragPassthrough = false; + private shellInteractionPassthrough = false; + + private getListeners(paneId: string): Set<() => void> { + let set = this.listenersByPaneId.get(paneId); + if (!set) { + set = new Set(); + this.listenersByPaneId.set(paneId, set); + } + return set; + } + + private ensureRootContainer(): HTMLDivElement { + if (this.rootContainer?.isConnected) { + this.installGlobalListeners(); + return this.rootContainer; + } + const existing = document.getElementById( + ROOT_CONTAINER_ID, + ) as HTMLDivElement | null; + if (existing) { + this.rootContainer = existing; + this.installGlobalListeners(); + return existing; + } + const root = document.createElement("div"); + root.id = ROOT_CONTAINER_ID; + root.style.position = "fixed"; + root.style.top = "0"; + root.style.left = "0"; + root.style.width = "0"; + root.style.height = "0"; + root.style.pointerEvents = "none"; + root.style.zIndex = "0"; + document.body.appendChild(root); + this.rootContainer = root; + this.installGlobalListeners(); + return root; + } + + private installGlobalListeners() { + if (this.globalListenersInstalled) return; + this.globalListenersInstalled = true; + + window.addEventListener( + "dragstart", + () => this.setWindowDragPassthrough(true), + true, + ); + window.addEventListener( + "dragend", + () => this.setWindowDragPassthrough(false), + true, + ); + window.addEventListener( + "drop", + () => this.setWindowDragPassthrough(false), + true, + ); + window.addEventListener("blur", () => this.setWindowDragPassthrough(false)); + + window.addEventListener("resize", () => { + for (const entry of this.entries.values()) { + if (entry.placeholder) this.updateLayout(entry); + } + }); + } + + private setWindowDragPassthrough(passthrough: boolean) { + const wasActive = this.isPointerPassthroughActive(); + this.windowDragPassthrough = passthrough; + this.applyPointerPassthroughIfChanged(wasActive); + } + + setShellInteractionPassthrough(passthrough: boolean): void { + const wasActive = this.isPointerPassthroughActive(); + this.shellInteractionPassthrough = passthrough; + this.applyPointerPassthroughIfChanged(wasActive); + } + + private isPointerPassthroughActive() { + return this.windowDragPassthrough || this.shellInteractionPassthrough; + } + + private applyPointerPassthroughIfChanged(wasActive: boolean) { + const isActive = this.isPointerPassthroughActive(); + if (wasActive !== isActive) this.applyPointerPassthrough(); + } + + private applyPointerPassthrough() { + const passthrough = this.isPointerPassthroughActive(); + for (const entry of this.entries.values()) { + if (!entry.visible) continue; + entry.webview.style.pointerEvents = passthrough ? "none" : "auto"; + } + } + + private updateLayout(entry: RegistryEntry) { + if (!entry.placeholder) return; + const rect = entry.placeholder.getBoundingClientRect(); + const w = entry.webview; + w.style.top = `${rect.top}px`; + w.style.left = `${rect.left}px`; + w.style.width = `${rect.width}px`; + w.style.height = `${rect.height}px`; + } + + private notify(paneId: string) { + const listeners = this.listenersByPaneId.get(paneId); + if (!listeners) return; + for (const listener of listeners) listener(); + } + + private setState(paneId: string, patch: Partial<BrowserRuntimeState>) { + const entry = this.entries.get(paneId); + if (!entry) return; + let changed = false; + for (const key in patch) { + const k = key as keyof BrowserRuntimeState; + if (entry.state[k] !== patch[k]) { + changed = true; + break; + } + } + if (!changed) return; + entry.state = { ...entry.state, ...patch }; + this.notify(paneId); + } + + private refreshNavState(paneId: string) { + const entry = this.entries.get(paneId); + if (!entry) return; + let canGoBack = false; + let canGoForward = false; + try { + canGoBack = entry.webview.canGoBack(); + canGoForward = entry.webview.canGoForward(); + } catch {} + this.setState(paneId, { canGoBack, canGoForward }); + } + + private createEntry(paneId: string, initialUrl: string): RegistryEntry { + const webview = document.createElement("webview") as Electron.WebviewTag; + webview.setAttribute("partition", "persist:superset"); + webview.setAttribute("allowpopups", ""); + webview.style.position = "fixed"; + webview.style.top = "0"; + webview.style.left = "0"; + webview.style.width = "0"; + webview.style.height = "0"; + webview.style.margin = "0"; + webview.style.padding = "0"; + webview.style.border = "none"; + webview.style.visibility = "hidden"; + webview.style.pointerEvents = "auto"; + webview.src = sanitizeUrl(initialUrl); + + const entry: RegistryEntry = { + webview, + state: { ...EMPTY_STATE, currentUrl: initialUrl }, + onPersist: null, + webContentsId: null, + detachHandlers: () => {}, + placeholder: null, + resizeObserver: null, + visible: false, + }; + + const firePersist = () => { + entry.onPersist?.({ + url: entry.state.currentUrl, + pageTitle: entry.state.pageTitle, + faviconUrl: entry.state.faviconUrl, + }); + }; + + const handleDomReady = () => { + const webContentsId = webview.getWebContentsId(); + if (entry.webContentsId !== webContentsId) { + entry.webContentsId = webContentsId; + electronTrpcClient.browser.register + .mutate({ paneId, webContentsId }) + .catch((err) => { + console.error("[browserRuntimeRegistry] register failed:", err); + }); + } + }; + + const handleDidStartLoading = () => { + this.setState(paneId, { + isLoading: true, + error: null, + faviconUrl: null, + }); + }; + + const handleDidStopLoading = () => { + const url = webview.getURL() ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { + isLoading: false, + currentUrl: url, + pageTitle: title, + }); + this.refreshNavState(paneId); + if (url && url !== "about:blank") { + electronTrpcClient.browserHistory.upsert + .mutate({ url, title, faviconUrl: entry.state.faviconUrl }) + .catch((err) => { + console.error("[browserRuntimeRegistry] upsert history:", err); + }); + } + firePersist(); + }; + + const handleDidNavigate = (e: Electron.DidNavigateEvent) => { + const url = e.url ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { + currentUrl: url, + pageTitle: title, + isLoading: false, + }); + this.refreshNavState(paneId); + }; + + const handleDidNavigateInPage = (e: Electron.DidNavigateInPageEvent) => { + const url = e.url ?? ""; + const title = webview.getTitle() ?? ""; + this.setState(paneId, { currentUrl: url, pageTitle: title }); + this.refreshNavState(paneId); + }; + + const handlePageTitleUpdated = (e: Electron.PageTitleUpdatedEvent) => { + this.setState(paneId, { pageTitle: e.title ?? "" }); + }; + + const handlePageFaviconUpdated = (e: Electron.PageFaviconUpdatedEvent) => { + const favicon = e.favicons?.[0]; + if (!favicon || favicon === entry.state.faviconUrl) return; + this.setState(paneId, { faviconUrl: favicon }); + const { currentUrl, pageTitle } = entry.state; + if (currentUrl && currentUrl !== "about:blank") { + electronTrpcClient.browserHistory.upsert + .mutate({ url: currentUrl, title: pageTitle, faviconUrl: favicon }) + .catch((err) => { + console.error("[browserRuntimeRegistry] upsert favicon:", err); + }); + } + firePersist(); + }; + + const handleDidFailLoad = (e: Electron.DidFailLoadEvent) => { + if (e.errorCode === -3) return; // ERR_ABORTED + this.setState(paneId, { + isLoading: false, + error: { + code: e.errorCode ?? 0, + description: e.errorDescription ?? "", + url: e.validatedURL ?? "", + }, + }); + }; + + webview.addEventListener("dom-ready", handleDomReady); + webview.addEventListener("did-start-loading", handleDidStartLoading); + webview.addEventListener("did-stop-loading", handleDidStopLoading); + webview.addEventListener( + "did-navigate", + handleDidNavigate as EventListener, + ); + webview.addEventListener( + "did-navigate-in-page", + handleDidNavigateInPage as EventListener, + ); + webview.addEventListener( + "page-title-updated", + handlePageTitleUpdated as EventListener, + ); + webview.addEventListener( + "page-favicon-updated", + handlePageFaviconUpdated as EventListener, + ); + webview.addEventListener( + "did-fail-load", + handleDidFailLoad as EventListener, + ); + + entry.detachHandlers = () => { + webview.removeEventListener("dom-ready", handleDomReady); + webview.removeEventListener("did-start-loading", handleDidStartLoading); + webview.removeEventListener("did-stop-loading", handleDidStopLoading); + webview.removeEventListener( + "did-navigate", + handleDidNavigate as EventListener, + ); + webview.removeEventListener( + "did-navigate-in-page", + handleDidNavigateInPage as EventListener, + ); + webview.removeEventListener( + "page-title-updated", + handlePageTitleUpdated as EventListener, + ); + webview.removeEventListener( + "page-favicon-updated", + handlePageFaviconUpdated as EventListener, + ); + webview.removeEventListener( + "did-fail-load", + handleDidFailLoad as EventListener, + ); + }; + + return entry; + } + + attach( + paneId: string, + placeholder: HTMLElement, + initialUrl: string, + onPersist: (state: PersistableBrowserState) => void, + ): void { + const root = this.ensureRootContainer(); + let entry = this.entries.get(paneId); + if (!entry) { + entry = this.createEntry(paneId, initialUrl); + this.entries.set(paneId, entry); + root.appendChild(entry.webview); + } else { + this.refreshNavState(paneId); + } + entry.onPersist = onPersist; + entry.placeholder = placeholder; + entry.visible = true; + + entry.resizeObserver?.disconnect(); + const observer = new ResizeObserver(() => { + if (entry) this.updateLayout(entry); + }); + observer.observe(placeholder); + entry.resizeObserver = observer; + + this.updateLayout(entry); + entry.webview.style.visibility = "visible"; + this.applyPointerPassthrough(); + } + + detach(paneId: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.onPersist = null; + entry.placeholder = null; + entry.resizeObserver?.disconnect(); + entry.resizeObserver = null; + entry.visible = false; + entry.webview.style.visibility = "hidden"; + } + + destroy(paneId: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.resizeObserver?.disconnect(); + entry.detachHandlers(); + entry.webview.remove(); + this.entries.delete(paneId); + this.listenersByPaneId.delete(paneId); + electronTrpcClient.browser.unregister.mutate({ paneId }).catch(() => {}); + } + + navigate(paneId: string, url: string): void { + const entry = this.entries.get(paneId); + if (!entry) return; + entry.webview.loadURL(sanitizeUrl(url)).catch((err) => { + console.error("[browserRuntimeRegistry] loadURL failed:", err); + }); + } + + goBack(paneId: string): void { + const entry = this.entries.get(paneId); + if (entry?.webview.canGoBack()) entry.webview.goBack(); + } + + goForward(paneId: string): void { + const entry = this.entries.get(paneId); + if (entry?.webview.canGoForward()) entry.webview.goForward(); + } + + reload(paneId: string): void { + const entry = this.entries.get(paneId); + entry?.webview.reload(); + } + + getState(paneId: string): BrowserRuntimeState { + return this.entries.get(paneId)?.state ?? EMPTY_STATE; + } + + onStateChange(paneId: string, listener: () => void): () => void { + const listeners = this.getListeners(paneId); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } +} + +export const browserRuntimeRegistry: BrowserRuntimeRegistryImpl = + (import.meta.hot?.data?.browserRegistry as + | BrowserRuntimeRegistryImpl + | undefined) ?? new BrowserRuntimeRegistryImpl(); + +if (import.meta.hot) { + import.meta.hot.data.browserRegistry = browserRuntimeRegistry; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx new file mode 100644 index 00000000000..ff97cc277bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/BrowserErrorOverlay.tsx @@ -0,0 +1,109 @@ +import { Button } from "@superset/ui/button"; +import { GlobeIcon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { TbCopy } from "react-icons/tb"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import type { BrowserLoadError } from "shared/tabs-types"; + +const ERROR_LABELS: Record<number, string> = { + [-2]: "Network Changed", + [-6]: "Connection Refused", + [-7]: "Connection Timed Out", + [-21]: "Network Changed", + [-100]: "Connection Closed", + [-102]: "Connection Refused", + [-105]: "Name Not Resolved", + [-106]: "Internet Disconnected", + [-109]: "Address Unreachable", + [-118]: "Connection Timed Out", + [-137]: "Name Not Resolved", + [-200]: "Certificate Error", + [-201]: "Certificate Date Invalid", + [-202]: "Certificate Authority Invalid", +}; + +const FRIENDLY_MESSAGES: Record<number, string> = { + [-2]: "The network connection changed", + [-6]: "Browser Connection was refused", + [-7]: "The connection timed out", + [-21]: "The network connection changed", + [-100]: "The connection was closed", + [-102]: "Browser Connection was refused", + [-105]: "The server could not be found", + [-106]: "You appear to be offline", + [-109]: "The address is unreachable", + [-118]: "The connection timed out", + [-137]: "The server could not be found", + [-200]: "The site's certificate is invalid", + [-201]: "The site's certificate has expired", + [-202]: "The site's certificate authority is not trusted", +}; + +interface BrowserErrorOverlayProps { + error: BrowserLoadError; + onRetry: () => void; +} + +export function BrowserErrorOverlay({ + error, + onRetry, +}: BrowserErrorOverlayProps) { + const [showDetails, setShowDetails] = useState(false); + const label = ERROR_LABELS[error.code] ?? "Page Load Failed"; + const friendlyMessage = + FRIENDLY_MESSAGES[error.code] ?? "The page could not be loaded"; + const detailsText = `Error Code: ${error.code} URL: ${error.url}`; + + const toggleDetails = useCallback(() => { + setShowDetails((prev) => !prev); + }, []); + + const { copyToClipboard } = useCopyToClipboard(); + const copyDetails = useCallback(() => { + copyToClipboard(detailsText); + }, [detailsText, copyToClipboard]); + + return ( + <div className="absolute inset-0 flex items-center justify-center bg-background z-10"> + <div className="flex flex-col items-start gap-4 w-80"> + <GlobeIcon className="size-10 text-muted-foreground/30" /> + <div> + <h2 className="text-xl font-medium text-muted-foreground/70"> + {label} + </h2> + <p className="mt-1.5 text-sm text-muted-foreground/50"> + {friendlyMessage} + </p> + <p className="mt-0.5 text-sm text-muted-foreground/50"> + {error.description} + {" · "} + <button + type="button" + onClick={toggleDetails} + className="hover:text-muted-foreground/70 transition-colors" + > + {showDetails ? "Hide Details" : "Show Details"} + </button> + </p> + </div> + {showDetails && ( + <div className="flex items-center gap-2 w-full rounded-md border border-muted-foreground/20 px-3 py-2"> + <span className="flex-1 text-sm text-muted-foreground/50 truncate"> + {detailsText} + </span> + <button + type="button" + onClick={copyDetails} + className="shrink-0 text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors" + > + <TbCopy className="size-4" /> + </button> + </div> + )} + <Button variant="outline" size="sm" onClick={onRetry}> + Restart Browser + </Button> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts new file mode 100644 index 00000000000..3d3140770b4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserErrorOverlay/index.ts @@ -0,0 +1 @@ +export { BrowserErrorOverlay } from "./BrowserErrorOverlay"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx new file mode 100644 index 00000000000..596aef63d65 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx @@ -0,0 +1,115 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { + TbCamera, + TbClock, + TbCopy, + TbDots, + TbReload, + TbTrash, +} from "react-icons/tb"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +interface BrowserOverflowMenuProps { + paneId: string; + currentUrl: string; + hasPage: boolean; +} + +export function BrowserOverflowMenu({ + paneId, + currentUrl, + hasPage, +}: BrowserOverflowMenuProps) { + const { copyToClipboard } = useCopyToClipboard(); + + const handleScreenshot = () => { + electronTrpcClient.browser.screenshot.mutate({ paneId }).catch(() => {}); + }; + + const handleHardReload = () => { + electronTrpcClient.browser.reload + .mutate({ paneId, hard: true }) + .catch(() => {}); + }; + + const handleCopyUrl = () => { + if (currentUrl) { + copyToClipboard(currentUrl); + } + }; + + const handleClearCookies = () => { + electronTrpcClient.browser.clearBrowsingData + .mutate({ type: "cookies" }) + .catch(() => {}); + }; + + const handleClearHistory = () => { + electronTrpcClient.browserHistory.clear.mutate().catch(() => {}); + }; + + const handleClearAllData = () => { + electronTrpcClient.browser.clearBrowsingData + .mutate({ type: "all" }) + .catch(() => {}); + }; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <button + type="button" + className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-muted-foreground" + > + <TbDots className="size-3.5" /> + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + <DropdownMenuItem + onClick={handleScreenshot} + disabled={!hasPage} + className="gap-2" + > + <TbCamera className="size-4" /> + Take Screenshot + </DropdownMenuItem> + <DropdownMenuItem + onClick={handleHardReload} + disabled={!hasPage} + className="gap-2" + > + <TbReload className="size-4" /> + Hard Reload + </DropdownMenuItem> + <DropdownMenuItem + onClick={handleCopyUrl} + disabled={!hasPage} + className="gap-2" + > + <TbCopy className="size-4" /> + Copy URL + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={handleClearHistory} className="gap-2"> + <TbClock className="size-4" /> + Clear Browsing History + </DropdownMenuItem> + <DropdownMenuItem onClick={handleClearCookies} className="gap-2"> + <TbTrash className="size-4" /> + Clear Cookies + </DropdownMenuItem> + <DropdownMenuItem onClick={handleClearAllData} className="gap-2"> + <TbTrash className="size-4" /> + Clear All Data + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts new file mode 100644 index 00000000000..abd17b406e1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserOverflowMenu/index.ts @@ -0,0 +1 @@ +export { BrowserOverflowMenu } from "./BrowserOverflowMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx new file mode 100644 index 00000000000..555146add71 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/BrowserToolbar.tsx @@ -0,0 +1,216 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + TbArrowLeft, + TbArrowRight, + TbLoader2, + TbRefresh, +} from "react-icons/tb"; +import { UrlSuggestions } from "./components/UrlSuggestions"; +import { useUrlAutocomplete } from "./hooks/useUrlAutocomplete"; + +function displayUrl(url: string): string { + if (url === "about:blank") return ""; + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +interface BrowserToolbarProps { + currentUrl: string; + pageTitle: string; + isLoading: boolean; + canGoBack: boolean; + canGoForward: boolean; + onGoBack: () => void; + onGoForward: () => void; + onReload: () => void; + onNavigate: (url: string) => void; +} + +export function BrowserToolbar({ + currentUrl, + pageTitle, + isLoading, + canGoBack, + canGoForward, + onGoBack, + onGoForward, + onReload, + onNavigate, +}: BrowserToolbarProps) { + const [isEditing, setIsEditing] = useState(false); + const [urlInputValue, setUrlInputValue] = useState(""); + const inputRef = useRef<HTMLInputElement>(null); + + const url = displayUrl(currentUrl); + const isBlank = !url; + + const autocomplete = useUrlAutocomplete({ + onSelect: (selectedUrl) => { + onNavigate(selectedUrl); + setIsEditing(false); + }, + }); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [isEditing]); + + const enterEditMode = useCallback(() => { + setUrlInputValue(url); + setIsEditing(true); + autocomplete.open(); + autocomplete.updateQuery(url); + }, [url, autocomplete]); + + const exitEditMode = useCallback(() => { + setIsEditing(false); + autocomplete.close(); + }, [autocomplete]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = urlInputValue.trim(); + if (trimmed) { + onNavigate(trimmed); + setIsEditing(false); + autocomplete.close(); + } + }, + [urlInputValue, onNavigate, autocomplete], + ); + + const handleInputChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const value = e.target.value; + setUrlInputValue(value); + autocomplete.updateQuery(value); + if (!autocomplete.isOpen) { + autocomplete.open(); + } + }, + [autocomplete], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const handled = autocomplete.handleKeyDown(e); + if (handled) return; + if (e.key === "Escape") { + setIsEditing(false); + } + }, + [autocomplete], + ); + + return ( + <div className="flex h-full min-w-0 flex-1 items-center px-2"> + <div className="flex shrink-0 items-center gap-0.5"> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={onGoBack} + disabled={!canGoBack} + className={`rounded p-1 transition-colors ${canGoBack ? "text-muted-foreground/60 hover:text-muted-foreground" : "opacity-30 pointer-events-none"}`} + > + <TbArrowLeft className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + Go Back + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={onGoForward} + disabled={!canGoForward} + className={`rounded p-1 transition-colors ${canGoForward ? "text-muted-foreground/60 hover:text-muted-foreground" : "opacity-30 pointer-events-none"}`} + > + <TbArrowRight className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + Go Forward + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={onReload} + className="rounded p-1 text-muted-foreground/60 transition-colors hover:text-muted-foreground" + > + {isLoading ? ( + <TbLoader2 className="size-3.5 animate-spin" /> + ) : ( + <TbRefresh className="size-3.5" /> + )} + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + {isLoading ? "Loading..." : "Reload"} + </TooltipContent> + </Tooltip> + </div> + <div className="mx-1.5 h-3.5 w-px shrink-0 bg-muted-foreground/60" /> + <div className="relative flex min-w-0 flex-1 items-center"> + {isEditing ? ( + <form + onSubmit={handleSubmit} + className="flex w-full min-w-0 items-center" + > + <input + ref={inputRef} + type="text" + value={urlInputValue} + onChange={handleInputChange} + onBlur={exitEditMode} + onKeyDown={handleKeyDown} + placeholder="Enter URL or search..." + className="h-[22px] w-full rounded-sm border border-ring bg-transparent px-2 text-xs text-foreground outline-none placeholder:text-muted-foreground/40" + spellCheck={false} + autoComplete="off" + /> + </form> + ) : ( + <button + type="button" + title={isBlank ? undefined : url} + onClick={enterEditMode} + className="group flex w-full min-w-0 items-center rounded-sm border border-transparent px-2 py-0.5 text-left text-xs" + > + {isBlank ? ( + <span className="min-w-0 truncate text-muted-foreground/40"> + Enter URL or search... + </span> + ) : ( + <> + <span className="min-w-0 shrink truncate text-muted-foreground/60 transition-colors group-hover:text-foreground"> + {url} + </span> + {pageTitle && ( + <span className="ml-1 min-w-0 flex-1 truncate text-muted-foreground/40 transition-opacity group-hover:opacity-0"> + / {pageTitle} + </span> + )} + </> + )} + </button> + )} + {isEditing && autocomplete.isOpen && ( + <UrlSuggestions + suggestions={autocomplete.suggestions} + highlightedIndex={autocomplete.highlightedIndex} + onSelect={autocomplete.selectSuggestion} + /> + )} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx new file mode 100644 index 00000000000..af335676392 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/UrlSuggestions.tsx @@ -0,0 +1,74 @@ +import { useEffect, useRef } from "react"; +import { TbGlobe } from "react-icons/tb"; +import type { HistorySuggestion } from "../../hooks/useUrlAutocomplete"; + +interface UrlSuggestionsProps { + suggestions: HistorySuggestion[]; + highlightedIndex: number; + onSelect: (url: string) => void; +} + +export function UrlSuggestions({ + suggestions, + highlightedIndex, + onSelect, +}: UrlSuggestionsProps) { + const listRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (highlightedIndex < 0 || !listRef.current) return; + const items = listRef.current.children; + const item = items[highlightedIndex] as HTMLElement | undefined; + item?.scrollIntoView({ block: "nearest" }); + }, [highlightedIndex]); + + if (suggestions.length === 0) return null; + + return ( + <div + ref={listRef} + className="absolute top-full left-0 right-0 mt-1 z-50 max-h-[320px] overflow-y-auto rounded-md border border-border bg-popover shadow-md" + > + {suggestions.map((item, index) => ( + <button + key={item.url} + type="button" + onMouseDown={(e) => { + e.preventDefault(); + onSelect(item.url); + }} + className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors ${ + index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50" + }`} + > + {item.faviconUrl ? ( + <img + src={item.faviconUrl} + alt="" + className="size-4 shrink-0" + onError={(e) => { + e.currentTarget.style.display = "none"; + e.currentTarget.nextElementSibling?.classList.remove("hidden"); + }} + /> + ) : null} + {!item.faviconUrl ? ( + <TbGlobe className="size-4 shrink-0 text-muted-foreground/50" /> + ) : ( + <TbGlobe className="hidden size-4 shrink-0 text-muted-foreground/50" /> + )} + <div className="min-w-0 flex-1"> + <div className="truncate text-foreground"> + {item.title || item.url} + </div> + {item.title && ( + <div className="truncate text-muted-foreground/60"> + {item.url} + </div> + )} + </div> + </button> + ))} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts new file mode 100644 index 00000000000..482fccd3641 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/components/UrlSuggestions/index.ts @@ -0,0 +1 @@ +export { UrlSuggestions } from "./UrlSuggestions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts new file mode 100644 index 00000000000..0530813da53 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/index.ts @@ -0,0 +1,4 @@ +export { + type HistorySuggestion, + useUrlAutocomplete, +} from "./useUrlAutocomplete"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts new file mode 100644 index 00000000000..185f39af077 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/hooks/useUrlAutocomplete/useUrlAutocomplete.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; + +export interface HistorySuggestion { + url: string; + title: string; + faviconUrl: string | null; + lastVisitedAt: number; + visitCount: number; +} + +interface UseUrlAutocompleteOptions { + onSelect: (url: string) => void; +} + +export function useUrlAutocomplete({ onSelect }: UseUrlAutocompleteOptions) { + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [query, setQuery] = useState(""); + const [allHistory, setAllHistory] = useState<HistorySuggestion[]>([]); + const suggestionsRef = useRef<HistorySuggestion[]>([]); + + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + electronTrpcClient.browserHistory.getAll + .query() + .then((items) => { + if (!cancelled) setAllHistory(items as HistorySuggestion[]); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [isOpen]); + + const suggestions = useMemo(() => { + if (!query.trim()) { + return allHistory.slice(0, 15); + } + const lower = query.toLowerCase(); + return allHistory + .filter( + (item) => + item.url.toLowerCase().includes(lower) || + item.title.toLowerCase().includes(lower), + ) + .slice(0, 8); + }, [allHistory, query]); + + suggestionsRef.current = suggestions; + + const open = useCallback(() => { + setIsOpen(true); + setHighlightedIndex(-1); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + setHighlightedIndex(-1); + }, []); + + const updateQuery = useCallback((value: string) => { + setQuery(value); + setHighlightedIndex(-1); + }, []); + + const selectSuggestion = useCallback( + (url: string) => { + onSelect(url); + close(); + }, + [onSelect, close], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): boolean => { + if (!isOpen || suggestions.length === 0) return false; + + switch (e.key) { + case "ArrowDown": { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < suggestions.length - 1 ? prev + 1 : 0, + ); + return true; + } + case "ArrowUp": { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : suggestions.length - 1, + ); + return true; + } + case "Enter": { + if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) { + e.preventDefault(); + selectSuggestion(suggestions[highlightedIndex].url); + return true; + } + return false; + } + case "Escape": { + if (isOpen) { + e.preventDefault(); + e.stopPropagation(); + close(); + return true; + } + return false; + } + default: + return false; + } + }, + [isOpen, suggestions, highlightedIndex, selectSuggestion, close], + ); + + return { + isOpen, + suggestions, + highlightedIndex, + open, + close, + updateQuery, + selectSuggestion, + handleKeyDown, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts new file mode 100644 index 00000000000..787341a48a8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/components/BrowserToolbar/index.ts @@ -0,0 +1 @@ +export { BrowserToolbar } from "./BrowserToolbar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts new file mode 100644 index 00000000000..8ff6c1ff18a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_BROWSER_URL = "about:blank"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts new file mode 100644 index 00000000000..e7521359cee --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/index.ts @@ -0,0 +1 @@ +export { usePersistentWebview } from "./usePersistentWebview"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts new file mode 100644 index 00000000000..afc8b27ba76 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -0,0 +1,115 @@ +import type { RendererContext } from "@superset/panes"; +import { useCallback, useEffect, useRef } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { + BrowserPaneData, + PaneViewerData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { browserRuntimeRegistry } from "../../browserRuntimeRegistry"; +import { DEFAULT_BROWSER_URL } from "../../constants"; + +interface UsePersistentWebviewOptions { + paneId: string; + ctx: RendererContext<PaneViewerData>; +} + +export function usePersistentWebview({ + paneId, + ctx, +}: UsePersistentWebviewOptions) { + const placeholderRef = useRef<HTMLDivElement | null>(null); + const ctxRef = useRef(ctx); + ctxRef.current = ctx; + + const paneData = ctx.pane.data as BrowserPaneData; + const initialUrlRef = useRef(paneData.url || DEFAULT_BROWSER_URL); + + useEffect(() => { + const placeholder = placeholderRef.current; + if (!placeholder) return; + + browserRuntimeRegistry.attach( + paneId, + placeholder, + initialUrlRef.current, + ({ url, pageTitle, faviconUrl }) => { + const current = ctxRef.current.pane.data as BrowserPaneData; + if ( + current.url === url && + current.pageTitle === pageTitle && + current.faviconUrl === faviconUrl + ) + return; + ctxRef.current.actions.updateData({ + ...current, + url, + pageTitle, + faviconUrl, + }); + }, + ); + + return () => { + browserRuntimeRegistry.detach(paneId); + }; + }, [paneId]); + + useEffect(() => { + const newWindowSub = electronTrpcClient.browser.onNewWindow.subscribe( + { paneId }, + { + onData: ({ url }: { url: string }) => { + ctxRef.current.actions.split("right", { + kind: "browser", + data: { url } as BrowserPaneData, + }); + }, + }, + ); + const contextMenuSub = + electronTrpcClient.browser.onContextMenuAction.subscribe( + { paneId }, + { + onData: ({ action, url }: { action: string; url: string }) => { + if (action === "open-in-split") { + ctxRef.current.actions.split("right", { + kind: "browser", + data: { url } as BrowserPaneData, + }); + } + }, + }, + ); + return () => { + newWindowSub.unsubscribe(); + contextMenuSub.unsubscribe(); + }; + }, [paneId]); + + const goBack = useCallback(() => { + browserRuntimeRegistry.goBack(paneId); + }, [paneId]); + + const goForward = useCallback(() => { + browserRuntimeRegistry.goForward(paneId); + }, [paneId]); + + const reload = useCallback(() => { + browserRuntimeRegistry.reload(paneId); + }, [paneId]); + + const navigateTo = useCallback( + (url: string) => { + browserRuntimeRegistry.navigate(paneId, url); + }, + [paneId], + ); + + return { + placeholderRef, + goBack, + goForward, + reload, + navigateTo, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts new file mode 100644 index 00000000000..e6b7ed03397 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/index.ts @@ -0,0 +1,6 @@ +export { + BrowserPane, + BrowserPaneToolbar, + renderBrowserTabIcon, +} from "./BrowserPane"; +export { browserRuntimeRegistry } from "./browserRuntimeRegistry"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts new file mode 100644 index 00000000000..422be7ed5df --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/sanitizeUrl.ts @@ -0,0 +1,13 @@ +export function sanitizeUrl(url: string): string { + const value = url.trim(); + if (/^https?:\/\//i.test(value) || value.startsWith("about:")) { + return value; + } + if (/^(localhost|127\.0\.0\.1)(:\d+)?(\/.*)?$/i.test(value)) { + return `http://${value}`; + } + if (/^[^\s/]+\.[^\s]+(\/.*)?$/.test(value)) { + return `https://${value}`; + } + return `https://www.google.com/search?q=${encodeURIComponent(value)}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/ChatPane.tsx new file mode 100644 index 00000000000..18d23eb4443 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/ChatPane.tsx @@ -0,0 +1,38 @@ +import type { ChatLaunchConfig } from "shared/tabs-types"; +import { ChatPaneInterface as WorkspaceChatInterface } from "./components/WorkspaceChatInterface"; +import { useWorkspaceChatController } from "./hooks/useWorkspaceChatController"; + +export function ChatPane({ + onSessionIdChange, + sessionId, + workspaceId, + initialLaunchConfig, + onConsumeLaunchConfig, +}: { + onSessionIdChange: (sessionId: string | null) => void; + sessionId: string | null; + workspaceId: string; + initialLaunchConfig?: ChatLaunchConfig | null; + onConsumeLaunchConfig?: () => void; +}) { + const { organizationId, workspacePath, handleNewChat, getOrCreateSession } = + useWorkspaceChatController({ + onSessionIdChange, + sessionId, + workspaceId, + }); + + return ( + <WorkspaceChatInterface + getOrCreateSession={getOrCreateSession} + initialLaunchConfig={initialLaunchConfig ?? null} + onConsumeLaunchConfig={onConsumeLaunchConfig} + isFocused + onResetSession={handleNewChat} + sessionId={sessionId} + workspaceId={workspaceId} + organizationId={organizationId} + cwd={workspacePath} + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatPaneTitle/ChatPaneTitle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatPaneTitle/ChatPaneTitle.tsx new file mode 100644 index 00000000000..a9dc3377b4f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatPaneTitle/ChatPaneTitle.tsx @@ -0,0 +1,53 @@ +import type { RendererContext } from "@superset/panes"; +import { useCallback } from "react"; +import { getV2NotificationSourcesForPane } from "renderer/stores/v2-notifications"; +import { V2NotificationStatusIndicator } from "../../../../../../components/V2NotificationStatusIndicator"; +import type { ChatPaneData, PaneViewerData } from "../../../../../../types"; +import { useWorkspaceChatController } from "../../hooks/useWorkspaceChatController"; +import { SessionSelector } from "../SessionSelector"; + +interface ChatPaneTitleProps { + context: RendererContext<PaneViewerData>; + workspaceId: string; +} + +export function ChatPaneTitle({ context, workspaceId }: ChatPaneTitleProps) { + const data = context.pane.data as ChatPaneData; + const { sessionId } = data; + const { actions } = context; + + const onSessionIdChange = useCallback( + (nextSessionId: string | null) => { + actions.updateData({ ...data, sessionId: nextSessionId }); + }, + [actions, data], + ); + + const { + sessionItems, + handleSelectSession, + handleNewChat, + handleDeleteSession, + } = useWorkspaceChatController({ + workspaceId, + sessionId, + onSessionIdChange, + }); + + return ( + <div className="flex min-w-0 flex-1 items-center gap-1.5"> + <SessionSelector + currentSessionId={sessionId} + sessions={sessionItems} + fallbackTitle="New Chat" + onSelectSession={handleSelectSession} + onNewChat={handleNewChat} + onDeleteSession={handleDeleteSession} + /> + <V2NotificationStatusIndicator + workspaceId={workspaceId} + sources={getV2NotificationSourcesForPane(context.pane)} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatPaneTitle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatPaneTitle/index.ts new file mode 100644 index 00000000000..30ae09d8f9b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/ChatPaneTitle/index.ts @@ -0,0 +1 @@ +export { ChatPaneTitle } from "./ChatPaneTitle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/SessionSelector.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/SessionSelector.tsx similarity index 95% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/SessionSelector.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/SessionSelector.tsx index 8a600a91fa0..cbccee3a151 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/SessionSelector.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/SessionSelector.tsx @@ -115,9 +115,10 @@ export function SessionSelector({ <DropdownMenuTrigger asChild> <button type="button" - className="flex min-w-0 flex-1 items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + title={currentTitle} + className="flex w-full min-w-0 flex-1 items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > - <HiMiniChevronDown className="size-3" /> + <HiMiniChevronDown className="size-3 shrink-0" /> <span className="min-w-0 flex-1 truncate text-left"> {currentTitle} </span> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx similarity index 75% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx index 87150c06a3f..a97f0b09753 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx @@ -36,17 +36,23 @@ export function SessionSelectorItem({ className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100" onClick={(event) => { event.stopPropagation(); - alert.destructive({ + alert({ title: "Delete Chat Session", description: "Are you sure you want to delete this session?", - confirmText: "Delete", - onConfirm: () => { - toast.promise(onDeleteSession(sessionId), { - loading: "Deleting session...", - success: "Session deleted", - error: "Failed to delete session", - }); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Delete", + variant: "destructive", + onClick: () => { + toast.promise(onDeleteSession(sessionId), { + loading: "Deleting session...", + success: "Session deleted", + error: "Failed to delete session", + }); + }, + }, + ], }); }} > diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/components/SessionSelectorItem/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/components/SessionSelectorItem/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/components/SessionSelectorItem/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/components/SessionSelectorItem/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/SessionSelector/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/SessionSelector/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/ChatPaneInterface.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx similarity index 89% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/ChatPaneInterface.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx index 39b63b9e716..604bccbc002 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/ChatPaneInterface.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/ChatPaneInterface.tsx @@ -1,3 +1,4 @@ +import type { AppRouter } from "@superset/host-service"; import { PromptInputAttachment, type PromptInputMessage, @@ -6,15 +7,19 @@ import { } from "@superset/ui/ai-elements/prompt-input"; import { workspaceTrpc } from "@superset/workspace-client"; import { useQuery } from "@tanstack/react-query"; +import type { inferRouterOutputs } from "@trpc/server"; import type { ChatStatus } from "ai"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { SlashCommand } from "renderer/components/Chat/ChatInterface/hooks/useSlashCommands"; import type { ModelOption, PermissionMode, } from "renderer/components/Chat/ChatInterface/types"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { + getDesktopChatModelOptions, + isDesktopChatDevMode, +} from "renderer/lib/dev-chat"; import { posthog } from "renderer/lib/posthog"; import { useChatPreferencesStore } from "renderer/stores/chat-preferences"; import { @@ -115,7 +120,6 @@ function ChatUploadFooter({ return ( <ChatInputFooter {...footerProps} - sessionId={sessionId} workspaceId={workspaceId} submitDisabled={sessionId ? isUploading : false} renderAttachment={renderAttachment} @@ -128,12 +132,14 @@ function useAvailableModels(): { models: ModelOption[]; defaultModel: ModelOption | null; } { + const localModels = getDesktopChatModelOptions(); const { data } = useQuery({ queryKey: ["chat", "models"], queryFn: () => apiTrpcClient.chat.getModels.query(), + enabled: !isDesktopChatDevMode(), staleTime: Number.POSITIVE_INFINITY, }); - const models = data?.models ?? []; + const models = localModels.length > 0 ? localModels : (data?.models ?? []); return { models, defaultModel: models[0] ?? null }; } @@ -184,6 +190,7 @@ function getLaunchConfigKey( export function ChatPaneInterface({ sessionId, initialLaunchConfig, + onConsumeLaunchConfig, workspaceId, organizationId, cwd, @@ -191,7 +198,6 @@ export function ChatPaneInterface({ getOrCreateSession, onResetSession, onUserMessageSubmitted, - onRawSnapshotChange, }: ChatPaneInterfaceProps) { const { models: availableModels, defaultModel } = useAvailableModels(); const selectedModelId = useChatPreferencesStore( @@ -219,6 +225,11 @@ export function ChatPaneInterface({ const [approvalResponsePending, setApprovalResponsePending] = useState(false); const [planResponsePending, setPlanResponsePending] = useState(false); const [questionResponsePending, setQuestionResponsePending] = useState(false); + const [footerScrollTrigger, setFooterScrollTrigger] = useState(0); + const bumpFooterScroll = useCallback( + () => setFooterScrollTrigger((n) => n + 1), + [], + ); const [editingUserMessageId, setEditingUserMessageId] = useState< string | null >(null); @@ -249,31 +260,40 @@ export function ChatPaneInterface({ [organizationId, sessionId, workspaceId], ); + // Memoize the select mapper so React Query can preserve the result's + // identity across polls — without this, every render produces a new + // mapper, every poll produces a new array, and every consumer of + // `slashCommands` rerenders even when nothing has changed. + const selectSlashCommands = useCallback( + ( + commands: NonNullable< + inferRouterOutputs<AppRouter>["chat"]["getSlashCommands"] + >, + ) => + commands.map((command) => ({ + ...command, + kind: + command.kind === "builtin" + ? ("builtin" as const) + : ("custom" as const), + source: + command.kind === "builtin" + ? ("builtin" as const) + : ("project" as const), + })), + [], + ); + const { data: slashCommands = [] } = workspaceTrpc.chat.getSlashCommands.useQuery( - { sessionId: sessionId ?? "", workspaceId }, - { - enabled: Boolean(sessionId), - select: (commands) => - commands.map((command) => ({ - ...command, - kind: - command.kind === "builtin" - ? ("builtin" as const) - : ("custom" as const), - source: - command.kind === "builtin" - ? ("builtin" as const) - : ("project" as const), - })), - }, + { workspaceId }, + { select: selectSlashCommands }, ); const chat = useChatDisplay({ sessionId, workspaceId, enabled: Boolean(sessionId), - fps: 60, }); const { commands, @@ -320,38 +340,20 @@ export function ChatPaneInterface({ const sendMessageToSession = useCallback( async (targetSessionId: string, input: ChatSendMessageInput) => { - const queryInput = { + // Optimistic state for this path lives in `pendingUserTurn` (set by + // the caller in handleSend), NOT in the snapshot cache. Writing to + // the cache here was racing with the 4fps snapshot polls — a poll + // could resolve mid-mutation with the harness's pre-message state + // and clobber the optimistic write, making the user message vanish + // briefly. The pendingUserTurn local state is merged in via + // getVisibleMessagesWithPendingUserTurn so it survives stale polls. + await sendMessageMutation.mutateAsync({ sessionId: targetSessionId, workspaceId, - }; - const optimisticMessage = toOptimisticUserMessage(input); - if (optimisticMessage) { - workspaceTrpcUtils.chat.listMessages.setData( - queryInput, - (existingMessages = []) => [...existingMessages, optimisticMessage], - ); - } - - try { - await sendMessageMutation.mutateAsync({ - sessionId: targetSessionId, - workspaceId, - ...input, - }); - } catch (error) { - if (optimisticMessage) { - workspaceTrpcUtils.chat.listMessages.setData( - queryInput, - (existingMessages = []) => - existingMessages.filter( - (message) => message.id !== optimisticMessage.id, - ), - ); - } - throw error; - } + ...input, + }); }, - [workspaceTrpcUtils.chat.listMessages, sendMessageMutation, workspaceId], + [sendMessageMutation, workspaceId], ); const canAbort = Boolean(isRunning); @@ -489,22 +491,11 @@ export function ChatPaneInterface({ setSubmitStatus(undefined); }, [isRunning]); + // Scroll chat to bottom whenever the footer question overlay appears, changes, or disappears + // biome-ignore lint/correctness/useExhaustiveDependencies: pendingQuestion is an intentional re-run trigger useEffect(() => { - onRawSnapshotChange?.({ - sessionId, - isRunning: canAbort, - currentMessage: currentMessage ?? null, - messages: messages ?? [], - error, - }); - }, [ - canAbort, - currentMessage, - error, - messages, - onRawSnapshotChange, - sessionId, - ]); + bumpFooterScroll(); + }, [bumpFooterScroll, pendingQuestion]); useEffect(() => { messagesLengthRef.current = messages?.length ?? 0; @@ -583,7 +574,25 @@ export function ChatPaneInterface({ if (sessionId && targetSessionId === sessionId) { await commands.sendMessage(sendInput); } else { - await sendMessageToSession(targetSessionId, sendInput); + // New-session path: the existing-session path's optimistic + // state lives inside useChatDisplay, but we don't have a + // session subscribed there yet. Hold the user message in + // pendingUserTurn so getVisibleMessagesWithPendingUserTurn + // keeps it visible across stale snapshot polls until the + // harness's response includes it. + const optimisticMessage = toOptimisticUserMessage(sendInput); + if (optimisticMessage) { + setPendingUserTurn({ + kind: "append", + message: optimisticMessage, + }); + } + try { + await sendMessageToSession(targetSessionId, sendInput); + } catch (error) { + setPendingUserTurn(null); + throw error; + } } if (content) { onUserMessageSubmitted?.(content); @@ -695,6 +704,7 @@ export function ChatPaneInterface({ consumedLaunchConfigRef.current = launchConfigKey; delete autoLaunchAttemptsRef.current[launchConfigKey]; delete autoLaunchSessionLockRef.current[launchConfigKey]; + onConsumeLaunchConfig?.(); captureChatEvent("chat_message_sent", { session_id: targetSessionId, @@ -744,6 +754,7 @@ export function ChatPaneInterface({ setRuntimeErrorMessage, onUserMessageSubmitted, thinkingLevel, + onConsumeLaunchConfig, ]); const handleStop = useCallback( @@ -754,14 +765,6 @@ export function ChatPaneInterface({ [stopActiveResponse], ); - const handleSlashCommandSend = useCallback( - (command: SlashCommand) => { - void handleSend({ content: `/${command.name}` }).catch((error) => { - console.debug("[chat] handleSlashCommandSend error", error); - }); - }, - [handleSend], - ); const restartFromUserMessage = useCallback( async ( request: UserMessageRestartRequest, @@ -897,6 +900,7 @@ export function ChatPaneInterface({ const trimmedAnswer = answer.trim(); if (!trimmedQuestionId || !trimmedAnswer) return; clearRuntimeError(); + bumpFooterScroll(); setQuestionResponsePending(true); try { await commands.respondToQuestion({ @@ -909,14 +913,14 @@ export function ChatPaneInterface({ setQuestionResponsePending(false); } }, - [clearRuntimeError, commands], + [bumpFooterScroll, clearRuntimeError, commands], ); const errorMessage = runtimeError ?? toErrorMessage(error); return ( <PromptInputProvider initialInput={initialLaunchConfig?.draftInput}> - <div className="flex h-full flex-col bg-background"> + <div className="flex h-full w-full flex-col bg-background"> <ChatMessageList messages={visibleMessages} isFocused={isFocused} @@ -938,15 +942,13 @@ export function ChatPaneInterface({ pendingPlanApproval={pendingPlanApproval} isPlanSubmitting={planResponsePending} onPlanRespond={handlePlanResponse} - pendingQuestion={pendingQuestion} - isQuestionSubmitting={questionResponsePending} - onQuestionRespond={handleQuestionResponse} editingUserMessageId={editingUserMessageId} isEditSubmitting={isAwaitingAssistant} onStartEditUserMessage={setEditingUserMessageId} onCancelEditUserMessage={() => setEditingUserMessageId(null)} onSubmitEditedUserMessage={handleSubmitEditedUserMessage} onRestartUserMessage={handleResendUserMessage} + footerScrollTrigger={footerScrollTrigger} /> <McpControls mcpUi={mcpUi} /> <ChatUploadFooter @@ -971,7 +973,13 @@ export function ChatPaneInterface({ onSend={handleSend} onSubmitStart={() => setSubmitStatus("submitted")} onStop={handleStop} - onSlashCommandSend={handleSlashCommandSend} + pendingQuestion={pendingQuestion} + isQuestionSubmitting={questionResponsePending} + onQuestionRespond={handleQuestionResponse} + onQuestionCancel={() => { + bumpFooterScroll(); + void stopActiveResponse(); + }} /> </div> </PromptInputProvider> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx new file mode 100644 index 00000000000..5b33af4e68e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -0,0 +1,240 @@ +import { + PromptInput, + PromptInputAttachment, + PromptInputAttachments, + type PromptInputMessage, + usePromptInputController, +} from "@superset/ui/ai-elements/prompt-input"; +import type { ThinkingLevel } from "@superset/ui/ai-elements/thinking-toggle"; +import { workspaceTrpc } from "@superset/workspace-client"; +import type { ChatStatus, FileUIPart } from "ai"; +import type React from "react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { QuestionInputOverlay } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/QuestionInputOverlay"; +import { TiptapPromptEditor } from "renderer/components/Chat/ChatInterface/components/TiptapPromptEditor"; +import { useFocusPromptOnPane } from "renderer/components/Chat/ChatInterface/hooks/useFocusPromptOnPane"; +import type { SlashCommand } from "renderer/components/Chat/ChatInterface/hooks/useSlashCommands"; +import type { + ModelOption, + PermissionMode, +} from "renderer/components/Chat/ChatInterface/types"; +import { useHotkeyDisplay } from "renderer/hotkeys"; +import { ChatComposerControls } from "./components/ChatComposerControls"; +import { ChatInputDropZone } from "./components/ChatInputDropZone"; +import { ChatShortcuts } from "./components/ChatShortcuts"; +import { FileDropOverlay } from "./components/FileDropOverlay"; +import { LinkedIssues } from "./components/LinkedIssues"; +import { SlashCommandPreview } from "./components/SlashCommandPreview"; +import type { LinkedIssue } from "./types"; +import { getErrorMessage } from "./utils/getErrorMessage"; + +interface ChatInputFooterProps { + workspaceId: string; + cwd: string; + isFocused: boolean; + error: unknown; + canAbort: boolean; + submitStatus?: ChatStatus; + availableModels: ModelOption[]; + selectedModel: ModelOption | null; + setSelectedModel: React.Dispatch<React.SetStateAction<ModelOption | null>>; + modelSelectorOpen: boolean; + setModelSelectorOpen: React.Dispatch<React.SetStateAction<boolean>>; + permissionMode: PermissionMode; + setPermissionMode: React.Dispatch<React.SetStateAction<PermissionMode>>; + thinkingLevel: ThinkingLevel; + setThinkingLevel: (level: ThinkingLevel) => void; + slashCommands: SlashCommand[]; + submitDisabled?: boolean; + renderAttachment?: (file: FileUIPart & { id: string }) => ReactNode; + onSubmitStart?: () => void; + onSubmitEnd?: () => void; + onSend: (message: PromptInputMessage) => Promise<void> | void; + onStop: (e: React.MouseEvent) => void; + pendingQuestion?: { + questionId: string; + question: string; + options?: { label: string; description?: string }[]; + } | null; + isQuestionSubmitting?: boolean; + onQuestionRespond?: (questionId: string, answer: string) => Promise<void>; + onQuestionCancel?: () => void; +} + +export function ChatInputFooter({ + workspaceId, + cwd, + isFocused, + error, + canAbort, + submitStatus, + availableModels, + selectedModel, + setSelectedModel, + modelSelectorOpen, + setModelSelectorOpen, + permissionMode, + setPermissionMode, + thinkingLevel, + setThinkingLevel, + slashCommands, + submitDisabled, + renderAttachment, + onSubmitStart, + onSubmitEnd, + onSend, + onStop, + pendingQuestion, + isQuestionSubmitting, + onQuestionRespond, + onQuestionCancel, +}: ChatInputFooterProps) { + useFocusPromptOnPane(isFocused); + + // Re-focus the editor when the question overlay dismisses. + const { textInput } = usePromptInputController(); + const prevPendingQuestionRef = useRef(pendingQuestion); + useEffect(() => { + const prev = prevPendingQuestionRef.current; + prevPendingQuestionRef.current = pendingQuestion; + if (prev != null && pendingQuestion == null) { + const id = requestAnimationFrame(() => textInput.focus()); + return () => cancelAnimationFrame(id); + } + }, [pendingQuestion, textInput]); + + const [linkedIssues, setLinkedIssues] = useState<LinkedIssue[]>([]); + const inputRootRef = useRef<HTMLDivElement>(null); + const errorMessage = getErrorMessage(error); + const focusShortcutText = useHotkeyDisplay("FOCUS_CHAT_INPUT").text; + const showFocusHint = focusShortcutText !== "Unassigned"; + + const removeLinkedIssue = useCallback((slug: string) => { + setLinkedIssues((prev) => prev.filter((issue) => issue.slug !== slug)); + }, []); + + const trpcUtils = workspaceTrpc.useUtils(); + const searchFiles = useCallback( + async (query: string) => { + const { matches } = await trpcUtils.filesystem.searchFiles.fetch({ + workspaceId, + query, + includeHidden: false, + limit: 20, + }); + return matches.map((m) => ({ + id: m.absolutePath, + name: m.name, + relativePath: m.relativePath, + })); + }, + [trpcUtils, workspaceId], + ); + + const handleSend = useCallback( + (message: PromptInputMessage) => { + if (linkedIssues.length === 0) return onSend(message); + + const prefix = linkedIssues + .map((issue) => `@task:${issue.slug}`) + .join(" "); + const modifiedMessage: PromptInputMessage = { + ...message, + text: `${prefix} ${message.text}`, + }; + setLinkedIssues([]); + return onSend(modifiedMessage); + }, + [linkedIssues, onSend], + ); + + return ( + <ChatInputDropZone className="bg-background px-4 py-3"> + {(dragType) => ( + <div className="mx-auto w-full max-w-[680px]"> + {errorMessage && ( + <p + role="alert" + className="mb-3 select-text rounded-md border border-destructive/20 bg-destructive/10 px-4 py-2 text-sm text-destructive" + > + {errorMessage} + </p> + )} + {pendingQuestion && onQuestionRespond && onQuestionCancel ? ( + <QuestionInputOverlay + key={pendingQuestion.questionId} + question={pendingQuestion} + isSubmitting={isQuestionSubmitting ?? false} + onRespond={onQuestionRespond} + onCancel={onQuestionCancel} + /> + ) : ( + <div + ref={inputRootRef} + className={ + dragType === "path" + ? "relative opacity-50 transition-opacity" + : "relative" + } + > + {showFocusHint && ( + <span className="pointer-events-none absolute top-3 right-3 z-10 text-xs text-muted-foreground/50 [:focus-within>&]:hidden"> + {focusShortcutText} to focus + </span> + )} + <PromptInput + className="[&>[data-slot=input-group]]:rounded-[13px] [&>[data-slot=input-group]]:border-[0.5px] [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:bg-foreground/[0.02]" + onSubmitStart={onSubmitStart} + onSubmitEnd={onSubmitEnd} + onSubmit={handleSend} + multiple + maxFiles={5} + maxFileSize={10 * 1024 * 1024} + globalDrop + > + <ChatShortcuts isFocused={isFocused} /> + <FileDropOverlay visible={dragType === "files"} /> + <PromptInputAttachments> + {renderAttachment ?? + ((file) => <PromptInputAttachment data={file} />)} + </PromptInputAttachments> + <LinkedIssues + issues={linkedIssues} + onRemove={removeLinkedIssue} + /> + <SlashCommandPreview + workspaceId={workspaceId} + slashCommands={slashCommands} + /> + <TiptapPromptEditor + cwd={cwd} + searchFiles={searchFiles} + slashCommands={slashCommands} + availableModels={availableModels} + placeholder="Ask to make changes, @mention files, run /commands" + /> + <ChatComposerControls + availableModels={availableModels} + selectedModel={selectedModel} + setSelectedModel={setSelectedModel} + modelSelectorOpen={modelSelectorOpen} + setModelSelectorOpen={setModelSelectorOpen} + permissionMode={permissionMode} + setPermissionMode={setPermissionMode} + thinkingLevel={thinkingLevel} + setThinkingLevel={setThinkingLevel} + canAbort={canAbort} + submitStatus={submitStatus} + submitDisabled={submitDisabled} + onStop={onStop} + /> + </PromptInput> + </div> + )} + <div className="py-1.5" /> + </div> + )} + </ChatInputDropZone> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx similarity index 97% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx index b6274ef8de3..80412316c4b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx @@ -33,7 +33,6 @@ interface ChatComposerControlsProps { submitStatus?: ChatStatus; submitDisabled?: boolean; onStop: (event: React.MouseEvent) => void; - onLinkIssue: () => void; } export function ChatComposerControls({ @@ -50,7 +49,6 @@ export function ChatComposerControls({ submitStatus, submitDisabled, onStop, - onLinkIssue, }: ChatComposerControlsProps) { return ( <PromptInputFooter> @@ -73,7 +71,7 @@ export function ChatComposerControls({ /> </PromptInputTools> <div className="flex items-center gap-2"> - <PlusMenu onLinkIssue={onLinkIssue} /> + <PlusMenu /> <PromptInputSubmit className="size-[23px] rounded-full border border-transparent bg-foreground/10 shadow-none p-[5px] hover:bg-foreground/20" status={submitStatus} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatComposerControls/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatInputDropZone/ChatInputDropZone.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatInputDropZone/ChatInputDropZone.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatInputDropZone/ChatInputDropZone.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatInputDropZone/ChatInputDropZone.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatInputDropZone/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatInputDropZone/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatInputDropZone/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatInputDropZone/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx new file mode 100644 index 00000000000..e879a261b00 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx @@ -0,0 +1,32 @@ +import { + usePromptInputAttachments, + usePromptInputController, +} from "@superset/ui/ai-elements/prompt-input"; +import { useHotkey } from "renderer/hotkeys"; + +interface ChatShortcutsProps { + isFocused: boolean; +} + +export function ChatShortcuts({ isFocused }: ChatShortcutsProps) { + const attachments = usePromptInputAttachments(); + const { textInput } = usePromptInputController(); + + useHotkey( + "CHAT_ADD_ATTACHMENT", + () => { + attachments.openFileDialog(); + }, + { enabled: isFocused, preventDefault: true }, + ); + + useHotkey( + "FOCUS_CHAT_INPUT", + () => { + textInput.focus(); + }, + { enabled: isFocused, preventDefault: true }, + ); + + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/ChatShortcuts/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/FileDropOverlay/FileDropOverlay.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/FileDropOverlay/FileDropOverlay.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/FileDropOverlay/FileDropOverlay.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/FileDropOverlay/FileDropOverlay.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/FileDropOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/FileDropOverlay/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/FileDropOverlay/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/FileDropOverlay/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssuePill/LinkedIssuePill.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssuePill/LinkedIssuePill.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssuePill/LinkedIssuePill.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssuePill/LinkedIssuePill.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssuePill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssuePill/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssuePill/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssuePill/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssues/LinkedIssues.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssues/LinkedIssues.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssues/LinkedIssues.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssues/LinkedIssues.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssues/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssues/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssues/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/LinkedIssues/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/SlashCommandPreview.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/SlashCommandPreview.tsx similarity index 96% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/SlashCommandPreview.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/SlashCommandPreview.tsx index 75a9e9016bd..57ed4f5b3e1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/SlashCommandPreview.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/SlashCommandPreview.tsx @@ -15,7 +15,6 @@ import { } from "./slash-command-preview.model"; interface SlashCommandPreviewProps { - sessionId: string | null; workspaceId: string; slashCommands: Array<{ name: string; @@ -45,7 +44,6 @@ function isRequiredField( } export function SlashCommandPreview({ - sessionId, workspaceId, slashCommands, }: SlashCommandPreviewProps) { @@ -72,7 +70,7 @@ export function SlashCommandPreview({ } | null>(null); useEffect(() => { - if (!sessionId || debouncedSlashPreviewInput.length <= 1) { + if (debouncedSlashPreviewInput.length <= 1) { setSlashPreview(null); return; } @@ -80,7 +78,6 @@ export function SlashCommandPreview({ let cancelled = false; void previewSlashCommand .mutateAsync({ - sessionId, workspaceId, text: debouncedSlashPreviewInput, }) @@ -107,7 +104,7 @@ export function SlashCommandPreview({ return () => { cancelled = true; }; - }, [debouncedSlashPreviewInput, previewSlashCommand, sessionId, workspaceId]); + }, [debouncedSlashPreviewInput, previewSlashCommand, workspaceId]); const commandDefinition = useMemo(() => { if (!parsedInput?.commandName) return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/components/SlashCommandParamField/SlashCommandParamField.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/components/SlashCommandParamField/SlashCommandParamField.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/components/SlashCommandParamField/SlashCommandParamField.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/components/SlashCommandParamField/SlashCommandParamField.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/components/SlashCommandParamField/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/components/SlashCommandParamField/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/components/SlashCommandParamField/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/components/SlashCommandParamField/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/components/SlashCommandPreview/slash-command-preview.model.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/hooks/useDocumentDrag/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/hooks/useDocumentDrag/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/hooks/useDocumentDrag/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/hooks/useDocumentDrag/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/hooks/useDocumentDrag/useDocumentDrag.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/hooks/useDocumentDrag/useDocumentDrag.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/hooks/useDocumentDrag/useDocumentDrag.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/hooks/useDocumentDrag/useDocumentDrag.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/types.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/types.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/types.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/utils/getErrorMessage.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/utils/getErrorMessage.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatInputFooter/utils/getErrorMessage.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatInputFooter/utils/getErrorMessage.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx similarity index 89% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx index 5046915ee9a..d7130d820e2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.tsx @@ -4,8 +4,9 @@ import { ConversationEmptyState, ConversationLoadingState, ConversationScrollButton, + useConversationContext, } from "@superset/ui/ai-elements/conversation"; -import { useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { HiMiniChatBubbleLeftRight } from "react-icons/hi2"; import type { ChatMessage, @@ -17,8 +18,6 @@ import { InterruptedFooter } from "./components/InterruptedFooter"; import { MessageScrollbackRail } from "./components/MessageScrollbackRail"; import { PendingApprovalMessage } from "./components/PendingApprovalMessage"; import { PendingPlanApprovalMessage } from "./components/PendingPlanApprovalMessage"; -import { PendingQuestionMessage } from "./components/PendingQuestionMessage"; -import { SubagentExecutionMessage } from "./components/SubagentExecutionMessage"; import { ThinkingMessage } from "./components/ThinkingMessage"; import { ToolPreviewMessage } from "./components/ToolPreviewMessage"; import { UserMessage } from "./components/UserMessage"; @@ -32,6 +31,21 @@ import { resolvePendingPlanToolCallId, } from "./utils/messageListHelpers"; +function ScrollAnchor({ trigger }: { trigger: number }) { + const { scrollToBottom, isAtBottom } = useConversationContext(); + const isAtBottomRef = useRef(isAtBottom); + isAtBottomRef.current = isAtBottom; + + // biome-ignore lint/correctness/useExhaustiveDependencies: trigger is an intentional re-run signal + useEffect(() => { + if (isAtBottomRef.current) { + scrollToBottom("instant"); + } + }, [trigger, scrollToBottom]); + + return null; +} + export function ChatMessageList({ messages, isFocused, @@ -46,22 +60,19 @@ export function ChatMessageList({ workspaceCwd, activeTools, toolInputBuffers, - activeSubagents, pendingApproval, isApprovalSubmitting, onApprovalRespond, pendingPlanApproval, isPlanSubmitting, onPlanRespond, - pendingQuestion, - isQuestionSubmitting, - onQuestionRespond, editingUserMessageId, isEditSubmitting, onStartEditUserMessage, onCancelEditUserMessage, onSubmitEditedUserMessage, onRestartUserMessage, + footerScrollTrigger = 0, }: ChatMessageListProps) { const messageListRef = useRef<HTMLDivElement>(null); const chatSearch = useChatMessageSearch({ @@ -105,12 +116,6 @@ export function ChatMessageList({ }), [activeTools, toolInputBuffers], ); - const activeSubagentEntries = useMemo( - () => (activeSubagents ? [...activeSubagents.entries()] : []), - [activeSubagents], - ); - const hasSubagentActivity = activeSubagentEntries.length > 0; - const pendingPlanToolCallId = useMemo(() => { const anchorMessages: ChatMessage[] = [...renderedMessages]; if (interruptedPreview) { @@ -142,11 +147,7 @@ export function ChatMessageList({ ); const canShowPendingAssistantUi = - isAwaitingAssistant && - !currentMessage && - !hasSubagentActivity && - !pendingApproval && - !pendingQuestion; + isAwaitingAssistant && !currentMessage && !pendingApproval; const shouldShowThinking = canShowPendingAssistantUi && !pendingPlanApproval && @@ -259,9 +260,6 @@ export function ChatMessageList({ onPlanRespond={onPlanRespond} /> ) : null} - {hasSubagentActivity ? ( - <SubagentExecutionMessage subagents={activeSubagentEntries} /> - ) : null} {pendingApproval && ( <PendingApprovalMessage approval={pendingApproval} @@ -276,13 +274,6 @@ export function ChatMessageList({ onRespond={onPlanRespond} /> )} - {pendingQuestion && ( - <PendingQuestionMessage - question={pendingQuestion} - isSubmitting={isQuestionSubmitting} - onRespond={onQuestionRespond} - /> - )} </div> </ConversationContent> <ChatSearch @@ -297,6 +288,7 @@ export function ChatMessageList({ onFindPrevious={chatSearch.findPrevious} onClose={chatSearch.closeSearch} /> + <ScrollAnchor trigger={footerScrollTrigger} /> <MessageScrollbackRail messages={renderedMessages} /> <ConversationScrollButton /> </Conversation> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts similarity index 88% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts index 795f06ab51c..7ffb9838953 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/ChatMessageList.types.ts @@ -1,4 +1,4 @@ -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; export type ChatMessage = NonNullable<UseChatDisplayReturn["messages"]>[number]; @@ -30,8 +30,6 @@ export type ChatPendingApproval = UseChatDisplayReturn["pendingApproval"]; export type ChatPendingPlanApproval = UseChatDisplayReturn["pendingPlanApproval"]; -export type ChatPendingQuestion = UseChatDisplayReturn["pendingQuestion"]; - export interface InterruptedMessagePreview { id: string; sourceMessageId: string; @@ -80,9 +78,6 @@ export interface ChatMessageListProps { action: "approved" | "rejected"; feedback?: string; }) => Promise<void>; - pendingQuestion: ChatPendingQuestion; - isQuestionSubmitting: boolean; - onQuestionRespond: (questionId: string, answer: string) => Promise<void>; editingUserMessageId: string | null; isEditSubmitting: boolean; onStartEditUserMessage: (messageId: string) => void; @@ -91,4 +86,5 @@ export interface ChatMessageListProps { request: UserMessageRestartRequest, ) => Promise<void>; onRestartUserMessage: (request: UserMessageRestartRequest) => Promise<void>; + footerScrollTrigger?: number; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx similarity index 98% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx index 6bf73f5698c..81fb0a139f7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx @@ -7,7 +7,7 @@ import { ReasoningBlock } from "renderer/components/Chat/ChatInterface/component import { ToolCallBlock } from "renderer/components/Chat/ChatInterface/components/ToolCallBlock"; import type { ToolPart } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; import { useTabsStore } from "renderer/stores/tabs/store"; import { AttachmentChip } from "../AttachmentChip"; import { PendingPlanApprovalMessage } from "../PendingPlanApprovalMessage"; @@ -261,6 +261,7 @@ export function AssistantMessage({ sessionId={sessionId} organizationId={organizationId} workspaceCwd={workspaceCwd} + isStreaming={isStreaming} />, ); nodes.push(...getInlineToolStateNodes(part.id)); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AssistantMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AttachmentChip/AttachmentChip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AttachmentChip/AttachmentChip.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AttachmentChip/AttachmentChip.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AttachmentChip/AttachmentChip.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AttachmentChip/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AttachmentChip/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/AttachmentChip/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/AttachmentChip/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ChatSearch/ChatSearch.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ChatSearch/ChatSearch.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ChatSearch/ChatSearch.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ChatSearch/ChatSearch.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ChatSearch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ChatSearch/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ChatSearch/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ChatSearch/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/InterruptedFooter/InterruptedFooter.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/InterruptedFooter/InterruptedFooter.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/InterruptedFooter/InterruptedFooter.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/InterruptedFooter/InterruptedFooter.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/InterruptedFooter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/InterruptedFooter/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/InterruptedFooter/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/InterruptedFooter/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/MessageScrollbackRail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/MessageScrollbackRail.tsx similarity index 98% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/MessageScrollbackRail.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/MessageScrollbackRail.tsx index 9d81ad22795..ccbb06dcf50 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/MessageScrollbackRail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/MessageScrollbackRail.tsx @@ -6,7 +6,7 @@ import { } from "@superset/ui/hover-card"; import { cn } from "@superset/ui/utils"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; type ChatMessage = NonNullable<UseChatDisplayReturn["messages"]>[number]; type ChatMessagePart = ChatMessage["content"][number]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/MessageScrollbackRail/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/PendingApprovalMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/PendingApprovalMessage.tsx similarity index 97% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/PendingApprovalMessage.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/PendingApprovalMessage.tsx index b18e430c9bb..faafb44f059 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/PendingApprovalMessage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/PendingApprovalMessage.tsx @@ -1,7 +1,7 @@ import { Message, MessageContent } from "@superset/ui/ai-elements/message"; import { Button } from "@superset/ui/button"; import { useEffect, useRef, useState } from "react"; -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; type ApprovalDecision = "approve" | "decline" | "always_allow_category"; type PendingApproval = UseChatDisplayReturn["pendingApproval"]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingApprovalMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/PendingPlanApprovalMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/PendingPlanApprovalMessage.tsx similarity index 97% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/PendingPlanApprovalMessage.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/PendingPlanApprovalMessage.tsx index a3585d513e3..4a45f78c9d0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/PendingPlanApprovalMessage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/PendingPlanApprovalMessage.tsx @@ -7,7 +7,7 @@ import { Button } from "@superset/ui/button"; import { Switch } from "@superset/ui/switch"; import { Textarea } from "@superset/ui/textarea"; import { useEffect, useId, useRef, useState } from "react"; -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; type PendingPlanApproval = UseChatDisplayReturn["pendingPlanApproval"]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingPlanApprovalMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/PendingQuestionMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/PendingQuestionMessage.tsx similarity index 97% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/PendingQuestionMessage.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/PendingQuestionMessage.tsx index 8556daf1301..7336d270b5e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/PendingQuestionMessage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/PendingQuestionMessage.tsx @@ -6,7 +6,7 @@ import { import { Button } from "@superset/ui/button"; import { Input } from "@superset/ui/input"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; type PendingQuestion = UseChatDisplayReturn["pendingQuestion"]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/PendingQuestionMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx new file mode 100644 index 00000000000..e861d4d9bb2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx @@ -0,0 +1,104 @@ +import { + Message, + MessageContent, + MessageResponse, +} from "@superset/ui/ai-elements/message"; +import { cn } from "@superset/ui/lib/utils"; +import { SubagentInnerToolCall } from "renderer/components/Chat/components/SubagentInnerToolCall"; +import { + type SubagentEntries, + toSubagentViewModels, +} from "./utils/toSubagentViewModels"; + +interface SubagentExecutionMessageProps { + subagents: SubagentEntries; + inline?: boolean; +} + +function getStatusLabel(status: "running" | "completed" | "error"): string { + if (status === "running") return "Running"; + if (status === "completed") return "Completed"; + return "Failed"; +} + +function getStatusClassName(status: "running" | "completed" | "error"): string { + if (status === "running") { + return "border-primary/40 bg-primary/10 text-primary"; + } + if (status === "completed") { + return "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"; + } + return "border-destructive/40 bg-destructive/10 text-destructive"; +} + +export function SubagentExecutionMessage({ + subagents, + inline = false, +}: SubagentExecutionMessageProps) { + if (subagents.length === 0) return null; + const viewModels = toSubagentViewModels(subagents); + + const content = ( + <div className="w-full max-w-none space-y-3 rounded-xl border bg-card/95 p-3"> + <div className="text-sm font-medium text-foreground"> + Subagent activity + </div> + <div className="space-y-3"> + {viewModels.map((subagent) => ( + <div + key={subagent.toolCallId} + className="space-y-2 rounded-md border bg-muted/20 p-3" + > + <div className="flex flex-wrap items-center justify-between gap-2"> + <div className="text-sm font-medium text-foreground"> + {subagent.task} + </div> + <span + className={cn( + "rounded-full border px-2 py-0.5 text-xs font-medium", + getStatusClassName(subagent.status), + )} + > + {getStatusLabel(subagent.status)} + </span> + </div> + {subagent.toolCalls.length > 0 ? ( + <div className="space-y-1"> + {subagent.toolCalls.map((tool, index) => ( + <SubagentInnerToolCall + key={`${subagent.toolCallId}-${tool.name}-${index}`} + name={tool.name} + isError={tool.isError} + isPending={ + subagent.status === "running" && + index === subagent.toolCalls.length - 1 + } + args={tool.args} + result={tool.result} + /> + ))} + </div> + ) : null} + {subagent.text ? ( + <MessageResponse + animated={false} + isAnimating={false} + mermaid={{ config: { theme: "default" } }} + > + {subagent.text} + </MessageResponse> + ) : null} + </div> + ))} + </div> + </div> + ); + + if (inline) return content; + + return ( + <Message from="assistant"> + <MessageContent>{content}</MessageContent> + </Message> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts similarity index 87% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts index 4006e24a885..3fe28f41af2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts @@ -1,4 +1,4 @@ -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; type ChatActiveSubagents = NonNullable<UseChatDisplayReturn["activeSubagents"]>; type ChatActiveSubagent = @@ -12,6 +12,8 @@ export type SubagentStatus = "running" | "completed" | "error"; interface SubagentToolCall { name: string; isError: boolean; + args: Record<string, unknown> | null; + result: string | null; } export interface SubagentViewModel { @@ -88,6 +90,16 @@ function toToolCalls(value: unknown): SubagentToolCall[] { return { name, isError: record.isError === true, + args: + typeof record.args === "object" && record.args !== null + ? (record.args as Record<string, unknown>) + : null, + result: + typeof record.result === "string" + ? record.result + : record.result !== null && record.result !== undefined + ? String(record.result) + : null, }; }) .filter((item): item is SubagentToolCall => item !== null); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ThinkingMessage/ThinkingMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ThinkingMessage/ThinkingMessage.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ThinkingMessage/ThinkingMessage.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ThinkingMessage/ThinkingMessage.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ThinkingMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ThinkingMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ThinkingMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ThinkingMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ToolPreviewMessage/ToolPreviewMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ToolPreviewMessage/ToolPreviewMessage.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ToolPreviewMessage/ToolPreviewMessage.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ToolPreviewMessage/ToolPreviewMessage.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ToolPreviewMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ToolPreviewMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/ToolPreviewMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/ToolPreviewMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/UserMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/UserMessage.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/UserMessage.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/UserMessage.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageActions/UserMessageActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageActions/UserMessageActions.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageActions/UserMessageActions.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageActions/UserMessageActions.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageActions/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageActions/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageActions/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageAttachments/UserMessageAttachments.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageAttachments/UserMessageAttachments.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageAttachments/UserMessageAttachments.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageAttachments/UserMessageAttachments.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageAttachments/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageAttachments/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageAttachments/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageAttachments/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageEditor/UserMessageEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageEditor/UserMessageEditor.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageEditor/UserMessageEditor.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageEditor/UserMessageEditor.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageEditor/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageEditor/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageEditor/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageText/UserMessageText.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageText/UserMessageText.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageText/UserMessageText.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageText/UserMessageText.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageText/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageText/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageText/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/components/UserMessageText/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/types.ts new file mode 100644 index 00000000000..ec4e67f955b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/types.ts @@ -0,0 +1,5 @@ +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; + +export type ChatMessage = NonNullable<UseChatDisplayReturn["messages"]>[number]; + +export type ChatMessagePart = ChatMessage["content"][number]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.ts similarity index 93% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.ts index 9b279e7ff42..e6bb1648ab6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/getUserMessageDraft.ts @@ -1,5 +1,5 @@ import type { FileUIPart } from "ai"; -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; type ChatMessage = NonNullable<UseChatDisplayReturn["messages"]>[number]; type ChatMessagePart = ChatMessage["content"][number]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/components/UserMessage/utils/getUserMessageDraft/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts similarity index 92% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts index cd72fe4944a..3581877fd3a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts @@ -1,7 +1,7 @@ import type { RefObject } from "react"; import { useEffect } from "react"; +import { useHotkey } from "renderer/hotkeys"; import { useTextSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks"; -import { useAppHotkey } from "renderer/stores/hotkeys"; interface UseChatMessageSearchOptions { containerRef: RefObject<HTMLDivElement | null>; @@ -36,7 +36,7 @@ export function useChatMessageSearch({ } }, [isFocused, textSearch.closeSearch, textSearch.isSearchOpen]); - useAppHotkey( + useHotkey( "FIND_IN_CHAT", () => { if (textSearch.isSearchOpen) { @@ -46,7 +46,6 @@ export function useChatMessageSearch({ textSearch.setIsSearchOpen(true); }, { enabled: isFocused, preventDefault: true }, - [textSearch.closeSearch, textSearch.isSearchOpen], ); return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts similarity index 83% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts index 95c8ed8c8b9..9d4cffef8fb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ChatMessageList/utils/messageListHelpers.ts @@ -1,3 +1,7 @@ +import { + hasAnsweredQuestionToolCall, + hasPendingQuestionToolCall, +} from "renderer/components/Chat/ChatInterface/utils/messageHelpers"; import type { ToolPart } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; import type { @@ -100,7 +104,12 @@ export function getVisibleMessages({ const previousTurns = messages.slice(0, turnStartIndex); const activeTurnNonAssistant = messages .slice(turnStartIndex) - .filter((message) => message.role !== "assistant"); + .filter( + (message) => + message.role !== "assistant" || + hasAnsweredQuestionToolCall(message) || + hasPendingQuestionToolCall(message), + ); return [...previousTurns, ...activeTurnNonAssistant]; } @@ -136,9 +145,27 @@ export function removeInterruptedSourceMessage({ interruptedMessage: InterruptedMessagePreview | null; }): ChatMessage[] { if (!interruptedMessage) return messages; - return messages.filter( + + // Try id-based dedup first (works when streaming id matches storage id) + const filtered = messages.filter( (message) => message.id !== interruptedMessage.sourceMessageId, ); + if (filtered.length < messages.length) return filtered; + + // Fallback: mastracode uses separate in-memory ids (currentMessage.id from processStream) + // and storage ids (from listMessages). When they differ, remove active-turn assistant + // messages the same way getVisibleMessages does when isRunning=true. + const turnStartIndex = findLastUserMessageIndex(messages) + 1; + const previousTurns = messages.slice(0, turnStartIndex); + const activeTurnFiltered = messages + .slice(turnStartIndex) + .filter( + (message) => + message.role !== "assistant" || + hasAnsweredQuestionToolCall(message) || + hasPendingQuestionToolCall(message), + ); + return [...previousTurns, ...activeTurnFiltered]; } export function getStreamingPreviewToolParts({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/McpControls/McpControls.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/McpControls/McpControls.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/McpControls/McpControls.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/McpControls/McpControls.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/McpControls/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/McpControls/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/McpControls/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/McpControls/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/MentionPopover/MentionPopover.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/MentionPopover/MentionPopover.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/MentionPopover/MentionPopover.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/MentionPopover/MentionPopover.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/MentionPopover/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/MentionPopover/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/MentionPopover/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/MentionPopover/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/ModelPicker.tsx new file mode 100644 index 00000000000..d9b03ee2a41 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/ModelPicker.tsx @@ -0,0 +1,102 @@ +import { + ModelSelector, + ModelSelectorContent, + ModelSelectorEmpty, + ModelSelectorInput, + ModelSelectorList, + ModelSelectorLogo, + ModelSelectorTrigger, +} from "@superset/ui/ai-elements/model-selector"; +import { PromptInputButton } from "@superset/ui/ai-elements/prompt-input"; +import { claudeIcon } from "@superset/ui/icons/preset-icons"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { useNavigate } from "@tanstack/react-router"; +import { ChevronDownIcon } from "lucide-react"; +import { useEffect, useMemo } from "react"; +import { PILL_BUTTON_CLASS } from "renderer/components/Chat/ChatInterface/styles"; +import type { ModelOption } from "renderer/components/Chat/ChatInterface/types"; +import { ModelProviderGroup } from "./components/ModelProviderGroup"; +import { groupModelsByProvider } from "./utils/groupModelsByProvider"; +import { + ANTHROPIC_LOGO_PROVIDER, + providerToLogo, +} from "./utils/providerToLogo"; + +interface ModelPickerProps { + models: ModelOption[]; + selectedModel: ModelOption | null; + onSelectModel: (model: ModelOption) => void; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ModelPicker({ + models, + selectedModel, + onSelectModel, + open, + onOpenChange, +}: ModelPickerProps) { + const navigate = useNavigate(); + const groupedModels = useMemo(() => groupModelsByProvider(models), [models]); + const selectedLogo = selectedModel + ? providerToLogo(selectedModel.provider) + : null; + const { data: anthropicStatus, refetch: refetchAnthropicStatus } = + workspaceTrpc.auth.getAnthropicStatus.useQuery(); + const { data: openAIStatus, refetch: refetchOpenAIStatus } = + workspaceTrpc.auth.getOpenAIStatus.useQuery(); + + useEffect(() => { + if (!open) return; + void Promise.all([refetchAnthropicStatus(), refetchOpenAIStatus()]); + }, [open, refetchAnthropicStatus, refetchOpenAIStatus]); + + const openModelsSettings = () => { + onOpenChange(false); + void navigate({ to: "/settings/models" }); + }; + + return ( + <ModelSelector open={open} onOpenChange={onOpenChange}> + <ModelSelectorTrigger asChild> + <PromptInputButton + className={`${PILL_BUTTON_CLASS} px-2 gap-1.5 text-xs text-foreground`} + > + {selectedLogo === ANTHROPIC_LOGO_PROVIDER ? ( + <img alt="Claude" className="size-3" src={claudeIcon} /> + ) : selectedLogo ? ( + <ModelSelectorLogo provider={selectedLogo} /> + ) : null} + <span>{selectedModel?.name ?? "Model"}</span> + <ChevronDownIcon className="size-2.5 opacity-50" /> + </PromptInputButton> + </ModelSelectorTrigger> + <ModelSelectorContent title="Select Model"> + <ModelSelectorInput placeholder="Search models..." /> + <ModelSelectorList> + <ModelSelectorEmpty>No models found.</ModelSelectorEmpty> + {groupedModels.map(([provider, providerModels]) => ( + <ModelProviderGroup + key={provider} + provider={provider} + models={providerModels} + isAnthropicAuthenticated={anthropicStatus?.authenticated ?? false} + isAnthropicOAuthPending={false} + isAnthropicApiKeyPending={false} + onOpenAnthropicAuthModal={openModelsSettings} + isOpenAIAuthenticated={openAIStatus?.authenticated ?? false} + isOpenAIOAuthPending={false} + isOpenAIApiKeyPending={false} + onOpenOpenAIAuthModal={openModelsSettings} + onSelectModel={onSelectModel} + onCloseModelSelector={() => { + onOpenChange(false); + }} + /> + ))} + </ModelSelectorList> + </ModelSelectorContent> + </ModelSelector> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/ModelProviderGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/ModelProviderGroup.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/ModelProviderGroup.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/ModelProviderGroup.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/AnthropicProviderHeading/AnthropicProviderHeading.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/AnthropicProviderHeading/AnthropicProviderHeading.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/AnthropicProviderHeading/AnthropicProviderHeading.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/AnthropicProviderHeading/AnthropicProviderHeading.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/AnthropicProviderHeading/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/AnthropicProviderHeading/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/AnthropicProviderHeading/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/AnthropicProviderHeading/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/OpenAIProviderHeading/OpenAIProviderHeading.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/OpenAIProviderHeading/OpenAIProviderHeading.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/OpenAIProviderHeading/OpenAIProviderHeading.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/OpenAIProviderHeading/OpenAIProviderHeading.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/OpenAIProviderHeading/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/OpenAIProviderHeading/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/OpenAIProviderHeading/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/components/OpenAIProviderHeading/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/components/ModelProviderGroup/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/utils/groupModelsByProvider/groupModelsByProvider.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/utils/groupModelsByProvider/groupModelsByProvider.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/utils/groupModelsByProvider/groupModelsByProvider.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/utils/groupModelsByProvider/groupModelsByProvider.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/utils/groupModelsByProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/utils/groupModelsByProvider/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/utils/groupModelsByProvider/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/utils/groupModelsByProvider/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/utils/providerToLogo/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/utils/providerToLogo/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/utils/providerToLogo/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/utils/providerToLogo/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/utils/providerToLogo/providerToLogo.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/utils/providerToLogo/providerToLogo.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/components/ModelPicker/utils/providerToLogo/providerToLogo.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/components/ModelPicker/utils/providerToLogo/providerToLogo.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useMcpUi/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useMcpUi/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useMcpUi/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useMcpUi/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useMcpUi/useMcpUi.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useMcpUi/useMcpUi.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useMcpUi/useMcpUi.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useMcpUi/useMcpUi.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useOptimisticUpload/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useOptimisticUpload/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useOptimisticUpload/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useOptimisticUpload/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useOptimisticUpload/useOptimisticUpload.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useOptimisticUpload/useOptimisticUpload.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useOptimisticUpload/useOptimisticUpload.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useOptimisticUpload/useOptimisticUpload.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/model-query.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/model-query.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/model-query.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/model-query.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/model-query.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/model-query.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/model-query.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/model-query.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/prompt-result.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/prompt-result.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/prompt-result.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/prompt-result.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/prompt-result.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/prompt-result.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/prompt-result.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/prompt-result.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/useSlashCommandExecutor.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/useSlashCommandExecutor.ts similarity index 78% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/useSlashCommandExecutor.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/useSlashCommandExecutor.ts index a89531f1073..d2f74cec0b7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/useSlashCommandExecutor.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/hooks/useSlashCommandExecutor/useSlashCommandExecutor.ts @@ -9,6 +9,7 @@ import { findModelByQuery, normalizeModelQueryFromActionArgument, } from "./model-query"; +import { resolveSlashPromptResult } from "./prompt-result"; interface UseSlashCommandExecutorOptions { sessionId: string | null; @@ -49,6 +50,8 @@ export function useSlashCommandExecutor({ onTrackEvent, }: UseSlashCommandExecutorOptions) { const workspaceTrpcUtils = workspaceTrpc.useUtils(); + const { mutateAsync: resolveSlashCommandMutateAsync } = + workspaceTrpc.chat.resolveSlashCommand.useMutation(); const resolveSlashCommandInput = useCallback( async (inputText: string): Promise<ResolveSlashCommandResult> => { @@ -57,21 +60,6 @@ export function useSlashCommandExecutor({ return { handled: false, nextText: text }; } - if (!sessionId) { - if (text === "/new" || text === "/clear") { - onClearError(); - await onResetSession(); - toast.success( - text === "/clear" - ? "Context cleared in a new chat session" - : "Started a new chat session", - ); - return { handled: true, nextText: "" }; - } - - return { handled: false, nextText: text }; - } - try { const [commandNameRaw, ...rest] = text.slice(1).split(/\s+/); const commandName = commandNameRaw?.toLowerCase() ?? ""; @@ -164,8 +152,41 @@ export function useSlashCommandExecutor({ }); return { handled: true, nextText: "" }; } - default: - return { handled: false, nextText: text }; + default: { + // Custom slash command — resolve via host-service so prompts + // from .claude/commands and .agents/commands get substituted. + // Workspace-scoped: works whether or not a session exists yet. + const resolved = await resolveSlashCommandMutateAsync({ + workspaceId, + text, + }); + if (!resolved.handled) { + return { handled: false, nextText: text }; + } + const promptResolution = resolveSlashPromptResult({ + handled: resolved.handled, + prompt: resolved.prompt, + commandName: resolved.commandName, + invokedAs: resolved.invokedAs, + }); + if (promptResolution.errorMessage) { + onSetErrorMessage(promptResolution.errorMessage); + toast.error(promptResolution.errorMessage); + return { handled: true, nextText: "" }; + } + onClearError(); + if (promptResolution.handled) { + onTrackEvent?.("chat_slash_command_used", { + command_name: + resolved.invokedAs ?? resolved.commandName ?? commandName, + command_type: "prompt", + }); + } + return { + handled: promptResolution.handled, + nextText: promptResolution.nextText, + }; + } } } catch (error) { console.warn( @@ -189,6 +210,7 @@ export function useSlashCommandExecutor({ loadMcpOverview, onResetSession, onStopActiveResponse, + resolveSlashCommandMutateAsync, sessionId, workspaceId, workspaceTrpcUtils.chat.getMcpOverview, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/types.ts new file mode 100644 index 00000000000..6c4f6b99b53 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/types.ts @@ -0,0 +1,19 @@ +import type { ChatLaunchConfig } from "shared/tabs-types"; + +export interface ChatPaneInterfaceProps { + sessionId: string | null; + initialLaunchConfig: ChatLaunchConfig | null; + /** + * Called after the ChatPaneInterface successfully auto-submits the + * initial launch config so the owning pane can clear its persisted + * launchConfig and not re-trigger on re-render. + */ + onConsumeLaunchConfig?: () => void; + workspaceId: string; + organizationId: string | null; + cwd: string; + isFocused: boolean; + getOrCreateSession: () => Promise<string>; + onResetSession: () => Promise<void>; + onUserMessageSubmitted?: (message: string) => void; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/optimisticUserMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/optimisticUserMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/optimisticUserMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/optimisticUserMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/optimisticUserMessage/optimisticUserMessage.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/optimisticUserMessage/optimisticUserMessage.ts similarity index 93% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/optimisticUserMessage/optimisticUserMessage.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/optimisticUserMessage/optimisticUserMessage.ts index 567184ee67a..ec9638222ac 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/optimisticUserMessage/optimisticUserMessage.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/optimisticUserMessage/optimisticUserMessage.ts @@ -1,4 +1,4 @@ -import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay"; +import type { UseChatDisplayReturn } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay"; import type { ChatSendMessageInput } from "../sendMessage"; export type ChatHistoryMessage = NonNullable< diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/sendMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/sendMessage/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/sendMessage/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/sendMessage/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/sendMessage/sendMessage.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/sendMessage/sendMessage.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/sendMessage/sendMessage.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/sendMessage/sendMessage.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/sendMessage/sendMessage.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/sendMessage/sendMessage.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/sendMessage/sendMessage.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/sendMessage/sendMessage.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/toRuntimeImages/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/toRuntimeImages/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/toRuntimeImages/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/toRuntimeImages/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/toRuntimeImages/toRuntimeImages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/toRuntimeImages/toRuntimeImages.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/toRuntimeImages/toRuntimeImages.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/toRuntimeImages/toRuntimeImages.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/transientUserTurn/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/transientUserTurn/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/transientUserTurn/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/transientUserTurn/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/transientUserTurn/transientUserTurn.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/transientUserTurn/transientUserTurn.test.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/transientUserTurn/transientUserTurn.test.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/transientUserTurn/transientUserTurn.test.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/transientUserTurn/transientUserTurn.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/transientUserTurn/transientUserTurn.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/transientUserTurn/transientUserTurn.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/transientUserTurn/transientUserTurn.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/uploadFiles/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/uploadFiles/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/uploadFiles/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/uploadFiles/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/uploadFiles/uploadFiles.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/uploadFiles/uploadFiles.ts similarity index 88% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/uploadFiles/uploadFiles.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/uploadFiles/uploadFiles.ts index db22f211ec7..e0009a7e9ba 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/components/WorkspaceChatInterface/utils/uploadFiles/uploadFiles.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/components/WorkspaceChatInterface/utils/uploadFiles/uploadFiles.ts @@ -1,5 +1,6 @@ import type { FileUIPart } from "ai"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { isDesktopChatDevMode } from "renderer/lib/dev-chat"; async function getHttpErrorDetail(response: Response): Promise<string> { const errorBody = await response @@ -45,12 +46,22 @@ async function uploadFile( if (signal?.aborted) { throw new DOMException("The operation was aborted", "AbortError"); } + const fileData = await blobToDataUrl(blob); + + if (isDesktopChatDevMode()) { + return { + type: "file", + url: fileData, + mediaType: file.mediaType, + filename, + }; + } const result = await apiTrpcClient.chat.uploadAttachment.mutate({ sessionId, filename, mediaType: file.mediaType, - fileData: await blobToDataUrl(blob), + fileData, }); return { type: "file", ...result }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatController/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatController/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/useWorkspaceChatController.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/useWorkspaceChatController.ts new file mode 100644 index 00000000000..506e30c2782 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatController/useWorkspaceChatController.ts @@ -0,0 +1,189 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useMemo } from "react"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { authClient } from "renderer/lib/auth-client"; +import { + isDesktopChatDevMode, + resolveDesktopChatOrganizationId, +} from "renderer/lib/dev-chat"; +import { posthog } from "renderer/lib/posthog"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +interface SessionSelectorItem { + sessionId: string; + title: string; + updatedAt: Date; +} + +function toSessionSelectorItem(session: { + id: string; + title: string | null; + lastActiveAt: Date | string | null; + createdAt: Date | string; +}): SessionSelectorItem { + return { + sessionId: session.id, + title: session.title ?? "", + updatedAt: + session.lastActiveAt instanceof Date + ? session.lastActiveAt + : session.lastActiveAt + ? new Date(session.lastActiveAt) + : session.createdAt instanceof Date + ? session.createdAt + : new Date(session.createdAt), + }; +} + +async function createSessionRecord(input: { + sessionId: string; + v2WorkspaceId: string; +}): Promise<void> { + if (isDesktopChatDevMode()) return; + await apiTrpcClient.chat.createSession.mutate({ + sessionId: input.sessionId, + v2WorkspaceId: input.v2WorkspaceId, + }); +} + +export function useWorkspaceChatController({ + sessionId, + onSessionIdChange, + workspaceId, +}: { + sessionId: string | null; + onSessionIdChange: (sessionId: string | null) => void; + workspaceId: string; +}) { + const { data: session } = authClient.useSession(); + const organizationId = resolveDesktopChatOrganizationId( + session?.session?.activeOrganizationId, + ); + const collections = useCollections(); + const endSessionMutation = workspaceTrpc.chat.endSession.useMutation(); + const { chatSessions: chatSessionActions } = useOptimisticCollectionActions(); + + const { data: workspace } = workspaceTrpc.workspace.get.useQuery( + { id: workspaceId }, + { enabled: Boolean(workspaceId) }, + ); + + const { data: allSessionsData } = useLiveQuery( + (q) => + q + .from({ chatSessions: collections.chatSessions }) + .where(({ chatSessions }) => + eq(chatSessions.v2WorkspaceId, workspaceId), + ) + .orderBy(({ chatSessions }) => chatSessions.lastActiveAt, "desc") + .select(({ chatSessions }) => ({ ...chatSessions })), + [collections.chatSessions, workspaceId], + ); + const sessions = allSessionsData ?? []; + + const handleSelectSession = useCallback( + (nextSessionId: string) => { + onSessionIdChange(nextSessionId); + }, + [onSessionIdChange], + ); + + const handleNewChat = useCallback(async () => { + onSessionIdChange(null); + }, [onSessionIdChange]); + + const handleDeleteSession = useCallback( + async (sessionIdToDelete: string) => { + const transaction = chatSessionActions.deleteSession(sessionIdToDelete); + if (!transaction && !isDesktopChatDevMode()) { + throw new Error("Failed to delete chat session"); + } + // Tear down the host-service in-memory runtime so it doesn't leak. + // Failures here must not block the user-visible delete. + void endSessionMutation + .mutateAsync({ sessionId: sessionIdToDelete, workspaceId }) + .catch(() => {}); + + posthog.capture("chat_session_deleted", { + workspace_id: workspaceId, + session_id: sessionIdToDelete, + organization_id: organizationId, + }); + if (sessionIdToDelete === sessionId) { + onSessionIdChange(null); + } + }, + [ + chatSessionActions, + endSessionMutation, + onSessionIdChange, + organizationId, + sessionId, + workspaceId, + ], + ); + + const getOrCreateSession = useCallback(async (): Promise<string> => { + if (!organizationId) { + throw new Error("No active organization selected"); + } + + if (sessionId) { + if (sessions.some((item) => item.id === sessionId)) { + return sessionId; + } + + await createSessionRecord({ + sessionId, + v2WorkspaceId: workspaceId, + }); + return sessionId; + } + + const nextSessionId = crypto.randomUUID(); + await createSessionRecord({ + sessionId: nextSessionId, + v2WorkspaceId: workspaceId, + }); + onSessionIdChange(nextSessionId); + posthog.capture("chat_session_created", { + workspace_id: workspaceId, + session_id: nextSessionId, + organization_id: organizationId, + }); + return nextSessionId; + }, [onSessionIdChange, organizationId, sessionId, sessions, workspaceId]); + + const sessionItems = useMemo(() => { + const nextItems = sessions.map((item) => toSessionSelectorItem(item)); + if ( + !isDesktopChatDevMode() || + !sessionId || + nextItems.some((item) => item.sessionId === sessionId) + ) { + return nextItems; + } + return [ + { + sessionId, + title: "", + updatedAt: new Date(), + }, + ...nextItems, + ]; + }, [sessionId, sessions]); + + return { + sessionId, + organizationId, + workspacePath: workspace?.worktreePath ?? "", + sessionItems, + handleSelectSession, + handleNewChat, + handleDeleteSession, + getOrCreateSession, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts similarity index 89% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts index 8bb6ac97efd..8d2338ea6ea 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/hooks/useWorkspaceChatDisplay/useWorkspaceChatDisplay.ts @@ -2,6 +2,7 @@ import type { AppRouter } from "@superset/host-service"; import { workspaceTrpc } from "@superset/workspace-client"; import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { useEffect, useMemo, useRef, useState } from "react"; +import { hasAnsweredQuestionToolCall } from "renderer/components/Chat/ChatInterface/utils/messageHelpers"; interface UseChatDisplayOptions { sessionId: string | null; @@ -19,8 +20,9 @@ type RouterInputs = inferRouterInputs<AppRouter>; type RouterOutputs = inferRouterOutputs<AppRouter>; type ChatInputs = RouterInputs["chat"]; type ChatOutputs = RouterOutputs["chat"]; -type DisplayStateOutput = ChatOutputs["getDisplayState"]; -type ListMessagesOutput = ChatOutputs["listMessages"]; +type SnapshotOutput = ChatOutputs["getSnapshot"]; +type DisplayStateOutput = SnapshotOutput["displayState"]; +type ListMessagesOutput = SnapshotOutput["messages"]; type HistoryMessage = ListMessagesOutput[number]; type HistoryMessagePart = HistoryMessage["content"][number]; type SendMessageInput = ChatInputs["sendMessage"]; @@ -62,7 +64,7 @@ function withoutActiveTurnAssistantHistory({ isRunning, }: { messages: ListMessagesOutput; - currentMessage: NonNullable<DisplayStateOutput>["currentMessage"] | null; + currentMessage: DisplayStateOutput["currentMessage"] | null; isRunning: boolean; }): ListMessagesOutput { if (!isRunning || !currentMessage || currentMessage.role !== "assistant") { @@ -73,7 +75,10 @@ function withoutActiveTurnAssistantHistory({ const previousTurns = messages.slice(0, turnStartIndex); const activeTurnNonAssistant = messages .slice(turnStartIndex) - .filter((message) => message.role !== "assistant"); + .filter( + (message) => + message.role !== "assistant" || hasAnsweredQuestionToolCall(message), + ); return [...previousTurns, ...activeTurnNonAssistant]; } @@ -107,8 +112,7 @@ function getLegacyImagePayload( } export function useChatDisplay(options: UseChatDisplayOptions) { - const { sessionId, workspaceId, enabled = true, fps = 60 } = options; - const utils = workspaceTrpc.useUtils(); + const { sessionId, workspaceId, enabled = true, fps = 4 } = options; const [commandError, setCommandError] = useState<unknown>(null); const queryInput = sessionId === null ? undefined : { sessionId, workspaceId }; @@ -119,16 +123,9 @@ export function useChatDisplay(options: UseChatDisplayOptions) { refetchInterval: refetchIntervalMs, refetchIntervalInBackground: true, refetchOnWindowFocus: false, - staleTime: 0, - gcTime: 0, } as const; - const displayQuery = workspaceTrpc.chat.getDisplayState.useQuery( - queryInput as { sessionId: string; workspaceId: string }, - queryOptions, - ); - - const messagesQuery = workspaceTrpc.chat.listMessages.useQuery( + const snapshotQuery = workspaceTrpc.chat.getSnapshot.useQuery( queryInput as { sessionId: string; workspaceId: string }, queryOptions, ); @@ -141,7 +138,8 @@ export function useChatDisplay(options: UseChatDisplayOptions) { workspaceTrpc.chat.respondToQuestion.useMutation(); const respondToPlanMutation = workspaceTrpc.chat.respondToPlan.useMutation(); - const displayState = displayQuery.data ?? null; + const snapshot = snapshotQuery.data ?? null; + const displayState = snapshot?.displayState ?? null; const runtimeErrorMessage = typeof displayState?.errorMessage === "string" && displayState.errorMessage.trim() @@ -151,9 +149,9 @@ export function useChatDisplay(options: UseChatDisplayOptions) { const isRunning = displayState?.isRunning ?? false; const isConversationLoading = isQueryEnabled && - messagesQuery.data === undefined && - (messagesQuery.isLoading || messagesQuery.isFetching); - const historicalMessages = messagesQuery.data ?? []; + snapshotQuery.data === undefined && + (snapshotQuery.isLoading || snapshotQuery.isFetching); + const historicalMessages = snapshot?.messages ?? []; const latestAssistantErrorMessage = isRunning ? null : findLatestAssistantErrorMessage(historicalMessages); @@ -351,20 +349,6 @@ export function useChatDisplay(options: UseChatDisplayOptions) { ], ); - useEffect(() => { - if (!queryInput) return; - if (!isRunning) return; - void Promise.all([ - utils.chat.getDisplayState.invalidate(queryInput), - utils.chat.listMessages.invalidate(queryInput), - ]); - }, [ - isRunning, - queryInput, - utils.chat.getDisplayState, - utils.chat.listMessages, - ]); - return { ...displayState, messages, @@ -372,8 +356,7 @@ export function useChatDisplay(options: UseChatDisplayOptions) { error: runtimeErrorMessage ?? latestAssistantErrorMessage ?? - displayQuery.error ?? - messagesQuery.error ?? + snapshotQuery.error ?? commandError ?? null, commands, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/index.ts new file mode 100644 index 00000000000..50356fa07cf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatPane/index.ts @@ -0,0 +1 @@ +export { ChatPane } from "./ChatPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/CommentPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/CommentPane.tsx new file mode 100644 index 00000000000..188b76e33c7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/CommentPane.tsx @@ -0,0 +1,208 @@ +import { mermaid } from "@streamdown/mermaid"; +import type { RendererContext } from "@superset/panes"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { LuCheck } from "react-icons/lu"; +import ReactMarkdown from "react-markdown"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { + oneDark, + oneLight, +} from "react-syntax-highlighter/dist/esm/styles/prism"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useTheme } from "renderer/stores"; +import { Streamdown } from "streamdown"; +import type { CommentPaneData, PaneViewerData } from "../../../../types"; +import "./comment-pane.css"; + +interface CommentPaneProps { + context: RendererContext<PaneViewerData>; +} + +export function CommentPane({ context }: CommentPaneProps) { + const data = context.pane.data as CommentPaneData; + + return ( + <div className="comment-pane-markdown min-h-0 min-w-0 flex-1 overflow-y-auto select-text"> + <article className="w-full px-6 py-5"> + <ReactMarkdown + remarkPlugins={[remarkGfm]} + rehypePlugins={[rehypeRaw, rehypeSanitize]} + components={commentComponents} + > + {data.body} + </ReactMarkdown> + </article> + </div> + ); +} + +const mermaidPlugins = { mermaid }; + +const MERMAID_DARK_VARS = { + background: "#1e1e2e", + primaryColor: "#313244", + primaryTextColor: "#cdd6f4", + primaryBorderColor: "#45475a", + secondaryColor: "#313244", + secondaryTextColor: "#cdd6f4", + secondaryBorderColor: "#45475a", + tertiaryColor: "#313244", + tertiaryTextColor: "#cdd6f4", + tertiaryBorderColor: "#45475a", + nodeBorder: "#45475a", + nodeTextColor: "#cdd6f4", + mainBkg: "#313244", + clusterBkg: "#1e1e2e", + titleColor: "#cdd6f4", + edgeLabelBackground: "transparent", + lineColor: "#6c7086", + textColor: "#cdd6f4", +}; + +const MERMAID_LIGHT_VARS = { + background: "#ffffff", + primaryColor: "#f0f0f4", + primaryTextColor: "#1e1e2e", + primaryBorderColor: "#d0d0d8", + lineColor: "#888", + textColor: "#1e1e2e", +}; + +function CommentCodeBlock({ + className, + children, +}: { + className?: string; + children?: ReactNode; +}) { + const theme = useTheme(); + const isDark = theme?.type !== "light"; + + const match = /language-(\w+)/.exec(className || ""); + const language = match ? match[1] : undefined; + const codeString = String(children).replace(/\n$/, ""); + + if (language === "mermaid") { + return ( + <Streamdown + mode="static" + plugins={mermaidPlugins} + mermaid={{ + config: { + theme: "base", + themeVariables: isDark ? MERMAID_DARK_VARS : MERMAID_LIGHT_VARS, + }, + }} + > + {`\`\`\`mermaid\n${codeString}\n\`\`\``} + </Streamdown> + ); + } + + if (!language) { + return ( + <code className="px-1.5 py-0.5 rounded bg-muted font-mono text-sm"> + {children} + </code> + ); + } + + return ( + <SyntaxHighlighter + style={ + (isDark ? oneDark : oneLight) as Record<string, React.CSSProperties> + } + language={language} + PreTag="div" + className="rounded-md text-sm" + > + {codeString} + </SyntaxHighlighter> + ); +} + +const commentComponents = { + code: CommentCodeBlock, + table: ({ children }: { children?: ReactNode }) => ( + <CopyableTable>{children}</CopyableTable> + ), +}; + +function CopyableTable({ children }: { children?: ReactNode }) { + const tableRef = useRef<HTMLTableElement>(null); + const [copied, setCopied] = useState(false); + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const handleCopy = useCallback(() => { + const el = tableRef.current; + if (!el) return; + + const rows = el.querySelectorAll("tr"); + const lines: string[] = []; + for (const row of rows) { + const cells = row.querySelectorAll("th, td"); + const values: string[] = []; + for (const cell of cells) { + values.push((cell.textContent ?? "").trim()); + } + lines.push(values.join("\t")); + } + const text = lines.join("\n"); + void electronTrpcClient.external.copyText + .mutate(text) + .then(() => { + if (!isMountedRef.current) return; + if (timerRef.current) clearTimeout(timerRef.current); + setCopied(true); + timerRef.current = setTimeout(() => { + if (!isMountedRef.current) return; + setCopied(false); + timerRef.current = null; + }, 1500); + }) + .catch((err) => { + console.warn("Failed to copy table text", err); + }); + }, []); + + return ( + <div className="relative"> + <button + type="button" + onClick={handleCopy} + className="absolute right-0 -top-6 z-10 rounded-sm px-1.5 py-0.5 text-2xs text-muted-foreground hover:text-foreground" + > + {copied ? ( + <span className="flex items-center gap-1"> + <LuCheck className="size-3" /> + Copied + </span> + ) : ( + "Copy" + )} + </button> + <div className="overflow-x-auto"> + <table ref={tableRef} className="table-auto w-full"> + {children} + </table> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/comment-pane.css b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/comment-pane.css new file mode 100644 index 00000000000..c0333e827ce --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/comment-pane.css @@ -0,0 +1,263 @@ +.comment-pane-markdown { + color: var(--foreground); + background: var(--background); + font-size: 0.875rem; + line-height: 1.625; + -webkit-font-smoothing: antialiased; +} + +.comment-pane-markdown article { + width: 100%; +} + +/* Headings */ +.comment-pane-markdown h1 { + font-size: 1.75rem; + font-weight: 700; + line-height: 1.25; + margin-top: 0; + margin-bottom: 0.75rem; + letter-spacing: -0.02em; +} + +.comment-pane-markdown h2 { + font-size: 1.35rem; + font-weight: 600; + line-height: 1.3; + margin-top: 1.5rem; + margin-bottom: 0.625rem; +} + +.comment-pane-markdown h3 { + font-size: 1.1rem; + font-weight: 600; + line-height: 1.4; + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.comment-pane-markdown h4, +.comment-pane-markdown h5, +.comment-pane-markdown h6 { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.5; + margin-top: 1rem; + margin-bottom: 0.375rem; +} + +/* First child no top margin */ +.comment-pane-markdown article > *:first-child { + margin-top: 0; +} + +/* Paragraphs */ +.comment-pane-markdown p { + margin-top: 0; + margin-bottom: 0.75rem; +} + +/* Links */ +.comment-pane-markdown a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.comment-pane-markdown a:hover { + opacity: 0.8; +} + +/* Strong & emphasis */ +.comment-pane-markdown strong { + font-weight: 600; +} + +.comment-pane-markdown em { + font-style: italic; +} + +/* Lists */ +.comment-pane-markdown ul, +.comment-pane-markdown ol { + margin-top: 0; + margin-bottom: 0.75rem; + padding-left: 1.5rem; +} + +.comment-pane-markdown ul { + list-style-type: disc; +} + +.comment-pane-markdown ol { + list-style-type: decimal; +} + +.comment-pane-markdown li { + margin-bottom: 0.25rem; +} + +.comment-pane-markdown li > ul, +.comment-pane-markdown li > ol { + margin-top: 0.25rem; + margin-bottom: 0; +} + +/* Tables — full width with borders */ +.comment-pane-markdown table { + width: 100%; + border-collapse: collapse; + margin: 0.75rem 0; + font-size: 0.8125rem; +} + +.comment-pane-markdown th, +.comment-pane-markdown td { + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + vertical-align: middle; +} + +.comment-pane-markdown th { + font-weight: 500; + text-align: left; + background: color-mix(in srgb, var(--muted) 50%, transparent); +} + +.comment-pane-markdown td img { + display: inline-block; + vertical-align: middle; +} + +/* Blockquotes */ +.comment-pane-markdown blockquote { + border-left: 3px solid var(--border); + padding-left: 1rem; + margin: 0.75rem 0; + color: var(--muted-foreground); +} + +.comment-pane-markdown blockquote p:last-child { + margin-bottom: 0; +} + +/* Horizontal rules */ +.comment-pane-markdown hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} + +/* Code — inline */ +.comment-pane-markdown code { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8125rem; + background: color-mix(in srgb, var(--muted) 60%, transparent); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; +} + +/* Code — blocks */ +.comment-pane-markdown pre { + margin: 0.75rem 0; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--muted) 40%, transparent); + border-radius: 0.375rem; + overflow-x: auto; +} + +.comment-pane-markdown pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: 0.8125rem; + line-height: 1.5; +} + +/* Images */ +.comment-pane-markdown img { + max-width: 100%; + height: auto; + border-radius: 0.375rem; +} + +/* Details/summary (common in GitHub bot comments) */ +.comment-pane-markdown details { + margin: 0.75rem 0; + border: 1px solid var(--border); + border-radius: 0.375rem; + overflow: hidden; +} + +.comment-pane-markdown details > summary { + cursor: pointer; + padding: 0.5rem 0.75rem; + font-weight: 500; + background: color-mix(in srgb, var(--muted) 30%, transparent); + user-select: none; +} + +.comment-pane-markdown details > summary:hover { + background: color-mix(in srgb, var(--muted) 50%, transparent); +} + +.comment-pane-markdown details[open] > summary { + border-bottom: 1px solid var(--border); +} + +.comment-pane-markdown details > *:not(summary) { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.comment-pane-markdown details > p:first-of-type { + margin-top: 0.5rem; +} + +/* Task lists (checkboxes) */ +.comment-pane-markdown .task-list-item { + list-style: none; + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.comment-pane-markdown .task-list-item input[type="checkbox"] { + margin-top: 0.25rem; +} + +/* Sub text */ +.comment-pane-markdown sub { + font-size: 0.75rem; + color: var(--muted-foreground); +} + +/* Strikethrough */ +.comment-pane-markdown del { + text-decoration: line-through; + opacity: 0.6; +} + +/* Mermaid diagrams */ +.comment-pane-markdown [data-streamdown="mermaid-block"] { + margin: 0.75rem 0; + border-radius: 0.375rem; + background: transparent; + border: none; + padding: 0; +} + +/* Hide "mermaid" label + action buttons */ +.comment-pane-markdown [data-streamdown="mermaid-block"] > .flex.h-8 { + display: none; +} + +.comment-pane-markdown [data-streamdown="mermaid-block-actions"] { + display: none; +} + +/* Remove the inner wrapper background */ +.comment-pane-markdown [data-streamdown="mermaid-block"] > div:last-child { + background: transparent; + border: none; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneHeaderExtras/CommentPaneHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneHeaderExtras/CommentPaneHeaderExtras.tsx new file mode 100644 index 00000000000..6aa00db8dbc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneHeaderExtras/CommentPaneHeaderExtras.tsx @@ -0,0 +1,87 @@ +import type { RendererContext } from "@superset/panes"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FaGithub } from "react-icons/fa"; +import { LuCheck, LuCopy } from "react-icons/lu"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { CommentPaneData, PaneViewerData } from "../../../../../../types"; + +interface CommentPaneHeaderExtrasProps { + context: RendererContext<PaneViewerData>; +} + +export function CommentPaneHeaderExtras({ + context, +}: CommentPaneHeaderExtrasProps) { + const data = context.pane.data as CommentPaneData; + const [copied, setCopied] = useState(false); + const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }; + }, []); + + const handleCopyAll = useCallback(() => { + void electronTrpcClient.external.copyText + .mutate(data.body) + .then(() => { + if (!isMountedRef.current) return; + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + setCopied(true); + copyTimerRef.current = setTimeout(() => { + if (!isMountedRef.current) return; + setCopied(false); + copyTimerRef.current = null; + }, 1500); + }) + .catch((err) => { + console.warn("Failed to copy comment text", err); + }); + }, [data.body]); + + return ( + <> + {data.url && ( + <Tooltip> + <TooltipTrigger asChild> + <a + href={data.url} + target="_blank" + rel="noopener noreferrer" + aria-label="Open on GitHub" + className="rounded p-1 text-muted-foreground/60 transition-colors hover:text-muted-foreground" + > + <FaGithub className="size-3.5" /> + </a> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + Open on GitHub + </TooltipContent> + </Tooltip> + )} + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + aria-label="Copy comment" + onClick={handleCopyAll} + className="rounded p-1 text-muted-foreground/60 transition-colors hover:text-muted-foreground" + > + {copied ? ( + <LuCheck className="size-3.5" /> + ) : ( + <LuCopy className="size-3.5" /> + )} + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + {copied ? "Copied" : "Copy comment"} + </TooltipContent> + </Tooltip> + </> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneHeaderExtras/index.ts new file mode 100644 index 00000000000..3f6f732e669 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneHeaderExtras/index.ts @@ -0,0 +1 @@ +export { CommentPaneHeaderExtras } from "./CommentPaneHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneTitle/CommentPaneTitle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneTitle/CommentPaneTitle.tsx new file mode 100644 index 00000000000..d46141028ee --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneTitle/CommentPaneTitle.tsx @@ -0,0 +1,45 @@ +import type { RendererContext } from "@superset/panes"; +import { cn } from "@superset/ui/utils"; +import { MessageSquare } from "lucide-react"; +import type { CommentPaneData, PaneViewerData } from "../../../../../../types"; + +interface CommentPaneTitleProps { + context: RendererContext<PaneViewerData>; +} + +export function CommentPaneTitle({ context }: CommentPaneTitleProps) { + const data = context.pane.data as CommentPaneData; + const { isActive } = context; + + return ( + <div className="flex min-w-0 flex-1 items-center gap-2"> + {data.avatarUrl ? ( + <img + src={data.avatarUrl} + alt="" + className="size-3.5 shrink-0 rounded-full" + /> + ) : ( + <MessageSquare className="size-3.5 shrink-0" /> + )} + <span + className={cn( + "shrink-0 text-xs transition-colors duration-150", + isActive ? "text-foreground" : "text-muted-foreground", + )} + title={data.authorLogin} + > + {data.authorLogin} + </span> + {data.path && ( + <span + className="min-w-0 truncate text-xs text-muted-foreground" + title={`${data.path}${data.line != null ? `:${data.line}` : ""}`} + > + {data.path} + {data.line != null ? `:${data.line}` : ""} + </span> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneTitle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneTitle/index.ts new file mode 100644 index 00000000000..5b3ca95ae72 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/components/CommentPaneTitle/index.ts @@ -0,0 +1 @@ +export { CommentPaneTitle } from "./CommentPaneTitle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/index.ts new file mode 100644 index 00000000000..ed0e956694b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/CommentPane/index.ts @@ -0,0 +1 @@ +export { CommentPane } from "./CommentPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx new file mode 100644 index 00000000000..4feef6946d2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx @@ -0,0 +1,133 @@ +import { useVirtualizer, Virtualizer } from "@pierre/diffs/react"; +import type { RendererContext } from "@superset/panes"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useSettings } from "renderer/stores/settings"; +import type { DiffPaneData, PaneViewerData } from "../../../../types"; +import { useChangeset } from "../../../useChangeset"; +import { useOpenInExternalEditor } from "../../../useOpenInExternalEditor"; +import { useSidebarDiffRef } from "../../../useSidebarDiffRef"; +import { useViewedFiles } from "../../../useViewedFiles"; +import { DiffFileEntry } from "./components/DiffFileEntry"; + +function ScrollToFile({ path }: { path: string }) { + const virtualizer = useVirtualizer(); + const lastScrolledPath = useRef<string | null>(null); + + useEffect(() => { + if (!path || path === lastScrolledPath.current || !virtualizer) return; + + requestAnimationFrame(() => { + const v = virtualizer as unknown as { + getScrollContainerElement: () => HTMLElement | undefined; + getOffsetInScrollContainer: (el: HTMLElement) => number; + }; + const scrollContainer = v.getScrollContainerElement(); + if (!scrollContainer) return; + + const target = scrollContainer.querySelector( + `[data-diff-path="${CSS.escape(path)}"]`, + ); + if (!target) return; + + const offset = v.getOffsetInScrollContainer(target as HTMLElement); + scrollContainer.scrollTo({ top: offset }); + lastScrolledPath.current = path; + }); + }, [path, virtualizer]); + + return null; +} + +interface DiffPaneProps { + context: RendererContext<PaneViewerData>; + workspaceId: string; + onOpenFile: (path: string, openInNewTab?: boolean) => void; +} + +export function DiffPane({ context, workspaceId, onOpenFile }: DiffPaneProps) { + const data = context.pane.data as DiffPaneData; + + const diffStyle = useSettings((s) => s.diffStyle); + const ref = useSidebarDiffRef(workspaceId); + + const { files, isLoading } = useChangeset({ workspaceId, ref }); + + const { viewedSet, setViewed } = useViewedFiles(workspaceId); + + const openInExternalEditor = useOpenInExternalEditor(workspaceId); + + // O(1) collapsed lookup per child instead of Array.includes. + const collapsedSet = useMemo( + () => new Set(data.collapsedFiles ?? []), + [data.collapsedFiles], + ); + const expandedSet = useMemo( + () => new Set(data.expandedFiles ?? []), + [data.expandedFiles], + ); + + // Stable callback via refs — identity does not churn as collapsedFiles + // updates, so memo'd children can skip re-renders on unrelated toggles. + const dataRef = useRef(data); + dataRef.current = data; + const updateData = context.actions.updateData; + const setCollapsed = useCallback( + (path: string, value: boolean) => { + const current = dataRef.current; + const collapsed = current.collapsedFiles ?? []; + const has = collapsed.includes(path); + if (value === has) return; + const next = value + ? [...collapsed, path] + : collapsed.filter((p) => p !== path); + updateData({ ...current, collapsedFiles: next } as PaneViewerData); + }, + [updateData], + ); + const setExpanded = useCallback( + (path: string, value: boolean) => { + const current = dataRef.current; + const expanded = current.expandedFiles ?? []; + const has = expanded.includes(path); + if (value === has) return; + const next = value + ? [...expanded, path] + : expanded.filter((p) => p !== path); + updateData({ ...current, expandedFiles: next } as PaneViewerData); + }, + [updateData], + ); + + if (files.length === 0) { + return ( + <div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground"> + {isLoading ? "Loading…" : "No changes"} + </div> + ); + } + + return ( + <Virtualizer + className="h-full w-full overflow-auto" + contentClassName="space-y-2" + > + <ScrollToFile path={data.path} /> + {files.map((file) => ( + <DiffFileEntry + key={`${file.source.kind}:${file.path}`} + file={file} + workspaceId={workspaceId} + diffStyle={diffStyle} + collapsed={collapsedSet.has(file.path)} + onSetCollapsed={setCollapsed} + expanded={expandedSet.has(file.path)} + onSetExpanded={setExpanded} + viewed={viewedSet.has(file.path)} + onSetViewed={setViewed} + onOpenFile={onOpenFile} + onOpenInExternalEditor={openInExternalEditor} + /> + ))} + </Virtualizer> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/CommentThread.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/CommentThread.tsx new file mode 100644 index 00000000000..279220a24c1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/CommentThread.tsx @@ -0,0 +1,67 @@ +interface Comment { + id: string; + authorLogin: string; + avatarUrl?: string; + body: string; + createdAt?: number; +} + +interface CommentThreadProps { + threadId: string; + isResolved: boolean; + comments: Comment[]; +} + +export function CommentThread({ isResolved, comments }: CommentThreadProps) { + return ( + <div + style={{ + padding: 8, + margin: "4px 8px", + background: isResolved + ? "rgba(128,128,128,0.08)" + : "rgba(255,200,0,0.1)", + border: `1px solid ${isResolved ? "rgba(128,128,128,0.2)" : "rgba(255,200,0,0.3)"}`, + borderRadius: 6, + fontSize: 12, + opacity: isResolved ? 0.6 : 1, + }} + > + {isResolved && ( + <div + style={{ + fontSize: 10, + color: "rgba(128,128,128,0.8)", + marginBottom: 4, + }} + > + Resolved + </div> + )} + {comments.map((comment, i) => ( + <div + key={comment.id} + style={{ + paddingTop: i > 0 ? 6 : 0, + marginTop: i > 0 ? 6 : 0, + borderTop: i > 0 ? "1px solid rgba(128,128,128,0.15)" : "none", + }} + > + <div style={{ display: "flex", alignItems: "center", gap: 6 }}> + {comment.avatarUrl && ( + <img + src={comment.avatarUrl} + alt="" + style={{ width: 16, height: 16, borderRadius: "50%" }} + /> + )} + <strong>{comment.authorLogin}</strong> + </div> + <div style={{ marginTop: 3, whiteSpace: "pre-wrap" }}> + {comment.body} + </div> + </div> + ))} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/index.ts new file mode 100644 index 00000000000..7d533f1bdf4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/CommentThread/index.ts @@ -0,0 +1 @@ +export { CommentThread } from "./CommentThread"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/DiffFileEntry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/DiffFileEntry.tsx new file mode 100644 index 00000000000..c6151811231 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/DiffFileEntry.tsx @@ -0,0 +1,239 @@ +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { memo, useCallback, useRef, useState } from "react"; +import type { ChangesetFile } from "../../../../../useChangeset"; +import { DiffFileHeader } from "../DiffFileHeader"; +import { WorkspaceDiff } from "../WorkspaceDiff"; +import { useInView } from "./hooks/useInView"; + +const LINE_HEIGHT_PX = 20; +const HEADER_HEIGHT_PX = 44; +const COLLAPSED_HEIGHT_PX = 48; +const MIN_HEIGHT_PX = 60; +const LARGE_DIFF_THRESHOLD_LINES = 250; +const LARGE_PLACEHOLDER_HEIGHT_PX = 260; +const DELETED_PLACEHOLDER_HEIGHT_PX = 160; + +type DeferReason = "large" | "deleted"; + +function deferReason(file: ChangesetFile): DeferReason | null { + if (file.status === "deleted") return "deleted"; + if (file.additions + file.deletions > LARGE_DIFF_THRESHOLD_LINES) + return "large"; + return null; +} + +function expandedHeight(file: ChangesetFile): number { + const content = (file.additions + file.deletions) * LINE_HEIGHT_PX; + return Math.max(MIN_HEIGHT_PX, HEADER_HEIGHT_PX + content); +} + +interface DiffFileEntryProps { + file: ChangesetFile; + workspaceId: string; + diffStyle: "split" | "unified"; + collapsed: boolean; + onSetCollapsed: (path: string, value: boolean) => void; + expanded: boolean; + onSetExpanded: (path: string, value: boolean) => void; + viewed: boolean; + onSetViewed: (path: string, next: boolean) => void; + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onOpenInExternalEditor: (path: string) => void; +} + +export const DiffFileEntry = memo(function DiffFileEntry({ + file, + workspaceId, + diffStyle, + collapsed, + onSetCollapsed, + expanded, + onSetExpanded, + viewed, + onSetViewed, + onOpenFile, + onOpenInExternalEditor, +}: DiffFileEntryProps) { + const wrapperRef = useRef<HTMLDivElement>(null); + const isNear = useInView(wrapperRef, { rootMargin: "2000px 0px" }); + const hasBeenNearRef = useRef(false); + if (isNear) hasBeenNearRef.current = true; + + const [expandUnchanged, setExpandUnchanged] = useState(false); + const reason = deferReason(file); + const showFullDiff = expanded; + + const handleToggleCollapsed = useCallback( + () => onSetCollapsed(file.path, !collapsed), + [onSetCollapsed, file.path, collapsed], + ); + const handleToggleViewed = useCallback(() => { + const next = !viewed; + onSetViewed(file.path, next); + onSetCollapsed(file.path, next); + }, [viewed, file.path, onSetViewed, onSetCollapsed]); + const showDeletedFileToast = useCallback(() => { + toast.error("File no longer exists", { + description: `${file.path} was deleted in this change.`, + }); + }, [file.path]); + const handleOpenFile = useCallback( + (openInNewTab?: boolean) => { + if (file.status === "deleted") { + showDeletedFileToast(); + return; + } + onOpenFile(file.path, openInNewTab); + }, + [file.status, file.path, onOpenFile, showDeletedFileToast], + ); + const handleOpenInExternalEditor = useCallback(() => { + if (file.status === "deleted") { + showDeletedFileToast(); + return; + } + onOpenInExternalEditor(file.path); + }, [file.status, file.path, onOpenInExternalEditor, showDeletedFileToast]); + const handleShowFullDiff = useCallback( + () => onSetExpanded(file.path, true), + [onSetExpanded, file.path], + ); + const handleToggleExpandUnchanged = useCallback( + () => setExpandUnchanged((prev) => !prev), + [], + ); + + if (reason && !showFullDiff) { + const placeholderHeight = + reason === "deleted" + ? DELETED_PLACEHOLDER_HEIGHT_PX + : LARGE_PLACEHOLDER_HEIGHT_PX; + return ( + <div + ref={wrapperRef} + data-diff-path={file.path} + style={{ + minHeight: collapsed ? COLLAPSED_HEIGHT_PX : placeholderHeight, + }} + > + <DeferredDiffPlaceholder + file={file} + reason={reason} + onShow={handleShowFullDiff} + collapsed={collapsed} + onToggleCollapsed={handleToggleCollapsed} + viewed={viewed} + onToggleViewed={handleToggleViewed} + onOpenFile={handleOpenFile} + onOpenInExternalEditor={handleOpenInExternalEditor} + /> + </div> + ); + } + + const shouldMount = reason ? showFullDiff : hasBeenNearRef.current; + + return ( + <div + ref={wrapperRef} + data-diff-path={file.path} + style={{ + minHeight: collapsed ? COLLAPSED_HEIGHT_PX : expandedHeight(file), + }} + > + {shouldMount ? ( + <WorkspaceDiff + workspaceId={workspaceId} + path={file.path} + status={file.status} + source={file.source} + additions={file.additions} + deletions={file.deletions} + diffStyle={diffStyle} + expandUnchanged={expandUnchanged} + onToggleExpandUnchanged={handleToggleExpandUnchanged} + collapsed={collapsed} + onToggleCollapsed={handleToggleCollapsed} + viewed={viewed} + onToggleViewed={handleToggleViewed} + onOpenFile={handleOpenFile} + onOpenInExternalEditor={handleOpenInExternalEditor} + /> + ) : null} + </div> + ); +}); + +interface DeferredDiffPlaceholderProps { + file: ChangesetFile; + reason: DeferReason; + onShow: () => void; + collapsed: boolean; + onToggleCollapsed: () => void; + viewed: boolean; + onToggleViewed: () => void; + onOpenFile?: (openInNewTab?: boolean) => void; + onOpenInExternalEditor?: () => void; +} + +function DeferredDiffPlaceholder({ + file, + reason, + onShow, + collapsed, + onToggleCollapsed, + viewed, + onToggleViewed, + onOpenFile, + onOpenInExternalEditor, +}: DeferredDiffPlaceholderProps) { + const isDeleted = reason === "deleted"; + const fullHeight = isDeleted + ? DELETED_PLACEHOLDER_HEIGHT_PX + : LARGE_PLACEHOLDER_HEIGHT_PX; + const title = isDeleted + ? "This file was deleted" + : "Large diffs are not rendered by default"; + const subtitle = isDeleted + ? null + : `${(file.additions + file.deletions).toLocaleString()} changed lines`; + + return ( + <div className="flex flex-col overflow-hidden"> + <DiffFileHeader + path={file.path} + status={file.status} + additions={file.additions} + deletions={file.deletions} + expandUnchanged={false} + collapsed={collapsed} + onToggleCollapsed={onToggleCollapsed} + viewed={viewed} + onToggleViewed={onToggleViewed} + onOpenFile={onOpenFile} + onOpenInExternalEditor={onOpenInExternalEditor} + /> + {!collapsed && ( + <div + className="flex flex-col items-center justify-center gap-2 px-6 text-center" + style={{ height: fullHeight - HEADER_HEIGHT_PX }} + > + <div className="text-sm font-medium text-foreground">{title}</div> + {subtitle && ( + <div className="text-xs text-muted-foreground">{subtitle}</div> + )} + <Button + type="button" + size="xs" + variant="outline" + onClick={onShow} + className="mt-1" + > + Show diff + </Button> + </div> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/hooks/useInView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/hooks/useInView/index.ts new file mode 100644 index 00000000000..e43a3611642 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/hooks/useInView/index.ts @@ -0,0 +1 @@ +export { useInView } from "./useInView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/hooks/useInView/useInView.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/hooks/useInView/useInView.ts new file mode 100644 index 00000000000..20d2380c737 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/hooks/useInView/useInView.ts @@ -0,0 +1,34 @@ +import { type RefObject, useEffect, useState } from "react"; + +interface UseInViewOptions { + root?: Element | Document | null; + rootMargin?: string; + threshold?: number | number[]; +} + +export function useInView( + ref: RefObject<HTMLElement | null>, + options: UseInViewOptions = {}, +): boolean { + const [inView, setInView] = useState(false); + const { root, rootMargin, threshold } = options; + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry) setInView(entry.isIntersecting); + }, + { root: root ?? null, rootMargin, threshold }, + ); + observer.observe(element); + return () => { + observer.disconnect(); + }; + }, [ref, root, rootMargin, threshold]); + + return inView; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/index.ts new file mode 100644 index 00000000000..379d969920f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileEntry/index.ts @@ -0,0 +1 @@ +export { DiffFileEntry } from "./DiffFileEntry"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx new file mode 100644 index 00000000000..a38ac4cacf3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/DiffFileHeader.tsx @@ -0,0 +1,193 @@ +import { Checkbox } from "@superset/ui/checkbox"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react"; +import { useId } from "react"; +import { LuCheck, LuCopy, LuUndo2 } from "react-icons/lu"; +import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { StatusIndicator } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/StatusIndicator"; +import { CLICK_HINT_TOOLTIP } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels"; +import { getSidebarClickIntent } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { GIT_STAT_TEXT_CLASSES } from "../../utils/gitDecorationColors"; + +interface DiffFileHeaderProps { + path: string; + status: string; + additions: number; + deletions: number; + expandUnchanged: boolean; + onToggleExpandUnchanged?: () => void; + collapsed: boolean; + onToggleCollapsed: () => void; + viewed: boolean; + onToggleViewed: () => void; + onOpenFile?: (openInNewTab?: boolean) => void; + onOpenInExternalEditor?: () => void; + onDiscard?: () => void; +} + +export function DiffFileHeader({ + path, + status, + additions, + deletions, + expandUnchanged, + onToggleExpandUnchanged, + collapsed, + onToggleCollapsed, + viewed, + onToggleViewed, + onOpenFile, + onOpenInExternalEditor, + onDiscard, +}: DiffFileHeaderProps) { + const viewedId = useId(); + const { copyToClipboard, copied } = useCopyToClipboard(); + + // Split into directory + basename so the basename stays visible when the + // header is narrow — the directory truncates with ellipsis instead of + // hiding the filename behind it. + const lastSlash = path.lastIndexOf("/"); + const dir = lastSlash >= 0 ? path.slice(0, lastSlash + 1) : ""; + const name = lastSlash >= 0 ? path.slice(lastSlash + 1) : path; + + return ( + <div className="@container/diff-file-header flex min-w-0 flex-nowrap items-center gap-1 border-y border-border bg-muted/30 px-3 py-2"> + <button + type="button" + onClick={onToggleCollapsed} + aria-label={collapsed ? "Expand file" : "Collapse file"} + className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-muted-foreground" + > + {collapsed ? ( + <ChevronRight className="size-3.5" /> + ) : ( + <ChevronDown className="size-3.5" /> + )} + </button> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={(event) => { + const intent = getSidebarClickIntent(event); + if (intent === "openInEditor") { + onOpenInExternalEditor?.(); + return; + } + onOpenFile?.(intent === "openInNewTab"); + }} + disabled={!onOpenFile && !onOpenInExternalEditor} + aria-label="Open in file viewer" + className="flex h-6 min-w-0 flex-1 items-center gap-1.5 rounded px-1 text-left transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-60" + > + <FileIcon fileName={path} className="size-3.5 shrink-0" /> + <span className="flex min-w-0 items-baseline font-mono text-xs"> + {dir && ( + <span className="min-w-0 truncate text-muted-foreground"> + {dir} + </span> + )} + <span className="shrink-0 text-foreground">{name}</span> + </span> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + {CLICK_HINT_TOOLTIP} + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => void copyToClipboard(path)} + aria-label="Copy path" + className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-muted-foreground" + > + {copied ? ( + <LuCheck className="size-3.5" /> + ) : ( + <LuCopy className="size-3.5" /> + )} + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + {copied ? "Copied" : "Copy path"} + </TooltipContent> + </Tooltip> + <div className="ml-auto flex shrink-0 items-center gap-1.5"> + <StatusIndicator status={status} iconClassName="size-3.5" /> + {(additions > 0 || deletions > 0) && ( + <span className="font-mono text-xs text-muted-foreground"> + {additions > 0 && ( + <span className={GIT_STAT_TEXT_CLASSES.addition}> + +{additions} + </span> + )} + {additions > 0 && deletions > 0 && " "} + {deletions > 0 && ( + <span className={GIT_STAT_TEXT_CLASSES.deletion}> + -{deletions} + </span> + )} + </span> + )} + + <div className="flex items-center gap-1"> + <Checkbox + id={viewedId} + checked={viewed} + onCheckedChange={() => onToggleViewed()} + className="size-3 border-muted-foreground/50" + /> + <label + htmlFor={viewedId} + className="hidden cursor-pointer select-none text-xs text-muted-foreground @min-[380px]/diff-file-header:inline" + > + Viewed + </label> + </div> + + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={onToggleExpandUnchanged} + disabled={!onToggleExpandUnchanged} + aria-label={ + expandUnchanged ? "Hide unchanged regions" : "Show all lines" + } + className="rounded p-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-40" + > + {expandUnchanged ? ( + <EyeOff className="size-3.5" /> + ) : ( + <Eye className="size-3.5" /> + )} + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + {expandUnchanged ? "Hide unchanged regions" : "Show all lines"} + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={onDiscard} + disabled={!onDiscard} + aria-label="Discard changes" + className="rounded p-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-destructive disabled:pointer-events-none disabled:opacity-40" + > + <LuUndo2 className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + Discard changes + </TooltipContent> + </Tooltip> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/index.ts new file mode 100644 index 00000000000..128d5bd4063 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffFileHeader/index.ts @@ -0,0 +1 @@ +export { DiffFileHeader } from "./DiffFileHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffPaneHeaderExtras/DiffPaneHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffPaneHeaderExtras/DiffPaneHeaderExtras.tsx new file mode 100644 index 00000000000..55ec0941792 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffPaneHeaderExtras/DiffPaneHeaderExtras.tsx @@ -0,0 +1,59 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { SquareSplitHorizontal } from "lucide-react"; +import { TbScan } from "react-icons/tb"; +import { useSettings } from "renderer/stores/settings"; + +export function DiffPaneHeaderExtras() { + const diffStyle = useSettings((s) => s.diffStyle); + const updateSetting = useSettings((s) => s.update); + + const buttonClass = (active: boolean) => + cn( + "flex size-5 items-center justify-center transition-colors", + active + ? "bg-secondary text-foreground" + : "text-muted-foreground hover:text-foreground", + ); + + return ( + <div className="flex items-center"> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => updateSetting("diffStyle", "unified")} + aria-label="Unified view" + aria-pressed={diffStyle === "unified"} + className={buttonClass(diffStyle === "unified")} + > + <TbScan className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + Unified view + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={() => updateSetting("diffStyle", "split")} + aria-label="Split view" + aria-pressed={diffStyle === "split"} + className={buttonClass(diffStyle === "split")} + > + <SquareSplitHorizontal className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + Split view + </TooltipContent> + </Tooltip> + <div + className="mx-1 h-3.5 w-px bg-muted-foreground/30" + aria-hidden="true" + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffPaneHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffPaneHeaderExtras/index.ts new file mode 100644 index 00000000000..a382a3358d7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffPaneHeaderExtras/index.ts @@ -0,0 +1 @@ +export { DiffPaneHeaderExtras } from "./DiffPaneHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/WorkspaceDiff/WorkspaceDiff.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/WorkspaceDiff/WorkspaceDiff.tsx new file mode 100644 index 00000000000..3a06ab7aae8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/WorkspaceDiff/WorkspaceDiff.tsx @@ -0,0 +1,179 @@ +import { MultiFileDiff } from "@pierre/diffs/react"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { useQuery } from "@tanstack/react-query"; +import { memo, useMemo } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { + getDiffsTheme, + getDiffViewerStyle, +} from "renderer/screens/main/components/WorkspaceView/utils/code-theme"; +import { useResolvedTheme, useTerminalTheme } from "renderer/stores/theme"; +import type { DiffFileSource } from "../../../../../useChangeset"; +import { DiffFileHeader } from "../DiffFileHeader"; + +interface WorkspaceDiffProps { + workspaceId: string; + path: string; + status: string; + source: DiffFileSource; + additions: number; + deletions: number; + diffStyle: "split" | "unified"; + expandUnchanged: boolean; + onToggleExpandUnchanged: () => void; + collapsed: boolean; + onToggleCollapsed: () => void; + viewed: boolean; + onToggleViewed: () => void; + onOpenFile?: (openInNewTab?: boolean) => void; + onOpenInExternalEditor?: () => void; +} + +export const WorkspaceDiff = memo(function WorkspaceDiff({ + workspaceId, + path, + status, + source, + additions, + deletions, + diffStyle, + expandUnchanged, + onToggleExpandUnchanged, + collapsed, + onToggleCollapsed, + viewed, + onToggleViewed, + onOpenFile, + onOpenInExternalEditor, +}: WorkspaceDiffProps) { + const activeTheme = useResolvedTheme(); + const terminalTheme = useTerminalTheme(); + const { data: fontSettings } = useQuery({ + queryKey: ["electron", "settings", "getFontSettings"], + queryFn: () => electronTrpcClient.settings.getFontSettings.query(), + staleTime: 30_000, + }); + const shikiTheme = getDiffsTheme(activeTheme); + const parsedEditorFontSize = + typeof fontSettings?.editorFontSize === "number" + ? fontSettings.editorFontSize + : typeof fontSettings?.editorFontSize === "string" + ? Number.parseFloat(fontSettings.editorFontSize) + : Number.NaN; + // Match the terminal pane's surface color so the diff body blends with + // the chrome. The actual override happens in unsafeCSS below — this just + // paints the wrapper before the diff mounts. + const surfaceBg = terminalTheme?.background ?? "var(--background)"; + const themeVars = { + ...getDiffViewerStyle(activeTheme, { + fontFamily: fontSettings?.editorFontFamily ?? undefined, + fontSize: Number.isFinite(parsedEditorFontSize) + ? parsedEditorFontSize + : undefined, + }), + backgroundColor: surfaceBg, + }; + + const diffInput = useMemo(() => { + if (source.kind === "against-base") { + return { + workspaceId, + path, + category: "against-base" as const, + baseBranch: source.baseBranch ?? undefined, + }; + } + if (source.kind === "commit") { + return { + workspaceId, + path, + category: "commit" as const, + commitHash: source.commitHash, + fromHash: source.fromHash, + }; + } + return { workspaceId, path, category: source.kind }; + }, [workspaceId, path, source]); + + const diffQuery = workspaceTrpc.git.getDiff.useQuery(diffInput, { + staleTime: Number.POSITIVE_INFINITY, + }); + + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const worktreePath = workspaceQuery.data?.worktreePath; + + const handleDiscard = useMemo(() => { + if (source.kind !== "unstaged" || !worktreePath) return undefined; + return () => { + electronTrpcClient.changes.discardChanges + .mutate({ worktreePath, filePath: path }) + .catch((err) => { + toast.error("Couldn't discard changes", { + description: err instanceof Error ? err.message : String(err), + }); + }); + }; + }, [source.kind, worktreePath, path]); + + return ( + <div className="flex flex-col overflow-hidden"> + <DiffFileHeader + path={path} + status={status} + additions={additions} + deletions={deletions} + expandUnchanged={expandUnchanged} + onToggleExpandUnchanged={onToggleExpandUnchanged} + collapsed={collapsed} + onToggleCollapsed={onToggleCollapsed} + viewed={viewed} + onToggleViewed={onToggleViewed} + onOpenFile={onOpenFile} + onOpenInExternalEditor={onOpenInExternalEditor} + onDiscard={handleDiscard} + /> + {diffQuery.data ? ( + <MultiFileDiff + oldFile={diffQuery.data.oldFile} + newFile={diffQuery.data.newFile} + style={themeVars} + options={{ + diffStyle, + expandUnchanged, + overflow: "wrap", + collapsed, + disableFileHeader: true, + theme: shikiTheme, + themeType: activeTheme.type, + unsafeCSS: ` + * { user-select: text; -webkit-user-select: text; } + /* Pierre sets --diffs-light-bg/--diffs-dark-bg + * inline on <pre data-diff> from the Shiki theme; + * inline beats :host so we override at the pre. */ + [data-diff] { + --diffs-light-bg: ${surfaceBg} !important; + --diffs-dark-bg: ${surfaceBg} !important; + } + /* Flatten the "N unmodified lines" strip flush to + * the pane edges (kills wrapper/content/expand- + * button rounding + inline gap on both + * line-info and line-info-basic). */ + [data-separator^='line-info'] [data-separator-wrapper], + [data-separator^='line-info'] [data-separator-content], + [data-separator^='line-info'] [data-expand-up], + [data-separator^='line-info'] [data-expand-down], + [data-separator^='line-info'] [data-expand-both] { + border-radius: 0 !important; + margin-inline: 0 !important; + padding-inline: 0 !important; + } + `, + }} + /> + ) : null} + </div> + ); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/WorkspaceDiff/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/WorkspaceDiff/index.ts new file mode 100644 index 00000000000..1689d59efd8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/WorkspaceDiff/index.ts @@ -0,0 +1 @@ +export { WorkspaceDiff } from "./WorkspaceDiff"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/index.ts new file mode 100644 index 00000000000..8db405474ac --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/index.ts @@ -0,0 +1 @@ +export { DiffPane } from "./DiffPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/gitDecorationColors/gitDecorationColors.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/gitDecorationColors/gitDecorationColors.ts new file mode 100644 index 00000000000..e3d6a2b4001 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/gitDecorationColors/gitDecorationColors.ts @@ -0,0 +1,5 @@ +export const GIT_STAT_TEXT_CLASSES = { + addition: "text-green-700 dark:text-green-400", + deletion: "text-red-700 dark:text-red-500", + modified: "text-yellow-600 dark:text-yellow-400", +} as const; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/gitDecorationColors/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/gitDecorationColors/index.ts new file mode 100644 index 00000000000..0108a16f8b4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/gitDecorationColors/index.ts @@ -0,0 +1 @@ +export { GIT_STAT_TEXT_CLASSES } from "./gitDecorationColors"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx new file mode 100644 index 00000000000..1faf9f3810e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/FilePane.tsx @@ -0,0 +1,156 @@ +import type { RendererContext } from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; +import { useCallback, useEffect } from "react"; +import { getBaseName } from "renderer/lib/pathBasename"; +import { useSharedFileDocument } from "../../../../state/fileDocumentStore"; +import type { FilePaneData, PaneViewerData } from "../../../../types"; +import { ErrorState } from "./components/ErrorState"; +import { LoadingState } from "./components/LoadingState"; +import { OrphanedBanner } from "./components/OrphanedBanner"; +import { SaveErrorBanner } from "./components/SaveErrorBanner"; +import { resolveActivePaneView } from "./registry"; + +interface FilePaneProps { + context: RendererContext<PaneViewerData>; + workspaceId: string; +} + +export function FilePane({ context, workspaceId }: FilePaneProps) { + const data = context.pane.data as FilePaneData; + const { filePath } = data; + + const document = useSharedFileDocument({ + workspaceId, + absolutePath: filePath, + }); + + // Follow the underlying file if it's renamed on disk — the store migrates + // the entry, document.absolutePath returns the new path, and we reconcile + // the pane's own filePath so the tab title updates. + useEffect(() => { + if (document.absolutePath !== data.filePath) { + context.actions.updateData({ + ...data, + filePath: document.absolutePath, + } as PaneViewerData); + } + }, [document.absolutePath, data, context.actions]); + + useEffect(() => { + if (document.dirty && !context.pane.pinned) { + context.actions.pin(); + } + }, [document.dirty, context.pane.pinned, context.actions]); + + const hasConflict = document.conflict !== null; + useEffect(() => { + if (!hasConflict) return; + const name = getBaseName(filePath); + alert({ + title: `Do you want to save the changes you made to ${name}?`, + description: "Your changes will be lost if you don't save them.", + actions: [ + { + label: "Save", + onClick: () => document.resolveConflict("overwrite"), + }, + { + label: "Don't Save", + variant: "secondary", + onClick: () => document.resolveConflict("reload"), + }, + { + label: "Cancel", + variant: "ghost", + onClick: () => document.resolveConflict("keep"), + }, + ], + }); + }, [hasConflict, document, filePath]); + + const handleChangeView = useCallback( + (viewId: string) => { + context.actions.updateData({ + ...data, + viewId, + } as PaneViewerData); + }, + [context.actions, data], + ); + + const handleForceView = useCallback( + (viewId: string) => { + context.actions.updateData({ + ...data, + forceViewId: viewId, + viewId, + } as PaneViewerData); + }, + [context.actions, data], + ); + + // Content gating — LoadingState/ErrorState rendered before view resolution when + // there's nothing for the view to consume. + if (document.content.kind === "loading") { + return <LoadingState />; + } + if (document.content.kind === "not-found" && !document.orphaned) { + return <ErrorState reason="not-found" />; + } + if (document.content.kind === "too-large") { + return ( + <ErrorState + reason="too-large" + onOpenAnyway={() => void document.loadUnlimited()} + /> + ); + } + if (document.content.kind === "is-directory") { + return <ErrorState reason="is-directory" />; + } + if (document.content.kind === "error") { + return ( + <ErrorState + reason="load-failed" + message={document.content.error.message} + onRetry={() => void document.reload()} + /> + ); + } + + // The same resolution runs in FilePaneHeaderExtras — toggle + active view + // stay in lockstep because both observe the same pane data + document. + const { activeView } = resolveActivePaneView(document, data); + if (!activeView) { + return <ErrorState reason="binary-unsupported" />; + } + + const ViewRenderer = activeView.Renderer; + + return ( + <div className="flex h-full w-full flex-col"> + {document.orphaned && ( + <OrphanedBanner + dirty={document.dirty} + onDiscard={() => void document.reload()} + /> + )} + {document.saveError && ( + <SaveErrorBanner + message={document.saveError.message} + onRetry={() => void document.save()} + onDismiss={() => document.clearSaveError()} + /> + )} + <div className="min-h-0 min-w-0 flex-1"> + <ViewRenderer + document={document} + filePath={filePath} + workspaceId={workspaceId} + onChangeView={handleChangeView} + onForceView={handleForceView} + /> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx new file mode 100644 index 00000000000..624e3585aca --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/ErrorState.tsx @@ -0,0 +1,46 @@ +import { Button } from "@superset/ui/button"; + +export type ErrorReason = + | "not-found" + | "too-large" + | "is-directory" + | "binary-unsupported" + | "load-failed"; + +interface ErrorStateProps { + reason: ErrorReason; + message?: string; + onOpenAnyway?: () => void; + onRetry?: () => void; +} + +const MESSAGES: Record<ErrorReason, string> = { + "not-found": "File not found", + "too-large": "File is too large to preview", + "is-directory": "This path is a directory", + "binary-unsupported": "Binary file — cannot display", + "load-failed": "Failed to load file", +}; + +export function ErrorState({ + reason, + message, + onOpenAnyway, + onRetry, +}: ErrorStateProps) { + return ( + <div className="flex h-full w-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground"> + <span>{message ?? MESSAGES[reason]}</span> + {reason === "too-large" && onOpenAnyway && ( + <Button variant="outline" size="sm" onClick={onOpenAnyway}> + Open anyway + </Button> + )} + {reason === "load-failed" && onRetry && ( + <Button variant="outline" size="sm" onClick={onRetry}> + Retry + </Button> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/index.ts new file mode 100644 index 00000000000..297e43f6c2f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/ErrorState/index.ts @@ -0,0 +1 @@ +export { type ErrorReason, ErrorState } from "./ErrorState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx new file mode 100644 index 00000000000..96939be2561 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/FilePaneHeaderExtras.tsx @@ -0,0 +1,74 @@ +import type { RendererContext } from "@superset/panes"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useCallback } from "react"; +import { TbExternalLink } from "react-icons/tb"; +import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; +import { useSharedFileDocument } from "../../../../../../state/fileDocumentStore"; +import type { FilePaneData, PaneViewerData } from "../../../../../../types"; +import { orderForToggle, resolveActivePaneView } from "../../registry"; +import { FileViewToggle } from "../FileViewToggle"; + +interface FilePaneHeaderExtrasProps { + context: RendererContext<PaneViewerData>; + workspaceId: string; +} + +export function FilePaneHeaderExtras({ + context, + workspaceId, +}: FilePaneHeaderExtrasProps) { + const data = context.pane.data as FilePaneData; + const { filePath } = data; + const openInExternalEditor = useOpenInExternalEditor(workspaceId); + + const document = useSharedFileDocument({ + workspaceId, + absolutePath: filePath, + }); + + const handleChangeView = useCallback( + (viewId: string) => { + context.actions.updateData({ + ...data, + viewId, + } as PaneViewerData); + }, + [context.actions, data], + ); + + const { views, activeView } = resolveActivePaneView(document, data); + const shouldShowToggle = + views.length > 1 && !data.forceViewId && activeView !== null; + + const handleOpenExternal = useCallback(() => { + openInExternalEditor(filePath); + }, [filePath, openInExternalEditor]); + + return ( + <div className="flex min-w-0 items-center gap-1"> + {shouldShowToggle && activeView && ( + <FileViewToggle + views={orderForToggle(views)} + activeViewId={activeView.id} + filePath={filePath} + onChange={handleChangeView} + /> + )} + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + aria-label="Open in editor" + onClick={handleOpenExternal} + className="rounded p-1 text-muted-foreground/60 transition-colors hover:text-muted-foreground" + > + <TbExternalLink className="size-3.5" /> + </button> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + Open in editor + </TooltipContent> + </Tooltip> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/index.ts new file mode 100644 index 00000000000..c240958abec --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FilePaneHeaderExtras/index.ts @@ -0,0 +1 @@ +export { FilePaneHeaderExtras } from "./FilePaneHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/FileViewToggle.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/FileViewToggle.tsx new file mode 100644 index 00000000000..a462934572d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/FileViewToggle.tsx @@ -0,0 +1,41 @@ +import { cn } from "@superset/ui/utils"; +import { type FileView, resolveViewLabel } from "../../registry"; + +interface FileViewToggleProps { + views: FileView[]; + activeViewId: string; + filePath: string; + onChange: (viewId: string) => void; +} + +export function FileViewToggle({ + views, + activeViewId, + filePath, + onChange, +}: FileViewToggleProps) { + return ( + <div className="inline-flex h-5 min-w-0 items-center gap-0.5 rounded-md bg-muted/50 p-0.5"> + {views.map((view) => { + const label = resolveViewLabel(view, filePath); + + return ( + <button + key={view.id} + type="button" + title={label} + className={cn( + "flex h-4 min-w-0 max-w-20 items-center rounded px-1.5 text-[10px] leading-none transition-colors", + view.id === activeViewId + ? "bg-background text-foreground shadow-sm" + : "text-muted-foreground hover:text-foreground", + )} + onClick={() => onChange(view.id)} + > + <span className="min-w-0 truncate">{label}</span> + </button> + ); + })} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/index.ts new file mode 100644 index 00000000000..61cf4174e71 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/FileViewToggle/index.ts @@ -0,0 +1 @@ +export { FileViewToggle } from "./FileViewToggle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/LoadingState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/LoadingState.tsx new file mode 100644 index 00000000000..47472cc169d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/LoadingState.tsx @@ -0,0 +1,7 @@ +export function LoadingState() { + return ( + <div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground"> + Loading… + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/index.ts new file mode 100644 index 00000000000..3ca614e829f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/LoadingState/index.ts @@ -0,0 +1 @@ +export { LoadingState } from "./LoadingState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/OrphanedBanner.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/OrphanedBanner.tsx new file mode 100644 index 00000000000..72af8c53432 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/OrphanedBanner.tsx @@ -0,0 +1,25 @@ +interface OrphanedBannerProps { + dirty: boolean; + onDiscard?: () => void; +} + +export function OrphanedBanner({ dirty, onDiscard }: OrphanedBannerProps) { + return ( + <div className="flex items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-xs text-destructive-foreground"> + <span> + {dirty + ? "File was deleted on disk. You still have unsaved changes." + : "File was deleted on disk."} + </span> + {dirty && onDiscard && ( + <button + type="button" + className="underline hover:no-underline" + onClick={onDiscard} + > + Discard + </button> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/index.ts new file mode 100644 index 00000000000..c7cf1a7e8b2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/OrphanedBanner/index.ts @@ -0,0 +1 @@ +export { OrphanedBanner } from "./OrphanedBanner"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/SaveErrorBanner.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/SaveErrorBanner.tsx new file mode 100644 index 00000000000..9e0eee1655e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/SaveErrorBanner.tsx @@ -0,0 +1,35 @@ +interface SaveErrorBannerProps { + message: string; + onRetry?: () => void; + onDismiss?: () => void; +} + +export function SaveErrorBanner({ + message, + onRetry, + onDismiss, +}: SaveErrorBannerProps) { + return ( + <div className="flex items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-xs text-destructive-foreground"> + <span className="flex-1 truncate">Save failed: {message}</span> + {onRetry && ( + <button + type="button" + className="underline hover:no-underline" + onClick={onRetry} + > + Retry + </button> + )} + {onDismiss && ( + <button + type="button" + className="underline hover:no-underline" + onClick={onDismiss} + > + Dismiss + </button> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/index.ts new file mode 100644 index 00000000000..21957cfac1e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/components/SaveErrorBanner/index.ts @@ -0,0 +1 @@ +export { SaveErrorBanner } from "./SaveErrorBanner"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/index.ts new file mode 100644 index 00000000000..bf2e051559b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/index.ts @@ -0,0 +1 @@ +export { FilePane } from "./FilePane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts new file mode 100644 index 00000000000..ed0e9f4e8df --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts @@ -0,0 +1,14 @@ +import type { FileView } from "./types"; +import { binaryWarningView } from "./views/BinaryWarningView"; +import { codeView } from "./views/CodeView"; +import { imageView } from "./views/ImageView"; +import { markdownPreviewView } from "./views/MarkdownPreviewView"; + +// Order is preserved as a stable tiebreaker for equal-priority views. +// Exclusives (image, binary-warning) short-circuit resolution when matched. +export const ALL_VIEWS: FileView[] = [ + imageView, + binaryWarningView, + markdownPreviewView, + codeView, +]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts new file mode 100644 index 00000000000..e67fc0a79bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/index.ts @@ -0,0 +1,20 @@ +export { ALL_VIEWS } from "./allViews"; +export { + orderForToggle, + pickDefaultView, + resolveViews, +} from "./resolveViews"; +export { + type DocumentKind, + type FileMeta, + type FileView, + type FileViewLabel, + PRIORITY_RANK, + type Priority, + resolveViewLabel, + type ViewProps, +} from "./types"; +export { + type ActivePaneView, + resolveActivePaneView, +} from "./utils/resolveActivePaneView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts new file mode 100644 index 00000000000..f37a67cf92d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/resolveViews.ts @@ -0,0 +1,23 @@ +import { ALL_VIEWS } from "./allViews"; +import { type FileMeta, type FileView, PRIORITY_RANK } from "./types"; + +export function resolveViews(filePath: string, meta: FileMeta): FileView[] { + const matches = ALL_VIEWS.filter((view) => view.match(filePath, meta)); + const exclusives = matches.filter((v) => v.priority === "exclusive"); + if (exclusives.length > 0) { + return exclusives; + } + return [...matches].sort( + (a, b) => PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority], + ); +} + +export function pickDefaultView(views: FileView[]): FileView | null { + return views[0] ?? null; +} + +// Reverse sort order so the default view (index 0) appears on the right of the toggle, +// closest to the editor surface. Matches Cursor's Preview · Markdown layout. +export function orderForToggle(views: FileView[]): FileView[] { + return [...views].reverse(); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts new file mode 100644 index 00000000000..9b1dfdb664c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/types.ts @@ -0,0 +1,43 @@ +import type { ComponentType } from "react"; +import type { SharedFileDocument } from "../../../../../state/fileDocumentStore"; + +export type FileMeta = { + size?: number; + isBinary?: boolean; +}; + +export type DocumentKind = "text" | "bytes" | "custom"; + +// Priorities mirror VS Code's RegisteredEditorPriority +// (editorResolverService.ts). Ranking: exclusive > default > builtin > option. +export type Priority = "builtin" | "option" | "default" | "exclusive"; + +export const PRIORITY_RANK: Record<Priority, number> = { + exclusive: 5, + default: 4, + builtin: 3, + option: 1, +}; + +export type FileViewLabel = string | ((filePath: string) => string); + +export interface FileView { + id: string; + label: FileViewLabel; + match: (filePath: string, meta: FileMeta) => boolean; + priority: Priority; + documentKind: DocumentKind; + Renderer: ComponentType<ViewProps>; +} + +export interface ViewProps { + document: SharedFileDocument; + filePath: string; + workspaceId: string; + onChangeView: (viewId: string) => void; + onForceView: (viewId: string) => void; +} + +export function resolveViewLabel(view: FileView, filePath: string): string { + return typeof view.label === "function" ? view.label(filePath) : view.label; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/index.ts new file mode 100644 index 00000000000..955dea094cc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/index.ts @@ -0,0 +1,4 @@ +export { + type ActivePaneView, + resolveActivePaneView, +} from "./resolveActivePaneView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/resolveActivePaneView.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/resolveActivePaneView.ts new file mode 100644 index 00000000000..35ab6840f01 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/utils/resolveActivePaneView/resolveActivePaneView.ts @@ -0,0 +1,31 @@ +import type { SharedFileDocument } from "../../../../../../../state/fileDocumentStore"; +import type { FilePaneData } from "../../../../../../../types"; +import { ALL_VIEWS } from "../../allViews"; +import { pickDefaultView, resolveViews } from "../../resolveViews"; +import type { FileMeta, FileView } from "../../types"; + +export interface ActivePaneView { + views: FileView[]; + activeView: FileView | null; +} + +/** + * Resolve the list of views available for a given pane's file plus the one + * currently active. Consumed by both the FilePane body and FilePaneHeaderExtras + * so the toggle and the rendered view stay in lockstep. + */ +export function resolveActivePaneView( + document: SharedFileDocument, + data: FilePaneData, +): ActivePaneView { + const meta: FileMeta = { + size: document.byteSize ?? undefined, + isBinary: document.isBinary ?? undefined, + }; + const views = data.forceViewId + ? ALL_VIEWS.filter((v) => v.id === data.forceViewId) + : resolveViews(data.filePath, meta); + const activeView = + views.find((v) => v.id === data.viewId) ?? pickDefaultView(views); + return { views, activeView }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/BinaryWarningView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/BinaryWarningView.tsx new file mode 100644 index 00000000000..06ac4c73e21 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/BinaryWarningView.tsx @@ -0,0 +1,19 @@ +import { Button } from "@superset/ui/button"; +import type { ViewProps } from "../../types"; + +export function BinaryWarningView({ filePath, onForceView }: ViewProps) { + const name = filePath.split(/[/\\]/).pop() ?? filePath; + + return ( + <div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center"> + <div className="text-sm font-medium">{name}</div> + <div className="max-w-md text-xs text-muted-foreground"> + This looks like a binary file. Opening it as text may show garbled + output or freeze the editor for large files. + </div> + <Button variant="outline" size="sm" onClick={() => onForceView("code")}> + Open Anyway + </Button> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts new file mode 100644 index 00000000000..73a7a5fb923 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/BinaryWarningView/index.ts @@ -0,0 +1,11 @@ +import type { FileView } from "../../types"; +import { BinaryWarningView } from "./BinaryWarningView"; + +export const binaryWarningView: FileView = { + id: "binary-warning", + label: "Binary", + match: (_, meta) => meta.isBinary === true, + priority: "default", + documentKind: "bytes", + Renderer: BinaryWarningView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx new file mode 100644 index 00000000000..1ae7283e6fc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/CodeView.tsx @@ -0,0 +1,20 @@ +import { detectLanguage } from "shared/detect-language"; +import type { ViewProps } from "../../types"; +import { CodeEditor } from "./components/CodeEditor"; + +export function CodeView({ document, filePath }: ViewProps) { + if (document.content.kind !== "text") { + return null; + } + + return ( + <CodeEditor + key={document.id} + value={document.content.value} + language={detectLanguage(filePath)} + onChange={(next) => document.setContent(next)} + onSave={() => void document.save()} + fillHeight + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx new file mode 100644 index 00000000000..e47e58fa9c8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditor.tsx @@ -0,0 +1,271 @@ +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, +} from "@codemirror/commands"; +import { + bracketMatching, + codeFolding, + foldGutter, + foldKeymap, + indentOnInput, +} from "@codemirror/language"; +import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; +import { Compartment, EditorState } from "@codemirror/state"; +import { + drawSelection, + dropCursor, + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + keymap, + lineNumbers, +} from "@codemirror/view"; +import { colorPicker } from "@replit/codemirror-css-color-picker"; +import { cn } from "@superset/ui/utils"; +import { useQuery } from "@tanstack/react-query"; +import { type MutableRefObject, useEffect, useRef } from "react"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useResolvedTheme } from "renderer/stores/theme"; +import { + type CodeEditorAdapter, + createCodeMirrorAdapter, +} from "./CodeEditorAdapter"; +import { createCodeMirrorTheme } from "./createCodeMirrorTheme"; +import { contourSelectionLayer } from "./extensions/contourSelectionLayer"; +import { buildFoldChevron } from "./extensions/foldChevron"; +import { buildFoldPlaceholder } from "./extensions/foldPlaceholder"; +import { selectionClassTogglePlugin } from "./extensions/selectionClassTogglePlugin"; +import { loadLanguageSupport } from "./loadLanguageSupport"; +import { getCodeSyntaxHighlighting } from "./syntax-highlighting"; + +interface CodeEditorProps { + value: string; + language: string; + readOnly?: boolean; + fillHeight?: boolean; + className?: string; + editorRef?: MutableRefObject<CodeEditorAdapter | null>; + onChange?: (value: string) => void; + onSave?: () => void; +} + +export function CodeEditor({ + value, + language, + readOnly = false, + fillHeight = true, + className, + editorRef, + onChange, + onSave, +}: CodeEditorProps) { + const containerRef = useRef<HTMLDivElement | null>(null); + const viewRef = useRef<EditorView | null>(null); + const languageCompartment = useRef(new Compartment()).current; + const themeCompartment = useRef(new Compartment()).current; + const editableCompartment = useRef(new Compartment()).current; + const onChangeRef = useRef(onChange); + const onSaveRef = useRef(onSave); + // Guards against re-entrant onChange calls triggered by the value-sync effect's own dispatch. + const isExternalUpdateRef = useRef(false); + const { data: fontSettings } = useQuery({ + queryKey: ["electron", "settings", "getFontSettings"], + queryFn: () => electronTrpcClient.settings.getFontSettings.query(), + staleTime: 30_000, + }); + const editorFontFamily = fontSettings?.editorFontFamily ?? undefined; + const editorFontSize = fontSettings?.editorFontSize ?? undefined; + const activeTheme = useResolvedTheme(); + + onChangeRef.current = onChange; + onSaveRef.current = onSave; + + // biome-ignore lint/correctness/useExhaustiveDependencies: Editor instance is created once and reconfigured via dedicated effects below + useEffect(() => { + if (!containerRef.current) return; + + const updateListener = EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + if (isExternalUpdateRef.current) return; + onChangeRef.current?.(update.state.doc.toString()); + }); + + const saveKeymap = keymap.of([ + { + key: "Mod-s", + run: () => { + onSaveRef.current?.(); + return true; + }, + }, + ]); + + const state = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter({ markerDOM: buildFoldChevron }), + codeFolding({ placeholderDOM: buildFoldPlaceholder }), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + highlightActiveLine(), + highlightSelectionMatches(), + colorPicker, + contourSelectionLayer, + selectionClassTogglePlugin, + editableCompartment.of([ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), + ]), + EditorView.contentAttributes.of({ + spellcheck: "false", + }), + keymap.of([ + indentWithTab, + ...defaultKeymap, + ...historyKeymap, + ...searchKeymap, + ...foldKeymap, + ]), + saveKeymap, + themeCompartment.of([ + getCodeSyntaxHighlighting(activeTheme), + createCodeMirrorTheme( + activeTheme, + { fontFamily: editorFontFamily, fontSize: editorFontSize }, + fillHeight, + ), + ]), + languageCompartment.of([]), + updateListener, + ], + }); + + const view = new EditorView({ + state, + parent: containerRef.current, + }); + const adapter = createCodeMirrorAdapter(view); + + viewRef.current = view; + if (editorRef) { + editorRef.current = adapter; + } + + return () => { + if (editorRef?.current === adapter) { + editorRef.current = null; + } + adapter.dispose(); + viewRef.current = null; + }; + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + const currentValue = view.state.doc.toString(); + if (currentValue === value) return; + + // Guarantee flag reset regardless of whether dispatch throws (e.g. view destroyed between null-check and dispatch). + isExternalUpdateRef.current = true; + try { + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: value, + }, + }); + } finally { + isExternalUpdateRef.current = false; + } + }, [value]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: themeCompartment.reconfigure([ + getCodeSyntaxHighlighting(activeTheme), + createCodeMirrorTheme( + activeTheme, + { fontFamily: editorFontFamily, fontSize: editorFontSize }, + fillHeight, + ), + ]), + }); + }, [ + activeTheme, + editorFontFamily, + editorFontSize, + fillHeight, + themeCompartment, + ]); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: editableCompartment.reconfigure([ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), + ]), + }); + }, [editableCompartment, readOnly]); + + useEffect(() => { + let cancelled = false; + + void loadLanguageSupport(language) + .then((extension) => { + if (cancelled) return; + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: languageCompartment.reconfigure(extension ?? []), + }); + }) + .catch((error) => { + if (cancelled) return; + const view = viewRef.current; + if (!view) return; + + console.error("[CodeEditor] Failed to load language support:", { + error, + language, + }); + view.dispatch({ + effects: languageCompartment.reconfigure([]), + }); + }); + + return () => { + cancelled = true; + }; + }, [language, languageCompartment]); + + return ( + <div + ref={containerRef} + className={cn( + "min-w-0", + fillHeight ? "h-full w-full" : "w-full", + className, + )} + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts new file mode 100644 index 00000000000..ee6c9d5d235 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/CodeEditorAdapter.ts @@ -0,0 +1,143 @@ +import { selectAll } from "@codemirror/commands"; +import { openSearchPanel } from "@codemirror/search"; +import { EditorSelection } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; + +export interface EditorSelectionLines { + startLine: number; + endLine: number; +} + +export interface CodeEditorAdapter { + focus(): void; + getValue(): string; + setValue(value: string): void; + revealPosition(line: number, column?: number): void; + getSelectionLines(): EditorSelectionLines | null; + selectAll(): void; + cut(): void; + copy(): void; + paste(): void; + openFind(): void; + dispose(): void; +} + +export function createCodeMirrorAdapter(view: EditorView): CodeEditorAdapter { + let disposed = false; + + return { + focus() { + view.focus(); + }, + getValue() { + return view.state.doc.toString(); + }, + setValue(value) { + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.length, + insert: value, + }, + }); + }, + revealPosition(line, column = 1) { + const safeLine = Math.max(1, Math.min(line, view.state.doc.lines)); + const lineInfo = view.state.doc.line(safeLine); + const offset = Math.min(column - 1, lineInfo.length); + const anchor = lineInfo.from + Math.max(0, offset); + + view.dispatch({ + selection: EditorSelection.cursor(anchor), + scrollIntoView: true, + }); + view.focus(); + }, + getSelectionLines() { + const selection = view.state.selection.main; + const startLine = view.state.doc.lineAt(selection.from).number; + const endLine = view.state.doc.lineAt(selection.to).number; + return { startLine, endLine }; + }, + selectAll() { + selectAll(view); + }, + cut() { + if (view.state.readOnly) return; + const clipboard = navigator.clipboard; + if (!clipboard) return; + + const selection = view.state.selection.main; + if (selection.empty) return; + + const text = view.state.sliceDoc(selection.from, selection.to); + void clipboard + .writeText(text) + .then(() => { + if (disposed) return; + const currentSelection = view.state.selection.main; + if ( + currentSelection.from !== selection.from || + currentSelection.to !== selection.to + ) { + return; + } + + if (view.state.sliceDoc(selection.from, selection.to) !== text) { + return; + } + + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert: "" }, + }); + }) + .catch((error) => { + console.error("[CodeEditor] Failed to cut selection:", error); + }); + }, + copy() { + const clipboard = navigator.clipboard; + if (!clipboard) return; + + const selection = view.state.selection.main; + if (selection.empty) return; + + void clipboard + .writeText(view.state.sliceDoc(selection.from, selection.to)) + .catch((error) => { + console.error("[CodeEditor] Failed to copy selection:", error); + }); + }, + paste() { + if (view.state.readOnly) return; + const clipboard = navigator.clipboard; + if (!clipboard) return; + + void clipboard + .readText() + .then((text) => { + if (disposed) return; + const selection = view.state.selection.main; + view.dispatch({ + changes: { + from: selection.from, + to: selection.to, + insert: text, + }, + selection: EditorSelection.cursor(selection.from + text.length), + }); + }) + .catch((error) => { + console.error("[CodeEditor] Failed to paste from clipboard:", error); + }); + }, + openFind() { + openSearchPanel(view); + }, + dispose() { + if (disposed) return; + disposed = true; + view.destroy(); + }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/index.ts new file mode 100644 index 00000000000..aaa547716b1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/CodeEditorAdapter/index.ts @@ -0,0 +1,5 @@ +export { + type CodeEditorAdapter, + createCodeMirrorAdapter, + type EditorSelectionLines, +} from "./CodeEditorAdapter"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/constants.ts new file mode 100644 index 00000000000..63c8e6774d0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_CODE_EDITOR_FONT_FAMILY = + "ui-monospace, Menlo, Consolas, Liberation Mono, monospace"; +export const DEFAULT_CODE_EDITOR_FONT_SIZE = 13; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts new file mode 100644 index 00000000000..eec9e8c7573 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/createCodeMirrorTheme.ts @@ -0,0 +1,171 @@ +import { EditorView } from "@codemirror/view"; +import { getEditorTheme, type Theme, withAlpha } from "shared/themes"; +import { + DEFAULT_CODE_EDITOR_FONT_FAMILY, + DEFAULT_CODE_EDITOR_FONT_SIZE, +} from "../constants"; + +interface CodeEditorFontSettings { + fontFamily?: string; + fontSize?: number; +} + +export function createCodeMirrorTheme( + theme: Theme, + fontSettings: CodeEditorFontSettings, + fillHeight: boolean, +) { + const fontSize = fontSettings.fontSize ?? DEFAULT_CODE_EDITOR_FONT_SIZE; + const lineHeight = Math.round(fontSize * 1.5); + const editorTheme = getEditorTheme(theme); + const accentOverlay = withAlpha(theme.ui.accent, 0.5); + const activeLineBackground = accentOverlay; + const selectionBackground = accentOverlay; + + return EditorView.theme( + { + "&": { + height: fillHeight ? "100%" : "auto", + backgroundColor: editorTheme.colors.background, + color: editorTheme.colors.foreground, + fontFamily: fontSettings.fontFamily ?? DEFAULT_CODE_EDITOR_FONT_FAMILY, + fontSize: `${fontSize}px`, + }, + ".cm-scroller": { + fontFamily: "inherit", + lineHeight: `${lineHeight}px`, + overflow: fillHeight ? "auto" : "visible", + }, + ".cm-content": { + padding: "8px 0", + caretColor: editorTheme.colors.cursor, + }, + ".cm-line": { + padding: "0 12px", + }, + ".cm-gutters": { + backgroundColor: editorTheme.colors.gutterBackground, + color: editorTheme.colors.gutterForeground, + border: "none", + }, + // Line numbers: more breathing room on the left edge, tighter on the + // right since the gutter/content separator is gone. + ".cm-lineNumbers .cm-gutterElement": { + padding: "0 2px 0 8px", + }, + // Fold placeholder (Lucide MoreHorizontal rendered when a block is + // collapsed). Reset button defaults, match our rounded / theme look, + // and add a mild hover state. + ".cm-foldPlaceholder": { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: editorTheme.colors.panel, + border: `1px solid ${editorTheme.colors.border}`, + color: editorTheme.colors.gutterForeground, + borderRadius: "4px", + margin: "0 2px", + padding: "0 3px", + height: `${Math.max(14, lineHeight - 4)}px`, + cursor: "pointer", + verticalAlign: "middle", + transition: "background-color 120ms ease", + }, + ".cm-foldPlaceholder:hover": { + backgroundColor: editorTheme.colors.activeLine, + }, + ".cm-foldPlaceholderIcon": { + width: "12px", + height: "12px", + display: "block", + }, + // Anchor every gutter cell to the editor's line-height so fold + // chevrons share a row box with the digit line numbers. + ".cm-gutterElement": { + lineHeight: `${lineHeight}px`, + }, + // Fold chevron: render the SVG centered in its cell, transparent by + // default, fade in when the user hovers the gutter (group-hover). + ".cm-foldGutter .cm-gutterElement": { + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "0 2px", + }, + ".cm-foldChevron": { + width: "12px", + height: "12px", + display: "block", + opacity: 0, + transition: "opacity 260ms ease", + }, + ".cm-gutters:hover .cm-foldChevron": { + opacity: 1, + }, + // Pointer cursor on foldable rows (they're the ones that render a chevron). + ".cm-foldGutter .cm-gutterElement:has(.cm-foldChevron)": { + cursor: "pointer", + }, + ".cm-activeLine": { + backgroundColor: activeLineBackground, + }, + ".cm-activeLineGutter": { + backgroundColor: activeLineBackground, + }, + // Suppress the active-line highlight while a selection is active — + // the selectionClassTogglePlugin adds .cm-hasSelection to the editor + // root whenever any selection range is non-empty. + "&.cm-hasSelection .cm-activeLine": { + backgroundColor: "transparent", + }, + "&.cm-hasSelection .cm-activeLineGutter": { + backgroundColor: "transparent", + }, + // Hide CM's default per-line-width selection rectangles — our + // contourSelectionLayer (in CodeEditor.tsx) paints per-line rects + // snug to actual text so trailing whitespace on middle lines of a + // multi-line selection isn't filled. + ".cm-selectionBackground": { + display: "none", + }, + ".cm-contourSelection": { + backgroundColor: selectionBackground, + }, + ".cm-content ::selection": { + backgroundColor: selectionBackground, + }, + ".cm-selectionMatch": { + backgroundColor: editorTheme.colors.search, + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: editorTheme.colors.cursor, + }, + ".cm-searchMatch": { + backgroundColor: editorTheme.colors.search, + outline: "none", + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: editorTheme.colors.searchActive, + }, + ".cm-panels": { + backgroundColor: editorTheme.colors.panel, + color: editorTheme.colors.foreground, + borderBottom: `1px solid ${editorTheme.colors.panelBorder}`, + }, + ".cm-panels .cm-textfield": { + backgroundColor: editorTheme.colors.panelInputBackground, + color: editorTheme.colors.panelInputForeground, + border: `1px solid ${editorTheme.colors.panelInputBorder}`, + }, + ".cm-button": { + backgroundImage: "none", + backgroundColor: editorTheme.colors.panelButtonBackground, + color: editorTheme.colors.panelButtonForeground, + border: `1px solid ${editorTheme.colors.panelButtonBorder}`, + }, + }, + { + dark: theme.type === "dark", + }, + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/index.ts new file mode 100644 index 00000000000..a2bd8a30ec9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/createCodeMirrorTheme/index.ts @@ -0,0 +1 @@ +export { createCodeMirrorTheme } from "./createCodeMirrorTheme"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/contourSelectionLayer.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/contourSelectionLayer.ts new file mode 100644 index 00000000000..a48e6895696 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/contourSelectionLayer.ts @@ -0,0 +1,80 @@ +import { EditorSelection } from "@codemirror/state"; +import { type LayerMarker, layer, RectangleMarker } from "@codemirror/view"; + +// How far past the last character each line's selection rect extends, so the +// selection breathes on the right edge instead of cutting flush with the text. +const TRAILING_PAD = 4; + +// Half-line-height-wide stub for empty lines in the middle of a selection so +// the selection reads as contiguous across blank gaps. +const EMPTY_LINE_WIDTH_RATIO = 0.5; + +// Custom selection layer: draws selection backgrounds per-line, snug to each +// line's actual text instead of CM's default full-line-width fill for middle +// lines of multi-line selections. +// +// We keep drawSelection() for cursor rendering (including multi-cursor); its +// own .cm-selectionBackground rectangles are hidden via CSS so this layer is +// the only thing painting selection backgrounds. +export const contourSelectionLayer = layer({ + above: false, + class: "cm-contourSelectionLayer", + update(update) { + return ( + update.docChanged || + update.viewportChanged || + update.selectionSet || + update.geometryChanged + ); + }, + markers(view) { + const markers: LayerMarker[] = []; + const lineHeight = view.defaultLineHeight; + const emptyLineWidth = Math.round(lineHeight * EMPTY_LINE_WIDTH_RATIO); + for (const range of view.state.selection.ranges) { + if (range.empty) continue; + const fromLine = view.state.doc.lineAt(range.from); + const toLine = view.state.doc.lineAt(range.to); + for (let n = fromLine.number; n <= toLine.number; n += 1) { + const line = view.state.doc.line(n); + const selStart = Math.max(range.from, line.from); + // Clamp selection end to actual text end so trailing whitespace + // space past the last visible character is never filled. + const textEnd = line.from + line.text.length; + const selEnd = Math.min(range.to, textEnd); + const isEmpty = selStart >= selEnd; + const isMiddleLine = n > fromLine.number && n < toLine.number; + // Skip edge lines that fall in empty territory (selection starts at + // end-of-line or ends at start-of-line); only show the stub for + // genuinely empty middle lines. + if (isEmpty && !isMiddleLine) continue; + const lineRange = isEmpty + ? EditorSelection.cursor(line.from) + : EditorSelection.range(selStart, selEnd); + for (const m of RectangleMarker.forRange( + view, + "cm-contourSelection", + lineRange, + )) { + // Expand each rect to fill the full line-cell height. Use exactly + // lineHeight (no +1) so consecutive rects abut without overlap — + // overlap darkens at transparent fill alphas into a visible stripe. + const gap = Math.max(0, lineHeight - m.height); + const width = isEmpty + ? emptyLineWidth + : (m.width ?? 0) + TRAILING_PAD; + markers.push( + new RectangleMarker( + "cm-contourSelection", + m.left, + m.top - gap / 2, + width, + lineHeight, + ), + ); + } + } + } + return markers; + }, +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/index.ts new file mode 100644 index 00000000000..559a3412154 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/contourSelectionLayer/index.ts @@ -0,0 +1 @@ +export { contourSelectionLayer } from "./contourSelectionLayer"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/foldChevron.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/foldChevron.ts new file mode 100644 index 00000000000..5ff977a806a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/foldChevron.ts @@ -0,0 +1,21 @@ +// Lucide chevron paths, inlined so we return a plain HTMLElement (foldGutter's +// markerDOM contract) without bridging React. Matches lucide-react's +// ChevronDown and ChevronRight exactly. +const CHEVRON_DOWN_PATH = "m6 9 6 6 6-6"; +const CHEVRON_RIGHT_PATH = "m9 18 6-6-6-6"; + +export function buildFoldChevron(open: boolean): HTMLElement { + const el = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + el.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + el.setAttribute("viewBox", "0 0 24 24"); + el.setAttribute("fill", "none"); + el.setAttribute("stroke", "currentColor"); + el.setAttribute("stroke-width", "2"); + el.setAttribute("stroke-linecap", "round"); + el.setAttribute("stroke-linejoin", "round"); + el.setAttribute("class", "cm-foldChevron"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", open ? CHEVRON_DOWN_PATH : CHEVRON_RIGHT_PATH); + el.appendChild(path); + return el as unknown as HTMLElement; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/index.ts new file mode 100644 index 00000000000..a5636efbe81 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldChevron/index.ts @@ -0,0 +1 @@ +export { buildFoldChevron } from "./foldChevron"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/foldPlaceholder.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/foldPlaceholder.ts new file mode 100644 index 00000000000..e467c7f5787 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/foldPlaceholder.ts @@ -0,0 +1,33 @@ +import type { EditorView } from "@codemirror/view"; + +// Lucide MoreHorizontal (three dots) — inline SVG built imperatively so we can +// return a plain HTMLElement to CM's placeholderDOM contract. +export function buildFoldPlaceholder( + _view: EditorView, + onclick: (event: Event) => void, +): HTMLElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "cm-foldPlaceholder"; + button.setAttribute("aria-label", "Unfold"); + button.addEventListener("click", onclick); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("class", "cm-foldPlaceholderIcon"); + for (const cx of ["5", "12", "19"]) { + const c = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + c.setAttribute("cx", cx); + c.setAttribute("cy", "12"); + c.setAttribute("r", "1"); + svg.appendChild(c); + } + button.appendChild(svg); + return button; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/index.ts new file mode 100644 index 00000000000..6086ba73e2f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/foldPlaceholder/index.ts @@ -0,0 +1 @@ +export { buildFoldPlaceholder } from "./foldPlaceholder"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/index.ts new file mode 100644 index 00000000000..575cc376cd9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/index.ts @@ -0,0 +1 @@ +export { selectionClassTogglePlugin } from "./selectionClassTogglePlugin"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/selectionClassTogglePlugin.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/selectionClassTogglePlugin.ts new file mode 100644 index 00000000000..96024dd89ff --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/extensions/selectionClassTogglePlugin/selectionClassTogglePlugin.ts @@ -0,0 +1,20 @@ +import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; + +// Toggle a class on the editor root when any selection range is non-empty, so +// CSS can suppress the active-line highlight while a selection is drawn. +export const selectionClassTogglePlugin = ViewPlugin.fromClass( + class { + constructor(view: EditorView) { + this.sync(view); + } + update(update: ViewUpdate) { + if (update.selectionSet || update.docChanged) { + this.sync(update.view); + } + } + sync(view: EditorView) { + const hasSelection = view.state.selection.ranges.some((r) => !r.empty); + view.dom.classList.toggle("cm-hasSelection", hasSelection); + } + }, +); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/index.ts new file mode 100644 index 00000000000..1143beaed43 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/index.ts @@ -0,0 +1,5 @@ +export { CodeEditor } from "./CodeEditor"; +export type { + CodeEditorAdapter, + EditorSelectionLines, +} from "./CodeEditorAdapter"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/index.ts new file mode 100644 index 00000000000..1b9171751c1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/index.ts @@ -0,0 +1 @@ +export { loadLanguageSupport } from "./loadLanguageSupport"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/loadLanguageSupport.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/loadLanguageSupport.ts new file mode 100644 index 00000000000..876ca792f05 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/loadLanguageSupport.ts @@ -0,0 +1,127 @@ +import { StreamLanguage, type StreamParser } from "@codemirror/language"; +import type { Extension } from "@codemirror/state"; +import { + graphqlStreamLanguage, + makefileStreamLanguage, +} from "./streamLanguages"; + +async function loadLegacyLanguage( + loader: () => Promise<Record<string, unknown>>, + key: string, +): Promise<Extension> { + const languageModule = await loader(); + return StreamLanguage.define(languageModule[key] as StreamParser<unknown>); +} + +export async function loadLanguageSupport( + language: string, +): Promise<Extension | null> { + switch (language) { + case "typescript": + case "javascript": { + const { javascript } = await import("@codemirror/lang-javascript"); + return javascript({ + typescript: language === "typescript", + jsx: true, + }); + } + case "json": { + const { json } = await import("@codemirror/lang-json"); + return json(); + } + case "html": { + const { html } = await import("@codemirror/lang-html"); + return html(); + } + case "css": + case "scss": + case "less": { + const { css } = await import("@codemirror/lang-css"); + return css(); + } + case "markdown": { + const { markdown } = await import("@codemirror/lang-markdown"); + return markdown(); + } + case "graphql": + return StreamLanguage.define(graphqlStreamLanguage); + case "plaintext": + return null; + case "yaml": { + const { yaml } = await import("@codemirror/lang-yaml"); + return yaml(); + } + case "xml": { + const { xml } = await import("@codemirror/lang-xml"); + return xml(); + } + case "python": { + const { python } = await import("@codemirror/lang-python"); + return python(); + } + case "rust": { + const { rust } = await import("@codemirror/lang-rust"); + return rust(); + } + case "sql": { + const { sql } = await import("@codemirror/lang-sql"); + return sql(); + } + case "php": { + const { php } = await import("@codemirror/lang-php"); + return php(); + } + case "java": { + const { java } = await import("@codemirror/lang-java"); + return java(); + } + case "c": + case "cpp": { + const { cpp } = await import("@codemirror/lang-cpp"); + return cpp(); + } + case "go": { + const { go } = await import("@codemirror/lang-go"); + return go(); + } + case "shell": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/shell"), + "shell", + ); + case "dockerfile": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/dockerfile"), + "dockerFile", + ); + case "makefile": + return StreamLanguage.define(makefileStreamLanguage); + case "toml": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/toml"), + "toml", + ); + case "ruby": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/ruby"), + "ruby", + ); + case "swift": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/swift"), + "swift", + ); + case "csharp": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/clike"), + "csharp", + ); + case "kotlin": + return loadLegacyLanguage( + () => import("@codemirror/legacy-modes/mode/clike"), + "kotlin", + ); + default: + return null; + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/streamLanguages.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/streamLanguages.ts new file mode 100644 index 00000000000..7096bb018c7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/loadLanguageSupport/streamLanguages.ts @@ -0,0 +1,232 @@ +import type { StreamParser } from "@codemirror/language"; + +const GRAPHQL_KEYWORDS = new Set([ + "directive", + "enum", + "extend", + "fragment", + "implements", + "input", + "interface", + "mutation", + "on", + "query", + "repeatable", + "scalar", + "schema", + "subscription", + "type", + "union", +]); + +const GRAPHQL_ATOMS = new Set(["false", "null", "true"]); + +interface GraphqlState { + inBlockString: boolean; + inString: boolean; +} + +export const graphqlStreamLanguage: StreamParser<GraphqlState> = { + name: "graphql", + startState: () => ({ + inBlockString: false, + inString: false, + }), + token(stream, state) { + if (state.inBlockString) { + while (!stream.eol()) { + if (stream.match('"""')) { + state.inBlockString = false; + break; + } + stream.next(); + } + + return "string"; + } + + if (state.inString) { + let escaped = false; + + while (!stream.eol()) { + const next = stream.next(); + if (next === '"' && !escaped) { + state.inString = false; + break; + } + escaped = !escaped && next === "\\"; + } + + return "string"; + } + + if (stream.eatSpace()) return null; + + if (stream.match('"""')) { + while (!stream.eol()) { + if (stream.match('"""')) { + return "string"; + } + stream.next(); + } + + state.inBlockString = true; + return "string"; + } + + const next = stream.next(); + if (!next) return null; + + if (next === "#") { + stream.skipToEnd(); + return "comment"; + } + + if (next === '"') { + let escaped = false; + + while (!stream.eol()) { + const char = stream.next(); + if (char === '"' && !escaped) { + return "string"; + } + escaped = !escaped && char === "\\"; + } + + state.inString = true; + return "string"; + } + + if (next === "$") { + stream.eatWhile(/[_0-9A-Za-z]/); + return "variableName"; + } + + if (next === "@") { + stream.eatWhile(/[_0-9A-Za-z]/); + return "meta"; + } + + if (next === "-" && /\d/.test(stream.peek() ?? "")) { + stream.eatWhile(/\d/); + if (stream.peek() === ".") { + stream.next(); + stream.eatWhile(/\d/); + } + return "number"; + } + + if (/\d/.test(next)) { + stream.eatWhile(/\d/); + if (stream.peek() === ".") { + stream.next(); + stream.eatWhile(/\d/); + } + return "number"; + } + + if (/[A-Za-z_]/.test(next)) { + stream.eatWhile(/[_0-9A-Za-z]/); + const word = stream.current(); + + if (GRAPHQL_KEYWORDS.has(word)) return "keyword"; + if (GRAPHQL_ATOMS.has(word)) return "atom"; + return /^[A-Z]/.test(word) ? "typeName" : "variableName"; + } + + return null; + }, + languageData: { + commentTokens: { line: "#" }, + }, +}; + +const MAKEFILE_DIRECTIVES = new Set([ + "-include", + "define", + "else", + "endef", + "endif", + "export", + "ifdef", + "ifndef", + "ifeq", + "ifneq", + "include", + "override", + "private", + "sinclude", + "undefine", + "unexport", + "vpath", +]); + +function escapeRegex(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +const MAKEFILE_DIRECTIVE_PATTERN = new RegExp( + `^\\s*(?:${[...MAKEFILE_DIRECTIVES].map(escapeRegex).join("|")})\\b`, +); + +export const makefileStreamLanguage: StreamParser<null> = { + name: "makefile", + token(stream) { + if (stream.sol()) { + if (stream.peek() === "\t") { + stream.skipToEnd(); + return "meta"; + } + + if (stream.match(MAKEFILE_DIRECTIVE_PATTERN)) { + return "keyword"; + } + + if (stream.match(/^\s*[A-Za-z_][A-Za-z0-9_.-]*(?=\s*(?::=|\+=|\?=|=))/)) { + return "variableName"; + } + + if (stream.match(/^\s*[^:=#\s][^:=#]*(?=\s*:)/)) { + return "def"; + } + } + + if (stream.eatSpace()) return null; + + if (stream.match(/^\$\(([^)]+)\)/) || stream.match(/^\$\{([^}]+)\}/)) { + return "variableName"; + } + + const next = stream.next(); + if (!next) return null; + + if (next === "#") { + stream.skipToEnd(); + return "comment"; + } + + if (next === ":" && stream.peek() === "=") { + stream.next(); + return "operator"; + } + + if ((next === "+" || next === "?") && stream.peek() === "=") { + stream.next(); + return "operator"; + } + + if (next === "=") { + return "operator"; + } + + if (/[A-Za-z_.-]/.test(next)) { + stream.eatWhile(/[A-Za-z0-9_.-]/); + return MAKEFILE_DIRECTIVES.has(stream.current()) ? "keyword" : null; + } + + return null; + }, + languageData: { + commentTokens: { line: "#" }, + }, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/index.ts new file mode 100644 index 00000000000..8d6313f9e34 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/index.ts @@ -0,0 +1 @@ +export { getCodeSyntaxHighlighting } from "./syntax-highlighting"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/syntax-highlighting.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/syntax-highlighting.ts new file mode 100644 index 00000000000..bd93300f5cb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/components/CodeEditor/syntax-highlighting/syntax-highlighting.ts @@ -0,0 +1,70 @@ +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import type { Extension } from "@codemirror/state"; +import { tags } from "@lezer/highlight"; +import { getEditorTheme, type Theme } from "shared/themes"; + +export function getCodeSyntaxHighlighting(theme: Theme): Extension { + const editorTheme = getEditorTheme(theme); + + return syntaxHighlighting( + HighlightStyle.define([ + { + tag: [tags.keyword, tags.operatorKeyword, tags.modifier], + color: editorTheme.syntax.keyword, + }, + { + tag: [tags.comment, tags.lineComment, tags.blockComment], + color: editorTheme.syntax.comment, + fontStyle: "italic", + }, + { + tag: [tags.string, tags.special(tags.string)], + color: editorTheme.syntax.string, + }, + { + tag: [tags.number, tags.integer, tags.float, tags.bool, tags.null], + color: editorTheme.syntax.number, + }, + { + tag: [ + tags.function(tags.variableName), + tags.function(tags.propertyName), + tags.labelName, + ], + color: editorTheme.syntax.functionCall, + }, + { + tag: [tags.variableName, tags.name, tags.propertyName], + color: editorTheme.syntax.variableName, + }, + { + tag: [tags.typeName, tags.definition(tags.typeName)], + color: editorTheme.syntax.typeName, + }, + { + tag: [tags.className], + color: editorTheme.syntax.className, + }, + { + tag: [tags.constant(tags.name), tags.standard(tags.name)], + color: editorTheme.syntax.constant, + }, + { + tag: [tags.regexp, tags.escape, tags.special(tags.regexp)], + color: editorTheme.syntax.regexp, + }, + { + tag: [tags.tagName, tags.angleBracket], + color: editorTheme.syntax.tagName, + }, + { + tag: [tags.attributeName], + color: editorTheme.syntax.attributeName, + }, + { + tag: [tags.invalid], + color: editorTheme.syntax.invalid, + }, + ]), + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts new file mode 100644 index 00000000000..b61e0ae6970 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/CodeView/index.ts @@ -0,0 +1,12 @@ +import { isMarkdownFile } from "shared/file-types"; +import type { FileView } from "../../types"; +import { CodeView } from "./CodeView"; + +export const codeView: FileView = { + id: "code", + label: (filePath) => (isMarkdownFile(filePath) ? "Markdown" : "Code"), + match: (_, meta) => meta.isBinary !== true, + priority: "builtin", + documentKind: "text", + Renderer: CodeView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx new file mode 100644 index 00000000000..807b40c56e5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/ImageView.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; +import { getBaseName } from "renderer/lib/pathBasename"; +import { getImageMimeType } from "shared/file-types"; +import type { ViewProps } from "../../types"; + +export function ImageView({ document, filePath }: ViewProps) { + const [objectUrl, setObjectUrl] = useState<string | null>(null); + + useEffect(() => { + if (document.content.kind !== "bytes") { + setObjectUrl(null); + return; + } + const mimeType = getImageMimeType(filePath) ?? "image/png"; + const url = URL.createObjectURL( + new Blob([document.content.value as BlobPart], { type: mimeType }), + ); + setObjectUrl(url); + return () => URL.revokeObjectURL(url); + }, [document.content, filePath]); + + if (!objectUrl) { + return null; + } + + return ( + <div className="flex h-full items-center justify-center overflow-auto bg-background p-4"> + <div + className="inline-block max-h-full max-w-full" + style={{ + backgroundImage: + "conic-gradient(color-mix(in srgb, var(--color-foreground) 10%, transparent) 25%, transparent 0 50%, color-mix(in srgb, var(--color-foreground) 10%, transparent) 0 75%, transparent 0)", + backgroundSize: "16px 16px", + }} + > + <img + src={objectUrl} + alt={getBaseName(filePath)} + className="block max-h-full max-w-full object-contain" + draggable={false} + /> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/index.ts new file mode 100644 index 00000000000..073b17bf911 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/ImageView/index.ts @@ -0,0 +1,12 @@ +import { isImageFile } from "shared/file-types"; +import type { FileView } from "../../types"; +import { ImageView } from "./ImageView"; + +export const imageView: FileView = { + id: "image", + label: "Image", + match: (filePath) => isImageFile(filePath), + priority: "exclusive", + documentKind: "bytes", + Renderer: ImageView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx new file mode 100644 index 00000000000..f6f0da93d73 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/MarkdownPreviewView.tsx @@ -0,0 +1,14 @@ +import { TipTapMarkdownRenderer } from "renderer/components/MarkdownRenderer/components/TipTapMarkdownRenderer"; +import type { ViewProps } from "../../types"; + +export function MarkdownPreviewView({ document }: ViewProps) { + if (document.content.kind !== "text") { + return null; + } + + return ( + <div className="h-full overflow-auto p-4"> + <TipTapMarkdownRenderer value={document.content.value} /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/index.ts new file mode 100644 index 00000000000..b5c4d105412 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/MarkdownPreviewView/index.ts @@ -0,0 +1,12 @@ +import { isMarkdownFile } from "shared/file-types"; +import type { FileView } from "../../types"; +import { MarkdownPreviewView } from "./MarkdownPreviewView"; + +export const markdownPreviewView: FileView = { + id: "markdown-preview", + label: "Preview", + match: (filePath) => isMarkdownFile(filePath), + priority: "default", + documentKind: "text", + Renderer: MarkdownPreviewView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx new file mode 100644 index 00000000000..87e17e4248a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -0,0 +1,432 @@ +import type { RendererContext } from "@superset/panes"; +import { cn } from "@superset/ui/utils"; +import { workspaceTrpc } from "@superset/workspace-client"; +import "@xterm/xterm/css/xterm.css"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { useTerminalLinkActions } from "renderer/hooks/useV2UserPreferences"; +import { useHotkey } from "renderer/hotkeys"; +import { + type ConnectionState, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useOpenInExternalEditor } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useOpenInExternalEditor"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { openUrlInV2Workspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace"; +import { useWorkspaceWsUrl } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; +import { ScrollToBottomButton } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton"; +import { TerminalSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/TerminalSearch"; +import { useTheme } from "renderer/stores/theme"; +import { resolveTerminalThemeType } from "renderer/stores/theme/utils"; +import { LinkHoverTooltip } from "./components/LinkHoverTooltip"; +import { useLinkClickHint } from "./hooks/useLinkClickHint"; +import { useLinkHoverState } from "./hooks/useLinkHoverState"; +import { useTerminalAppearance } from "./hooks/useTerminalAppearance"; +import { shellEscapePaths } from "./utils"; + +interface TerminalPaneProps { + ctx: RendererContext<PaneViewerData>; + workspaceId: string; + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string, options?: { isDirectory?: boolean }) => void; +} + +export function TerminalPane({ + ctx, + workspaceId, + onOpenFile, + onRevealPath, +}: TerminalPaneProps) { + const { getFileAction, getUrlAction } = useTerminalLinkActions(); + const { + hoveredLink, + onHover: onLinkHover, + onLeave: onLinkLeave, + } = useLinkHoverState(); + const { hint, showHint } = useLinkClickHint(); + const openInExternalEditor = useOpenInExternalEditor(workspaceId); + const paneData = ctx.pane.data as TerminalPaneData; + const { terminalId } = paneData; + const initialCommandRef = useRef(paneData.initialCommand); + const terminalInstanceId = ctx.pane.id; + const containerRef = useRef<HTMLDivElement | null>(null); + const activeTheme = useTheme(); + const [isSearchOpen, setIsSearchOpen] = useState(false); + + const appearance = useTerminalAppearance(); + const appearanceRef = useRef(appearance); + appearanceRef.current = appearance; + const initialThemeTypeRef = useRef< + ReturnType<typeof resolveTerminalThemeType> + >( + resolveTerminalThemeType({ + activeThemeType: activeTheme?.type, + }), + ); + + // Include workspaceId/themeType so the WebSocket route can create the + // session on open. Terminal attach should not wait behind workspace tRPC. + const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { + workspaceId, + themeType: initialThemeTypeRef.current, + }); + const websocketUrlRef = useRef(websocketUrl); + websocketUrlRef.current = websocketUrl; + const workspaceIdRef = useRef(workspaceId); + workspaceIdRef.current = workspaceId; + + const workspaceTrpcUtils = workspaceTrpc.useUtils(); + const invalidateTerminalSessionsRef = useRef( + workspaceTrpcUtils.terminal.listSessions.invalidate, + ); + invalidateTerminalSessionsRef.current = + workspaceTrpcUtils.terminal.listSessions.invalidate; + + // useCallback so useSyncExternalStore doesn't re-subscribe every render — + // otherwise every keystroke-triggered re-render unsubscribes and + // re-subscribes the registry listener. See React's useSyncExternalStore + // docs ("If you don't memoize the subscribe function…"). + const subscribe = useCallback( + (callback: () => void) => + terminalRuntimeRegistry.onStateChange( + terminalId, + callback, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], + ); + const getSnapshot = useCallback( + (): ConnectionState => + terminalRuntimeRegistry.getConnectionState( + terminalId, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], + ); + const connectionState = useSyncExternalStore(subscribe, getSnapshot); + + // DOM-first lifecycle (VSCode/Tabby pattern): + // 1. mount() attaches xterm to the container synchronously — terminal + // is visible immediately, even on cold start. For a warm return + // (workspace switch) this reparents the wrapper from the parking + // container back into the live tree, preserving the buffer. + // 2. connect() opens the WebSocket immediately. The host-service terminal + // route creates the session from the URL workspaceId if needed, avoiding + // tRPC head-of-line blocking during workspace switches. + // Deps narrowed to the terminal identity so provider key remount churn + // (workspaceId briefly flipping while pane data catches up) doesn't re-run + // this effect. workspaceId / websocketUrl are read through refs. + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + terminalRuntimeRegistry.mount( + terminalId, + container, + appearanceRef.current, + terminalInstanceId, + ); + + terminalRuntimeRegistry.connect( + terminalId, + websocketUrlRef.current, + terminalInstanceId, + { initialCommand: initialCommandRef.current }, + ); + + return () => { + terminalRuntimeRegistry.detach(terminalId, terminalInstanceId); + }; + }, [terminalId, terminalInstanceId]); + + useEffect(() => { + if (connectionState !== "open" || !initialCommandRef.current) return; + + initialCommandRef.current = undefined; + if (paneData.initialCommand === undefined) return; + + ctx.actions.updateData({ + ...paneData, + initialCommand: undefined, + } as PaneViewerData); + }, [connectionState, ctx.actions, paneData]); + + const lastInvalidatedOpenSessionRef = useRef<string | null>(null); + useEffect(() => { + const invalidateSessionsAfterSocketOpen = () => { + if ( + terminalRuntimeRegistry.getConnectionState( + terminalId, + terminalInstanceId, + ) !== "open" + ) { + lastInvalidatedOpenSessionRef.current = null; + return; + } + + const sessionWorkspaceId = workspaceIdRef.current; + const invalidateKey = `${sessionWorkspaceId}:${terminalId}:${terminalInstanceId}:${websocketUrlRef.current}`; + if (lastInvalidatedOpenSessionRef.current === invalidateKey) return; + lastInvalidatedOpenSessionRef.current = invalidateKey; + + void invalidateTerminalSessionsRef.current({ + workspaceId: sessionWorkspaceId, + }); + }; + + invalidateSessionsAfterSocketOpen(); + return terminalRuntimeRegistry.onStateChange( + terminalId, + invalidateSessionsAfterSocketOpen, + terminalInstanceId, + ); + }, [terminalId, terminalInstanceId]); + + // WS URL can change while the terminal stays mounted (token refresh, host + // URL re-resolution on provider remount). Reconnect only if the transport + // is already live — on initial mount the transport is "disconnected" and + // we let the mount path above open it. + useEffect(() => { + terminalRuntimeRegistry.reconnect( + terminalId, + websocketUrl, + terminalInstanceId, + ); + }, [terminalId, terminalInstanceId, websocketUrl]); + + useEffect(() => { + terminalRuntimeRegistry.updateAppearance( + terminalId, + appearance, + terminalInstanceId, + ); + }, [terminalId, terminalInstanceId, appearance]); + + // --- Link handlers --- + // All filesystem operations go through the host service. + // statPath is a mutation (POST) to avoid tRPC GET URL encoding issues + // with paths containing special characters like (). + const statPathMutation = workspaceTrpc.filesystem.statPath.useMutation(); + const statPathRef = useRef(statPathMutation.mutateAsync); + statPathRef.current = statPathMutation.mutateAsync; + + useEffect(() => { + terminalRuntimeRegistry.setLinkHandlers( + terminalId, + { + stat: async (path) => { + try { + const result = await statPathRef.current({ + workspaceId, + path, + }); + if (!result) return null; + return { + isDirectory: result.isDirectory, + resolvedPath: result.resolvedPath, + }; + } catch { + return null; + } + }, + onFileLinkClick: (event, link) => { + // Folders are not settings-controlled: ⌘ reveals in sidebar, + // ⌘⇧ falls through to the external editor path, plain = hint. + if (link.isDirectory) { + if (!event.metaKey && !event.ctrlKey) { + showHint(event.clientX, event.clientY); + return; + } + event.preventDefault(); + if (event.shiftKey) { + openInExternalEditor(link.resolvedPath); + } else { + onRevealPath(link.resolvedPath, { isDirectory: true }); + } + return; + } + + const action = getFileAction(event); + if (action === null) { + showHint(event.clientX, event.clientY); + return; + } + event.preventDefault(); + if (action === "external") { + openInExternalEditor(link.resolvedPath, { + line: link.row, + column: link.col, + }); + } else { + onOpenFile(link.resolvedPath); + } + }, + onUrlClick: (event, url) => { + const action = getUrlAction(event); + if (action === null) { + showHint(event.clientX, event.clientY); + return; + } + event.preventDefault(); + if (action === "external") { + electronTrpcClient.external.openUrl.mutate(url).catch((error) => { + console.error("[v2 Terminal] Failed to open URL:", url, error); + }); + } else { + openUrlInV2Workspace({ + store: ctx.store, + target: "current-tab", + url, + }); + } + }, + onLinkHover, + onLinkLeave, + }, + terminalInstanceId, + ); + }, [ + terminalId, + terminalInstanceId, + workspaceId, + ctx.store, + onOpenFile, + onRevealPath, + openInExternalEditor, + onLinkHover, + onLinkLeave, + showHint, + getFileAction, + getUrlAction, + ]); + + useHotkey( + "CLEAR_TERMINAL", + () => { + terminalRuntimeRegistry.clear(terminalId, terminalInstanceId); + }, + { enabled: ctx.isActive }, + ); + + useHotkey( + "SCROLL_TO_BOTTOM", + () => { + terminalRuntimeRegistry.scrollToBottom(terminalId, terminalInstanceId); + }, + { enabled: ctx.isActive }, + ); + + useHotkey("FIND_IN_TERMINAL", () => setIsSearchOpen((prev) => !prev), { + enabled: ctx.isActive, + preventDefault: true, + }); + + // connectionState in deps ensures terminal ref re-derives after connect/disconnect + // biome-ignore lint/correctness/useExhaustiveDependencies: connectionState is intentionally included to trigger re-derive + const terminal = useMemo( + () => terminalRuntimeRegistry.getTerminal(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId, connectionState], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: connectionState is intentionally included to trigger re-derive + const searchAddon = useMemo( + () => + terminalRuntimeRegistry.getSearchAddon(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId, connectionState], + ); + + const [isDropActive, setIsDropActive] = useState(false); + const dragCounterRef = useRef(0); + + const resolveDroppedText = (dataTransfer: DataTransfer): string | null => { + const files = Array.from(dataTransfer.files); + if (files.length > 0) { + const paths = files + .map((file) => window.webUtils.getPathForFile(file)) + .filter(Boolean); + return paths.length > 0 ? shellEscapePaths(paths) : null; + } + const plainText = dataTransfer.getData("text/plain"); + return plainText ? shellEscapePaths([plainText]) : null; + }; + + const handleDragEnter = (event: React.DragEvent) => { + event.preventDefault(); + dragCounterRef.current += 1; + setIsDropActive(true); + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + dragCounterRef.current -= 1; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setIsDropActive(false); + } + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + dragCounterRef.current = 0; + setIsDropActive(false); + if (connectionState === "closed") return; + const text = resolveDroppedText(event.dataTransfer); + if (!text) return; + terminalRuntimeRegistry + .getTerminal(terminalId, terminalInstanceId) + ?.focus(); + terminalRuntimeRegistry.paste(terminalId, text, terminalInstanceId); + }; + + return ( + <div + role="application" + className="relative flex h-full w-full flex-col p-2" + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + > + <div className="relative min-h-0 flex-1 overflow-hidden"> + <TerminalSearch + searchAddon={searchAddon} + isOpen={isSearchOpen} + onClose={() => setIsSearchOpen(false)} + /> + <div + ref={containerRef} + className="h-full w-full" + style={{ backgroundColor: appearance.background }} + /> + <ScrollToBottomButton terminal={terminal} /> + </div> + <div + className={cn( + "pointer-events-none absolute inset-0 bg-primary/10 transition-opacity duration-100", + isDropActive ? "opacity-75" : "opacity-0", + )} + /> + {connectionState === "closed" && ( + <div className="flex items-center gap-2 border-t border-border px-3 py-1.5 text-xs text-muted-foreground"> + <span>Disconnected</span> + </div> + )} + <LinkHoverTooltip hoveredLink={hoveredLink} hint={hint} /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx new file mode 100644 index 00000000000..f7597c4e4cd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/LinkHoverTooltip.tsx @@ -0,0 +1,110 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { createPortal } from "react-dom"; +import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; +import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry"; +import type { + LinkAction, + LinkTier, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import type { LinkClickHint } from "../../hooks/useLinkClickHint"; +import type { HoveredLink } from "../../hooks/useLinkHoverState"; + +const TOOLTIP_OFFSET_PX = 14; +const TOOLTIP_CLASSES = + "pointer-events-none fixed z-50 w-fit rounded-md bg-foreground px-3 py-1.5 text-xs text-background"; + +const HINT_LABEL = "Not bound · configure in Settings → Links"; + +function tierFor(modifier: boolean, shift: boolean): LinkTier { + if (modifier) return shift ? "metaShift" : "meta"; + return "plain"; +} + +function labelForFile(action: LinkAction | null): string | null { + if (action === null) return null; + return action === "external" + ? "Open in external editor" + : "Open in file viewer"; +} + +function labelForUrl(action: LinkAction | null): string | null { + if (action === null) return null; + return action === "external" ? "Open in system browser" : "Open in browser"; +} + +function labelForHover( + info: LinkHoverInfo, + tier: LinkTier, + fileAction: LinkAction | null, + urlAction: LinkAction | null, +): string | null { + if (info.kind === "url") return labelForUrl(urlAction); + // Folder click behavior is hardcoded, not settings-driven: + // ⌘ reveals in sidebar, ⌘⇧ opens in external editor, plain = hint. + if (info.isDirectory) { + if (tier === "plain") return null; + return tier === "metaShift" ? "Open in editor" : "Reveal in sidebar"; + } + return labelForFile(fileAction); +} + +interface LinkHoverTooltipProps { + hoveredLink: HoveredLink | null; + hint: LinkClickHint | null; +} + +export function LinkHoverTooltip({ hoveredLink, hint }: LinkHoverTooltipProps) { + const { preferences } = useV2UserPreferences(); + + // Only surface the hover tooltip when a modifier is held — matches the + // original intent of "here's what pressing this will do". For unbound + // tiers the label resolves to null, so the tooltip stays hidden. + const tier = hoveredLink?.modifier + ? tierFor(hoveredLink.modifier, hoveredLink.shift) + : null; + const hoverLabel = + hoveredLink && tier + ? labelForHover( + hoveredLink.info, + tier, + preferences.fileLinks[tier], + preferences.urlLinks[tier], + ) + : null; + const showingHover = hoverLabel !== null; + + return createPortal( + <> + {hoveredLink && showingHover && ( + <div + className={TOOLTIP_CLASSES} + style={{ + left: hoveredLink.clientX + TOOLTIP_OFFSET_PX, + top: hoveredLink.clientY + TOOLTIP_OFFSET_PX, + }} + > + {hoverLabel} + </div> + )} + <AnimatePresence> + {hint && !showingHover && ( + <motion.div + key="hint" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={{ duration: 0.15 }} + className={TOOLTIP_CLASSES} + style={{ + left: hint.clientX + TOOLTIP_OFFSET_PX, + top: hint.clientY + TOOLTIP_OFFSET_PX, + }} + > + {HINT_LABEL} + </motion.div> + )} + </AnimatePresence> + </>, + document.body, + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts new file mode 100644 index 00000000000..8be6f8ce6fd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/LinkHoverTooltip/index.ts @@ -0,0 +1 @@ +export { LinkHoverTooltip } from "./LinkHoverTooltip"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx new file mode 100644 index 00000000000..0d4f5fa6153 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx @@ -0,0 +1,25 @@ +import type { RendererContext } from "@superset/panes"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { TerminalLogsButton } from "../TerminalLogsButton"; + +interface TerminalHeaderExtrasProps { + context: RendererContext<PaneViewerData>; +} + +export function TerminalHeaderExtras({ context }: TerminalHeaderExtrasProps) { + if (context.pane.kind !== "terminal") return null; + + const data = context.pane.data as TerminalPaneData; + + return ( + <div className="flex items-center gap-0.5"> + <TerminalLogsButton + terminalId={data.terminalId} + terminalInstanceId={context.pane.id} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts new file mode 100644 index 00000000000..7f654de91f5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts @@ -0,0 +1 @@ +export { TerminalHeaderExtras } from "./TerminalHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/TerminalLogsButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/TerminalLogsButton.tsx new file mode 100644 index 00000000000..1c9b4769320 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/TerminalLogsButton.tsx @@ -0,0 +1,125 @@ +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { AlertTriangle } from "lucide-react"; +import { useCallback, useState, useSyncExternalStore } from "react"; +import { + type TerminalLogEntry, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; + +interface TerminalLogsButtonProps { + terminalId: string; + terminalInstanceId: string; +} + +export function TerminalLogsButton({ + terminalId, + terminalInstanceId, +}: TerminalLogsButtonProps) { + const subscribe = useCallback( + (cb: () => void) => + terminalRuntimeRegistry.onLogsChange(terminalId, cb, terminalInstanceId), + [terminalId, terminalInstanceId], + ); + const getSnapshot = useCallback( + () => terminalRuntimeRegistry.getLogs(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId], + ); + const logs = useSyncExternalStore(subscribe, getSnapshot); + const [open, setOpen] = useState(false); + + if (logs.length === 0) return null; + + const hasError = logs.some((entry) => entry.level === "error"); + + const handleClear = (event: React.MouseEvent) => { + event.stopPropagation(); + terminalRuntimeRegistry.clearLogs(terminalId, terminalInstanceId); + setOpen(false); + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <Tooltip> + <TooltipTrigger asChild> + <PopoverTrigger asChild> + <button + type="button" + aria-label={`View terminal connection log (${logs.length} ${logs.length === 1 ? "event" : "events"})`} + onClick={(event) => event.stopPropagation()} + className={cn( + "rounded p-1 transition-colors", + hasError + ? "text-destructive/70 hover:text-destructive" + : "text-amber-500/70 hover:text-amber-500", + )} + > + <AlertTriangle className="size-3.5" /> + </button> + </PopoverTrigger> + </TooltipTrigger> + <TooltipContent side="bottom" showArrow={false}> + {logs.length} connection {logs.length === 1 ? "event" : "events"} + </TooltipContent> + </Tooltip> + <PopoverContent + align="end" + className="w-96 p-0" + onClick={(event) => event.stopPropagation()} + > + <div className="flex items-center justify-between border-b border-border px-3 py-2"> + <div className="text-xs font-medium text-foreground"> + Connection log + </div> + <button + type="button" + onClick={handleClear} + className="rounded px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + > + Clear + </button> + </div> + <div className="max-h-72 overflow-y-auto"> + <ul className="divide-y divide-border"> + {[...logs].reverse().map((entry) => ( + <LogRow key={entry.id} entry={entry} /> + ))} + </ul> + </div> + </PopoverContent> + </Popover> + ); +} + +function LogRow({ entry }: { entry: TerminalLogEntry }) { + return ( + <li className="min-w-0 px-3 py-2 text-xs"> + <div className="flex items-baseline gap-2"> + <span + className={cn( + "shrink-0 font-mono uppercase tracking-wider", + entry.level === "error" && "text-destructive", + entry.level === "warn" && "text-amber-500", + entry.level === "info" && "text-muted-foreground", + )} + > + {entry.level} + </span> + <time className="shrink-0 font-mono text-muted-foreground"> + {formatTime(entry.timestamp)} + </time> + </div> + <p className="mt-1 wrap-anywhere text-foreground">{entry.message}</p> + </li> + ); +} + +function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/index.ts new file mode 100644 index 00000000000..fb09d565514 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalLogsButton/index.ts @@ -0,0 +1 @@ +export { TerminalLogsButton } from "./TerminalLogsButton"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx new file mode 100644 index 00000000000..3da9cae0d1d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -0,0 +1,342 @@ +import type { RendererContext } from "@superset/panes"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { + Check, + ChevronDown, + LoaderCircle, + Plus, + TerminalSquare, + Trash2, +} from "lucide-react"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; + +interface TerminalSessionDropdownProps { + context: RendererContext<PaneViewerData>; + workspaceId: string; +} + +interface VisibleTerminalSession { + terminalId: string; + createdAt?: number; + exited: boolean; + exitCode: number; + attached: boolean; + title: string | null; + pending?: boolean; +} + +interface TerminalPaneLocation { + tabId: string; + paneId: string; + titleOverride?: string; +} + +const EMPTY_TERMINAL_PANE_LOCATIONS = new Map<string, TerminalPaneLocation[]>(); + +function formatCreatedAt(createdAt: number | undefined): string { + if (!createdAt) return "Creating"; + + return getRelativeTime(createdAt, { format: "compact" }); +} + +function getTerminalPaneLocations( + context: RendererContext<PaneViewerData>, +): Map<string, TerminalPaneLocation[]> { + const locations = new Map<string, TerminalPaneLocation[]>(); + for (const tab of context.store.getState().tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.id === context.pane.id || pane.kind !== "terminal") continue; + const data = pane.data as Partial<TerminalPaneData>; + if (data.terminalId) { + const terminalLocations = locations.get(data.terminalId) ?? []; + terminalLocations.push({ + tabId: tab.id, + paneId: pane.id, + titleOverride: pane.titleOverride, + }); + locations.set(data.terminalId, terminalLocations); + } + } + } + return locations; +} + +export function TerminalSessionDropdown({ + context, + workspaceId, +}: TerminalSessionDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const { terminalId } = context.pane.data as TerminalPaneData; + const terminalInstanceId = context.pane.id; + const utils = workspaceTrpc.useUtils(); + const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); + const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( + { workspaceId }, + { + refetchInterval: isOpen ? 2_000 : false, + refetchOnWindowFocus: true, + }, + ); + + const sessions = useMemo<VisibleTerminalSession[]>(() => { + const liveSessions = sessionsQuery.data?.sessions ?? []; + const ordered = [...liveSessions].sort((a, b) => { + if (a.terminalId === terminalId) return -1; + if (b.terminalId === terminalId) return 1; + return (b.createdAt ?? 0) - (a.createdAt ?? 0); + }); + if (ordered.some((session) => session.terminalId === terminalId)) { + return ordered; + } + return [ + { + terminalId, + exited: false, + exitCode: 0, + attached: false, + title: null, + pending: true, + }, + ...ordered, + ]; + }, [sessionsQuery.data?.sessions, terminalId]); + const currentSession = sessions.find( + (session) => session.terminalId === terminalId, + ); + const subscribeTitle = useCallback( + (callback: () => void) => + terminalRuntimeRegistry.onTitleChange( + terminalId, + callback, + terminalInstanceId, + ), + [terminalId, terminalInstanceId], + ); + const getTitleSnapshot = useCallback( + () => terminalRuntimeRegistry.getTitle(terminalId, terminalInstanceId), + [terminalId, terminalInstanceId], + ); + const runtimeTitle = useSyncExternalStore(subscribeTitle, getTitleSnapshot); + const renderTerminalPaneLocations = isOpen + ? getTerminalPaneLocations(context) + : EMPTY_TERMINAL_PANE_LOCATIONS; + + const handleSelectSession = (session: VisibleTerminalSession) => { + const nextTerminalId = session.terminalId; + if (nextTerminalId === terminalId) { + setIsOpen(false); + return; + } + + const state = context.store.getState(); + const terminalPaneLocations = getTerminalPaneLocations(context); + const existingLocation = terminalPaneLocations.get(nextTerminalId)?.[0]; + if (existingLocation) { + state.setActiveTab(existingLocation.tabId); + state.setActivePane({ + tabId: existingLocation.tabId, + paneId: existingLocation.paneId, + }); + setIsOpen(false); + return; + } + + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } + + state.setPaneData({ + paneId: context.pane.id, + data: { + terminalId: nextTerminalId, + } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: undefined, + }); + setIsOpen(false); + }; + + const closePanesForTerminal = (targetTerminalId: string) => { + const terminalPaneLocations = getTerminalPaneLocations(context); + for (const location of terminalPaneLocations.get(targetTerminalId) ?? []) { + context.store.getState().closePane({ + tabId: location.tabId, + paneId: location.paneId, + }); + } + + if (targetTerminalId === terminalId) { + void context.actions.close(); + } + }; + + const removeTerminalSession = async (session: VisibleTerminalSession) => { + try { + await killTerminalSession.mutateAsync({ + terminalId: session.terminalId, + workspaceId, + }); + closePanesForTerminal(session.terminalId); + } finally { + await utils.terminal.listSessions.invalidate({ workspaceId }); + } + }; + + const handleRemoveTerminal = (session: VisibleTerminalSession) => { + toast.promise(removeTerminalSession(session), { + loading: "Removing terminal...", + success: "Terminal removed", + error: "Failed to remove terminal", + }); + }; + + const handleNewTerminal = () => { + const state = context.store.getState(); + const terminalPaneLocations = getTerminalPaneLocations(context); + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { + markTerminalForBackground(terminalId); + } + state.setPaneData({ + paneId: context.pane.id, + data: { + terminalId: crypto.randomUUID(), + } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: undefined, + }); + void utils.terminal.listSessions.invalidate({ workspaceId }); + setIsOpen(false); + }; + + const hostTitle = + runtimeTitle !== undefined ? runtimeTitle : currentSession?.title; + const titleOverride = context.pane.titleOverride; + const triggerTitle = hostTitle ?? titleOverride ?? "Terminal"; + + return ( + <DropdownMenu open={isOpen} onOpenChange={setIsOpen}> + <DropdownMenuTrigger asChild> + <button + type="button" + aria-label="Terminal sessions" + title={triggerTitle} + className="flex min-w-32 max-w-96 items-center gap-1.5 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > + <TerminalSquare className="size-3.5 shrink-0" /> + <span className="min-w-0 flex-1 truncate text-left"> + {triggerTitle} + </span> + {sessionsQuery.isFetching && isOpen ? ( + <LoaderCircle className="size-3 shrink-0 animate-spin" /> + ) : ( + <ChevronDown className="size-3 shrink-0" /> + )} + </button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start" className="w-96"> + <DropdownMenuLabel className="flex items-center gap-2 text-xs"> + <span className="min-w-0 flex-1 truncate">Terminal Sessions</span> + <button + type="button" + aria-label="New terminal" + title="New terminal" + className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + handleNewTerminal(); + }} + > + <Plus className="size-3.5" /> + </button> + </DropdownMenuLabel> + <DropdownMenuSeparator /> + <div className="max-h-80 overflow-y-auto"> + {sessions.length > 0 ? ( + sessions.map((session) => { + const isCurrent = session.terminalId === terminalId; + const location = renderTerminalPaneLocations.get( + session.terminalId, + )?.[0]; + const createdAtLabel = formatCreatedAt(session.createdAt); + const status = isCurrent + ? "Current" + : session.pending + ? "Starting" + : session.attached + ? "Attached" + : "Detached"; + const title = isCurrent + ? triggerTitle + : (session.title ?? location?.titleOverride ?? "Terminal"); + + return ( + <DropdownMenuItem + key={session.terminalId} + className="group flex items-center gap-2" + onSelect={(_event) => { + handleSelectSession(session); + }} + > + <span className="w-4 shrink-0"> + {isCurrent && <Check className="size-3.5" />} + </span> + <span className="min-w-0 flex-1 truncate text-xs"> + {title} + </span> + <span className="shrink-0 text-xs text-muted-foreground/70"> + {createdAtLabel} + </span> + <span className="shrink-0 text-xs text-muted-foreground"> + {status} + </span> + <button + type="button" + aria-label={`Remove terminal ${session.createdAt ? createdAtLabel : "session"}`} + disabled={killTerminalSession.isPending} + className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-30 group-hover:opacity-100" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + handleRemoveTerminal(session); + }} + > + <Trash2 className="size-3" /> + </button> + </DropdownMenuItem> + ); + }) + ) : ( + <div className="px-2 py-1.5 text-xs text-muted-foreground"> + No live sessions + </div> + )} + </div> + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts new file mode 100644 index 00000000000..320b21eb495 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts @@ -0,0 +1 @@ +export { TerminalSessionDropdown } from "./TerminalSessionDropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts new file mode 100644 index 00000000000..c9aacc8b15e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/index.ts @@ -0,0 +1 @@ +export { type LinkClickHint, useLinkClickHint } from "./useLinkClickHint"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts new file mode 100644 index 00000000000..4c6d5286f58 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkClickHint/useLinkClickHint.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface LinkClickHint { + clientX: number; + clientY: number; +} + +const HINT_DURATION_MS = 2000; +const MAX_HINTS_PER_SESSION = 2; + +let hintsRemaining = MAX_HINTS_PER_SESSION; + +export function useLinkClickHint() { + const [hint, setHint] = useState<LinkClickHint | null>(null); + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + const showHint = useCallback((clientX: number, clientY: number) => { + if (hintsRemaining <= 0) return; + hintsRemaining -= 1; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setHint({ clientX, clientY }); + timeoutRef.current = setTimeout(() => { + setHint(null); + timeoutRef.current = null; + }, HINT_DURATION_MS); + }, []); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { hint, showHint }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/index.ts new file mode 100644 index 00000000000..086ba38be7e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/index.ts @@ -0,0 +1 @@ +export { type HoveredLink, useLinkHoverState } from "./useLinkHoverState"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/useLinkHoverState.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/useLinkHoverState.ts new file mode 100644 index 00000000000..e635d62331a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useLinkHoverState/useLinkHoverState.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from "react"; +import type { LinkHoverInfo } from "renderer/lib/terminal/terminal-runtime-registry"; + +export interface HoveredLink { + clientX: number; + clientY: number; + info: LinkHoverInfo; + modifier: boolean; + shift: boolean; +} + +const MODIFIER_KEYS = new Set(["Meta", "Control", "Shift", "Alt"]); + +export function useLinkHoverState() { + const [hoveredLink, setHoveredLink] = useState<HoveredLink | null>(null); + const hovering = hoveredLink !== null; + + useEffect(() => { + if (!hovering) return; + const update = (event: KeyboardEvent) => { + if (!MODIFIER_KEYS.has(event.key)) return; + setHoveredLink((prev) => { + if (!prev) return null; + const nextModifier = event.metaKey || event.ctrlKey; + const nextShift = event.shiftKey; + if (prev.modifier === nextModifier && prev.shift === nextShift) { + return prev; + } + return { ...prev, modifier: nextModifier, shift: nextShift }; + }); + }; + window.addEventListener("keydown", update); + window.addEventListener("keyup", update); + return () => { + window.removeEventListener("keydown", update); + window.removeEventListener("keyup", update); + }; + }, [hovering]); + + const onHover = useCallback((event: MouseEvent, info: LinkHoverInfo) => { + setHoveredLink({ + clientX: event.clientX, + clientY: event.clientY, + info, + modifier: event.metaKey || event.ctrlKey, + shift: event.shiftKey, + }); + }, []); + + const onLeave = useCallback(() => { + setHoveredLink(null); + }, []); + + return { hoveredLink, onHover, onLeave }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/index.ts new file mode 100644 index 00000000000..5595c0f3953 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/index.ts @@ -0,0 +1 @@ +export { useTerminalAppearance } from "./useTerminalAppearance"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts new file mode 100644 index 00000000000..f3b137cb2e0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/hooks/useTerminalAppearance/useTerminalAppearance.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { + DEFAULT_TERMINAL_FONT_SIZE, + getDefaultTerminalAppearance, + sanitizeTerminalFontFamily, + type TerminalAppearance, +} from "renderer/lib/terminal/appearance"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useTerminalTheme } from "renderer/stores/theme"; + +const fallbackTheme = getDefaultTerminalAppearance().theme; + +export function useTerminalAppearance(): TerminalAppearance { + const terminalTheme = useTerminalTheme(); + const { data: fontSettings } = useQuery({ + queryKey: ["electron", "settings", "getFontSettings"], + queryFn: () => electronTrpcClient.settings.getFontSettings.query(), + staleTime: 30_000, + }); + + return useMemo(() => { + const theme = terminalTheme ?? fallbackTheme; + const fontFamily = sanitizeTerminalFontFamily( + fontSettings?.terminalFontFamily, + ); + const fontSize = + fontSettings?.terminalFontSize ?? DEFAULT_TERMINAL_FONT_SIZE; + + return { + theme, + background: theme.background ?? "#151110", + fontFamily, + fontSize, + }; + }, [terminalTheme, fontSettings]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/index.ts new file mode 100644 index 00000000000..3827916e916 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/index.ts @@ -0,0 +1 @@ +export { TerminalPane } from "./TerminalPane"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/utils.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/utils.ts new file mode 100644 index 00000000000..e27ba49361f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/utils.ts @@ -0,0 +1,5 @@ +import { quote } from "shell-quote"; + +export function shellEscapePaths(paths: string[]): string { + return quote(paths); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/index.ts new file mode 100644 index 00000000000..e141f4c6246 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/index.ts @@ -0,0 +1 @@ +export { usePaneRegistry } from "./usePaneRegistry"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx new file mode 100644 index 00000000000..8dc3ea26272 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -0,0 +1,474 @@ +import type { + ContextMenuActionConfig, + PaneRegistry, + RendererContext, +} from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { + Circle, + GitCompareArrows, + Globe, + MessageSquare, + TerminalSquare, +} from "lucide-react"; +import { useMemo } from "react"; +import { + LuArrowDownToLine, + LuClipboard, + LuClipboardCopy, + LuEraser, + LuPower, +} from "react-icons/lu"; +import { useHotkeyDisplay } from "renderer/hotkeys"; +import { getBaseName } from "renderer/lib/pathBasename"; +import { consumeTerminalBackgroundIntent } from "renderer/lib/terminal/terminal-background-intents"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { getV2NotificationSourcesForPane } from "renderer/stores/v2-notifications"; +import { V2NotificationStatusIndicator } from "../../components/V2NotificationStatusIndicator"; +import { + getDocument, + useSharedFileDocument, +} from "../../state/fileDocumentStore"; +import type { + BrowserPaneData, + ChatPaneData, + CommentPaneData, + DevtoolsPaneData, + FilePaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; +import { BrowserPane, BrowserPaneToolbar } from "./components/BrowserPane"; +import { ChatPane } from "./components/ChatPane"; +import { ChatPaneTitle } from "./components/ChatPane/components/ChatPaneTitle"; +import { CommentPane } from "./components/CommentPane"; +import { CommentPaneHeaderExtras } from "./components/CommentPane/components/CommentPaneHeaderExtras"; +import { CommentPaneTitle } from "./components/CommentPane/components/CommentPaneTitle"; +import { DiffPane } from "./components/DiffPane"; +import { DiffPaneHeaderExtras } from "./components/DiffPane/components/DiffPaneHeaderExtras"; +import { FilePane } from "./components/FilePane"; +import { FilePaneHeaderExtras } from "./components/FilePane/components/FilePaneHeaderExtras"; +import { TerminalPane } from "./components/TerminalPane"; +import { TerminalHeaderExtras } from "./components/TerminalPane/components/TerminalHeaderExtras"; +import { TerminalSessionDropdown } from "./components/TerminalPane/components/TerminalSessionDropdown"; + +function getFileName(filePath: string): string { + return getBaseName(filePath); +} + +function FilePaneTabTitle({ + filePath, + isActive, + pinned, + workspaceId, +}: { + filePath: string; + isActive: boolean; + pinned: boolean; + workspaceId: string; +}) { + const document = useSharedFileDocument({ + workspaceId, + absolutePath: filePath, + }); + const name = getFileName(filePath); + return ( + <div + className={cn( + "flex min-w-0 items-center gap-1.5 text-xs transition-colors duration-150", + isActive ? "text-foreground" : "text-muted-foreground", + )} + title={filePath} + > + <FileIcon fileName={name} className="size-3.5 shrink-0" /> + <span className={cn("min-w-0 truncate", !pinned && "italic")}> + {name} + </span> + {document.dirty && ( + <Circle className="size-2 shrink-0 fill-current text-muted-foreground" /> + )} + </div> + ); +} + +const MOD_KEY = navigator.platform.toLowerCase().includes("mac") + ? "⌘" + : "Ctrl+"; + +interface UsePaneRegistryOptions { + onOpenFile: (path: string, openInNewTab?: boolean) => void; + onRevealPath: (path: string) => void; +} + +export function usePaneRegistry( + workspaceId: string, + { onOpenFile, onRevealPath }: UsePaneRegistryOptions, +): PaneRegistry<PaneViewerData> { + const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; + const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; + const workspaceTrpcUtils = workspaceTrpc.useUtils(); + const { mutate: killTerminalSession, isPending: isKillingTerminalSession } = + workspaceTrpc.terminal.killSession.useMutation({ + onSuccess: () => { + toast.success("Terminal session killed"); + void workspaceTrpcUtils.terminal.listSessions.invalidate({ + workspaceId, + }); + }, + onError: (error) => { + toast.error("Failed to kill terminal session", { + description: error.message, + }); + }, + }); + // onAfterClose-driven kill: silent on both success and failure, since + // the user's intent was already expressed by closing the pane. + const { mutate: killTerminalSessionSilently } = + workspaceTrpc.terminal.killSession.useMutation({ + onSuccess: () => { + void workspaceTrpcUtils.terminal.listSessions.invalidate({ + workspaceId, + }); + }, + onError: (error) => { + console.warn("Failed to kill removed terminal session", { + workspaceId, + error, + }); + }, + }); + + return useMemo<PaneRegistry<PaneViewerData>>( + () => ({ + file: { + getIcon: (ctx: RendererContext<PaneViewerData>) => { + const data = ctx.pane.data as FilePaneData; + const name = getFileName(data.filePath); + return <FileIcon fileName={name} className="size-4" />; + }, + getTitle: (pane) => getFileName((pane.data as FilePaneData).filePath), + renderTitle: (ctx: RendererContext<PaneViewerData>) => { + const data = ctx.pane.data as FilePaneData; + return ( + <FilePaneTabTitle + filePath={data.filePath} + isActive={ctx.isActive} + pinned={Boolean(ctx.pane.pinned)} + workspaceId={workspaceId} + /> + ); + }, + renderPane: (ctx: RendererContext<PaneViewerData>) => ( + <FilePane context={ctx} workspaceId={workspaceId} /> + ), + renderHeaderExtras: (ctx: RendererContext<PaneViewerData>) => ( + <FilePaneHeaderExtras context={ctx} workspaceId={workspaceId} /> + ), + onHeaderClick: (ctx: RendererContext<PaneViewerData>) => + ctx.actions.pin(), + onBeforeClose: (pane) => { + const data = pane.data as FilePaneData; + const doc = getDocument(workspaceId, data.filePath); + if (!doc?.dirty) return true; + const name = getFileName(data.filePath); + return new Promise<boolean>((resolve) => { + alert({ + title: `Do you want to save the changes you made to ${name}?`, + description: "Your changes will be lost if you don't save them.", + actions: [ + { + label: "Save", + onClick: async () => { + const doc = getDocument(workspaceId, data.filePath); + if (!doc) { + resolve(true); + return; + } + const result = await doc.save(); + // Only proceed to close if the save succeeded; otherwise + // leave the pane open so the user can see the conflict / + // error state and retry. + resolve(result.status === "saved"); + }, + }, + { + label: "Don't Save", + variant: "secondary", + onClick: async () => { + const doc = getDocument(workspaceId, data.filePath); + if (doc) await doc.reload(); + resolve(true); + }, + }, + { + label: "Cancel", + variant: "ghost", + onClick: () => resolve(false), + }, + ], + }); + }); + }, + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close File" } : d, + ), + }, + diff: { + getIcon: () => <GitCompareArrows className="size-3.5" />, + getTitle: () => "Changes", + renderPane: (ctx: RendererContext<PaneViewerData>) => ( + <DiffPane + context={ctx} + workspaceId={workspaceId} + onOpenFile={onOpenFile} + /> + ), + renderHeaderExtras: () => <DiffPaneHeaderExtras />, + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Diff" } : d, + ), + }, + terminal: { + getIcon: () => <TerminalSquare className="size-3.5" />, + getTitle: () => "Terminal", + onAfterClose: (pane) => { + const { terminalId } = pane.data as TerminalPaneData; + if (consumeTerminalBackgroundIntent(terminalId)) { + terminalRuntimeRegistry.release(terminalId); + return; + } + terminalRuntimeRegistry.dispose(terminalId); + killTerminalSessionSilently({ terminalId, workspaceId }); + }, + renderTitle: (ctx: RendererContext<PaneViewerData>) => ( + <div className="flex min-w-0 flex-1 items-center gap-1.5"> + <TerminalSessionDropdown context={ctx} workspaceId={workspaceId} /> + <V2NotificationStatusIndicator + workspaceId={workspaceId} + sources={getV2NotificationSourcesForPane(ctx.pane)} + /> + </div> + ), + renderHeaderExtras: (ctx: RendererContext<PaneViewerData>) => ( + <TerminalHeaderExtras context={ctx} /> + ), + renderPane: (ctx: RendererContext<PaneViewerData>) => ( + <TerminalPane + ctx={ctx} + workspaceId={workspaceId} + onOpenFile={onOpenFile} + onRevealPath={onRevealPath} + /> + ), + contextMenuActions: (_ctx, defaults) => { + const terminalActions: ContextMenuActionConfig<PaneViewerData>[] = [ + { + key: "copy", + label: "Copy", + icon: <LuClipboardCopy />, + shortcut: `${MOD_KEY}C`, + disabled: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + return !terminalRuntimeRegistry.getSelection( + terminalId, + ctx.pane.id, + ); + }, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + const text = terminalRuntimeRegistry.getSelection( + terminalId, + ctx.pane.id, + ); + if (text) navigator.clipboard.writeText(text); + }, + }, + { + key: "paste", + label: "Paste", + icon: <LuClipboard />, + shortcut: `${MOD_KEY}V`, + onSelect: async (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + try { + const text = await navigator.clipboard.readText(); + if (text) { + terminalRuntimeRegistry.paste( + terminalId, + text, + ctx.pane.id, + ); + } + } catch { + // Clipboard access denied + } + }, + }, + { key: "sep-terminal-clipboard", type: "separator" }, + { + key: "clear-terminal", + label: "Clear Terminal", + icon: <LuEraser />, + shortcut: + clearShortcut !== "Unassigned" ? clearShortcut : undefined, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + terminalRuntimeRegistry.clear(terminalId, ctx.pane.id); + }, + }, + { + key: "scroll-to-bottom", + label: "Scroll to Bottom", + icon: <LuArrowDownToLine />, + shortcut: + scrollToBottomShortcut !== "Unassigned" + ? scrollToBottomShortcut + : undefined, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + terminalRuntimeRegistry.scrollToBottom(terminalId, ctx.pane.id); + }, + }, + { key: "sep-terminal-defaults", type: "separator" }, + ]; + + const modifiedDefaults = defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Terminal" } : d, + ); + + const killAction: ContextMenuActionConfig<PaneViewerData> = { + key: "kill-terminal-session", + label: "Kill Terminal Session", + icon: <LuPower />, + variant: "destructive", + disabled: isKillingTerminalSession, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + killTerminalSession({ + terminalId, + workspaceId, + }); + }, + }; + + return [ + ...terminalActions, + ...modifiedDefaults, + { key: "sep-terminal-kill", type: "separator" }, + killAction, + ]; + }, + }, + browser: { + getIcon: () => <Globe className="size-3.5" />, + getTitle: (pane) => { + const data = pane.data as BrowserPaneData; + if (data.pageTitle) return data.pageTitle; + if (data.url && data.url !== "about:blank") { + try { + return new URL(data.url).host; + } catch {} + } + return "Browser"; + }, + renderPane: (ctx: RendererContext<PaneViewerData>) => ( + <BrowserPane ctx={ctx} /> + ), + renderToolbar: (ctx: RendererContext<PaneViewerData>) => ( + <BrowserPaneToolbar ctx={ctx} /> + ), + // Destruction handled by useGlobalBrowserLifecycle for now. + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Browser" } : d, + ), + }, + chat: { + getIcon: () => <MessageSquare className="size-3.5" />, + getTitle: () => "Chat", + renderTitle: (ctx: RendererContext<PaneViewerData>) => ( + <ChatPaneTitle context={ctx} workspaceId={workspaceId} /> + ), + renderPane: (ctx: RendererContext<PaneViewerData>) => { + const data = ctx.pane.data as ChatPaneData; + return ( + <ChatPane + workspaceId={workspaceId} + sessionId={data.sessionId} + onSessionIdChange={(id) => + ctx.actions.updateData({ ...data, sessionId: id }) + } + initialLaunchConfig={data.launchConfig ?? null} + onConsumeLaunchConfig={() => + ctx.actions.updateData({ ...data, launchConfig: null }) + } + /> + ); + }, + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Chat" } : d, + ), + }, + comment: { + getIcon: (ctx: RendererContext<PaneViewerData>) => { + const data = ctx.pane.data as CommentPaneData; + if (!data.avatarUrl) { + return <MessageSquare className="size-3.5" />; + } + return ( + <img + src={data.avatarUrl} + alt="" + className="size-3.5 rounded-full" + /> + ); + }, + getTitle: (pane) => { + const data = pane.data as CommentPaneData; + return data.authorLogin; + }, + renderTitle: (ctx: RendererContext<PaneViewerData>) => ( + <CommentPaneTitle context={ctx} /> + ), + renderPane: (ctx: RendererContext<PaneViewerData>) => ( + <CommentPane context={ctx} /> + ), + renderHeaderExtras: (ctx: RendererContext<PaneViewerData>) => ( + <CommentPaneHeaderExtras context={ctx} /> + ), + contextMenuActions: (_ctx, defaults) => + defaults.map((d) => + d.key === "close-pane" ? { ...d, label: "Close Comment" } : d, + ), + }, + devtools: { + getTitle: () => "DevTools", + renderPane: (ctx: RendererContext<PaneViewerData>) => { + const data = ctx.pane.data as DevtoolsPaneData; + return ( + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + Inspecting {data.targetTitle} + </div> + ); + }, + }, + }), + [ + workspaceId, + clearShortcut, + scrollToBottomShortcut, + killTerminalSession, + killTerminalSessionSilently, + isKillingTerminalSession, + onOpenFile, + onRevealPath, + ], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts new file mode 100644 index 00000000000..3f02e9c6a05 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts @@ -0,0 +1,2 @@ +export const RECENT_STORE_LIMIT = 25; +export const RECENT_DISPLAY_LIMIT = 10; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts new file mode 100644 index 00000000000..a6713ecc018 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts @@ -0,0 +1,6 @@ +export { RECENT_DISPLAY_LIMIT, RECENT_STORE_LIMIT } from "./constants"; +export { + type RecentFile, + type RecentlyViewedFilesApi, + useRecentlyViewedFiles, +} from "./useRecentlyViewedFiles"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts new file mode 100644 index 00000000000..f9a36b31c68 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts @@ -0,0 +1,59 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useMemo } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { RECENT_STORE_LIMIT } from "./constants"; + +export interface RecentFile { + relativePath: string; + absolutePath: string; + lastAccessedAt: number; +} + +interface RecentFileInput { + relativePath: string; + absolutePath: string; +} + +export interface RecentlyViewedFilesApi { + recentFiles: RecentFile[]; + recordView: (file: RecentFileInput) => void; +} + +export function useRecentlyViewedFiles( + workspaceId: string, +): RecentlyViewedFilesApi { + const collections = useCollections(); + + const { data: rows = [] } = useLiveQuery( + (query) => + query + .from({ state: collections.v2WorkspaceLocalState }) + .where(({ state }) => eq(state.workspaceId, workspaceId)), + [collections, workspaceId], + ); + const recentFiles = useMemo(() => rows[0]?.recentlyViewedFiles ?? [], [rows]); + + const recordView = useCallback( + (file: RecentFileInput) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + const current = draft.recentlyViewedFiles ?? []; + const withoutDup = current.filter( + (f) => f.relativePath !== file.relativePath, + ); + draft.recentlyViewedFiles = [ + { + relativePath: file.relativePath, + absolutePath: file.absolutePath, + lastAccessedAt: Date.now(), + }, + ...withoutDup, + ].slice(0, RECENT_STORE_LIMIT); + }); + }, + [collections, workspaceId], + ); + + return { recentFiles, recordView }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/index.ts new file mode 100644 index 00000000000..ce8b59c86fc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/index.ts @@ -0,0 +1 @@ +export { useSidebarDiffRef } from "./useSidebarDiffRef"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts new file mode 100644 index 00000000000..cbf779c25b7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts @@ -0,0 +1,51 @@ +import { workspaceTrpc } from "@superset/workspace-client"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { DiffRef } from "../useChangeset/types"; + +export function useSidebarDiffRef(workspaceId: string): DiffRef { + const collections = useCollections(); + const { data: rows = [] } = useLiveQuery( + (query) => + query + .from({ state: collections.v2WorkspaceLocalState }) + .where(({ state }) => eq(state.workspaceId, workspaceId)), + [collections, workspaceId], + ); + const sidebarState = rows[0]?.sidebarState; + const filter = sidebarState?.changesFilter ?? { kind: "all" }; + + const baseBranchQuery = workspaceTrpc.git.getBaseBranch.useQuery( + { workspaceId }, + { staleTime: Number.POSITIVE_INFINITY }, + ); + const baseBranch = baseBranchQuery.data?.baseBranch ?? null; + + const filterKind = filter.kind; + const commitHash = + filter.kind === "commit" + ? filter.hash + : filter.kind === "range" + ? filter.toHash + : null; + const fromHash = filter.kind === "range" ? filter.fromHash : null; + + return useMemo<DiffRef>(() => { + switch (filterKind) { + case "uncommitted": + return { kind: "uncommitted" }; + case "commit": + return { kind: "commit", commitHash: commitHash ?? "" }; + case "range": + return { + kind: "commit", + commitHash: commitHash ?? "", + fromHash: fromHash ?? undefined, + }; + default: + return { kind: "against-base", baseBranch }; + } + }, [filterKind, commitHash, fromHash, baseBranch]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/index.ts new file mode 100644 index 00000000000..1d931de3147 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/index.ts @@ -0,0 +1 @@ +export { useV2PresetExecution } from "./useV2PresetExecution"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts new file mode 100644 index 00000000000..d953a450a56 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts @@ -0,0 +1,190 @@ +import type { CreatePaneInput, WorkspaceStore } from "@superset/panes"; +import { toast } from "@superset/ui/sonner"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useMemo } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { getPresetLaunchPlan } from "renderer/stores/tabs/preset-launch"; +import { filterMatchingPresetsForProject } from "shared/preset-project-targeting"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData, TerminalPaneData } from "../../types"; + +function makeTerminalPane( + terminalId: string, + titleOverride?: string, + initialCommand?: string, +): CreatePaneInput<PaneViewerData> { + return { + kind: "terminal", + titleOverride, + data: { terminalId, initialCommand } as TerminalPaneData, + }; +} + +function resolveTarget(executionMode: V2TerminalPresetRow["executionMode"]) { + return executionMode === "split-pane" ? "active-tab" : "new-tab"; +} + +interface UseV2PresetExecutionArgs { + store: StoreApi<WorkspaceStore<PaneViewerData>>; + workspaceId: string; + projectId: string; +} + +export function useV2PresetExecution({ + store, + projectId, +}: UseV2PresetExecutionArgs) { + const collections = useCollections(); + + const { data: allPresets = [] } = useLiveQuery( + (query) => + query + .from({ v2TerminalPresets: collections.v2TerminalPresets }) + .orderBy(({ v2TerminalPresets }) => v2TerminalPresets.tabOrder), + [collections], + ); + + const matchedPresets = useMemo( + () => filterMatchingPresetsForProject(allPresets, projectId), + [allPresets, projectId], + ); + + const executePreset = useCallback( + (preset: V2TerminalPresetRow) => { + const state = store.getState(); + const activeTabId = state.activeTabId; + const target = resolveTarget(preset.executionMode); + + const plan = getPresetLaunchPlan({ + mode: preset.executionMode, + target, + commandCount: preset.commands.length, + hasActiveTab: !!activeTabId, + }); + + try { + switch (plan) { + case "new-tab-single": { + const id = crypto.randomUUID(); + state.addTab({ + panes: [ + makeTerminalPane( + id, + preset.name || undefined, + preset.commands[0], + ), + ], + }); + break; + } + + case "new-tab-multi-pane": { + const panes = preset.commands.map((command) => + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + command, + ), + ); + state.addTab({ + panes: + panes.length > 0 + ? (panes as [ + CreatePaneInput<PaneViewerData>, + ...CreatePaneInput<PaneViewerData>[], + ]) + : [ + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + ), + ], + }); + break; + } + + case "new-tab-per-command": { + for (const command of preset.commands) { + state.addTab({ + panes: [ + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + command, + ), + ], + }); + } + break; + } + + case "active-tab-single": { + const id = crypto.randomUUID(); + const pane = makeTerminalPane( + id, + preset.name || undefined, + preset.commands[0], + ); + if (!activeTabId) { + state.addTab({ + panes: [pane], + }); + break; + } + state.addPane({ + tabId: activeTabId, + pane, + }); + break; + } + + case "active-tab-multi-pane": { + const panes = preset.commands.map((command) => + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + command, + ), + ); + if (!activeTabId) { + state.addTab({ + panes: + panes.length > 0 + ? (panes as [ + CreatePaneInput<PaneViewerData>, + ...CreatePaneInput<PaneViewerData>[], + ]) + : [ + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + ), + ], + }); + break; + } + for (const pane of panes) { + state.addPane({ + tabId: activeTabId, + pane, + }); + } + break; + } + } + } catch (err) { + console.error("[useV2PresetExecution] Failed to execute preset:", err); + toast.error("Failed to run preset", { + description: + err instanceof Error + ? err.message + : "Terminal session creation failed.", + }); + } + }, + [store], + ); + + return { matchedPresets, executePreset }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/hooks/useV2WorkspacePaneLayout/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/hooks/useV2WorkspacePaneLayout/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts new file mode 100644 index 00000000000..36da8ff40c6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts @@ -0,0 +1,100 @@ +import { createWorkspaceStore, type WorkspaceState } from "@superset/panes"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { PaneViewerData } from "../../types"; + +const EMPTY_STATE: WorkspaceState<PaneViewerData> = { + version: 1, + tabs: [], + activeTabId: null, +}; + +function getSnapshot(state: WorkspaceState<PaneViewerData>): string { + return JSON.stringify(state); +} + +interface UseV2WorkspacePaneLayoutParams { + projectId: string; + workspaceId: string; +} + +export function useV2WorkspacePaneLayout({ + projectId, + workspaceId, +}: UseV2WorkspacePaneLayoutParams) { + const collections = useCollections(); + const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); + const [store] = useState(() => + createWorkspaceStore<PaneViewerData>({ + initialState: EMPTY_STATE, + }), + ); + const lastSyncedSnapshotRef = useRef(getSnapshot(EMPTY_STATE)); + + const { data: localWorkspaceRows = [] } = useLiveQuery( + (query) => + query + .from({ v2WorkspaceLocalState: collections.v2WorkspaceLocalState }) + .where(({ v2WorkspaceLocalState }) => + eq(v2WorkspaceLocalState.workspaceId, workspaceId), + ), + [collections, workspaceId], + ); + const localWorkspaceState = localWorkspaceRows[0] ?? null; + const persistedPaneLayout = useMemo( + () => + (localWorkspaceState?.paneLayout as + | WorkspaceState<PaneViewerData> + | undefined) ?? EMPTY_STATE, + [localWorkspaceState], + ); + + useEffect(() => { + ensureWorkspaceInSidebar(workspaceId, projectId); + }, [ensureWorkspaceInSidebar, projectId, workspaceId]); + + useEffect(() => { + const nextSnapshot = getSnapshot(persistedPaneLayout); + if (nextSnapshot === lastSyncedSnapshotRef.current) { + return; + } + + lastSyncedSnapshotRef.current = nextSnapshot; + store.getState().replaceState(persistedPaneLayout); + }, [persistedPaneLayout, store]); + + useEffect(() => { + const unsubscribe = store.subscribe((nextStore) => { + const nextSnapshot = getSnapshot({ + version: nextStore.version, + tabs: nextStore.tabs, + activeTabId: nextStore.activeTabId, + }); + if (nextSnapshot === lastSyncedSnapshotRef.current) { + return; + } + + if (!collections.v2WorkspaceLocalState.get(workspaceId)) { + return; + } + + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.paneLayout = { + version: nextStore.version, + tabs: nextStore.tabs, + activeTabId: nextStore.activeTabId, + }; + }); + lastSyncedSnapshotRef.current = nextSnapshot; + }); + + return () => { + unsubscribe(); + }; + }, [collections, store, workspaceId]); + + return { store }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useViewedFiles/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useViewedFiles/index.ts new file mode 100644 index 00000000000..1a79d1ac3a1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useViewedFiles/index.ts @@ -0,0 +1 @@ +export { useViewedFiles, type ViewedFilesApi } from "./useViewedFiles"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useViewedFiles/useViewedFiles.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useViewedFiles/useViewedFiles.ts new file mode 100644 index 00000000000..d534f113a87 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useViewedFiles/useViewedFiles.ts @@ -0,0 +1,40 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useMemo } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +export interface ViewedFilesApi { + viewedSet: Set<string>; + setViewed: (path: string, next: boolean) => void; +} + +export function useViewedFiles(workspaceId: string): ViewedFilesApi { + const collections = useCollections(); + const { data: rows = [] } = useLiveQuery( + (query) => + query + .from({ state: collections.v2WorkspaceLocalState }) + .where(({ state }) => eq(state.workspaceId, workspaceId)), + [collections, workspaceId], + ); + const viewedFiles = rows[0]?.viewedFiles ?? []; + const viewedSet = useMemo(() => new Set(viewedFiles), [viewedFiles]); + + const setViewed = useCallback( + (path: string, next: boolean) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + const current = draft.viewedFiles ?? []; + const has = current.includes(path); + if (next && !has) { + draft.viewedFiles = [...current, path]; + } else if (!next && has) { + draft.viewedFiles = current.filter((p) => p !== path); + } + }); + }, + [collections, workspaceId], + ); + + return { viewedSet, setViewed }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/index.ts new file mode 100644 index 00000000000..b151bd7f0ef --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/index.ts @@ -0,0 +1 @@ +export { useWorkspaceFileNavigation } from "./useWorkspaceFileNavigation"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/useWorkspaceFileNavigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/useWorkspaceFileNavigation.ts new file mode 100644 index 00000000000..9f5eb8910de --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceFileNavigation/useWorkspaceFileNavigation.ts @@ -0,0 +1,161 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { V2UserPreferencesApi } from "renderer/hooks/useV2UserPreferences"; +import { + toAbsoluteWorkspacePath, + toRelativeWorkspacePath, +} from "shared/absolute-paths"; +import { useStore } from "zustand"; +import type { StoreApi } from "zustand/vanilla"; +import type { FilePaneData, PaneViewerData } from "../../types"; +import { + type RecentFile, + useRecentlyViewedFiles, +} from "../useRecentlyViewedFiles"; + +interface PendingReveal { + path: string; + isDirectory: boolean; +} + +export function useWorkspaceFileNavigation({ + workspaceId, + store, + setRightSidebarOpen, + setRightSidebarTab, +}: { + workspaceId: string; + store: StoreApi<WorkspaceStore<PaneViewerData>>; + setRightSidebarOpen: V2UserPreferencesApi["setRightSidebarOpen"]; + setRightSidebarTab: V2UserPreferencesApi["setRightSidebarTab"]; +}): { + openFilePane: (filePath: string, openInNewTab?: boolean) => void; + revealPath: ( + path: string, + options?: { + isDirectory?: boolean; + }, + ) => void; + selectedFilePath: string | undefined; + pendingReveal: PendingReveal | null; + recentFiles: RecentFile[]; + openFilePaths: Set<string>; +} { + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const worktreePath = workspaceQuery.data?.worktreePath ?? ""; + + const { recentFiles, recordView } = useRecentlyViewedFiles(workspaceId); + + const activeFilePanePath = useStore(store, (state) => { + const tab = state.tabs.find( + (candidate) => candidate.id === state.activeTabId, + ); + if (!tab?.activePaneId) return undefined; + const pane = tab.panes[tab.activePaneId]; + if (pane?.kind === "file") return (pane.data as FilePaneData).filePath; + return undefined; + }); + + const [selectedFilePath, setSelectedFilePath] = useState<string | undefined>( + activeFilePanePath, + ); + // Every reveal request is a fresh object, so the FilesTab effect keyed on + // `pendingReveal` re-runs even when the path is the same (for example, the + // user collapsed a folder and re-requested it from the terminal). + const [pendingReveal, setPendingReveal] = useState<PendingReveal | null>( + null, + ); + + useEffect(() => { + if (activeFilePanePath !== undefined) { + setSelectedFilePath(activeFilePanePath); + setPendingReveal({ path: activeFilePanePath, isDirectory: false }); + } + }, [activeFilePanePath]); + + const openFilePathsKey = useStore(store, (state) => + state.tabs + .flatMap((tab) => + Object.values(tab.panes) + .filter((pane) => pane.kind === "file") + .map((pane) => (pane.data as FilePaneData).filePath), + ) + .join("\u0000"), + ); + const openFilePaths = useMemo( + () => new Set(openFilePathsKey ? openFilePathsKey.split("\u0000") : []), + [openFilePathsKey], + ); + + const openFilePane = useCallback( + (filePath: string, openInNewTab?: boolean) => { + const absoluteFilePath = worktreePath + ? toAbsoluteWorkspacePath(worktreePath, filePath) + : filePath; + if (worktreePath) { + const relativePath = toRelativeWorkspacePath( + worktreePath, + absoluteFilePath, + ); + if (relativePath && relativePath !== ".") { + recordView({ relativePath, absolutePath: absoluteFilePath }); + } + } + const state = store.getState(); + if (openInNewTab) { + state.addTab({ + panes: [ + { + kind: "file", + data: { + filePath: absoluteFilePath, + mode: "editor", + } as FilePaneData, + }, + ], + }); + return; + } + const active = state.getActivePane(); + if ( + active?.pane.kind === "file" && + (active.pane.data as FilePaneData).filePath === absoluteFilePath + ) { + state.setPanePinned({ paneId: active.pane.id, pinned: true }); + return; + } + state.openPane({ + pane: { + kind: "file", + data: { + filePath: absoluteFilePath, + mode: "editor", + } as FilePaneData, + }, + }); + }, + [store, worktreePath, recordView], + ); + + const revealPath = useCallback( + (path: string, options?: { isDirectory?: boolean }) => { + setRightSidebarOpen(true); + setRightSidebarTab("files"); + setSelectedFilePath(path); + setPendingReveal({ path, isDirectory: options?.isDirectory === true }); + }, + [setRightSidebarOpen, setRightSidebarTab], + ); + + return { + openFilePane, + revealPath, + selectedFilePath, + pendingReveal, + recentFiles, + openFilePaths, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/index.ts new file mode 100644 index 00000000000..fc3d7043003 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/index.ts @@ -0,0 +1 @@ +export { useWorkspaceHotkeys } from "./useWorkspaceHotkeys"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts new file mode 100644 index 00000000000..207ee80449c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -0,0 +1,302 @@ +import { + type FocusDirection, + getSpatialNeighborPaneId, + type PaneRegistry, + type WorkspaceStore, +} from "@superset/panes"; +import { useCallback, useMemo, useRef } from "react"; +import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; +import { useHotkey } from "renderer/hotkeys"; +import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import type { StoreApi } from "zustand"; +import type { + BrowserPaneData, + ChatPaneData, + DiffPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +export function useWorkspaceHotkeys({ + store, + matchedPresets, + executePreset, + paneRegistry, +}: { + store: StoreApi<WorkspaceStore<PaneViewerData>>; + matchedPresets: V2TerminalPresetRow[]; + executePreset: (preset: V2TerminalPresetRow) => void; + paneRegistry: PaneRegistry<PaneViewerData>; +}) { + const { setRightSidebarOpen, setRightSidebarTab } = useV2UserPreferences(); + const visiblePresets = useMemo( + () => matchedPresets.filter((preset) => preset.pinnedToBar !== false), + [matchedPresets], + ); + + useHotkey("TOGGLE_SIDEBAR", () => { + setRightSidebarOpen((prev) => !prev); + }); + + // --- Tab creation --- + + useHotkey("NEW_GROUP", () => { + store.getState().addTab({ + panes: [ + { + kind: "terminal", + data: { terminalId: crypto.randomUUID() } as TerminalPaneData, + }, + ], + }); + }); + + useHotkey("NEW_CHAT", () => { + store.getState().addTab({ + panes: [{ kind: "chat", data: { sessionId: null } as ChatPaneData }], + }); + }); + + useHotkey("NEW_BROWSER", () => { + store.getState().addTab({ + panes: [ + { + kind: "browser", + data: { + url: "about:blank", + } as BrowserPaneData, + }, + ], + }); + }); + + useHotkey("OPEN_DIFF_VIEWER", () => { + setRightSidebarOpen(true); + setRightSidebarTab("changes"); + + const state = store.getState(); + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "diff") continue; + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + state.addTab({ + panes: [ + { + kind: "diff", + data: { path: "", collapsedFiles: [] } as DiffPaneData, + }, + ], + }); + }); + + // --- Tab management --- + + const isClosingPaneRef = useRef(false); + useHotkey("CLOSE_PANE", async () => { + if (isClosingPaneRef.current) return; + isClosingPaneRef.current = true; + try { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + const definition = paneRegistry[active.pane.kind]; + if (definition?.onBeforeClose) { + const allowed = await definition.onBeforeClose(active.pane); + if (!allowed) return; + } + state.closePane({ tabId: active.tabId, paneId: active.pane.id }); + } finally { + isClosingPaneRef.current = false; + } + }); + + useHotkey("CLOSE_TAB", () => { + const state = store.getState(); + if (state.activeTabId) { + state.removeTab(state.activeTabId); + } + }); + + useHotkey("PREV_TAB", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const prevIndex = index <= 0 ? state.tabs.length - 1 : index - 1; + state.setActiveTab(state.tabs[prevIndex].id); + }); + + useHotkey("NEXT_TAB", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const nextIndex = + index >= state.tabs.length - 1 || index === -1 ? 0 : index + 1; + state.setActiveTab(state.tabs[nextIndex].id); + }); + + useHotkey("PREV_TAB_ALT", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const prevIndex = index <= 0 ? state.tabs.length - 1 : index - 1; + state.setActiveTab(state.tabs[prevIndex].id); + }); + + useHotkey("NEXT_TAB_ALT", () => { + const state = store.getState(); + if (!state.activeTabId || state.tabs.length === 0) return; + const index = state.tabs.findIndex((t) => t.id === state.activeTabId); + const nextIndex = + index >= state.tabs.length - 1 || index === -1 ? 0 : index + 1; + state.setActiveTab(state.tabs[nextIndex].id); + }); + + const switchToTab = useCallback( + (index: number) => { + const state = store.getState(); + const tab = state.tabs[index]; + if (tab) state.setActiveTab(tab.id); + }, + [store], + ); + + useHotkey("JUMP_TO_TAB_1", () => switchToTab(0)); + useHotkey("JUMP_TO_TAB_2", () => switchToTab(1)); + useHotkey("JUMP_TO_TAB_3", () => switchToTab(2)); + useHotkey("JUMP_TO_TAB_4", () => switchToTab(3)); + useHotkey("JUMP_TO_TAB_5", () => switchToTab(4)); + useHotkey("JUMP_TO_TAB_6", () => switchToTab(5)); + useHotkey("JUMP_TO_TAB_7", () => switchToTab(6)); + useHotkey("JUMP_TO_TAB_8", () => switchToTab(7)); + useHotkey("JUMP_TO_TAB_9", () => switchToTab(8)); + + // --- Pane management --- + + const moveFocusDirectional = useCallback( + (dir: FocusDirection) => { + const state = store.getState(); + const tab = state.getActiveTab(); + if (!tab || !tab.activePaneId) return; + const neighbor = getSpatialNeighborPaneId( + tab.layout, + tab.activePaneId, + dir, + ); + if (neighbor) state.setActivePane({ tabId: tab.id, paneId: neighbor }); + }, + [store], + ); + + useHotkey("FOCUS_PANE_LEFT", () => moveFocusDirectional("left")); + useHotkey("FOCUS_PANE_RIGHT", () => moveFocusDirectional("right")); + useHotkey("FOCUS_PANE_UP", () => moveFocusDirectional("up")); + useHotkey("FOCUS_PANE_DOWN", () => moveFocusDirectional("down")); + + useHotkey("SPLIT_AUTO", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "right", + newPane: { + kind: "terminal", + data: { terminalId: crypto.randomUUID() } as TerminalPaneData, + }, + }); + }); + + useHotkey("SPLIT_RIGHT", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "right", + newPane: { + kind: "terminal", + data: { terminalId: crypto.randomUUID() } as TerminalPaneData, + }, + }); + }); + + useHotkey("SPLIT_DOWN", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "bottom", + newPane: { + kind: "terminal", + data: { terminalId: crypto.randomUUID() } as TerminalPaneData, + }, + }); + }); + + useHotkey("SPLIT_WITH_CHAT", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "right", + newPane: { + kind: "chat", + data: { sessionId: null } as ChatPaneData, + }, + }); + }); + + useHotkey("SPLIT_WITH_BROWSER", () => { + const state = store.getState(); + const active = state.getActivePane(); + if (!active) return; + state.splitPane({ + tabId: active.tabId, + paneId: active.pane.id, + position: "right", + newPane: { + kind: "browser", + data: { + url: "about:blank", + } as BrowserPaneData, + }, + }); + }); + + useHotkey("EQUALIZE_PANE_SPLITS", () => { + const state = store.getState(); + const tab = state.getActiveTab(); + if (!tab) return; + state.equalizeTab({ tabId: tab.id }); + }); + + // --- Preset hotkeys --- + + const openPresetByIndex = useCallback( + (index: number) => { + const preset = visiblePresets[index]; + if (preset) executePreset(preset); + }, + [visiblePresets, executePreset], + ); + + useHotkey("OPEN_PRESET_1", () => openPresetByIndex(0)); + useHotkey("OPEN_PRESET_2", () => openPresetByIndex(1)); + useHotkey("OPEN_PRESET_3", () => openPresetByIndex(2)); + useHotkey("OPEN_PRESET_4", () => openPresetByIndex(3)); + useHotkey("OPEN_PRESET_5", () => openPresetByIndex(4)); + useHotkey("OPEN_PRESET_6", () => openPresetByIndex(5)); + useHotkey("OPEN_PRESET_7", () => openPresetByIndex(6)); + useHotkey("OPEN_PRESET_8", () => openPresetByIndex(7)); + useHotkey("OPEN_PRESET_9", () => openPresetByIndex(8)); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/index.ts new file mode 100644 index 00000000000..869f49ebfc2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/index.ts @@ -0,0 +1 @@ +export { useWorkspacePaneOpeners } from "./useWorkspacePaneOpeners"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts new file mode 100644 index 00000000000..9453e4e5fa6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts @@ -0,0 +1,150 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { useCallback } from "react"; +import type { StoreApi } from "zustand/vanilla"; +import type { + BrowserPaneData, + ChatPaneData, + CommentPaneData, + DiffPaneData, + PaneViewerData, + TerminalPaneData, +} from "../../types"; + +export function useWorkspacePaneOpeners({ + store, +}: { + store: StoreApi<WorkspaceStore<PaneViewerData>>; +}): { + openDiffPane: (filePath: string, openInNewTab?: boolean) => void; + addTerminalTab: () => void; + addChatTab: () => void; + addBrowserTab: () => void; + openCommentPane: (comment: CommentPaneData) => void; +} { + const openDiffPane = useCallback( + (filePath: string, openInNewTab?: boolean) => { + const state = store.getState(); + if (openInNewTab) { + state.addTab({ + panes: [ + { + kind: "diff", + data: { + path: filePath, + collapsedFiles: [], + expandedFiles: [filePath], + } as DiffPaneData, + }, + ], + }); + return; + } + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "diff") continue; + const prev = pane.data as DiffPaneData; + const prevExpanded = prev.expandedFiles ?? []; + state.setPaneData({ + paneId: pane.id, + data: { + ...prev, + path: filePath, + collapsedFiles: (prev.collapsedFiles ?? []).filter( + (p) => p !== filePath, + ), + expandedFiles: prevExpanded.includes(filePath) + ? prevExpanded + : [...prevExpanded, filePath], + } as PaneViewerData, + }); + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + state.openPane({ + pane: { + kind: "diff", + data: { + path: filePath, + collapsedFiles: [], + expandedFiles: [filePath], + } as DiffPaneData, + }, + }); + }, + [store], + ); + + const addTerminalTab = useCallback(() => { + store.getState().addTab({ + panes: [ + { + kind: "terminal", + data: { + terminalId: crypto.randomUUID(), + } as TerminalPaneData, + }, + ], + }); + }, [store]); + + const addChatTab = useCallback(() => { + store.getState().addTab({ + panes: [ + { + kind: "chat", + data: { sessionId: null } as ChatPaneData, + }, + ], + }); + }, [store]); + + const addBrowserTab = useCallback(() => { + store.getState().addTab({ + panes: [ + { + kind: "browser", + data: { + url: "about:blank", + } as BrowserPaneData, + }, + ], + }); + }, [store]); + + const openCommentPane = useCallback( + (comment: CommentPaneData) => { + const state = store.getState(); + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "comment") continue; + state.setPaneData({ + paneId: pane.id, + data: comment as PaneViewerData, + }); + state.setActiveTab(tab.id); + state.setActivePane({ tabId: tab.id, paneId: pane.id }); + return; + } + } + state.addTab({ + panes: [ + { + kind: "comment", + data: comment as PaneViewerData, + }, + ], + }); + }, + [store], + ); + + return { + openDiffPane, + addTerminalTab, + addChatTab, + addBrowserTab, + openCommentPane, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index b937d6689e6..905da6d6121 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -1,18 +1,84 @@ +import { Workspace } from "@superset/panes"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute } from "@tanstack/react-router"; +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; +import { useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { PaneViewer } from "./components/PaneViewer"; -import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; +import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; +import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; +import { getV2NotificationSourcesForTab } from "renderer/stores/v2-notifications"; +import { WorkspaceNotFoundState } from "../components/WorkspaceNotFoundState"; +import { AddTabMenu } from "./components/AddTabMenu"; +import { V2NotificationStatusIndicator } from "./components/V2NotificationStatusIndicator"; +import { V2PresetsBar } from "./components/V2PresetsBar"; +import { WorkspaceEmptyState } from "./components/WorkspaceEmptyState"; +import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; +import { useBrowserShellInteractionPassthrough } from "./hooks/useBrowserShellInteractionPassthrough"; +import { useClearActivePaneAttention } from "./hooks/useClearActivePaneAttention"; +import { useConsumeAutomationRunLink } from "./hooks/useConsumeAutomationRunLink"; +import { useConsumeOpenUrlRequest } from "./hooks/useConsumeOpenUrlRequest"; +import { useConsumePendingLaunch } from "./hooks/useConsumePendingLaunch"; +import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; +import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; +import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; +import { usePaneRegistry } from "./hooks/usePaneRegistry"; +import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; +import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; +import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; +import { useWorkspaceFileNavigation } from "./hooks/useWorkspaceFileNavigation"; +import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; +import { useWorkspacePaneOpeners } from "./hooks/useWorkspacePaneOpeners"; +import { FileDocumentStoreProvider } from "./state/fileDocumentStore"; +import type { PaneViewerData } from "./types"; +import type { V2WorkspaceUrlOpenTarget } from "./utils/openUrlInV2Workspace"; + +interface WorkspaceSearch { + terminalId?: string; + chatSessionId?: string; + focusRequestId?: string; + openUrl?: string; + openUrlTarget?: V2WorkspaceUrlOpenTarget; + openUrlRequestId?: string; +} + +function parseOpenUrlTarget( + value: unknown, +): V2WorkspaceUrlOpenTarget | undefined { + if (value === "current-tab" || value === "new-tab") return value; + return undefined; +} + +function parseNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} export const Route = createFileRoute( "/_authenticated/_dashboard/v2-workspace/$workspaceId/", )({ component: V2WorkspacePage, + validateSearch: (raw: Record<string, unknown>): WorkspaceSearch => ({ + terminalId: parseNonEmptyString(raw.terminalId), + chatSessionId: parseNonEmptyString(raw.chatSessionId), + focusRequestId: parseNonEmptyString(raw.focusRequestId), + openUrl: parseNonEmptyString(raw.openUrl), + openUrlTarget: parseOpenUrlTarget(raw.openUrlTarget), + openUrlRequestId: parseNonEmptyString(raw.openUrlRequestId), + }), }); function V2WorkspacePage() { const { workspaceId } = Route.useParams(); + const { + terminalId, + chatSessionId, + focusRequestId, + openUrl, + openUrlTarget, + openUrlRequestId, + } = Route.useSearch(); const collections = useCollections(); const { data: workspaces } = useLiveQuery( @@ -33,30 +99,221 @@ function V2WorkspacePage() { } return ( - <V2WorkspacePageContent + // key={workspaceId} so each workspace gets its own pane store rather + // than sharing one and replaceState-ing data across switches. + <WorkspaceContent key={workspace.id} projectId={workspace.projectId} workspaceId={workspace.id} - workspaceName={workspace.name} + terminalId={terminalId} + chatSessionId={chatSessionId} + focusRequestId={focusRequestId} + openUrl={openUrl} + openUrlTarget={openUrlTarget} + openUrlRequestId={openUrlRequestId} /> ); } -function V2WorkspacePageContent({ +function WorkspaceContent({ projectId, workspaceId, - workspaceName, + terminalId, + chatSessionId, + focusRequestId, + openUrl, + openUrlTarget, + openUrlRequestId, }: { projectId: string; workspaceId: string; - workspaceName: string; + terminalId?: string; + chatSessionId?: string; + focusRequestId?: string; + openUrl?: string; + openUrlTarget?: V2WorkspaceUrlOpenTarget; + openUrlRequestId?: string; }) { + const { + preferences: v2UserPreferences, + setRightSidebarOpen, + setRightSidebarTab, + setRightSidebarWidth, + } = useV2UserPreferences(); + const { store } = useV2WorkspacePaneLayout({ + projectId, + workspaceId, + }); + useClearActivePaneAttention({ workspaceId, store }); + const { matchedPresets, executePreset } = useV2PresetExecution({ + store, + workspaceId, + projectId, + }); + useConsumePendingLaunch({ workspaceId, store }); + useConsumeAutomationRunLink({ + store, + terminalId, + chatSessionId, + focusRequestId, + }); + useConsumeOpenUrlRequest({ + store, + url: openUrl, + target: openUrlTarget, + requestId: openUrlRequestId, + }); + + const { + openFilePane, + revealPath, + selectedFilePath, + pendingReveal, + recentFiles, + openFilePaths, + } = useWorkspaceFileNavigation({ + workspaceId, + store, + setRightSidebarOpen, + setRightSidebarTab, + }); + + const paneRegistry = usePaneRegistry(workspaceId, { + onOpenFile: openFilePane, + onRevealPath: revealPath, + }); + const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); + const { + openDiffPane, + addTerminalTab, + addChatTab, + addBrowserTab, + openCommentPane, + } = useWorkspacePaneOpeners({ store }); + + const [quickOpenOpen, setQuickOpenOpen] = useState(false); + const handleQuickOpen = useCallback(() => setQuickOpenOpen(true), []); + const defaultPaneActions = useDefaultPaneActions(); + const onBeforeCloseTab = useDirtyTabCloseGuard({ workspaceId }); + + const sidebarOpen = v2UserPreferences.rightSidebarOpen; + // Fallback for rows persisted before the rightSidebarWidth field existed — + // the live collection skips zod defaults, so an older row reads undefined + // here and would render the ResizablePanel without a width (full-bleed). + const sidebarWidth = v2UserPreferences.rightSidebarWidth ?? 340; + const [isSidebarResizing, setIsSidebarResizing] = useState(false); + const { onSidebarResizeDragging, onWorkspaceInteractionStateChange } = + useBrowserShellInteractionPassthrough({ sidebarOpen }); + const handleSidebarResizingChange = useCallback( + (resizing: boolean) => { + setIsSidebarResizing(resizing); + onSidebarResizeDragging(resizing); + }, + [onSidebarResizeDragging], + ); + + // The sidebar slot lives at the dashboard layout level (next to TopBar) so + // the sidebar runs full-height. The slot is mounted by the parent layout + // before this child renders, so look it up synchronously during state init — + // otherwise users with rightSidebarOpen=true persisted see a 1-frame flash + // while the post-mount effect fills the ref. + const [sidebarSlotEl, setSidebarSlotEl] = useState<HTMLElement | null>(() => + typeof document !== "undefined" + ? document.getElementById("workspace-right-sidebar-slot") + : null, + ); + useEffect(() => { + if (sidebarSlotEl) return; + setSidebarSlotEl(document.getElementById("workspace-right-sidebar-slot")); + }, [sidebarSlotEl]); + + useWorkspaceHotkeys({ + store, + matchedPresets, + executePreset, + paneRegistry, + }); + useHotkey("QUICK_OPEN", handleQuickOpen); + return ( - <PaneViewer - key={workspaceId} - projectId={projectId} - workspaceId={workspaceId} - workspaceName={workspaceName} - /> + <FileDocumentStoreProvider workspaceId={workspaceId}> + <div className="flex min-h-0 min-w-0 flex-1"> + <div + className="flex min-h-0 min-w-[320px] flex-1 flex-col overflow-hidden" + data-workspace-id={workspaceId} + > + <Workspace<PaneViewerData> + registry={paneRegistry} + paneActions={defaultPaneActions} + contextMenuActions={defaultContextMenuActions} + renderTabIcon={renderBrowserTabIcon} + renderTabAccessory={(tab) => ( + <V2NotificationStatusIndicator + workspaceId={workspaceId} + sources={getV2NotificationSourcesForTab(tab)} + /> + )} + renderBelowTabBar={() => ( + <V2PresetsBar + matchedPresets={matchedPresets} + executePreset={executePreset} + /> + )} + renderAddTabMenu={() => ( + <AddTabMenu + onAddTerminal={addTerminalTab} + onAddChat={addChatTab} + onAddBrowser={addBrowserTab} + /> + )} + renderEmptyState={() => ( + <WorkspaceEmptyState + onOpenBrowser={addBrowserTab} + onOpenChat={addChatTab} + onOpenQuickOpen={handleQuickOpen} + onOpenTerminal={addTerminalTab} + /> + )} + onBeforeCloseTab={onBeforeCloseTab} + onInteractionStateChange={onWorkspaceInteractionStateChange} + store={store} + /> + </div> + </div> + {sidebarOpen && + sidebarSlotEl && + createPortal( + <ResizablePanel + width={sidebarWidth} + onWidthChange={setRightSidebarWidth} + isResizing={isSidebarResizing} + onResizingChange={handleSidebarResizingChange} + minWidth={240} + maxWidth={640} + handleSide="left" + onDoubleClickHandle={() => setRightSidebarWidth(340)} + > + <WorkspaceSidebar + workspaceId={workspaceId} + onSelectFile={openFilePane} + onSelectDiffFile={openDiffPane} + onOpenComment={openCommentPane} + onSearch={handleQuickOpen} + selectedFilePath={selectedFilePath} + pendingReveal={pendingReveal} + /> + </ResizablePanel>, + sidebarSlotEl, + )} + <CommandPalette + workspaceId={workspaceId} + open={quickOpenOpen} + onOpenChange={setQuickOpenOpen} + onSelectFile={openFilePane} + variant="v2" + recentlyViewedFiles={recentFiles} + openFilePaths={openFilePaths} + /> + </FileDocumentStoreProvider> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx new file mode 100644 index 00000000000..7476fb01fff --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/FileDocumentStoreProvider.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; +import { useWorkspaceEvent } from "renderer/hooks/host-service/useWorkspaceEvent"; +import { dispatchFsEvent } from "./fileDocumentStore"; + +interface FileDocumentStoreProviderProps { + workspaceId: string; + children: ReactNode; +} + +export function FileDocumentStoreProvider({ + workspaceId, + children, +}: FileDocumentStoreProviderProps) { + useWorkspaceEvent("fs:events", workspaceId, (event) => { + dispatchFsEvent(workspaceId, event); + }); + + return <>{children}</>; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts new file mode 100644 index 00000000000..7e1120d7c70 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -0,0 +1,429 @@ +import type { workspaceTrpc } from "@superset/workspace-client"; +import type { FsWatchEvent } from "@superset/workspace-fs/client"; +import { isImageFile } from "shared/file-types"; +import type { + ConflictResolution, + ConflictState, + ContentState, + SaveResult, + SharedFileDocument, +} from "./types"; + +type WorkspaceTrpcClient = ReturnType<typeof workspaceTrpc.createClient>; + +interface DocumentEntry { + id: string; + workspaceId: string; + absolutePath: string; + trpcClient: WorkspaceTrpcClient; + content: ContentState; + savedContentText: string | null; + pendingSave: boolean; + saveError: Error | null; + conflict: ConflictState | null; + orphaned: boolean; + hasExternalChange: boolean; + isBinary: boolean | null; + byteSize: number | null; + refCount: number; + version: number; + subscribers: Set<() => void>; +} + +const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; +const BINARY_CHECK_SIZE = 8192; + +const entries = new Map<string, DocumentEntry>(); + +function key(workspaceId: string, absolutePath: string): string { + return `${workspaceId}:${absolutePath}`; +} + +function notify(entry: DocumentEntry): void { + entry.version += 1; + for (const listener of entry.subscribers) { + listener(); + } +} + +function computeDirty(entry: DocumentEntry): boolean { + if (entry.content.kind !== "text") return false; + if (entry.savedContentText === null) return false; + return entry.content.value !== entry.savedContentText; +} + +function resetForLoad(entry: DocumentEntry): void { + entry.content = { kind: "loading" }; + entry.savedContentText = null; + entry.conflict = null; + entry.hasExternalChange = false; + entry.saveError = null; +} + +function isBinaryText(content: string): boolean { + const checkLength = Math.min(content.length, BINARY_CHECK_SIZE); + for (let i = 0; i < checkLength; i += 1) { + if (content.charCodeAt(i) === 0) { + return true; + } + } + return false; +} + +function decodeBase64(value: string): Uint8Array { + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(value, "base64")); + } + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function toBytes(value: string | Uint8Array): Uint8Array { + return typeof value === "string" ? decodeBase64(value) : value; +} + +async function loadEntry( + entry: DocumentEntry, + options: { unlimited?: boolean } = {}, +): Promise<void> { + const client = entry.trpcClient; + const readAsBinary = isImageFile(entry.absolutePath); + const maxBytes = options.unlimited ? undefined : DEFAULT_MAX_BYTES; + try { + const result = await client.filesystem.readFile.query({ + workspaceId: entry.workspaceId, + absolutePath: entry.absolutePath, + encoding: readAsBinary ? undefined : "utf-8", + maxBytes, + }); + + entry.byteSize = result.byteLength; + entry.orphaned = false; + entry.hasExternalChange = false; + + if (result.exceededLimit) { + entry.content = { kind: "too-large" }; + notify(entry); + return; + } + + if (result.kind === "bytes") { + entry.isBinary = true; + entry.content = { + kind: "bytes", + value: toBytes(result.content), + revision: result.revision, + }; + notify(entry); + return; + } + + entry.isBinary = isBinaryText(result.content); + entry.content = { + kind: "text", + value: result.content, + revision: result.revision, + }; + entry.savedContentText = result.content; + notify(entry); + } catch (error) { + const isNotFound = isEnoentLikeError(error); + entry.content = isNotFound + ? { kind: "not-found" } + : { kind: "error", error: error as Error }; + notify(entry); + } +} + +function isEnoentLikeError(error: unknown): boolean { + if (!error) return false; + const message = + error instanceof Error ? error.message.toLowerCase() : String(error); + return ( + message.includes("enoent") || + message.includes("no such file") || + message.includes("not found") + ); +} + +async function fetchCurrentDiskContent( + entry: DocumentEntry, +): Promise<string | null> { + if (entry.isBinary) return null; + const client = entry.trpcClient; + try { + const result = await client.filesystem.readFile.query({ + workspaceId: entry.workspaceId, + absolutePath: entry.absolutePath, + encoding: "utf-8", + maxBytes: DEFAULT_MAX_BYTES, + }); + if (result.kind !== "text" || result.exceededLimit) return null; + if (isBinaryText(result.content)) return null; + return result.content; + } catch { + return null; + } +} + +function createHandle(entry: DocumentEntry): SharedFileDocument { + return { + get id() { + return entry.id; + }, + get workspaceId() { + return entry.workspaceId; + }, + get absolutePath() { + return entry.absolutePath; + }, + get content() { + return entry.content; + }, + get dirty() { + return computeDirty(entry); + }, + get pendingSave() { + return entry.pendingSave; + }, + get saveError() { + return entry.saveError; + }, + get conflict() { + return entry.conflict; + }, + get orphaned() { + return entry.orphaned; + }, + get hasExternalChange() { + return entry.hasExternalChange; + }, + get isBinary() { + return entry.isBinary; + }, + get byteSize() { + return entry.byteSize; + }, + setContent(next) { + if (entry.content.kind !== "text") return; + if (entry.content.value === next) return; + entry.content = { ...entry.content, value: next }; + notify(entry); + }, + async save(opts): Promise<SaveResult> { + if (entry.content.kind !== "text") { + return { + status: "error", + error: new Error("Cannot save non-text content"), + }; + } + const client = entry.trpcClient; + const currentValue = entry.content.value; + const currentRevision = entry.content.revision; + entry.pendingSave = true; + entry.saveError = null; + notify(entry); + try { + const result = await client.filesystem.writeFile.mutate({ + workspaceId: entry.workspaceId, + absolutePath: entry.absolutePath, + content: currentValue, + encoding: "utf-8", + precondition: + opts?.force || !currentRevision + ? undefined + : { ifMatch: currentRevision }, + }); + + entry.pendingSave = false; + + if (!result.ok) { + if (result.reason === "conflict") { + const diskContent = await fetchCurrentDiskContent(entry); + entry.conflict = { diskContent }; + entry.hasExternalChange = true; + notify(entry); + return { status: "conflict", diskContent }; + } + notify(entry); + return { status: result.reason }; + } + + if (entry.content.kind === "text") { + entry.content = { + ...entry.content, + revision: result.revision, + }; + } + entry.savedContentText = currentValue; + entry.conflict = null; + entry.hasExternalChange = false; + notify(entry); + return { status: "saved", revision: result.revision }; + } catch (error) { + entry.pendingSave = false; + entry.saveError = error as Error; + notify(entry); + return { status: "error", error: error as Error }; + } + }, + async reload() { + resetForLoad(entry); + notify(entry); + await loadEntry(entry); + }, + async loadUnlimited() { + resetForLoad(entry); + notify(entry); + await loadEntry(entry, { unlimited: true }); + }, + async resolveConflict(choice: ConflictResolution) { + if (!entry.conflict) return; + if (choice === "reload") { + await this.reload(); + return; + } + if (choice === "overwrite") { + entry.conflict = null; + notify(entry); + await this.save({ force: true }); + return; + } + // keep — dismiss the dialog; buffer stays dirty against stale revision + entry.conflict = null; + notify(entry); + }, + clearSaveError() { + if (entry.saveError === null) return; + entry.saveError = null; + notify(entry); + }, + subscribe(listener) { + entry.subscribers.add(listener); + return () => { + entry.subscribers.delete(listener); + }; + }, + getVersion() { + return entry.version; + }, + }; +} + +export function acquireDocument( + workspaceId: string, + absolutePath: string, + trpcClient: WorkspaceTrpcClient, +): SharedFileDocument { + const k = key(workspaceId, absolutePath); + let entry = entries.get(k); + if (!entry) { + entry = { + id: crypto.randomUUID(), + workspaceId, + absolutePath, + trpcClient, + content: { kind: "loading" }, + savedContentText: null, + pendingSave: false, + saveError: null, + conflict: null, + orphaned: false, + hasExternalChange: false, + isBinary: null, + byteSize: null, + refCount: 0, + version: 0, + subscribers: new Set(), + }; + entries.set(k, entry); + void loadEntry(entry); + } + entry.refCount += 1; + return createHandle(entry); +} + +export function releaseDocument( + workspaceId: string, + absolutePath: string, +): void { + const k = key(workspaceId, absolutePath); + const entry = entries.get(k); + if (!entry) return; + entry.refCount -= 1; + if (entry.refCount <= 0 && !computeDirty(entry) && !entry.orphaned) { + entries.delete(k); + } +} + +export function getDocument( + workspaceId: string, + absolutePath: string, +): SharedFileDocument | null { + const entry = entries.get(key(workspaceId, absolutePath)); + if (!entry) return null; + return createHandle(entry); +} + +/** + * Reacts to a workspace file-system event. Called by FileDocumentStoreProvider + * from its `useWorkspaceEvent("fs:events", ...)` subscription. + * + * The @parcel/watcher layer under `packages/workspace-fs/src/watch.ts` already + * coalesces rapid-fire events and pairs delete+create sequences into rename + * events, so a "delete" event here is a real delete — no additional debounce + * is required. + */ +export function dispatchFsEvent( + workspaceId: string, + event: FsWatchEvent, +): void { + // Snapshot before iterating — the rename branch below does entries.delete + + // entries.set on the same map, and JS Map iterators visit keys inserted + // mid-iteration, which would revisit the same entry and loop forever. + for (const entry of Array.from(entries.values())) { + if (entry.workspaceId !== workspaceId) continue; + const affects = + entry.absolutePath === event.absolutePath || + (event.kind === "rename" && event.oldAbsolutePath === entry.absolutePath); + if (!affects) continue; + + const isContentMutation = + event.kind === "create" || + event.kind === "update" || + event.kind === "overflow" || + (event.kind === "rename" && event.absolutePath === entry.absolutePath); + + if (event.kind === "delete") { + entry.orphaned = true; + notify(entry); + continue; + } + + if ( + event.kind === "rename" && + event.oldAbsolutePath === entry.absolutePath + ) { + const oldKey = key(entry.workspaceId, entry.absolutePath); + entries.delete(oldKey); + entry.absolutePath = event.absolutePath; + entries.set(key(entry.workspaceId, entry.absolutePath), entry); + notify(entry); + continue; + } + + if (isContentMutation) { + if (entry.orphaned) entry.orphaned = false; + if (computeDirty(entry)) { + entry.hasExternalChange = true; + notify(entry); + } else { + void loadEntry(entry); + } + } + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts new file mode 100644 index 00000000000..b21cfba5c96 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/index.ts @@ -0,0 +1,15 @@ +export { FileDocumentStoreProvider } from "./FileDocumentStoreProvider"; +export { + acquireDocument, + dispatchFsEvent, + getDocument, + releaseDocument, +} from "./fileDocumentStore"; +export type { + ConflictResolution, + ConflictState, + ContentState, + SaveResult, + SharedFileDocument, +} from "./types"; +export { useSharedFileDocument } from "./useSharedFileDocument"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts new file mode 100644 index 00000000000..30dd8523459 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/types.ts @@ -0,0 +1,49 @@ +export type ContentState = + | { kind: "loading" } + | { kind: "text"; value: string; revision: string } + | { kind: "bytes"; value: Uint8Array; revision: string } + | { kind: "not-found" } + | { kind: "too-large" } + | { kind: "is-directory" } + | { kind: "error"; error: Error }; + +export type SaveResult = + | { status: "saved"; revision: string } + | { status: "conflict"; diskContent: string | null } + | { status: "not-found" } + | { status: "exists" } + | { status: "error"; error: Error }; + +export type ConflictResolution = "reload" | "overwrite" | "keep"; + +export interface ConflictState { + diskContent: string | null; +} + +export interface SharedFileDocument { + readonly id: string; + readonly workspaceId: string; + readonly absolutePath: string; + + readonly content: ContentState; + readonly dirty: boolean; + + readonly pendingSave: boolean; + readonly saveError: Error | null; + readonly conflict: ConflictState | null; + readonly orphaned: boolean; + readonly hasExternalChange: boolean; + + readonly isBinary: boolean | null; + readonly byteSize: number | null; + + setContent(next: string): void; + save(opts?: { force?: boolean }): Promise<SaveResult>; + reload(): Promise<void>; + loadUnlimited(): Promise<void>; + resolveConflict(choice: ConflictResolution): Promise<void>; + clearSaveError(): void; + + subscribe(listener: () => void): () => void; + getVersion(): number; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts new file mode 100644 index 00000000000..2d6ac4e9233 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/useSharedFileDocument.ts @@ -0,0 +1,62 @@ +import { useWorkspaceClient } from "@superset/workspace-client"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { acquireDocument, releaseDocument } from "./fileDocumentStore"; +import type { SharedFileDocument } from "./types"; + +interface UseSharedFileDocumentParams { + workspaceId: string; + absolutePath: string; +} + +export function useSharedFileDocument({ + workspaceId, + absolutePath, +}: UseSharedFileDocumentParams): SharedFileDocument { + const { trpcClient } = useWorkspaceClient(); + + const [state, setState] = useState<{ + handle: SharedFileDocument; + workspaceId: string; + absolutePath: string; + }>(() => ({ + handle: acquireDocument(workspaceId, absolutePath, trpcClient), + workspaceId, + absolutePath, + })); + + // Swap handles synchronously when the pane is retargeted at a different + // file (e.g. a preview pane reassigned from env.ts to bun.lock). setState + // during render restarts the render before commit so consumers never + // observe a handle pointing at the previous file. + if ( + state.workspaceId !== workspaceId || + state.absolutePath !== absolutePath + ) { + // Rename case: the entry behind our existing handle was migrated to + // match the new props. Reuse the handle — acquiring again would bump + // refCount a second time and release() of the old key no-ops (the + // entry isn't at that key anymore), which would leak one lease per + // rename. + const handleAlreadyPointsAtNewPath = + state.handle.workspaceId === workspaceId && + state.handle.absolutePath === absolutePath; + const handle = handleAlreadyPointsAtNewPath + ? state.handle + : acquireDocument(workspaceId, absolutePath, trpcClient); + setState({ handle, workspaceId, absolutePath }); + } + + useEffect(() => { + return () => { + releaseDocument(workspaceId, absolutePath); + }; + }, [workspaceId, absolutePath]); + + useSyncExternalStore( + state.handle.subscribe, + state.handle.getVersion, + state.handle.getVersion, + ); + + return state.handle; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts new file mode 100644 index 00000000000..c5ef6e83376 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -0,0 +1,67 @@ +export interface FilePaneData { + filePath: string; + mode: "editor" | "diff" | "preview"; + language?: string; + viewId?: string; + forceViewId?: string; +} + +export interface TerminalPaneData { + terminalId: string; + initialCommand?: string; +} + +export interface ChatPaneData { + sessionId: string | null; + /** + * Transient initial launch config for a freshly-opened chat pane. + * Cleared by the chat pane on first consume. Set by the V2 workspace + * page's useConsumePendingLaunch when a pending chat launch exists. + */ + launchConfig?: { + initialPrompt?: string; + initialFiles?: Array<{ + data: string; + mediaType: string; + filename?: string; + }>; + model?: string; + taskSlug?: string; + } | null; +} + +export interface BrowserPaneData { + url: string; + pageTitle?: string; + faviconUrl?: string | null; +} + +export interface DevtoolsPaneData { + targetPaneId: string; + targetTitle: string; +} + +export interface DiffPaneData { + path: string; + collapsedFiles: string[]; + expandedFiles?: string[]; +} + +export interface CommentPaneData { + commentId: string; + authorLogin: string; + avatarUrl?: string; + body: string; + url?: string; + path?: string; + line?: number; +} + +export type PaneViewerData = + | FilePaneData + | TerminalPaneData + | ChatPaneData + | BrowserPaneData + | DevtoolsPaneData + | DiffPaneData + | CommentPaneData; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels/clickModifierLabels.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels/clickModifierLabels.ts new file mode 100644 index 00000000000..3e119840a76 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels/clickModifierLabels.ts @@ -0,0 +1,8 @@ +const isMac = + typeof navigator !== "undefined" && + navigator.platform.toLowerCase().includes("mac"); + +export const SHIFT_CLICK_LABEL = isMac ? "⇧ click" : "Shift+click"; +export const MOD_CLICK_LABEL = isMac ? "⌘ click" : "Ctrl+click"; + +export const CLICK_HINT_TOOLTIP = `${SHIFT_CLICK_LABEL}: new tab · ${MOD_CLICK_LABEL}: editor`; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels/index.ts new file mode 100644 index 00000000000..5045d828c11 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/clickModifierLabels/index.ts @@ -0,0 +1,5 @@ +export { + CLICK_HINT_TOOLTIP, + MOD_CLICK_LABEL, + SHIFT_CLICK_LABEL, +} from "./clickModifierLabels"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.test.ts new file mode 100644 index 00000000000..6d5e0ce876b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { + getOpenTargetClickIntent, + getSidebarClickIntent, + type ModifierClickEvent, +} from "./getSidebarClickIntent"; + +function event(init: Partial<ModifierClickEvent> = {}): ModifierClickEvent { + return { + ctrlKey: init.ctrlKey ?? false, + metaKey: init.metaKey ?? false, + shiftKey: init.shiftKey ?? false, + }; +} + +describe("getOpenTargetClickIntent", () => { + it("maps plain, shift, and mod clicks to shared open targets", () => { + expect(getOpenTargetClickIntent(event())).toBe("openInCurrentTab"); + expect(getOpenTargetClickIntent(event({ shiftKey: true }))).toBe( + "openInNewTab", + ); + expect(getOpenTargetClickIntent(event({ metaKey: true }))).toBe( + "openExternally", + ); + expect(getOpenTargetClickIntent(event({ ctrlKey: true }))).toBe( + "openExternally", + ); + }); +}); + +describe("getSidebarClickIntent", () => { + it("preserves file-sidebar labels over the shared open targets", () => { + expect(getSidebarClickIntent(event())).toBe("select"); + expect(getSidebarClickIntent(event({ shiftKey: true }))).toBe( + "openInNewTab", + ); + expect(getSidebarClickIntent(event({ metaKey: true }))).toBe( + "openInEditor", + ); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.ts new file mode 100644 index 00000000000..ce8e2874a13 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/getSidebarClickIntent.ts @@ -0,0 +1,29 @@ +export type SidebarClickIntent = "openInEditor" | "openInNewTab" | "select"; + +export type OpenTargetClickIntent = + | "openExternally" + | "openInNewTab" + | "openInCurrentTab"; + +export interface ModifierClickEvent { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; +} + +export function getOpenTargetClickIntent( + e: ModifierClickEvent, +): OpenTargetClickIntent { + if (e.metaKey || e.ctrlKey) return "openExternally"; + if (e.shiftKey) return "openInNewTab"; + return "openInCurrentTab"; +} + +export function getSidebarClickIntent( + e: ModifierClickEvent, +): SidebarClickIntent { + const intent = getOpenTargetClickIntent(e); + if (intent === "openExternally") return "openInEditor"; + if (intent === "openInNewTab") return "openInNewTab"; + return "select"; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/index.ts new file mode 100644 index 00000000000..f3b7308dcc4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/getSidebarClickIntent/index.ts @@ -0,0 +1,7 @@ +export { + getOpenTargetClickIntent, + getSidebarClickIntent, + type ModifierClickEvent, + type OpenTargetClickIntent, + type SidebarClickIntent, +} from "./getSidebarClickIntent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/index.ts new file mode 100644 index 00000000000..a4da2deb659 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/index.ts @@ -0,0 +1,4 @@ +export { + openUrlInV2Workspace, + type V2WorkspaceUrlOpenTarget, +} from "./openUrlInV2Workspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts new file mode 100644 index 00000000000..91eccc77193 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/utils/openUrlInV2Workspace/openUrlInV2Workspace.ts @@ -0,0 +1,28 @@ +import type { WorkspaceStore } from "@superset/panes"; +import type { StoreApi } from "zustand/vanilla"; +import type { BrowserPaneData, PaneViewerData } from "../../types"; + +export type V2WorkspaceUrlOpenTarget = "current-tab" | "new-tab"; + +export function openUrlInV2Workspace({ + store, + target, + url, +}: { + store: StoreApi<WorkspaceStore<PaneViewerData>>; + target: V2WorkspaceUrlOpenTarget; + url: string; +}): void { + const pane = { + kind: "browser", + data: { url } satisfies BrowserPaneData, + }; + const state = store.getState(); + + if (target === "new-tab") { + state.addTab({ panes: [pane] }); + return; + } + + state.openPane({ pane }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceNotFoundState/WorkspaceNotFoundState.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotFoundState/WorkspaceNotFoundState.tsx similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceNotFoundState/WorkspaceNotFoundState.tsx rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotFoundState/WorkspaceNotFoundState.tsx diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceNotFoundState/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotFoundState/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceNotFoundState/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/components/WorkspaceNotFoundState/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx index 8cb7390ba38..9547e5e3cd1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/layout.tsx @@ -1,12 +1,17 @@ -import { and, eq } from "@tanstack/db"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, Outlet, useMatchRoute } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getWorkspaceHostUrlForWorkspace } from "renderer/lib/v2-workspace-host"; +import { env } from "renderer/env.renderer"; +import { + getHostServiceHeaders, + getHostServiceWsToken, +} from "renderer/lib/host-service-auth"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useHostService } from "renderer/routes/_authenticated/providers/HostServiceProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { WorkspaceNotFoundState } from "./components/WorkspaceNotFoundState"; import { WorkspaceTrpcProvider } from "./providers/WorkspaceTrpcProvider"; export const Route = createFileRoute("/_authenticated/_dashboard/v2-workspace")( @@ -23,42 +28,32 @@ function V2WorkspaceLayout() { const workspaceId = workspaceMatch !== false ? workspaceMatch.workspaceId : null; const collections = useCollections(); - const { services } = useHostService(); + const { machineId, activeHostUrl } = useLocalHostService(); const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); - const { data: deviceInfo, isPending: isDeviceInfoPending } = - electronTrpc.auth.getDeviceInfo.useQuery(); - const { data: workspaces = [] } = useLiveQuery( + const { data: workspaces = [], isReady } = useLiveQuery( (q) => q .from({ v2Workspaces: collections.v2Workspaces }) - .where(({ v2Workspaces }) => eq(v2Workspaces.id, workspaceId ?? "")), + .where(({ v2Workspaces }) => eq(v2Workspaces.id, workspaceId ?? "")) + .select(({ v2Workspaces }) => ({ + id: v2Workspaces.id, + organizationId: v2Workspaces.organizationId, + hostId: v2Workspaces.hostId, + projectId: v2Workspaces.projectId, + branch: v2Workspaces.branch, + })), [collections, workspaceId], ); const workspace = workspaces[0] ?? null; - const { data: currentDevices = [] } = useLiveQuery( - (q) => - q - .from({ v2Devices: collections.v2Devices }) - .where(({ v2Devices }) => - and( - eq(v2Devices.clientId, deviceInfo?.deviceId ?? ""), - eq(v2Devices.organizationId, workspace?.organizationId ?? ""), - ), - ), - [collections, deviceInfo?.deviceId, workspace?.organizationId], - ); - const currentDevice = currentDevices[0] ?? null; - const localHostUrl = workspace - ? (services.get(workspace.organizationId)?.url ?? null) - : null; - const shouldWaitForDeviceInfo = workspace !== null && isDeviceInfoPending; - const hostUrl = - !workspace || shouldWaitForDeviceInfo - ? null - : workspace.deviceId === currentDevice?.id - ? localHostUrl - : getWorkspaceHostUrlForWorkspace(workspace.id); + + const isLocal = workspace?.hostId === machineId; + const hostUrl = !workspace + ? null + : isLocal + ? activeHostUrl + : `${env.RELAY_URL}/hosts/${buildHostRoutingKey(workspace.organizationId, workspace.hostId)}`; + const lastEnsuredWorkspaceIdRef = useRef<string | null>(null); useEffect(() => { @@ -68,24 +63,12 @@ function V2WorkspaceLayout() { ensureWorkspaceInSidebar(workspace.id, workspace.projectId); }, [ensureWorkspaceInSidebar, workspace]); - if (!workspaceId || !workspace) { - return <Outlet />; - } - - if (shouldWaitForDeviceInfo) { - return ( - <div className="flex h-full items-center justify-center text-muted-foreground"> - Resolving workspace host... - </div> - ); + if (!workspaceId || !isReady) { + return null; } - if (!hostUrl) { - return ( - <div className="flex h-full items-center justify-center text-muted-foreground"> - Workspace host service not available - </div> - ); + if (!workspace || !hostUrl) { + return <WorkspaceNotFoundState workspaceId={workspaceId} />; } return ( @@ -93,6 +76,8 @@ function V2WorkspaceLayout() { cacheKey={workspace.id} key={`${workspace.id}:${hostUrl}`} hostUrl={hostUrl} + headers={() => getHostServiceHeaders(hostUrl)} + wsToken={() => getHostServiceWsToken(hostUrl)} > <Outlet /> </WorkspaceTrpcProvider> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx index bd13c44de59..6533f56e4e3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider.tsx @@ -1,4 +1,5 @@ export { useWorkspaceHostUrl, + useWorkspaceWsUrl, WorkspaceClientProvider as WorkspaceTrpcProvider, } from "@superset/workspace-client"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/V2WorkspacePrHoverCardContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/V2WorkspacePrHoverCardContent.tsx new file mode 100644 index 00000000000..e6385030437 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/V2WorkspacePrHoverCardContent.tsx @@ -0,0 +1,88 @@ +import { Button } from "@superset/ui/button"; +import { formatDistanceToNow } from "date-fns"; +import { FaGithub } from "react-icons/fa"; +import { LuGitBranch } from "react-icons/lu"; +import type { V2WorkspacePrSummary } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import { ChecksList } from "./components/ChecksList"; +import { ChecksSummary } from "./components/ChecksSummary"; +import { PrStateBadge } from "./components/PrStateBadge"; +import { ReviewStatusBadge } from "./components/ReviewStatusBadge"; + +interface V2WorkspacePrHoverCardContentProps { + pr: V2WorkspacePrSummary; + branch: string; +} + +export function V2WorkspacePrHoverCardContent({ + pr, + branch, +}: V2WorkspacePrHoverCardContentProps) { + const showChecks = + (pr.state === "open" || pr.state === "draft") && pr.checksStatus !== "none"; + + return ( + <div className="space-y-3"> + <div className="space-y-0.5"> + <span className="text-[10px] uppercase tracking-wide text-muted-foreground"> + Branch + </span> + <div className="flex items-center gap-1.5 text-sm"> + <LuGitBranch className="size-3 shrink-0 text-muted-foreground" /> + <code className="block min-w-0 flex-1 break-all font-mono text-xs"> + {branch} + </code> + </div> + </div> + + <div className="space-y-2 border-t border-border pt-2"> + <div className="flex items-center justify-between gap-2"> + <div className="flex flex-wrap items-center gap-1.5"> + <span className="text-xs font-medium text-muted-foreground"> + #{pr.prNumber} + </span> + <PrStateBadge state={pr.state} /> + {pr.state === "open" || pr.state === "draft" ? ( + <ReviewStatusBadge status={pr.reviewDecision} /> + ) : null} + </div> + <div className="flex shrink-0 items-center gap-1.5 font-mono text-xs"> + <span className="text-emerald-500">+{pr.additions}</span> + <span className="text-destructive-foreground">-{pr.deletions}</span> + </div> + </div> + + <p className="line-clamp-2 text-xs leading-relaxed">{pr.title}</p> + + <span className="block text-[10px] text-muted-foreground"> + Updated {formatDistanceToNow(pr.updatedAt, { addSuffix: true })} + </span> + + {showChecks ? ( + <div className="space-y-2 pt-1"> + <div className="flex items-center gap-2 text-xs"> + <ChecksSummary checks={pr.checks} status={pr.checksStatus} /> + </div> + {pr.checks.length > 0 ? <ChecksList checks={pr.checks} /> : null} + </div> + ) : null} + + <Button + variant="outline" + size="sm" + className="mt-1 h-7 w-full gap-1.5 text-xs" + asChild + > + <a + href={pr.url} + target="_blank" + rel="noopener noreferrer" + onClick={(event) => event.stopPropagation()} + > + <FaGithub className="size-3" /> + View on GitHub + </a> + </Button> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/ChecksList.tsx new file mode 100644 index 00000000000..e199b80fa74 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/ChecksList.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import type { V2WorkspacePrSummary } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import { CheckRow } from "./components/CheckRow"; + +interface ChecksListProps { + checks: V2WorkspacePrSummary["checks"]; +} + +export function ChecksList({ checks }: ChecksListProps) { + const [expanded, setExpanded] = useState(false); + + const relevant = checks.filter( + (c) => c.status !== "skipped" && c.status !== "cancelled", + ); + if (relevant.length === 0) return null; + + return ( + <div className="text-xs"> + <button + type="button" + onClick={() => setExpanded((prev) => !prev)} + className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground" + > + {expanded ? ( + <LuChevronDown className="size-3" /> + ) : ( + <LuChevronRight className="size-3" /> + )} + <span>{expanded ? "Hide checks" : "Show checks"}</span> + </button> + + {expanded ? ( + <div className="mt-1.5 space-y-1 pl-1"> + {relevant.map((check) => ( + <CheckRow key={check.name} check={check} /> + ))} + </div> + ) : null} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/components/CheckRow/CheckRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/components/CheckRow/CheckRow.tsx new file mode 100644 index 00000000000..653b269a577 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/components/CheckRow/CheckRow.tsx @@ -0,0 +1,50 @@ +import { cn } from "@superset/ui/utils"; +import { LuCheck, LuLoaderCircle, LuMinus, LuX } from "react-icons/lu"; +import type { V2WorkspacePrSummary } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; + +const CHECK_ROW_CONFIG: Record< + V2WorkspacePrSummary["checks"][number]["status"], + { Icon: typeof LuCheck; className: string } +> = { + success: { Icon: LuCheck, className: "text-emerald-500" }, + failure: { Icon: LuX, className: "text-destructive-foreground" }, + pending: { Icon: LuLoaderCircle, className: "text-amber-500" }, + skipped: { Icon: LuMinus, className: "text-muted-foreground" }, + cancelled: { Icon: LuMinus, className: "text-muted-foreground" }, +}; + +interface CheckRowProps { + check: V2WorkspacePrSummary["checks"][number]; +} + +export function CheckRow({ check }: CheckRowProps) { + const { Icon, className } = CHECK_ROW_CONFIG[check.status]; + const content = ( + <span className="flex items-center gap-1.5 py-0.5"> + <Icon + className={cn( + "size-3 shrink-0", + className, + check.status === "pending" && "animate-spin", + )} + /> + <span className="truncate">{check.name}</span> + </span> + ); + + if (check.url) { + return ( + <a + href={check.url} + target="_blank" + rel="noopener noreferrer" + onClick={(event) => event.stopPropagation()} + className="block text-muted-foreground transition-colors hover:text-foreground" + > + {content} + </a> + ); + } + + return <div className="text-muted-foreground">{content}</div>; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/components/CheckRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/components/CheckRow/index.ts new file mode 100644 index 00000000000..da0bda99d0b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/components/CheckRow/index.ts @@ -0,0 +1 @@ +export { CheckRow } from "./CheckRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/index.ts new file mode 100644 index 00000000000..7fcb36d6b4e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksList/index.ts @@ -0,0 +1 @@ +export { ChecksList } from "./ChecksList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksSummary/ChecksSummary.tsx new file mode 100644 index 00000000000..9887023c853 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksSummary/ChecksSummary.tsx @@ -0,0 +1,36 @@ +import { cn } from "@superset/ui/utils"; +import { LuCheck, LuLoaderCircle, LuX } from "react-icons/lu"; +import type { + V2WorkspacePrChecksStatus, + V2WorkspacePrSummary, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; + +interface ChecksSummaryProps { + checks: V2WorkspacePrSummary["checks"]; + status: V2WorkspacePrChecksStatus; +} + +export function ChecksSummary({ checks, status }: ChecksSummaryProps) { + if (status === "none") return null; + + const passing = checks.filter((c) => c.status === "success").length; + const total = checks.filter( + (c) => c.status !== "skipped" && c.status !== "cancelled", + ).length; + + const config = { + success: { Icon: LuCheck, className: "text-emerald-500" }, + failure: { Icon: LuX, className: "text-destructive-foreground" }, + pending: { Icon: LuLoaderCircle, className: "text-amber-500" }, + } as const; + + const { Icon, className } = config[status]; + const label = total > 0 ? `${passing}/${total} checks` : "Checks"; + + return ( + <span className={cn("flex items-center gap-1", className)}> + <Icon className={cn("size-3", status === "pending" && "animate-spin")} /> + <span>{label}</span> + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksSummary/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksSummary/index.ts new file mode 100644 index 00000000000..25d7c97e309 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ChecksSummary/index.ts @@ -0,0 +1 @@ +export { ChecksSummary } from "./ChecksSummary"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/PrStateBadge/PrStateBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/PrStateBadge/PrStateBadge.tsx new file mode 100644 index 00000000000..46db24aca01 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/PrStateBadge/PrStateBadge.tsx @@ -0,0 +1,33 @@ +import { cn } from "@superset/ui/utils"; +import type { V2WorkspacePrState } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; + +const STATE_BADGE_STYLES: Record<V2WorkspacePrState, string> = { + open: "bg-emerald-500/15 text-emerald-500", + draft: "bg-muted text-muted-foreground", + merged: "bg-violet-500/15 text-violet-500", + closed: "bg-destructive/15 text-destructive-foreground", +}; + +const STATE_BADGE_LABELS: Record<V2WorkspacePrState, string> = { + open: "Open", + draft: "Draft", + merged: "Merged", + closed: "Closed", +}; + +interface PrStateBadgeProps { + state: V2WorkspacePrState; +} + +export function PrStateBadge({ state }: PrStateBadgeProps) { + return ( + <span + className={cn( + "shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium", + STATE_BADGE_STYLES[state], + )} + > + {STATE_BADGE_LABELS[state]} + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/PrStateBadge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/PrStateBadge/index.ts new file mode 100644 index 00000000000..167a6f6bf2d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/PrStateBadge/index.ts @@ -0,0 +1 @@ +export { PrStateBadge } from "./PrStateBadge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ReviewStatusBadge/ReviewStatusBadge.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ReviewStatusBadge/ReviewStatusBadge.tsx new file mode 100644 index 00000000000..50d4a8e5750 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ReviewStatusBadge/ReviewStatusBadge.tsx @@ -0,0 +1,31 @@ +import { cn } from "@superset/ui/utils"; +import type { V2WorkspacePrReviewDecision } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; + +const REVIEW_BADGE_STYLES: Record<V2WorkspacePrReviewDecision, string> = { + approved: "bg-emerald-500/15 text-emerald-500", + changes_requested: "bg-destructive/15 text-destructive-foreground", + pending: "bg-amber-500/15 text-amber-500", +}; + +const REVIEW_BADGE_LABELS: Record<V2WorkspacePrReviewDecision, string> = { + approved: "Approved", + changes_requested: "Changes requested", + pending: "Review pending", +}; + +interface ReviewStatusBadgeProps { + status: V2WorkspacePrReviewDecision; +} + +export function ReviewStatusBadge({ status }: ReviewStatusBadgeProps) { + return ( + <span + className={cn( + "max-w-[200px] shrink-0 truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium", + REVIEW_BADGE_STYLES[status], + )} + > + {REVIEW_BADGE_LABELS[status]} + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ReviewStatusBadge/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ReviewStatusBadge/index.ts new file mode 100644 index 00000000000..c66f831e645 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/components/ReviewStatusBadge/index.ts @@ -0,0 +1 @@ +export { ReviewStatusBadge } from "./ReviewStatusBadge"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/index.ts new file mode 100644 index 00000000000..c1b31e5c49d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent/index.ts @@ -0,0 +1 @@ +export { V2WorkspacePrHoverCardContent } from "./V2WorkspacePrHoverCardContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspaceProjectIcon/V2WorkspaceProjectIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspaceProjectIcon/V2WorkspaceProjectIcon.tsx new file mode 100644 index 00000000000..25b67452084 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspaceProjectIcon/V2WorkspaceProjectIcon.tsx @@ -0,0 +1,66 @@ +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; + +interface V2WorkspaceProjectIconProps { + projectName: string; + githubOwner: string | null; + size?: "sm" | "md"; + className?: string; +} + +const SIZE_CLASSES: Record< + NonNullable<V2WorkspaceProjectIconProps["size"]>, + string +> = { + sm: "size-5 text-[10px]", + md: "size-6 text-xs", +}; + +function githubAvatarUrl(owner: string): string { + return `https://github.com/${owner}.png?size=64`; +} + +export function V2WorkspaceProjectIcon({ + projectName, + githubOwner, + size = "md", + className, +}: V2WorkspaceProjectIconProps) { + const [failedOwner, setFailedOwner] = useState<string | null>(null); + const imageFailed = githubOwner != null && failedOwner === githubOwner; + const dimensions = SIZE_CLASSES[size]; + const showImage = githubOwner != null && !imageFailed; + + if (showImage) { + return ( + <div + className={cn( + "relative shrink-0 overflow-hidden rounded border border-border bg-muted", + dimensions, + className, + )} + > + <img + src={githubAvatarUrl(githubOwner)} + alt="" + aria-hidden + className="size-full object-cover" + onError={() => setFailedOwner(githubOwner)} + /> + </div> + ); + } + + return ( + <div + className={cn( + "flex shrink-0 items-center justify-center rounded border border-border bg-muted font-medium text-muted-foreground", + dimensions, + className, + )} + aria-hidden + > + {projectName.charAt(0).toUpperCase() || "?"} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspaceProjectIcon/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspaceProjectIcon/index.ts new file mode 100644 index 00000000000..ac3e77c0e69 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspaceProjectIcon/index.ts @@ -0,0 +1 @@ +export { V2WorkspaceProjectIcon } from "./V2WorkspaceProjectIcon"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx new file mode 100644 index 00000000000..3493409d1af --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/V2WorkspacesHeader.tsx @@ -0,0 +1,256 @@ +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@superset/ui/input-group"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { + LuFolders, + LuLaptop, + LuLayers, + LuMonitor, + LuSearch, + LuX, +} from "react-icons/lu"; +import type { + V2WorkspaceDeviceCounts, + V2WorkspaceHostOption, + V2WorkspaceProjectOption, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import { + DEVICE_FILTER_ALL, + DEVICE_FILTER_THIS_DEVICE, + PROJECT_FILTER_ALL, + useV2WorkspacesFilterStore, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; +import { V2WorkspaceProjectIcon } from "../V2WorkspaceProjectIcon"; +import { DeviceFilterTriggerLabel } from "./components/DeviceFilterTriggerLabel"; +import { DeviceOptionLabel } from "./components/DeviceOptionLabel"; +import { ProjectFilterTriggerLabel } from "./components/ProjectFilterTriggerLabel"; + +interface V2WorkspacesHeaderProps { + counts: V2WorkspaceDeviceCounts; + hostOptions: V2WorkspaceHostOption[]; + projectOptions: V2WorkspaceProjectOption[]; + hostsById: Map< + string, + { hostName: string; isOnline: boolean; isLocal: boolean } + >; + projectsById: Map< + string, + { projectName: string; githubOwner: string | null } + >; +} + +export function V2WorkspacesHeader({ + counts, + hostOptions, + projectOptions, + hostsById, + projectsById, +}: V2WorkspacesHeaderProps) { + const searchQuery = useV2WorkspacesFilterStore((state) => state.searchQuery); + const setSearchQuery = useV2WorkspacesFilterStore( + (state) => state.setSearchQuery, + ); + const deviceFilter = useV2WorkspacesFilterStore( + (state) => state.deviceFilter, + ); + const setDeviceFilter = useV2WorkspacesFilterStore( + (state) => state.setDeviceFilter, + ); + const projectFilter = useV2WorkspacesFilterStore( + (state) => state.projectFilter, + ); + const setProjectFilter = useV2WorkspacesFilterStore( + (state) => state.setProjectFilter, + ); + + const remoteHosts = hostOptions.filter((host) => !host.isLocal); + const selectedRemoteHostFromOptions = remoteHosts.find( + (host) => host.hostId === deviceFilter, + ); + const selectedHostFallback = + !selectedRemoteHostFromOptions && + deviceFilter !== DEVICE_FILTER_ALL && + deviceFilter !== DEVICE_FILTER_THIS_DEVICE + ? hostsById.get(deviceFilter) + : undefined; + const selectedHostLabel = selectedRemoteHostFromOptions + ? { + hostName: selectedRemoteHostFromOptions.hostName, + isOnline: selectedRemoteHostFromOptions.isOnline, + } + : selectedHostFallback + ? { + hostName: selectedHostFallback.hostName, + isOnline: selectedHostFallback.isOnline, + } + : undefined; + + const selectedProjectFromOptions = projectOptions.find( + (project) => project.projectId === projectFilter, + ); + const selectedProjectFallback = + !selectedProjectFromOptions && projectFilter !== PROJECT_FILTER_ALL + ? projectsById.get(projectFilter) + : undefined; + const selectedProjectLabel = selectedProjectFromOptions + ? { + projectName: selectedProjectFromOptions.projectName, + githubOwner: selectedProjectFromOptions.githubOwner, + } + : selectedProjectFallback + ? { + projectName: selectedProjectFallback.projectName, + githubOwner: selectedProjectFallback.githubOwner, + } + : undefined; + + return ( + <div className="border-b border-border"> + <div className="flex w-full flex-wrap items-center justify-between gap-3 px-6 py-4"> + <h1 className="text-sm font-semibold tracking-tight">Workspaces</h1> + + <div className="flex flex-wrap items-center gap-2"> + <InputGroup className="w-72"> + <InputGroupAddon align="inline-start"> + <LuSearch className="size-4" /> + </InputGroupAddon> + <InputGroupInput + type="text" + placeholder="Search workspaces…" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + /> + {searchQuery ? ( + <InputGroupAddon align="inline-end"> + <InputGroupButton + size="icon-xs" + aria-label="Clear search" + onClick={() => setSearchQuery("")} + > + <LuX /> + </InputGroupButton> + </InputGroupAddon> + ) : null} + </InputGroup> + + <Select value={projectFilter} onValueChange={setProjectFilter}> + <SelectTrigger size="sm" className="min-w-[12rem]"> + <SelectValue placeholder="Filter projects"> + <ProjectFilterTriggerLabel + projectFilter={projectFilter} + selectedProject={selectedProjectLabel} + /> + </SelectValue> + </SelectTrigger> + <SelectContent align="end" className="min-w-[16rem]"> + <SelectGroup> + <SelectItem value={PROJECT_FILTER_ALL}> + <span className="flex w-full min-w-0 items-center gap-2"> + <LuFolders className="size-3.5" /> + <span className="min-w-0 flex-1 truncate"> + All projects + </span> + </span> + </SelectItem> + </SelectGroup> + + {projectOptions.length > 0 ? ( + <> + <SelectSeparator /> + <SelectGroup> + <SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70"> + Projects + </SelectLabel> + {projectOptions.map((project) => ( + <SelectItem + key={project.projectId} + value={project.projectId} + > + <span className="flex w-full min-w-0 items-center gap-2"> + <V2WorkspaceProjectIcon + projectName={project.projectName} + githubOwner={project.githubOwner} + size="sm" + /> + <span className="min-w-0 flex-1 truncate"> + {project.projectName} + </span> + <span className="tabular-nums text-xs text-muted-foreground"> + {project.count} + </span> + </span> + </SelectItem> + ))} + </SelectGroup> + </> + ) : null} + </SelectContent> + </Select> + + <Select value={deviceFilter} onValueChange={setDeviceFilter}> + <SelectTrigger size="sm" className="min-w-[12rem]"> + <SelectValue placeholder="Filter devices"> + <DeviceFilterTriggerLabel + deviceFilter={deviceFilter} + selectedRemoteHost={selectedHostLabel} + /> + </SelectValue> + </SelectTrigger> + <SelectContent align="end" className="min-w-[16rem]"> + <SelectGroup> + <SelectItem value={DEVICE_FILTER_THIS_DEVICE}> + <DeviceOptionLabel + icon={<LuLaptop className="size-3.5" />} + label="This device" + count={counts.thisDevice} + /> + </SelectItem> + <SelectItem value={DEVICE_FILTER_ALL}> + <DeviceOptionLabel + icon={<LuLayers className="size-3.5" />} + label="All devices" + count={counts.all} + /> + </SelectItem> + </SelectGroup> + + {remoteHosts.length > 0 ? ( + <> + <SelectSeparator /> + <SelectGroup> + <SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70"> + Other devices + </SelectLabel> + {remoteHosts.map((host) => ( + <SelectItem key={host.hostId} value={host.hostId}> + <DeviceOptionLabel + icon={<LuMonitor className="size-3.5" />} + label={host.hostName} + count={host.count} + isOnline={host.isOnline} + /> + </SelectItem> + ))} + </SelectGroup> + </> + ) : null} + </SelectContent> + </Select> + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceFilterTriggerLabel/DeviceFilterTriggerLabel.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceFilterTriggerLabel/DeviceFilterTriggerLabel.tsx new file mode 100644 index 00000000000..e711a9d092e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceFilterTriggerLabel/DeviceFilterTriggerLabel.tsx @@ -0,0 +1,52 @@ +import { cn } from "@superset/ui/utils"; +import { LuLaptop, LuLayers, LuMonitor } from "react-icons/lu"; +import { + DEVICE_FILTER_ALL, + DEVICE_FILTER_THIS_DEVICE, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; + +interface DeviceFilterTriggerLabelProps { + deviceFilter: string; + selectedRemoteHost: { hostName: string; isOnline: boolean } | undefined; +} + +export function DeviceFilterTriggerLabel({ + deviceFilter, + selectedRemoteHost, +}: DeviceFilterTriggerLabelProps) { + if (deviceFilter === DEVICE_FILTER_ALL) { + return ( + <span className="flex items-center gap-2"> + <LuLayers className="size-3.5" /> + <span>All devices</span> + </span> + ); + } + if (deviceFilter === DEVICE_FILTER_THIS_DEVICE) { + return ( + <span className="flex items-center gap-2"> + <LuLaptop className="size-3.5" /> + <span>This device</span> + </span> + ); + } + return ( + <span className="flex min-w-0 items-center gap-2"> + <LuMonitor className="size-3.5" /> + <span className="min-w-0 truncate"> + {selectedRemoteHost?.hostName ?? "Unknown device"} + </span> + {selectedRemoteHost ? ( + <span + aria-hidden + className={cn( + "inline-block size-1.5 shrink-0 rounded-full", + selectedRemoteHost.isOnline + ? "bg-emerald-500" + : "bg-muted-foreground/40", + )} + /> + ) : null} + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceFilterTriggerLabel/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceFilterTriggerLabel/index.ts new file mode 100644 index 00000000000..c0b3d612b57 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceFilterTriggerLabel/index.ts @@ -0,0 +1 @@ +export { DeviceFilterTriggerLabel } from "./DeviceFilterTriggerLabel"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceOptionLabel/DeviceOptionLabel.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceOptionLabel/DeviceOptionLabel.tsx new file mode 100644 index 00000000000..e6e8ac6151d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceOptionLabel/DeviceOptionLabel.tsx @@ -0,0 +1,34 @@ +import { cn } from "@superset/ui/utils"; + +interface DeviceOptionLabelProps { + icon: React.ReactNode; + label: string; + count: number; + isOnline?: boolean; +} + +export function DeviceOptionLabel({ + icon, + label, + count, + isOnline, +}: DeviceOptionLabelProps) { + return ( + <span className="flex w-full min-w-0 items-center gap-2"> + {icon} + <span className="min-w-0 flex-1 truncate">{label}</span> + {isOnline !== undefined ? ( + <span + aria-hidden + className={cn( + "inline-block size-1.5 rounded-full", + isOnline ? "bg-emerald-500" : "bg-muted-foreground/40", + )} + /> + ) : null} + <span className="tabular-nums text-xs text-muted-foreground"> + {count} + </span> + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceOptionLabel/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceOptionLabel/index.ts new file mode 100644 index 00000000000..94fcf714fef --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/DeviceOptionLabel/index.ts @@ -0,0 +1 @@ +export { DeviceOptionLabel } from "./DeviceOptionLabel"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/ProjectFilterTriggerLabel/ProjectFilterTriggerLabel.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/ProjectFilterTriggerLabel/ProjectFilterTriggerLabel.tsx new file mode 100644 index 00000000000..e6b82faa86e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/ProjectFilterTriggerLabel/ProjectFilterTriggerLabel.tsx @@ -0,0 +1,42 @@ +import { LuFolders } from "react-icons/lu"; +import { V2WorkspaceProjectIcon } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspaceProjectIcon"; +import { PROJECT_FILTER_ALL } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; + +interface ProjectFilterTriggerLabelProps { + projectFilter: string; + selectedProject: + | { projectName: string; githubOwner: string | null } + | undefined; +} + +export function ProjectFilterTriggerLabel({ + projectFilter, + selectedProject, +}: ProjectFilterTriggerLabelProps) { + if (projectFilter === PROJECT_FILTER_ALL) { + return ( + <span className="flex items-center gap-2"> + <LuFolders className="size-3.5" /> + <span>All projects</span> + </span> + ); + } + if (!selectedProject) { + return ( + <span className="flex items-center gap-2"> + <LuFolders className="size-3.5" /> + <span className="text-muted-foreground">Unknown project</span> + </span> + ); + } + return ( + <span className="flex min-w-0 items-center gap-2"> + <V2WorkspaceProjectIcon + projectName={selectedProject.projectName} + githubOwner={selectedProject.githubOwner} + size="sm" + /> + <span className="min-w-0 truncate">{selectedProject.projectName}</span> + </span> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/ProjectFilterTriggerLabel/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/ProjectFilterTriggerLabel/index.ts new file mode 100644 index 00000000000..26c8c1a0ce4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/components/ProjectFilterTriggerLabel/index.ts @@ -0,0 +1 @@ +export { ProjectFilterTriggerLabel } from "./ProjectFilterTriggerLabel"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/index.ts new file mode 100644 index 00000000000..414cc108e0b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesHeader/index.ts @@ -0,0 +1 @@ +export { V2WorkspacesHeader } from "./V2WorkspacesHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx new file mode 100644 index 00000000000..062897594a8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/V2WorkspacesList.tsx @@ -0,0 +1,336 @@ +import { Button } from "@superset/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@superset/ui/empty"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; +import { useMatchRoute } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { + LuChevronDown, + LuChevronRight, + LuLayers, + LuSearchX, +} from "react-icons/lu"; +import type { + AccessibleV2Workspace, + V2WorkspaceHostType, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import { + DEVICE_FILTER_THIS_DEVICE, + PROJECT_FILTER_ALL, + useV2WorkspacesFilterStore, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; +import { useV2ProjectLocalMetaStore } from "renderer/stores/v2-project-local-meta"; +import { V2WorkspaceProjectIcon } from "../V2WorkspaceProjectIcon"; +import { SortableHeader } from "./components/SortableHeader"; +import { V2WorkspaceRow } from "./components/V2WorkspaceRow"; +import { V2_WORKSPACES_ROW_GRID } from "./constants"; +import type { SortDirection, SortField } from "./types"; + +interface V2WorkspacesListProps { + workspaces: AccessibleV2Workspace[]; +} + +interface ProjectGroup { + projectId: string; + projectName: string; + githubOwner: string | null; + workspaces: AccessibleV2Workspace[]; + latestCreatedAt: number; +} + +function hostTypeRank(hostType: V2WorkspaceHostType): number { + return hostType === "local-device" ? 0 : 1; +} + +function compareWorkspaces( + a: AccessibleV2Workspace, + b: AccessibleV2Workspace, + field: SortField, + direction: SortDirection, +): number { + let cmp = 0; + switch (field) { + case "sidebar": + cmp = Number(a.isInSidebar) - Number(b.isInSidebar); + break; + case "name": + cmp = a.name.localeCompare(b.name); + break; + case "host": + cmp = hostTypeRank(a.hostType) - hostTypeRank(b.hostType); + if (cmp === 0) cmp = a.hostName.localeCompare(b.hostName); + break; + case "branch": + cmp = a.branch.localeCompare(b.branch); + break; + case "created": + cmp = a.createdAt.getTime() - b.createdAt.getTime(); + break; + } + if (cmp === 0) { + cmp = b.createdAt.getTime() - a.createdAt.getTime(); + } + return direction === "asc" ? cmp : -cmp; +} + +function groupByProject( + workspaces: AccessibleV2Workspace[], + sortField: SortField, + sortDirection: SortDirection, +): ProjectGroup[] { + const projectsById = new Map<string, ProjectGroup>(); + + for (const workspace of workspaces) { + let project = projectsById.get(workspace.projectId); + if (!project) { + project = { + projectId: workspace.projectId, + projectName: workspace.projectName, + githubOwner: workspace.projectGithubOwner, + workspaces: [], + latestCreatedAt: 0, + }; + projectsById.set(workspace.projectId, project); + } + project.workspaces.push(workspace); + const createdAt = workspace.createdAt.getTime(); + if (createdAt > project.latestCreatedAt) { + project.latestCreatedAt = createdAt; + } + } + + for (const project of projectsById.values()) { + project.workspaces.sort((a, b) => + compareWorkspaces(a, b, sortField, sortDirection), + ); + } + + return Array.from(projectsById.values()).sort( + (a, b) => b.latestCreatedAt - a.latestCreatedAt, + ); +} + +const DEFAULT_DIRECTION_BY_FIELD: Record<SortField, SortDirection> = { + sidebar: "desc", + name: "asc", + host: "asc", + branch: "asc", + created: "desc", +}; + +export function V2WorkspacesList({ workspaces }: V2WorkspacesListProps) { + const matchRoute = useMatchRoute(); + const currentWorkspaceMatch = matchRoute({ + to: "/v2-workspace/$workspaceId", + }); + const currentWorkspaceId = + currentWorkspaceMatch !== false ? currentWorkspaceMatch.workspaceId : null; + + const searchQuery = useV2WorkspacesFilterStore((state) => state.searchQuery); + const deviceFilter = useV2WorkspacesFilterStore( + (state) => state.deviceFilter, + ); + const projectFilter = useV2WorkspacesFilterStore( + (state) => state.projectFilter, + ); + const resetFilters = useV2WorkspacesFilterStore((state) => state.reset); + + const [sortField, setSortField] = useState<SortField>("created"); + const [sortDirection, setSortDirection] = useState<SortDirection>("desc"); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDirection(DEFAULT_DIRECTION_BY_FIELD[field]); + } + }; + + const projectGroups = useMemo( + () => groupByProject(workspaces, sortField, sortDirection), + [workspaces, sortField, sortDirection], + ); + + const totalCount = projectGroups.reduce( + (total, project) => total + project.workspaces.length, + 0, + ); + const hasActiveFilters = + searchQuery.trim() !== "" || + deviceFilter !== DEVICE_FILTER_THIS_DEVICE || + projectFilter !== PROJECT_FILTER_ALL; + + const columnHeader = ( + <div + className={cn( + V2_WORKSPACES_ROW_GRID, + "sticky top-0 z-10 h-8 border-b border-border bg-background px-6 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/80", + )} + > + <SortableHeader + field="sidebar" + label="In sidebar" + align="center" + srOnlyLabel + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + /> + <SortableHeader + field="name" + label="Name" + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + /> + <SortableHeader + field="host" + label="Host" + className="hidden md:flex" + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + /> + <SortableHeader + field="branch" + label="Branch" + className="hidden lg:flex" + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + /> + <SortableHeader + field="created" + label="Created" + className="hidden xl:flex" + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + /> + </div> + ); + + if (totalCount === 0) { + return ( + <div className="flex min-h-0 flex-1 flex-col"> + {columnHeader} + <Empty className="flex-1 border-0"> + <EmptyHeader> + <EmptyMedia + variant="icon" + className="size-14 [&_svg:not([class*='size-'])]:size-7" + > + {hasActiveFilters ? <LuSearchX /> : <LuLayers />} + </EmptyMedia> + <EmptyTitle> + {hasActiveFilters + ? "No workspaces match your filters" + : "No workspaces yet"} + </EmptyTitle> + <EmptyDescription> + {hasActiveFilters + ? "Try a different search term or clear the device filter." + : "Workspaces you have access to across all your devices will show up here."} + </EmptyDescription> + </EmptyHeader> + {hasActiveFilters ? ( + <EmptyContent> + <Button + variant="outline" + size="sm" + onClick={() => resetFilters()} + > + Clear filters + </Button> + </EmptyContent> + ) : null} + </Empty> + </div> + ); + } + + return ( + <ScrollArea className="min-h-0 flex-1"> + <div className="flex w-full flex-col"> + {columnHeader} + + {projectGroups.map((project) => ( + <ProjectSection + key={project.projectId} + project={project} + currentWorkspaceId={currentWorkspaceId} + /> + ))} + </div> + </ScrollArea> + ); +} + +interface ProjectSectionProps { + project: ProjectGroup; + currentWorkspaceId: string | null; +} + +function ProjectSection({ project, currentWorkspaceId }: ProjectSectionProps) { + const persistedCollapsed = useV2ProjectLocalMetaStore( + (state) => state.projects[project.projectId]?.isCollapsed ?? false, + ); + const toggleCollapsed = useV2ProjectLocalMetaStore( + (state) => state.toggleProjectCollapsed, + ); + const containsCurrent = project.workspaces.some( + (workspace) => workspace.id === currentWorkspaceId, + ); + const isCollapsed = persistedCollapsed && !containsCurrent; + const Chevron = isCollapsed ? LuChevronRight : LuChevronDown; + + return ( + <div className="flex flex-col"> + <button + type="button" + onClick={() => toggleCollapsed(project.projectId)} + aria-expanded={!isCollapsed} + aria-controls={`v2-workspaces-project-${project.projectId}`} + className="sticky top-8 z-[5] flex w-full items-center gap-2 border-b border-border/60 bg-muted px-6 py-1.5 text-left transition-colors hover:bg-muted/80" + > + <Chevron className="size-3 shrink-0 text-muted-foreground" /> + <V2WorkspaceProjectIcon + projectName={project.projectName} + githubOwner={project.githubOwner} + size="sm" + /> + <h3 + className="min-w-0 truncate text-xs font-semibold text-foreground/80" + title={project.projectName} + > + {project.projectName} + </h3> + <span className="shrink-0 text-xs tabular-nums text-muted-foreground/60"> + {project.workspaces.length} + </span> + </button> + {isCollapsed ? null : ( + <ul + id={`v2-workspaces-project-${project.projectId}`} + className="flex flex-col" + > + {project.workspaces.map((workspace) => ( + <V2WorkspaceRow + key={workspace.id} + workspace={workspace} + isCurrentRoute={workspace.id === currentWorkspaceId} + /> + ))} + </ul> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx new file mode 100644 index 00000000000..3d5a0cf8de3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/SortableHeader.tsx @@ -0,0 +1,60 @@ +import { cn } from "@superset/ui/utils"; +import { LuChevronDown, LuChevronsUpDown, LuChevronUp } from "react-icons/lu"; +import type { SortDirection, SortField } from "../../types"; + +interface SortableHeaderProps { + field: SortField; + label: string; + align?: "start" | "center"; + className?: string; + sortField: SortField; + sortDirection: SortDirection; + onSort: (field: SortField) => void; + srOnlyLabel?: boolean; +} + +export function SortableHeader({ + field, + label, + align = "start", + className, + sortField, + sortDirection, + onSort, + srOnlyLabel = false, +}: SortableHeaderProps) { + const isActive = sortField === field; + const Icon = !isActive + ? LuChevronsUpDown + : sortDirection === "asc" + ? LuChevronUp + : LuChevronDown; + const sortLabel = isActive + ? sortDirection === "asc" + ? "ascending" + : "descending" + : "not sorted"; + + return ( + <button + type="button" + onClick={() => onSort(field)} + aria-label={`Sort by ${label}, currently ${sortLabel}`} + className={cn( + "group flex min-w-0 items-center gap-1 rounded outline-none transition-colors", + "hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40", + align === "center" && "justify-center", + isActive && "text-foreground", + className, + )} + > + <span className={cn("truncate", srOnlyLabel && "sr-only")}>{label}</span> + <Icon + className={cn( + "size-3 shrink-0 transition-opacity", + isActive ? "opacity-100" : "opacity-0 group-hover:opacity-60", + )} + /> + </button> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts new file mode 100644 index 00000000000..c85413e268f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/SortableHeader/index.ts @@ -0,0 +1 @@ +export { SortableHeader } from "./SortableHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx new file mode 100644 index 00000000000..fa6d1f62452 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/V2WorkspaceRow.tsx @@ -0,0 +1,302 @@ +import { Button } from "@superset/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@superset/ui/hover-card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { + LuCircleCheck, + LuCircleDashed, + LuCircleX, + LuGitBranch, + LuLaptop, + LuMinus, + LuMonitor, + LuPlus, +} from "react-icons/lu"; +import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; +import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { V2WorkspacePrHoverCardContent } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacePrHoverCardContent"; +import type { + AccessibleV2Workspace, + V2WorkspaceHostType, + V2WorkspacePrSummary, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { PRIcon } from "renderer/screens/main/components/PRIcon/PRIcon"; +import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; +import { V2_WORKSPACES_ROW_GRID } from "../../constants"; + +interface V2WorkspaceRowProps { + workspace: AccessibleV2Workspace; + isCurrentRoute: boolean; +} + +function hostIconFor(hostType: V2WorkspaceHostType) { + return hostType === "local-device" ? LuLaptop : LuMonitor; +} + +export function V2WorkspaceRow({ + workspace, + isCurrentRoute, +}: V2WorkspaceRowProps) { + const navigate = useNavigate(); + const { gateFeature } = usePaywall(); + const { + ensureWorkspaceInSidebar, + hideWorkspaceInSidebar, + removeWorkspaceFromSidebar, + } = useDashboardSidebarState(); + const isMainWorkspace = workspace.type === "main"; + + const HostIcon = hostIconFor(workspace.hostType); + + const treatAsOffline = + !workspace.hostIsOnline && workspace.hostType !== "local-device"; + + const handleOpen = useCallback(() => { + const open = () => navigateToV2Workspace(workspace.id, navigate); + if (workspace.hostType === "local-device") { + open(); + return; + } + gateFeature(GATED_FEATURES.REMOTE_WORKSPACES, open); + }, [gateFeature, navigate, workspace.hostType, workspace.id]); + + const handleAddToSidebar = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + const add = () => + ensureWorkspaceInSidebar(workspace.id, workspace.projectId); + if (workspace.hostType === "local-device") { + add(); + return; + } + gateFeature(GATED_FEATURES.REMOTE_WORKSPACES, add); + }, + [ + ensureWorkspaceInSidebar, + gateFeature, + workspace.hostType, + workspace.id, + workspace.projectId, + ], + ); + + const handleRemoveFromSidebar = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (isCurrentRoute) { + event.preventDefault(); + return; + } + if (isMainWorkspace) { + hideWorkspaceInSidebar(workspace.id, workspace.projectId); + return; + } + removeWorkspaceFromSidebar(workspace.id); + }, + [ + hideWorkspaceInSidebar, + isCurrentRoute, + isMainWorkspace, + removeWorkspaceFromSidebar, + workspace.id, + workspace.projectId, + ], + ); + + const creatorLabel = workspace.isCreatedByCurrentUser + ? "you" + : (workspace.createdByName ?? "unknown"); + + const timeLabel = getRelativeTime(workspace.createdAt.getTime(), { + format: "compact", + }); + + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleOpen(); + } + }, + [handleOpen], + ); + + const hostCell = ( + <span + className={cn( + "hidden min-w-0 items-center gap-1.5 text-xs text-muted-foreground md:flex", + treatAsOffline && "text-muted-foreground/60", + )} + title={workspace.hostName} + > + <HostIcon className="size-3 shrink-0" /> + <span className="min-w-0 truncate">{workspace.hostName}</span> + {treatAsOffline ? ( + <span + aria-hidden + className="inline-block size-1.5 shrink-0 rounded-full bg-muted-foreground/40" + /> + ) : null} + </span> + ); + + return ( + <li + aria-current={isCurrentRoute ? "page" : undefined} + className="border-b border-border/50 last:border-b-0" + > + {/* biome-ignore lint/a11y/useSemanticElements: interactive row needs nested buttons, so the outer element is a div with role/tabIndex */} + <div + role="button" + tabIndex={0} + onClick={handleOpen} + onKeyDown={handleRowKeyDown} + className={cn( + V2_WORKSPACES_ROW_GRID, + "group/row relative min-w-0 px-6 py-2.5 text-sm outline-none", + "cursor-pointer transition-colors", + "focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-inset", + isCurrentRoute + ? "bg-muted hover:bg-muted focus-visible:bg-muted" + : "hover:bg-accent/50 focus-visible:bg-accent/50", + )} + > + <div className="flex items-center justify-center"> + {workspace.isInSidebar ? ( + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <Button + size="icon" + variant="ghost" + onClick={handleRemoveFromSidebar} + aria-disabled={isCurrentRoute} + aria-label="Remove from sidebar" + className={cn( + "size-7", + isCurrentRoute && "cursor-not-allowed opacity-50", + )} + > + <LuMinus className="size-3.5" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right"> + {isCurrentRoute + ? "Can't remove the current workspace" + : "Remove from sidebar"} + </TooltipContent> + </Tooltip> + ) : ( + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <Button + size="icon" + variant="ghost" + onClick={handleAddToSidebar} + aria-label="Add to sidebar" + className="size-7" + > + <LuPlus className="size-3.5" /> + </Button> + </TooltipTrigger> + <TooltipContent side="right">Add to sidebar</TooltipContent> + </Tooltip> + )} + </div> + + <span className="flex min-w-0 items-center gap-2"> + <span + className="min-w-0 truncate font-medium text-foreground" + title={workspace.name} + > + {workspace.name} + </span> + {workspace.pr ? ( + <WorkspacePrPill pr={workspace.pr} branch={workspace.branch} /> + ) : null} + </span> + + {treatAsOffline ? ( + <Tooltip delayDuration={300}> + <TooltipTrigger asChild>{hostCell}</TooltipTrigger> + <TooltipContent side="top">Host is offline</TooltipContent> + </Tooltip> + ) : ( + hostCell + )} + + <span + className="hidden min-w-0 items-center gap-1.5 text-xs text-muted-foreground lg:flex" + title={workspace.branch} + > + <LuGitBranch className="size-3 shrink-0" /> + <span className="min-w-0 truncate font-mono text-[11px]"> + {workspace.branch} + </span> + </span> + + <span + className="hidden truncate text-xs tabular-nums text-muted-foreground xl:block" + title={`Created ${workspace.createdAt.toLocaleString()} by ${creatorLabel}`} + > + {timeLabel} · {creatorLabel} + </span> + </div> + </li> + ); +} + +interface WorkspacePrPillProps { + pr: V2WorkspacePrSummary; + branch: string; +} + +function WorkspacePrPill({ pr, branch }: WorkspacePrPillProps) { + return ( + <HoverCard openDelay={200} closeDelay={120}> + <HoverCardTrigger asChild> + <a + href={pr.url} + target="_blank" + rel="noreferrer" + onClick={(event) => event.stopPropagation()} + className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border/60 bg-muted/40 px-2 py-0.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + > + <PRIcon state={pr.state} className="size-3" /> + <span className="tabular-nums">#{pr.prNumber}</span> + <ChecksDot status={pr.checksStatus} /> + </a> + </HoverCardTrigger> + <HoverCardContent + side="top" + align="start" + className="w-80 p-3" + onClick={(event) => event.stopPropagation()} + > + <V2WorkspacePrHoverCardContent pr={pr} branch={branch} /> + </HoverCardContent> + </HoverCard> + ); +} + +interface ChecksDotProps { + status: V2WorkspacePrSummary["checksStatus"]; +} + +function ChecksDot({ status }: ChecksDotProps) { + if (status === "none") return null; + if (status === "pending") { + return <LuCircleDashed className="size-3 text-amber-500" />; + } + if (status === "success") { + return <LuCircleCheck className="size-3 text-emerald-500" />; + } + return <LuCircleX className="size-3 text-red-500" />; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/index.ts new file mode 100644 index 00000000000..447df34cacc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/components/V2WorkspaceRow/index.ts @@ -0,0 +1 @@ +export { V2WorkspaceRow } from "./V2WorkspaceRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts new file mode 100644 index 00000000000..ed4b034fa9a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/constants.ts @@ -0,0 +1,5 @@ +// Shared grid template used by the column header row and every workspace row +// so the Sidebar action / Name / Host / Branch / Created columns align across +// the whole view. Columns hide progressively on narrower viewports. +export const V2_WORKSPACES_ROW_GRID = + "grid grid-cols-[2.5rem_minmax(0,1fr)] gap-4 md:grid-cols-[2.5rem_minmax(0,1fr)_12rem] lg:grid-cols-[2.5rem_minmax(0,1fr)_12rem_14rem] xl:grid-cols-[2.5rem_minmax(0,1fr)_12rem_14rem_11rem] items-center"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/index.ts new file mode 100644 index 00000000000..72b240a1c0b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/index.ts @@ -0,0 +1 @@ +export { V2WorkspacesList } from "./V2WorkspacesList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts new file mode 100644 index 00000000000..09ae323f499 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/components/V2WorkspacesList/types.ts @@ -0,0 +1,2 @@ +export type SortField = "sidebar" | "name" | "host" | "branch" | "created"; +export type SortDirection = "asc" | "desc"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/index.ts new file mode 100644 index 00000000000..16534124495 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/index.ts @@ -0,0 +1,14 @@ +export { + type AccessibleV2Workspace, + type UseAccessibleV2WorkspacesResult, + useAccessibleV2Workspaces, + type V2WorkspaceDeviceCounts, + type V2WorkspaceHostOption, + type V2WorkspaceHostType, + type V2WorkspacePrChecksStatus, + type V2WorkspaceProjectGroup, + type V2WorkspaceProjectOption, + type V2WorkspacePrReviewDecision, + type V2WorkspacePrState, + type V2WorkspacePrSummary, +} from "./useAccessibleV2Workspaces"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts new file mode 100644 index 00000000000..eb904571c63 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces/useAccessibleV2Workspaces.ts @@ -0,0 +1,538 @@ +import type { CheckItem } from "@superset/local-db"; +import { and, eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { + DEVICE_FILTER_ALL, + DEVICE_FILTER_THIS_DEVICE, + PROJECT_FILTER_ALL, + type V2WorkspacesDeviceFilter, + type V2WorkspacesProjectFilter, +} from "renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { isSidebarWorkspaceVisible } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { MOCK_ORG_ID } from "shared/constants"; + +export type V2WorkspaceHostType = "local-device" | "remote-device"; + +export type V2WorkspacePrState = "open" | "merged" | "closed" | "draft"; + +export type V2WorkspacePrReviewDecision = + | "approved" + | "changes_requested" + | "pending"; + +export type V2WorkspacePrChecksStatus = + | "none" + | "pending" + | "success" + | "failure"; + +export interface V2WorkspacePrSummary { + prNumber: number; + title: string; + url: string; + state: V2WorkspacePrState; + checksStatus: V2WorkspacePrChecksStatus; + reviewDecision: V2WorkspacePrReviewDecision; + checks: CheckItem[]; + additions: number; + deletions: number; + updatedAt: Date; +} + +export interface AccessibleV2Workspace { + id: string; + name: string; + branch: string; + type: "main" | "worktree"; + createdAt: Date; + createdByUserId: string | null; + createdByName: string | null; + createdByImage: string | null; + isCreatedByCurrentUser: boolean; + projectId: string; + projectName: string; + projectRepoId: string | null; + projectGithubOwner: string | null; + hostId: string; + hostName: string; + hostIsOnline: boolean; + hostType: V2WorkspaceHostType; + isInSidebar: boolean; + pr: V2WorkspacePrSummary | null; +} + +export interface V2WorkspaceProjectGroup { + projectId: string; + projectName: string; + workspaces: AccessibleV2Workspace[]; +} + +export interface V2WorkspaceDeviceCounts { + all: number; + thisDevice: number; +} + +export interface V2WorkspaceHostOption { + hostId: string; + hostName: string; + isOnline: boolean; + isLocal: boolean; + count: number; +} + +export interface V2WorkspaceProjectOption { + projectId: string; + projectName: string; + githubOwner: string | null; + count: number; +} + +export interface UseAccessibleV2WorkspacesResult { + all: AccessibleV2Workspace[]; + pinned: AccessibleV2Workspace[]; + others: AccessibleV2Workspace[]; + counts: V2WorkspaceDeviceCounts; + hostOptions: V2WorkspaceHostOption[]; + projectOptions: V2WorkspaceProjectOption[]; + hostsById: Map< + string, + { hostName: string; isOnline: boolean; isLocal: boolean } + >; + projectsById: Map< + string, + { projectName: string; githubOwner: string | null } + >; +} + +interface UseAccessibleV2WorkspacesOptions { + searchQuery?: string; + deviceFilter?: V2WorkspacesDeviceFilter; + projectFilter?: V2WorkspacesProjectFilter; +} + +function workspaceMatchesSearch( + workspace: AccessibleV2Workspace, + searchQuery: string, +): boolean { + if (!searchQuery.trim()) return true; + const query = searchQuery.trim().toLowerCase(); + return ( + workspace.name.toLowerCase().includes(query) || + workspace.projectName.toLowerCase().includes(query) || + workspace.branch.toLowerCase().includes(query) || + workspace.hostName.toLowerCase().includes(query) || + (workspace.createdByName ?? "").toLowerCase().includes(query) || + (workspace.pr ? `#${workspace.pr.prNumber}`.includes(query) : false) || + (workspace.pr?.title.toLowerCase().includes(query) ?? false) + ); +} + +function matchesDeviceFilter( + workspace: AccessibleV2Workspace, + deviceFilter: V2WorkspacesDeviceFilter, + machineId: string | null, +): boolean { + if (deviceFilter === DEVICE_FILTER_ALL) return true; + if (deviceFilter === DEVICE_FILTER_THIS_DEVICE) { + return machineId == null || workspace.hostId === machineId; + } + return workspace.hostId === deviceFilter; +} + +function matchesProjectFilter( + workspace: AccessibleV2Workspace, + projectFilter: V2WorkspacesProjectFilter, +): boolean { + if (projectFilter === PROJECT_FILTER_ALL) return true; + return workspace.projectId === projectFilter; +} + +function prStateFor( + state: string, + isDraft: boolean, + mergedAt: Date | string | null, +): V2WorkspacePrState { + if (mergedAt != null) return "merged"; + if (isDraft) return "draft"; + if (state === "closed") return "closed"; + return "open"; +} + +function reviewDecisionFor( + raw: string | null | undefined, +): V2WorkspacePrReviewDecision { + if (raw === "APPROVED") return "approved"; + if (raw === "CHANGES_REQUESTED") return "changes_requested"; + return "pending"; +} + +type RawCheckEntry = { + name: string; + status: string; + conclusion: string | null; + detailsUrl?: string; +}; + +function checkItemStatusFor( + rawStatus: string, + rawConclusion: string | null, +): CheckItem["status"] { + if (rawStatus !== "completed") return "pending"; + switch (rawConclusion) { + case "success": + case "neutral": + return "success"; + case "skipped": + return "skipped"; + case "cancelled": + return "cancelled"; + case "failure": + case "timed_out": + case "action_required": + case "stale": + case "startup_failure": + return "failure"; + default: + return "pending"; + } +} + +function mapChecks(rawChecks: RawCheckEntry[] | null | undefined): CheckItem[] { + if (!rawChecks) return []; + return rawChecks.map((entry) => ({ + name: entry.name, + status: checkItemStatusFor(entry.status, entry.conclusion), + url: entry.detailsUrl, + })); +} + +export function useAccessibleV2Workspaces( + options: UseAccessibleV2WorkspacesOptions = {}, +): UseAccessibleV2WorkspacesResult { + const searchQuery = options.searchQuery ?? ""; + const deviceFilter = options.deviceFilter ?? DEVICE_FILTER_ALL; + const projectFilter = options.projectFilter ?? PROJECT_FILTER_ALL; + const { data: session } = authClient.useSession(); + const collections = useCollections(); + const { machineId } = useLocalHostService(); + + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + const currentUserId = session?.user?.id ?? null; + + const { data: rows = [] } = useLiveQuery( + (q) => + q + .from({ workspaces: collections.v2Workspaces }) + .innerJoin({ hosts: collections.v2Hosts }, ({ workspaces, hosts }) => + eq(workspaces.hostId, hosts.machineId), + ) + .innerJoin( + { userHosts: collections.v2UsersHosts }, + ({ hosts, userHosts }) => eq(userHosts.hostId, hosts.machineId), + ) + .innerJoin( + { projects: collections.v2Projects }, + ({ workspaces, projects }) => eq(workspaces.projectId, projects.id), + ) + .leftJoin( + { sidebarState: collections.v2WorkspaceLocalState }, + ({ workspaces, sidebarState }) => + eq(sidebarState.workspaceId, workspaces.id), + ) + .leftJoin( + { sidebarProject: collections.v2SidebarProjects }, + ({ projects, sidebarProject }) => + eq(sidebarProject.projectId, projects.id), + ) + .leftJoin( + { repos: collections.githubRepositories }, + ({ projects, repos }) => eq(projects.githubRepositoryId, repos.id), + ) + .leftJoin({ creators: collections.users }, ({ workspaces, creators }) => + eq(workspaces.createdByUserId, creators.id), + ) + .where(({ workspaces, userHosts }) => + and( + eq(workspaces.organizationId, activeOrganizationId ?? ""), + eq(userHosts.userId, currentUserId ?? ""), + ), + ) + .select( + ({ + workspaces, + hosts, + projects, + sidebarState, + sidebarProject, + repos, + creators, + }) => ({ + id: workspaces.id, + name: workspaces.name, + branch: workspaces.branch, + type: workspaces.type, + createdAt: workspaces.createdAt, + createdByUserId: workspaces.createdByUserId, + createdByName: creators?.name ?? null, + createdByImage: creators?.image ?? null, + projectId: projects.id, + projectName: projects.name, + projectRepoId: projects.githubRepositoryId, + projectGithubOwner: repos?.owner ?? null, + hostId: workspaces.hostId, + hostName: hosts.name, + hostIsOnline: hosts.isOnline, + sidebarProjectId: sidebarProject?.projectId ?? null, + sidebarWorkspaceId: sidebarState?.workspaceId ?? null, + sidebarIsHidden: sidebarState?.sidebarState.isHidden ?? false, + }), + ), + [activeOrganizationId, collections, currentUserId], + ); + + const { data: prRows = [] } = useLiveQuery( + (q) => + q + .from({ prs: collections.githubPullRequests }) + .where(({ prs }) => eq(prs.organizationId, activeOrganizationId ?? "")) + .select(({ prs }) => ({ + id: prs.id, + repositoryId: prs.repositoryId, + prNumber: prs.prNumber, + headBranch: prs.headBranch, + title: prs.title, + url: prs.url, + state: prs.state, + isDraft: prs.isDraft, + checksStatus: prs.checksStatus, + checks: prs.checks, + reviewDecision: prs.reviewDecision, + additions: prs.additions, + deletions: prs.deletions, + updatedAt: prs.updatedAt, + mergedAt: prs.mergedAt, + })), + [collections, activeOrganizationId], + ); + + const prsByRepoBranch = useMemo(() => { + const map = new Map<string, V2WorkspacePrSummary>(); + const stateRank: Record<string, number> = { + open: 0, + draft: 1, + merged: 2, + closed: 3, + }; + for (const row of prRows) { + const key = `${row.repositoryId}::${row.headBranch}`; + const candidate: V2WorkspacePrSummary = { + prNumber: row.prNumber, + title: row.title, + url: row.url, + state: prStateFor(row.state, row.isDraft, row.mergedAt), + checksStatus: (row.checksStatus as V2WorkspacePrChecksStatus) ?? "none", + reviewDecision: reviewDecisionFor(row.reviewDecision), + checks: mapChecks(row.checks as RawCheckEntry[] | null | undefined), + additions: row.additions, + deletions: row.deletions, + updatedAt: new Date(row.updatedAt), + }; + const existing = map.get(key); + if (!existing) { + map.set(key, candidate); + continue; + } + const cmpState = stateRank[candidate.state] - stateRank[existing.state]; + if (cmpState < 0) { + map.set(key, candidate); + } else if ( + cmpState === 0 && + candidate.updatedAt.getTime() > existing.updatedAt.getTime() + ) { + map.set(key, candidate); + } + } + return map; + }, [prRows]); + + const enriched = useMemo<AccessibleV2Workspace[]>(() => { + const deduped = new Map<string, AccessibleV2Workspace>(); + for (const row of rows) { + if (deduped.has(row.id)) continue; + const hostType: V2WorkspaceHostType = + row.hostId === machineId ? "local-device" : "remote-device"; + const isAutoVisibleMain = + row.type === "main" && + row.hostId === machineId && + row.sidebarProjectId != null; + const isInSidebar = + isSidebarWorkspaceVisible({ isHidden: row.sidebarIsHidden }) && + (row.sidebarWorkspaceId != null || isAutoVisibleMain); + const pr = row.projectRepoId + ? (prsByRepoBranch.get(`${row.projectRepoId}::${row.branch}`) ?? null) + : null; + + deduped.set(row.id, { + id: row.id, + name: row.name, + branch: row.branch, + type: row.type, + createdAt: new Date(row.createdAt), + createdByUserId: row.createdByUserId, + createdByName: row.createdByName ?? null, + createdByImage: row.createdByImage ?? null, + isCreatedByCurrentUser: + currentUserId != null && row.createdByUserId === currentUserId, + projectId: row.projectId, + projectName: row.projectName, + projectRepoId: row.projectRepoId, + projectGithubOwner: row.projectGithubOwner ?? null, + hostId: row.hostId, + hostName: row.hostName, + hostIsOnline: row.hostIsOnline, + hostType, + isInSidebar, + pr, + }); + } + return Array.from(deduped.values()).sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ); + }, [rows, machineId, currentUserId, prsByRepoBranch]); + + const searchFiltered = useMemo( + () => + enriched.filter((workspace) => + workspaceMatchesSearch(workspace, searchQuery), + ), + [enriched, searchQuery], + ); + + const deviceFiltered = useMemo( + () => + searchFiltered.filter((workspace) => + matchesDeviceFilter(workspace, deviceFilter, machineId), + ), + [searchFiltered, deviceFilter, machineId], + ); + + const fullyFiltered = useMemo( + () => + deviceFiltered.filter((workspace) => + matchesProjectFilter(workspace, projectFilter), + ), + [deviceFiltered, projectFilter], + ); + + const pinned = useMemo( + () => fullyFiltered.filter((workspace) => workspace.isInSidebar), + [fullyFiltered], + ); + + const others = useMemo( + () => fullyFiltered.filter((workspace) => !workspace.isInSidebar), + [fullyFiltered], + ); + + const counts = useMemo<V2WorkspaceDeviceCounts>(() => { + let thisDevice = 0; + for (const workspace of searchFiltered) { + if (workspace.hostId === machineId) thisDevice += 1; + } + return { + all: searchFiltered.length, + thisDevice, + }; + }, [searchFiltered, machineId]); + + const hostOptions = useMemo<V2WorkspaceHostOption[]>(() => { + const byHost = new Map<string, V2WorkspaceHostOption>(); + for (const workspace of searchFiltered) { + const existing = byHost.get(workspace.hostId); + if (existing) { + existing.count += 1; + continue; + } + byHost.set(workspace.hostId, { + hostId: workspace.hostId, + hostName: workspace.hostName, + isOnline: workspace.hostIsOnline, + isLocal: workspace.hostId === machineId, + count: 1, + }); + } + return Array.from(byHost.values()).sort((a, b) => { + if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; + return a.hostName.localeCompare(b.hostName); + }); + }, [searchFiltered, machineId]); + + const projectOptions = useMemo<V2WorkspaceProjectOption[]>(() => { + const byProject = new Map<string, V2WorkspaceProjectOption>(); + for (const workspace of deviceFiltered) { + const existing = byProject.get(workspace.projectId); + if (existing) { + existing.count += 1; + continue; + } + byProject.set(workspace.projectId, { + projectId: workspace.projectId, + projectName: workspace.projectName, + githubOwner: workspace.projectGithubOwner, + count: 1, + }); + } + return Array.from(byProject.values()).sort((a, b) => + a.projectName.localeCompare(b.projectName), + ); + }, [deviceFiltered]); + + const hostsById = useMemo(() => { + const map = new Map< + string, + { hostName: string; isOnline: boolean; isLocal: boolean } + >(); + for (const workspace of enriched) { + if (map.has(workspace.hostId)) continue; + map.set(workspace.hostId, { + hostName: workspace.hostName, + isOnline: workspace.hostIsOnline, + isLocal: workspace.hostId === machineId, + }); + } + return map; + }, [enriched, machineId]); + + const projectsById = useMemo(() => { + const map = new Map< + string, + { projectName: string; githubOwner: string | null } + >(); + for (const workspace of enriched) { + if (map.has(workspace.projectId)) continue; + map.set(workspace.projectId, { + projectName: workspace.projectName, + githubOwner: workspace.projectGithubOwner, + }); + } + return map; + }, [enriched]); + + return { + all: fullyFiltered, + pinned, + others, + counts, + hostOptions, + projectOptions, + hostsById, + projectsById, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx index 92f04c612d4..56ac0ba05d8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/page.tsx @@ -1,4 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { V2WorkspacesHeader } from "./components/V2WorkspacesHeader"; +import { V2WorkspacesList } from "./components/V2WorkspacesList"; +import { useAccessibleV2Workspaces } from "./hooks/useAccessibleV2Workspaces"; +import { useV2WorkspacesFilterStore } from "./stores/v2WorkspacesFilterStore"; export const Route = createFileRoute( "/_authenticated/_dashboard/v2-workspaces/", @@ -7,26 +12,38 @@ export const Route = createFileRoute( }); function V2WorkspacesPage() { - return ( - <div className="flex h-full flex-col overflow-y-auto p-6"> - <div className="mx-auto flex w-full max-w-4xl flex-col gap-6"> - <div className="space-y-2"> - <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1> - <p className="max-w-2xl text-sm text-muted-foreground"> - This page will become the browse surface for all accessible V2 - workspaces, with sidebar workspaces prioritized first. - </p> - </div> + const searchQuery = useV2WorkspacesFilterStore((state) => state.searchQuery); + const deviceFilter = useV2WorkspacesFilterStore( + (state) => state.deviceFilter, + ); + const projectFilter = useV2WorkspacesFilterStore( + (state) => state.projectFilter, + ); + const setSearchQuery = useV2WorkspacesFilterStore( + (state) => state.setSearchQuery, + ); - <div className="rounded-xl border border-border bg-card p-5"> - <h2 className="text-sm font-medium">WIP</h2> - <p className="mt-2 text-sm text-muted-foreground"> - Next up is splitting local sidebar workspaces from the full set of - accessible shared workspaces and giving this page proper search, - filtering, and recents. - </p> - </div> - </div> + useEffect(() => { + setSearchQuery(""); + }, [setSearchQuery]); + + const { all, counts, hostOptions, projectOptions, hostsById, projectsById } = + useAccessibleV2Workspaces({ + searchQuery, + deviceFilter, + projectFilter, + }); + + return ( + <div className="flex h-full w-full flex-1 flex-col overflow-hidden"> + <V2WorkspacesHeader + counts={counts} + hostOptions={hostOptions} + projectOptions={projectOptions} + hostsById={hostsById} + projectsById={projectsById} + /> + <V2WorkspacesList workspaces={all} /> </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/index.ts new file mode 100644 index 00000000000..4e6d7328178 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/index.ts @@ -0,0 +1,8 @@ +export { + DEVICE_FILTER_ALL, + DEVICE_FILTER_THIS_DEVICE, + PROJECT_FILTER_ALL, + useV2WorkspacesFilterStore, + type V2WorkspacesDeviceFilter, + type V2WorkspacesProjectFilter, +} from "./v2WorkspacesFilterStore"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.ts new file mode 100644 index 00000000000..8ad4014588e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspaces/stores/v2WorkspacesFilterStore/v2WorkspacesFilterStore.ts @@ -0,0 +1,35 @@ +import { create } from "zustand"; + +export const DEVICE_FILTER_ALL = "all"; +export const DEVICE_FILTER_THIS_DEVICE = "this-device"; +export const PROJECT_FILTER_ALL = "all"; + +export type V2WorkspacesDeviceFilter = string; +export type V2WorkspacesProjectFilter = string; + +interface V2WorkspacesFilterState { + searchQuery: string; + deviceFilter: V2WorkspacesDeviceFilter; + projectFilter: V2WorkspacesProjectFilter; + setSearchQuery: (searchQuery: string) => void; + setDeviceFilter: (deviceFilter: V2WorkspacesDeviceFilter) => void; + setProjectFilter: (projectFilter: V2WorkspacesProjectFilter) => void; + reset: () => void; +} + +export const useV2WorkspacesFilterStore = create<V2WorkspacesFilterState>()( + (set) => ({ + searchQuery: "", + deviceFilter: DEVICE_FILTER_THIS_DEVICE, + projectFilter: PROJECT_FILTER_ALL, + setSearchQuery: (searchQuery) => set({ searchQuery }), + setDeviceFilter: (deviceFilter) => set({ deviceFilter }), + setProjectFilter: (projectFilter) => set({ projectFilter }), + reset: () => + set({ + searchQuery: "", + deviceFilter: DEVICE_FILTER_THIS_DEVICE, + projectFilter: PROJECT_FILTER_ALL, + }), + }), +); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts index 6232b8a8a74..4fc76da4db7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys.ts @@ -1,5 +1,4 @@ -import { useAppHotkey } from "renderer/stores/hotkeys"; -import type { HotkeyId } from "shared/hotkeys"; +import { type HotkeyId, useHotkey } from "renderer/hotkeys"; export const PRESET_HOTKEY_IDS: HotkeyId[] = [ "OPEN_PRESET_1", @@ -16,31 +15,13 @@ export const PRESET_HOTKEY_IDS: HotkeyId[] = [ export function usePresetHotkeys( openTabWithPreset: (presetIndex: number) => void, ) { - useAppHotkey(PRESET_HOTKEY_IDS[0], () => openTabWithPreset(0), undefined, [ - openTabWithPreset, - ]); - useAppHotkey(PRESET_HOTKEY_IDS[1], () => openTabWithPreset(1), undefined, [ - openTabWithPreset, - ]); - useAppHotkey(PRESET_HOTKEY_IDS[2], () => openTabWithPreset(2), undefined, [ - openTabWithPreset, - ]); - useAppHotkey(PRESET_HOTKEY_IDS[3], () => openTabWithPreset(3), undefined, [ - openTabWithPreset, - ]); - useAppHotkey(PRESET_HOTKEY_IDS[4], () => openTabWithPreset(4), undefined, [ - openTabWithPreset, - ]); - useAppHotkey(PRESET_HOTKEY_IDS[5], () => openTabWithPreset(5), undefined, [ - openTabWithPreset, - ]); - useAppHotkey(PRESET_HOTKEY_IDS[6], () => openTabWithPreset(6), undefined, [ - openTabWithPreset, - ]); - useAppHotkey(PRESET_HOTKEY_IDS[7], () => openTabWithPreset(7), undefined, [ - openTabWithPreset, - ]); - useAppHotkey(PRESET_HOTKEY_IDS[8], () => openTabWithPreset(8), undefined, [ - openTabWithPreset, - ]); + useHotkey("OPEN_PRESET_1", () => openTabWithPreset(0)); + useHotkey("OPEN_PRESET_2", () => openTabWithPreset(1)); + useHotkey("OPEN_PRESET_3", () => openTabWithPreset(2)); + useHotkey("OPEN_PRESET_4", () => openTabWithPreset(3)); + useHotkey("OPEN_PRESET_5", () => openTabWithPreset(4)); + useHotkey("OPEN_PRESET_6", () => openTabWithPreset(5)); + useHotkey("OPEN_PRESET_7", () => openTabWithPreset(6)); + useHotkey("OPEN_PRESET_8", () => openTabWithPreset(7)); + useHotkey("OPEN_PRESET_9", () => openTabWithPreset(8)); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts index a7b0acd2d66..3d2d0705962 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand.ts @@ -6,7 +6,6 @@ import { } from "renderer/lib/terminal/launch-command"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useTabsStore } from "renderer/stores/tabs/store"; -import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; import { clearPaneWorkspaceRunLaunchPending, createWorkspaceRun, @@ -33,9 +32,6 @@ export function useWorkspaceRunCommand({ const setActiveTab = useTabsStore((s) => s.setActiveTab); const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const setPaneWorkspaceRun = useTabsStore((s) => s.setPaneWorkspaceRun); - const getRestartCallback = useTerminalCallbacksStore( - (s) => s.getRestartCallback, - ); // Derive run state from pane metadata (single source of truth) const runPane = useTabsStore((s) => { @@ -152,25 +148,12 @@ export function useWorkspaceRunCommand({ ); try { - const restartCallback = getRestartCallback(runPane.id); - if (restartCallback) { - await restartCallback({ command }); - } else { - await launchWorkspaceRunInPane({ - paneId: runPane.id, - tabId: runPane.tabId, - command, - cwd: initialCwd, - }); - setPaneWorkspaceRun( - runPane.id, - createWorkspaceRun({ - workspaceId, - state: "running", - command, - }), - ); - } + await launchWorkspaceRunInPane({ + paneId: runPane.id, + tabId: runPane.tabId, + command, + cwd: initialCwd, + }); } catch (error) { setPaneWorkspaceRunState(runPane.id, "stopped-by-exit"); toast.error("Failed to run workspace command", { @@ -221,7 +204,6 @@ export function useWorkspaceRunCommand({ } }, [ addTab, - getRestartCallback, isRunning, launchWorkspaceRunInPane, runPane, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index ed7656ab114..7910d4ff9c9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -1,10 +1,10 @@ import type { ExternalApp } from "@superset/local-db"; import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { useFileOpenMode } from "renderer/hooks/useFileOpenMode"; +import { useHotkey } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { usePresets } from "renderer/react-query/presets"; import type { WorkspaceSearchParams } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; @@ -12,14 +12,7 @@ import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/u import { usePresetHotkeys } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/usePresetHotkeys"; import { useWorkspaceRunCommand } from "renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/hooks/useWorkspaceRunCommand"; import { NotFound } from "renderer/routes/not-found"; -import { - CommandPalette, - useCommandPalette, -} from "renderer/screens/main/components/CommandPalette"; -import { - KeywordSearch, - useKeywordSearch, -} from "renderer/screens/main/components/KeywordSearch"; +import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; import { UnsavedChangesDialog } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog"; import { useWorkspaceFileEventBridge } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceFileEvents"; import { useWorkspaceRenameReconciliation } from "renderer/screens/main/components/WorkspaceView/hooks/useWorkspaceRenameReconciliation"; @@ -34,20 +27,20 @@ import { saveAndClosePendingTab, } from "renderer/stores/editor-state/editorCoordinator"; import { useEditorSessionsStore } from "renderer/stores/editor-state/useEditorSessionsStore"; -import { useAppHotkey } from "renderer/stores/hotkeys"; import { SidebarMode, useSidebarStore } from "renderer/stores/sidebar-state"; import { getPaneDimensions } from "renderer/stores/tabs/pane-refs"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { Tab } from "renderer/stores/tabs/types"; import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; import { + type FocusDirection, findPanePath, getFirstPaneId, - getNextPaneId, - getPreviousPaneId, + getSpatialNeighborMosaicPaneId, resolveActiveTabIdForWorkspace, } from "renderer/stores/tabs/utils"; import { + useHasCompletedInitThisSession, useHasWorkspaceFailed, useIsWorkspaceInitializing, } from "renderer/stores/workspace-init"; @@ -130,12 +123,18 @@ function WorkspacePage() { // Check if workspace is initializing or failed const isInitializing = useIsWorkspaceInitializing(workspaceId); const hasFailed = useHasWorkspaceFailed(workspaceId); + // If we witnessed this workspace reach "ready" in the current app session, + // never misidentify it as mid-init even if the workspace query momentarily + // returns a null gitStatus (happens on the first navigation after create, + // because WorkspaceInitEffects clears the progress entry post-setup). + const completedThisSession = useHasCompletedInitThisSession(workspaceId); // Check for incomplete init after app restart const gitStatus = workspace?.worktree?.gitStatus; const hasIncompleteInit = + !completedThisSession && workspace?.type === "worktree" && - (gitStatus === null || gitStatus === undefined); + gitStatus === null; // Show full-screen initialization view for: // - Actively initializing workspaces (shows progress) @@ -213,104 +212,56 @@ function WorkspacePage() { [presets, workspaceId, addTab, openPreset], ); - useAppHotkey("NEW_GROUP", () => addTab(workspaceId), undefined, [ - workspaceId, - addTab, - ]); - useAppHotkey("NEW_CHAT", () => addChatTab(workspaceId), undefined, [ - workspaceId, - addChatTab, - ]); - useAppHotkey( - "REOPEN_TAB", - () => { - if (!reopenClosedTab(workspaceId)) { - addChatTab(workspaceId); - } - }, - undefined, - [workspaceId, reopenClosedTab, addChatTab], - ); - useAppHotkey("NEW_BROWSER", () => addBrowserTab(workspaceId), undefined, [ - workspaceId, - addBrowserTab, - ]); + useHotkey("NEW_GROUP", () => addTab(workspaceId)); + useHotkey("NEW_CHAT", () => addChatTab(workspaceId)); + useHotkey("REOPEN_TAB", () => { + if (!reopenClosedTab(workspaceId)) { + addChatTab(workspaceId); + } + }); + useHotkey("NEW_BROWSER", () => addBrowserTab(workspaceId)); usePresetHotkeys(openTabWithPreset); - useAppHotkey("RUN_WORKSPACE_COMMAND", () => toggleWorkspaceRun(), undefined, [ - toggleWorkspaceRun, - ]); + useHotkey("RUN_WORKSPACE_COMMAND", () => toggleWorkspaceRun()); - useAppHotkey( - "CLOSE_TERMINAL", - () => { - if (focusedPaneId) { - requestPaneClose(focusedPaneId); - } - }, - undefined, - [focusedPaneId], - ); - useAppHotkey( - "CLOSE_TAB", - () => { - if (activeTabId) { - requestTabClose(activeTabId); - } - }, - undefined, - [activeTabId], - ); + useHotkey("CLOSE_TERMINAL", () => { + if (focusedPaneId) { + requestPaneClose(focusedPaneId); + } + }); + useHotkey("CLOSE_TAB", () => { + if (activeTabId) { + requestTabClose(activeTabId); + } + }); - useAppHotkey( - "PREV_TAB", - () => { - if (!activeTabId || tabs.length === 0) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; - setActiveTab(workspaceId, tabs[prevIndex].id); - }, - undefined, - [workspaceId, activeTabId, tabs, setActiveTab], - ); + useHotkey("PREV_TAB", () => { + if (!activeTabId || tabs.length === 0) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; + setActiveTab(workspaceId, tabs[prevIndex].id); + }); - useAppHotkey( - "NEXT_TAB", - () => { - if (!activeTabId || tabs.length === 0) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - const nextIndex = - index >= tabs.length - 1 || index === -1 ? 0 : index + 1; - setActiveTab(workspaceId, tabs[nextIndex].id); - }, - undefined, - [workspaceId, activeTabId, tabs, setActiveTab], - ); + useHotkey("NEXT_TAB", () => { + if (!activeTabId || tabs.length === 0) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + const nextIndex = index >= tabs.length - 1 || index === -1 ? 0 : index + 1; + setActiveTab(workspaceId, tabs[nextIndex].id); + }); - useAppHotkey( - "PREV_TAB_ALT", - () => { - if (!activeTabId || tabs.length === 0) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; - setActiveTab(workspaceId, tabs[prevIndex].id); - }, - undefined, - [workspaceId, activeTabId, tabs, setActiveTab], - ); + useHotkey("PREV_TAB_ALT", () => { + if (!activeTabId || tabs.length === 0) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + const prevIndex = index <= 0 ? tabs.length - 1 : index - 1; + setActiveTab(workspaceId, tabs[prevIndex].id); + }); - useAppHotkey( - "NEXT_TAB_ALT", - () => { - if (!activeTabId || tabs.length === 0) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - const nextIndex = - index >= tabs.length - 1 || index === -1 ? 0 : index + 1; - setActiveTab(workspaceId, tabs[nextIndex].id); - }, - undefined, - [workspaceId, activeTabId, tabs, setActiveTab], - ); + useHotkey("NEXT_TAB_ALT", () => { + if (!activeTabId || tabs.length === 0) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + const nextIndex = index >= tabs.length - 1 || index === -1 ? 0 : index + 1; + setActiveTab(workspaceId, tabs[nextIndex].id); + }); const switchToTab = useCallback( (index: number) => { @@ -322,41 +273,15 @@ function WorkspacePage() { [tabs, workspaceId, setActiveTab], ); - useAppHotkey("JUMP_TO_TAB_1", () => switchToTab(0), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_2", () => switchToTab(1), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_3", () => switchToTab(2), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_4", () => switchToTab(3), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_5", () => switchToTab(4), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_6", () => switchToTab(5), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_7", () => switchToTab(6), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_8", () => switchToTab(7), undefined, [switchToTab]); - useAppHotkey("JUMP_TO_TAB_9", () => switchToTab(8), undefined, [switchToTab]); - - useAppHotkey( - "PREV_PANE", - () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); - if (prevPaneId) { - setFocusedPane(activeTabId, prevPaneId); - } - }, - undefined, - [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], - ); - - useAppHotkey( - "NEXT_PANE", - () => { - if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; - const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); - if (nextPaneId) { - setFocusedPane(activeTabId, nextPaneId); - } - }, - undefined, - [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], - ); + useHotkey("JUMP_TO_TAB_1", () => switchToTab(0)); + useHotkey("JUMP_TO_TAB_2", () => switchToTab(1)); + useHotkey("JUMP_TO_TAB_3", () => switchToTab(2)); + useHotkey("JUMP_TO_TAB_4", () => switchToTab(3)); + useHotkey("JUMP_TO_TAB_5", () => switchToTab(4)); + useHotkey("JUMP_TO_TAB_6", () => switchToTab(5)); + useHotkey("JUMP_TO_TAB_7", () => switchToTab(6)); + useHotkey("JUMP_TO_TAB_8", () => switchToTab(7)); + useHotkey("JUMP_TO_TAB_9", () => switchToTab(8)); // Open in last used app shortcut const projectId = workspace?.projectId; @@ -383,79 +308,45 @@ function WorkspacePage() { }); } }, [workspace?.worktreePath, resolvedDefaultApp, mutateOpenInApp, projectId]); - useAppHotkey("OPEN_IN_APP", handleOpenInApp, undefined, [handleOpenInApp]); // Copy path shortcut const { copyToClipboard } = useCopyToClipboard(); - useAppHotkey( - "COPY_PATH", - () => { - if (workspace?.worktreePath) { - copyToClipboard(workspace.worktreePath); - } - }, - undefined, - [workspace?.worktreePath], - ); + useHotkey("COPY_PATH", () => { + if (workspace?.worktreePath) { + copyToClipboard(workspace.worktreePath); + } + }); // Open PR shortcut (⌘⇧P) const { pr } = usePRStatus({ workspaceId, surface: "workspace-page" }); const { createOrOpenPR } = useCreateOrOpenPR({ worktreePath: workspace?.worktreePath, }); - useAppHotkey( - "OPEN_PR", - () => { - if (pr?.url) { - window.open(pr.url, "_blank"); - } else { - createOrOpenPR(); - } - }, - undefined, - [pr?.url, createOrOpenPR], - ); - - const commandPalette = useCommandPalette({ - workspaceId, - navigate, - }); - const keywordSearch = useKeywordSearch({ - workspaceId, + useHotkey("OPEN_PR", () => { + if (pr?.url) { + window.open(pr.url, "_blank"); + } else { + createOrOpenPR(); + } }); - const handleQuickOpen = useCallback(() => { - keywordSearch.handleOpenChange(false); - commandPalette.toggle(); - }, [commandPalette.toggle, keywordSearch.handleOpenChange]); - const handleKeywordSearch = useCallback(() => { - commandPalette.handleOpenChange(false); - keywordSearch.toggle(); - }, [commandPalette.handleOpenChange, keywordSearch.toggle]); - useAppHotkey("QUICK_OPEN", handleQuickOpen, undefined, [handleQuickOpen]); - useAppHotkey("KEYWORD_SEARCH", handleKeywordSearch, undefined, [ - handleKeywordSearch, - ]); + + const [quickOpenOpen, setQuickOpenOpen] = useState(false); + const handleQuickOpen = useCallback(() => setQuickOpenOpen(true), []); + useHotkey("QUICK_OPEN", handleQuickOpen); // Toggle changes sidebar (⌘L) - useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), undefined, [ - toggleSidebar, - ]); - - // Toggle expand/collapse sidebar (⌘⇧L) - useAppHotkey( - "TOGGLE_EXPAND_SIDEBAR", - () => { - if (!isSidebarOpen) { - setSidebarOpen(true); - setSidebarMode(SidebarMode.Changes); - } else { - const isExpanded = currentSidebarMode === SidebarMode.Changes; - setSidebarMode(isExpanded ? SidebarMode.Tabs : SidebarMode.Changes); - } - }, - undefined, - [isSidebarOpen, setSidebarOpen, setSidebarMode, currentSidebarMode], - ); + useHotkey("TOGGLE_SIDEBAR", () => toggleSidebar()); + + // Open diff viewer (⌘⇧L) + useHotkey("OPEN_DIFF_VIEWER", () => { + if (!isSidebarOpen) { + setSidebarOpen(true); + setSidebarMode(SidebarMode.Changes); + } else { + const isExpanded = currentSidebarMode === SidebarMode.Changes; + setSidebarMode(isExpanded ? SidebarMode.Tabs : SidebarMode.Changes); + } + }); // Pane splitting helper - resolves target pane for split operations const resolveSplitTarget = useCallback( @@ -472,168 +363,99 @@ function WorkspacePage() { ); // Pane splitting shortcuts - useAppHotkey( - "SPLIT_AUTO", - () => { - if (activeTabId && focusedPaneId && activeTab) { - const target = resolveSplitTarget( - focusedPaneId, - activeTabId, - activeTab, - ); - if (!target) return; - const dimensions = getPaneDimensions(target.paneId); - if (dimensions) { - splitPaneAuto(activeTabId, target.paneId, dimensions, target.path); - } + useHotkey("SPLIT_AUTO", () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget(focusedPaneId, activeTabId, activeTab); + if (!target) return; + const dimensions = getPaneDimensions(target.paneId); + if (dimensions) { + splitPaneAuto(activeTabId, target.paneId, dimensions, target.path); } - }, - undefined, - [activeTabId, focusedPaneId, activeTab, splitPaneAuto, resolveSplitTarget], - ); + } + }); - useAppHotkey( - "SPLIT_RIGHT", - () => { - if (activeTabId && focusedPaneId && activeTab) { - const target = resolveSplitTarget( - focusedPaneId, - activeTabId, - activeTab, - ); - if (!target) return; - splitPaneVertical(activeTabId, target.paneId, target.path); - } - }, - undefined, - [ - activeTabId, - focusedPaneId, - activeTab, - splitPaneVertical, - resolveSplitTarget, - ], - ); + useHotkey("SPLIT_RIGHT", () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget(focusedPaneId, activeTabId, activeTab); + if (!target) return; + splitPaneVertical(activeTabId, target.paneId, target.path); + } + }); - useAppHotkey( - "SPLIT_DOWN", - () => { - if (activeTabId && focusedPaneId && activeTab) { - const target = resolveSplitTarget( - focusedPaneId, - activeTabId, - activeTab, - ); - if (!target) return; - splitPaneHorizontal(activeTabId, target.paneId, target.path); - } - }, - undefined, - [ - activeTabId, - focusedPaneId, - activeTab, - splitPaneHorizontal, - resolveSplitTarget, - ], - ); + useHotkey("SPLIT_DOWN", () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget(focusedPaneId, activeTabId, activeTab); + if (!target) return; + splitPaneHorizontal(activeTabId, target.paneId, target.path); + } + }); - useAppHotkey( - "SPLIT_WITH_CHAT", - () => { - if (activeTabId && focusedPaneId && activeTab) { - const target = resolveSplitTarget( - focusedPaneId, - activeTabId, - activeTab, - ); - if (!target) return; - splitPaneVertical(activeTabId, target.paneId, target.path, { - paneType: "chat", - }); - } - }, - undefined, - [ - activeTabId, - focusedPaneId, - activeTab, - splitPaneVertical, - resolveSplitTarget, - ], - ); + useHotkey("SPLIT_WITH_CHAT", () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget(focusedPaneId, activeTabId, activeTab); + if (!target) return; + splitPaneVertical(activeTabId, target.paneId, target.path, { + paneType: "chat", + }); + } + }); - useAppHotkey( - "SPLIT_WITH_BROWSER", - () => { - if (activeTabId && focusedPaneId && activeTab) { - const target = resolveSplitTarget( - focusedPaneId, - activeTabId, - activeTab, - ); - if (!target) return; - splitPaneVertical(activeTabId, target.paneId, target.path, { - paneType: "webview", - }); - } - }, - undefined, - [ - activeTabId, - focusedPaneId, - activeTab, - splitPaneVertical, - resolveSplitTarget, - ], - ); + useHotkey("SPLIT_WITH_BROWSER", () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget(focusedPaneId, activeTabId, activeTab); + if (!target) return; + splitPaneVertical(activeTabId, target.paneId, target.path, { + paneType: "webview", + }); + } + }); const equalizePaneSplits = useTabsStore((s) => s.equalizePaneSplits); - useAppHotkey( - "EQUALIZE_PANE_SPLITS", - () => { - if (activeTabId) { - equalizePaneSplits(activeTabId); - } + useHotkey("EQUALIZE_PANE_SPLITS", () => { + if (activeTabId) { + equalizePaneSplits(activeTabId); + } + }); + + const moveFocusDirectional = useCallback( + (dir: FocusDirection) => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const neighbor = getSpatialNeighborMosaicPaneId( + activeTab.layout, + focusedPaneId, + dir, + ); + if (neighbor) setFocusedPane(activeTabId, neighbor); }, - undefined, - [activeTabId, equalizePaneSplits], + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], ); + useHotkey("FOCUS_PANE_LEFT", () => moveFocusDirectional("left")); + useHotkey("FOCUS_PANE_RIGHT", () => moveFocusDirectional("right")); + useHotkey("FOCUS_PANE_UP", () => moveFocusDirectional("up")); + useHotkey("FOCUS_PANE_DOWN", () => moveFocusDirectional("down")); - // Navigate to previous workspace (⌘↑) const getPreviousWorkspace = electronTrpc.workspaces.getPreviousWorkspace.useQuery( { id: workspaceId }, { enabled: !!workspaceId }, ); - useAppHotkey( - "PREV_WORKSPACE", - () => { - const prevWorkspaceId = getPreviousWorkspace.data; - if (prevWorkspaceId) { - navigateToWorkspace(prevWorkspaceId, navigate); - } - }, - undefined, - [getPreviousWorkspace.data, navigate], - ); + useHotkey("PREV_WORKSPACE", () => { + const prevWorkspaceId = getPreviousWorkspace.data; + if (prevWorkspaceId) { + navigateToWorkspace(prevWorkspaceId, navigate); + } + }); - // Navigate to next workspace (⌘↓) const getNextWorkspace = electronTrpc.workspaces.getNextWorkspace.useQuery( { id: workspaceId }, { enabled: !!workspaceId }, ); - useAppHotkey( - "NEXT_WORKSPACE", - () => { - const nextWorkspaceId = getNextWorkspace.data; - if (nextWorkspaceId) { - navigateToWorkspace(nextWorkspaceId, navigate); - } - }, - undefined, - [getNextWorkspace.data, navigate], - ); + useHotkey("NEXT_WORKSPACE", () => { + const nextWorkspaceId = getNextWorkspace.data; + if (nextWorkspaceId) { + navigateToWorkspace(nextWorkspaceId, navigate); + } + }); return ( <div className="flex-1 h-full flex flex-col overflow-hidden"> @@ -653,46 +475,13 @@ function WorkspacePage() { )} </div> <CommandPalette - open={commandPalette.open} - onOpenChange={commandPalette.handleOpenChange} - query={commandPalette.query} - onQueryChange={commandPalette.setQuery} - filtersOpen={commandPalette.filtersOpen} - onFiltersOpenChange={commandPalette.setFiltersOpen} - includePattern={commandPalette.includePattern} - onIncludePatternChange={commandPalette.setIncludePattern} - excludePattern={commandPalette.excludePattern} - onExcludePatternChange={commandPalette.setExcludePattern} - isLoading={commandPalette.isFetching} - searchResults={commandPalette.searchResults} - onSelectFile={commandPalette.selectFile} - scope={commandPalette.scope} - onScopeChange={commandPalette.setScope} - workspaceName={ - workspace - ? getWorkspaceDisplayName( - workspace.name, - workspace.type, - workspace.project?.name, - ) - : undefined + workspaceId={workspaceId} + open={quickOpenOpen} + onOpenChange={setQuickOpenOpen} + onSelectFile={(filePath) => + useTabsStore.getState().addFileViewerPane(workspaceId, { filePath }) } /> - <KeywordSearch - open={keywordSearch.open} - onOpenChange={keywordSearch.handleOpenChange} - query={keywordSearch.query} - onQueryChange={keywordSearch.setQuery} - filtersOpen={keywordSearch.filtersOpen} - onFiltersOpenChange={keywordSearch.setFiltersOpen} - includePattern={keywordSearch.includePattern} - onIncludePatternChange={keywordSearch.setIncludePattern} - excludePattern={keywordSearch.excludePattern} - onExcludePatternChange={keywordSearch.setExcludePattern} - isLoading={keywordSearch.isFetching} - searchResults={keywordSearch.searchResults} - onSelectMatch={keywordSearch.selectMatch} - /> <UnsavedChangesDialog open={pendingTabClose !== null} onOpenChange={(open) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts index ae7a5933f10..7bd89efed4c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts @@ -1,43 +1,35 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; -const HEARTBEAT_INTERVAL_MS = 30_000; - +/** + * Registers this device once on startup so MCP can verify ownership. + * No polling — just a single upsert into device_presence. + */ export function useDevicePresence() { const { data: session } = authClient.useSession(); - const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); + const registeredScopeRef = useRef<string | null>(null); - const sendHeartbeat = useCallback(async () => { - if (!deviceInfo || !session?.session?.activeOrganizationId) return; + useEffect(() => { + const orgId = session?.session?.activeOrganizationId; + if (!deviceInfo || !orgId) return; + if (registeredScopeRef.current === orgId) return; + registeredScopeRef.current = orgId; - try { - await apiTrpcClient.device.heartbeat.mutate({ + apiTrpcClient.device.registerDevice + .mutate({ deviceId: deviceInfo.deviceId, deviceName: deviceInfo.deviceName, deviceType: "desktop", + }) + .catch(() => { + // Registration can fail when offline — will retry on next app launch + registeredScopeRef.current = null; }); - } catch { - // Heartbeat can fail when offline - ignore - } }, [deviceInfo, session?.session?.activeOrganizationId]); - useEffect(() => { - if (!deviceInfo || !session?.session?.activeOrganizationId) return; - - sendHeartbeat(); - intervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - }, [deviceInfo, session?.session?.activeOrganizationId, sendHeartbeat]); - return { deviceInfo, isActive: !!deviceInfo && !!session?.session?.activeOrganizationId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx index 1f8ad072681..f50d50fb15e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceDraftContext.tsx @@ -1,3 +1,4 @@ +import { generateFriendlyBranchName } from "@superset/shared/workspace-launch"; import { toast } from "@superset/ui/sonner"; import { createContext, @@ -7,52 +8,85 @@ import { useMemo, useState, } from "react"; -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; +import type { WorkspaceHostTarget } from "./components/DashboardNewWorkspaceForm/components/DevicePicker"; +import { useCreateDashboardWorkspace } from "./hooks/useCreateDashboardWorkspace"; + +export type LinkedIssue = { + slug: string; // "#123" for GitHub, "SUP-123" for internal + title: string; + source?: "github" | "internal"; + url?: string; // GitHub issue URL + taskId?: string; // Internal task ID for navigation + number?: number; // GitHub issue number + state?: "open" | "closed"; +}; + +export type LinkedPR = { + prNumber: number; + title: string; + url: string; + state: string; +}; -export type DashboardNewWorkspaceTab = - | "prompt" - | "issues" - | "pull-requests" - | "branches"; +export type BaseBranchSource = "local" | "remote-tracking"; export interface DashboardNewWorkspaceDraft { - activeTab: DashboardNewWorkspaceTab; selectedProjectId: string | null; hostTarget: WorkspaceHostTarget; prompt: string; + baseBranch: string | null; + /** Picker hint: which form of `baseBranch` the user selected. */ + baseBranchSource: BaseBranchSource | null; + runSetupScript: boolean; + workspaceName: string; + workspaceNameEdited: boolean; branchName: string; branchNameEdited: boolean; - compareBaseBranch: string | null; - showAdvanced: boolean; - branchSearch: string; - issuesQuery: string; - pullRequestsQuery: string; - branchesQuery: string; + linkedIssues: LinkedIssue[]; + linkedPR: LinkedPR | null; + /** + * Random friendly name (e.g. `curious-otter`) generated once per draft. + * Used as the submit fallback AND the picker preview so the user sees the + * same name that will be committed. + */ + friendlyFallback: string; } interface DashboardNewWorkspaceDraftState extends DashboardNewWorkspaceDraft { draftVersion: number; + resetKey: number; } -const initialDraft: DashboardNewWorkspaceDraft = { - activeTab: "prompt", +const initialDraftWithoutFallback: Omit< + DashboardNewWorkspaceDraft, + "friendlyFallback" +> = { selectedProjectId: null, hostTarget: { kind: "local" }, prompt: "", + baseBranch: null, + baseBranchSource: null, + runSetupScript: true, + workspaceName: "", + workspaceNameEdited: false, branchName: "", branchNameEdited: false, - compareBaseBranch: null, - showAdvanced: false, - branchSearch: "", - issuesQuery: "", - pullRequestsQuery: "", - branchesQuery: "", + linkedIssues: [], + linkedPR: null, }; +function buildInitialDraft(): DashboardNewWorkspaceDraft { + return { + ...initialDraftWithoutFallback, + friendlyFallback: generateFriendlyBranchName(), + }; +} + function buildInitialDraftState(): DashboardNewWorkspaceDraftState { return { - ...initialDraft, + ...buildInitialDraft(), draftVersion: 0, + resetKey: 0, }; } @@ -69,8 +103,10 @@ interface DashboardNewWorkspaceActionOptions { interface DashboardNewWorkspaceDraftContextValue { draft: DashboardNewWorkspaceDraft; draftVersion: number; + resetKey: number; closeModal: () => void; closeAndResetDraft: () => void; + createWorkspace: ReturnType<typeof useCreateDashboardWorkspace>; runAsyncAction: <T>( promise: Promise<T>, messages: DashboardNewWorkspaceActionMessages, @@ -89,34 +125,25 @@ export function DashboardNewWorkspaceDraftProvider({ }: PropsWithChildren<{ onClose: () => void }>) { const [state, setState] = useState(buildInitialDraftState); + // Owned here so onSuccess survives Dialog unmounting content on close. + const createWorkspace = useCreateDashboardWorkspace(); + const updateDraft = useCallback( (patch: Partial<DashboardNewWorkspaceDraft>) => { - setState((state) => { - const entries = Object.entries(patch) as Array< - [ - keyof DashboardNewWorkspaceDraft, - DashboardNewWorkspaceDraft[keyof DashboardNewWorkspaceDraft], - ] - >; - const hasChanges = entries.some(([key, value]) => state[key] !== value); - if (!hasChanges) { - return state; - } - - return { - ...state, - ...patch, - draftVersion: state.draftVersion + 1, - }; - }); + setState((state) => ({ + ...state, + ...patch, + draftVersion: state.draftVersion + 1, + })); }, [], ); const resetDraft = useCallback(() => { setState((state) => ({ - ...initialDraft, + ...buildInitialDraft(), draftVersion: state.draftVersion + 1, + resetKey: state.resetKey + 1, })); }, []); @@ -148,28 +175,32 @@ export function DashboardNewWorkspaceDraftProvider({ const value = useMemo<DashboardNewWorkspaceDraftContextValue>( () => ({ draft: { - activeTab: state.activeTab, selectedProjectId: state.selectedProjectId, hostTarget: state.hostTarget, prompt: state.prompt, + baseBranch: state.baseBranch, + baseBranchSource: state.baseBranchSource, + runSetupScript: state.runSetupScript, + workspaceName: state.workspaceName, + workspaceNameEdited: state.workspaceNameEdited, branchName: state.branchName, branchNameEdited: state.branchNameEdited, - compareBaseBranch: state.compareBaseBranch, - showAdvanced: state.showAdvanced, - branchSearch: state.branchSearch, - issuesQuery: state.issuesQuery, - pullRequestsQuery: state.pullRequestsQuery, - branchesQuery: state.branchesQuery, + linkedIssues: state.linkedIssues, + linkedPR: state.linkedPR, + friendlyFallback: state.friendlyFallback, }, draftVersion: state.draftVersion, + resetKey: state.resetKey, closeModal: onClose, closeAndResetDraft, + createWorkspace, runAsyncAction, updateDraft, resetDraft, }), [ closeAndResetDraft, + createWorkspace, onClose, resetDraft, runAsyncAction, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx index 12bf45f75db..2d06ee25d5f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/DashboardNewWorkspaceModal.tsx @@ -1,3 +1,7 @@ +import { + PromptInputProvider, + usePromptInputController, +} from "@superset/ui/ai-elements/prompt-input"; import { Dialog, DialogContent, @@ -5,38 +9,69 @@ import { DialogHeader, DialogTitle, } from "@superset/ui/dialog"; +import { useEffect, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; -import { DashboardNewWorkspaceForm } from "./components/DashboardNewWorkspaceForm"; -import { DashboardNewWorkspaceDraftProvider } from "./DashboardNewWorkspaceDraftContext"; +import { DashboardNewWorkspaceModalContent } from "./components/DashboardNewWorkspaceModalContent"; +import { + DashboardNewWorkspaceDraftProvider, + useDashboardNewWorkspaceDraft, +} from "./DashboardNewWorkspaceDraftContext"; + +/** Clears the PromptInputProvider text & attachments when the draft resets. */ +function PromptInputResetSync() { + const { resetKey } = useDashboardNewWorkspaceDraft(); + const { textInput, attachments } = usePromptInputController(); + const prevResetKeyRef = useRef(resetKey); + + useEffect(() => { + if (resetKey !== prevResetKeyRef.current) { + prevResetKeyRef.current = resetKey; + textInput.clear(); + attachments.clear(); + } + }, [resetKey, textInput.clear, attachments.clear]); + + return null; +} export function DashboardNewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); const preSelectedProjectId = usePreSelectedProjectId(); + // Prevents AgentSelect from flashing "No agent" while presets load after refresh. + electronTrpc.settings.getAgentPresets.useQuery(); + return ( <DashboardNewWorkspaceDraftProvider onClose={closeModal}> - <Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}> - <DialogHeader className="sr-only"> - <DialogTitle>New Workspace</DialogTitle> - <DialogDescription> - Create a new workspace from a PR, branch, issue, or prompt. - </DialogDescription> - </DialogHeader> - <DialogContent - showCloseButton={false} - className="bg-popover text-popover-foreground sm:max-w-[560px] max-h-[min(70vh,600px)] !top-[calc(50%-min(35vh,300px))] !-translate-y-0 flex flex-col overflow-hidden p-0" + <PromptInputProvider> + <PromptInputResetSync /> + <Dialog + modal + open={isOpen} + onOpenChange={(open) => !open && closeModal()} > - <DashboardNewWorkspaceForm - isOpen={isOpen} - preSelectedProjectId={preSelectedProjectId} - /> - </DialogContent> - </Dialog> + <DialogHeader className="sr-only"> + <DialogTitle>New Workspace</DialogTitle> + <DialogDescription>Create a new workspace</DialogDescription> + </DialogHeader> + <DialogContent + showCloseButton={false} + onFocusOutside={(e) => e.preventDefault()} + className="bg-popover text-popover-foreground sm:max-w-[560px] max-h-[min(70vh,600px)] !top-[calc(50%-min(35vh,300px))] !-translate-y-0 flex flex-col overflow-hidden p-0" + > + <DashboardNewWorkspaceModalContent + isOpen={isOpen} + preSelectedProjectId={preSelectedProjectId} + /> + </DialogContent> + </Dialog> + </PromptInputProvider> </DashboardNewWorkspaceDraftProvider> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx deleted file mode 100644 index fe5b9b3ab0c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/DashboardNewWorkspaceForm.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useCallback } from "react"; -import { useDashboardNewWorkspaceDraft } from "../../DashboardNewWorkspaceDraftContext"; -import { DashboardNewWorkspaceFormHeader } from "./components/DashboardNewWorkspaceFormHeader"; -import { DashboardNewWorkspaceListTabContent } from "./components/DashboardNewWorkspaceListTabContent"; -import { DashboardNewWorkspacePromptTabContent } from "./components/DashboardNewWorkspacePromptTabContent"; -import { useDashboardNewWorkspaceProjectSelection } from "./hooks/useDashboardNewWorkspaceProjectSelection"; -import { useResolvedLocalProject } from "./hooks/useResolvedLocalProject"; - -interface DashboardNewWorkspaceFormProps { - isOpen: boolean; - preSelectedProjectId: string | null; -} - -/** Main form for the new workspace modal with collection-based project selection. */ -export function DashboardNewWorkspaceForm({ - isOpen, - preSelectedProjectId, -}: DashboardNewWorkspaceFormProps) { - const { draft, updateDraft } = useDashboardNewWorkspaceDraft(); - const handleSelectProject = useCallback( - (selectedProjectId: string | null) => { - updateDraft({ selectedProjectId }); - }, - [updateDraft], - ); - const { githubRepository, githubRepositoryId } = - useDashboardNewWorkspaceProjectSelection({ - isOpen, - preSelectedProjectId, - selectedProjectId: draft.selectedProjectId, - onSelectProject: handleSelectProject, - }); - const resolvedLocalProjectId = useResolvedLocalProject(githubRepository); - - const listTab = draft.activeTab === "prompt" ? null : draft.activeTab; - const isListTab = listTab !== null; - const listQuery = - draft.activeTab === "issues" - ? draft.issuesQuery - : draft.activeTab === "branches" - ? draft.branchesQuery - : draft.pullRequestsQuery; - - const handleListQueryChange = (value: string) => { - switch (draft.activeTab) { - case "issues": - updateDraft({ issuesQuery: value }); - return; - case "branches": - updateDraft({ branchesQuery: value }); - return; - case "pull-requests": - updateDraft({ pullRequestsQuery: value }); - return; - default: - return; - } - }; - - return ( - <> - <DashboardNewWorkspaceFormHeader - activeTab={draft.activeTab} - hostTarget={draft.hostTarget} - selectedProjectId={draft.selectedProjectId} - onSelectTab={(activeTab) => updateDraft({ activeTab })} - onSelectHostTarget={(hostTarget) => updateDraft({ hostTarget })} - onSelectProject={handleSelectProject} - /> - - {isListTab ? ( - <DashboardNewWorkspaceListTabContent - activeTab={listTab} - projectId={draft.selectedProjectId} - githubRepositoryId={githubRepositoryId} - hostTarget={draft.hostTarget} - localProjectId={resolvedLocalProjectId} - query={listQuery} - onQueryChange={handleListQueryChange} - /> - ) : ( - <DashboardNewWorkspacePromptTabContent - projectId={draft.selectedProjectId} - localProjectId={resolvedLocalProjectId} - hostTarget={draft.hostTarget} - /> - )} - </> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx new file mode 100644 index 00000000000..c3f703a3abb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/PromptGroup.tsx @@ -0,0 +1,475 @@ +import { sanitizeUserBranchName } from "@superset/shared/workspace-launch"; +import { + PromptInput, + PromptInputAttachment, + PromptInputAttachments, + PromptInputButton, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, + useProviderAttachments, +} from "@superset/ui/ai-elements/prompt-input"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { isEnterSubmit } from "@superset/ui/lib/keyboard"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import type { FileUIPart } from "ai"; +import { AnimatePresence, motion } from "framer-motion"; +import { ArrowUpIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { GoIssueOpened } from "react-icons/go"; +import { LuGitPullRequest } from "react-icons/lu"; +import { SiLinear } from "react-icons/si"; +import { AgentSelect } from "renderer/components/AgentSelect"; +import { LinkedIssuePill } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/LinkedIssuePill"; +import { IssueLinkCommand } from "renderer/components/Chat/ChatInterface/components/IssueLinkCommand"; +import { useAgentLaunchPreferences } from "renderer/hooks/useAgentLaunchPreferences"; +import { useEnabledAgents } from "renderer/hooks/useEnabledAgents"; +import { PLATFORM } from "renderer/hotkeys"; +import { useNewWorkspaceModalOpen } from "renderer/stores/new-workspace-modal"; +import { useV2WorkspaceCreateDefaultsStore } from "renderer/stores/v2-workspace-create-defaults"; +import { useDashboardNewWorkspaceDraft } from "../../../DashboardNewWorkspaceDraftContext"; +import { DevicePicker } from "../components/DevicePicker"; +import { AttachmentButtons } from "./components/AttachmentButtons"; +import { CompareBaseBranchPicker } from "./components/CompareBaseBranchPicker"; +import { GitHubIssueLinkCommand } from "./components/GitHubIssueLinkCommand"; +import { LinkedGitHubIssuePill } from "./components/LinkedGitHubIssuePill"; +import { LinkedPRPill } from "./components/LinkedPRPill"; +import { PRLinkCommand } from "./components/PRLinkCommand"; +import { ProjectPickerPill } from "./components/ProjectPickerPill"; +import { useBranchPickerController } from "./hooks/useBranchPickerController"; +import { useLinkedContext } from "./hooks/useLinkedContext"; +import { + type SubmitAttachment, + useSubmitWorkspace, +} from "./hooks/useSubmitWorkspace"; +import { + AGENT_STORAGE_KEY, + PILL_BUTTON_CLASS, + type ProjectOption, + type WorkspaceCreateAgent, +} from "./types"; + +interface PromptGroupProps { + projectId: string | null; + selectedProject: ProjectOption | undefined; + recentProjects: ProjectOption[]; + onSelectProject: (projectId: string) => void; +} + +export function PromptGroup({ + projectId, + selectedProject, + recentProjects, + onSelectProject, +}: PromptGroupProps) { + const modKey = PLATFORM === "mac" ? "⌘" : "Ctrl"; + const isNewWorkspaceModalOpen = useNewWorkspaceModalOpen(); + const { closeModal, draft, updateDraft } = useDashboardNewWorkspaceDraft(); + const navigate = useNavigate(); + const attachments = useProviderAttachments(); + const needsSetup = selectedProject?.needsSetup === true; + const persistedBaseBranchDefault = useV2WorkspaceCreateDefaultsStore( + (state) => + projectId ? (state.baseBranchesByProjectId[projectId] ?? null) : null, + ); + const setBaseBranchDefault = useV2WorkspaceCreateDefaultsStore( + (state) => state.setBaseBranchDefault, + ); + const clearBaseBranchDefault = useV2WorkspaceCreateDefaultsStore( + (state) => state.clearBaseBranchDefault, + ); + const setLastHostTarget = useV2WorkspaceCreateDefaultsStore( + (state) => state.setLastHostTarget, + ); + const handleGoToSetup = useCallback(() => { + if (!selectedProject?.id) return; + const targetProjectId = selectedProject.id; + closeModal(); + void navigate({ + to: "/settings/projects/$projectId", + params: { projectId: targetProjectId }, + }); + }, [closeModal, navigate, selectedProject?.id]); + const { + baseBranch, + hostTarget, + prompt, + workspaceName, + branchName, + branchNameEdited, + linkedIssues, + linkedPR, + friendlyFallback, + } = draft; + + // ── Agent presets ──────────────────────────────────────────────── + const { agents: enabledAgentPresets, isFetched: agentsFetched } = + useEnabledAgents(); + const selectableAgentIds = useMemo( + () => enabledAgentPresets.map((preset) => preset.id), + [enabledAgentPresets], + ); + const { selectedAgent, setSelectedAgent } = + useAgentLaunchPreferences<WorkspaceCreateAgent>({ + agentStorageKey: AGENT_STORAGE_KEY, + defaultAgent: "claude", + fallbackAgent: "none", + validAgents: ["none", ...selectableAgentIds], + agentsReady: agentsFetched, + }); + + const branchPreview = branchNameEdited + ? sanitizeUserBranchName(branchName) + : friendlyFallback; + + // Reset baseBranch on project or host change, defaulting to the user's + // last selected branch for that project when one exists. + const previousProjectIdRef = useRef(projectId); + const previousHostRef = useRef(JSON.stringify(hostTarget)); + useEffect(() => { + const nextHost = JSON.stringify(hostTarget); + if ( + previousProjectIdRef.current !== projectId || + previousHostRef.current !== nextHost + ) { + previousProjectIdRef.current = projectId; + previousHostRef.current = nextHost; + updateDraft({ + baseBranch: persistedBaseBranchDefault?.branchName ?? null, + baseBranchSource: persistedBaseBranchDefault?.source ?? null, + }); + } + }, [projectId, hostTarget, persistedBaseBranchDefault, updateDraft]); + + // ── Branch picker controller ───────────────────────────────────── + const { pickerProps } = useBranchPickerController({ + projectId, + hostTarget, + baseBranch, + runSetupScript: draft.runSetupScript, + typedWorkspaceName: workspaceName, + onBaseBranchChange: (branch, source) => { + if (projectId) { + if (branch && source) { + setBaseBranchDefault(projectId, branch, source); + } else { + clearBaseBranchDefault(projectId); + } + } + updateDraft({ baseBranch: branch, baseBranchSource: source }); + }, + closeModal, + }); + + // ── Submit (fork) ──────────────────────────────────────────────── + const createWorkspace = useSubmitWorkspace(projectId, selectedAgent); + const handleSubmit = useCallback( + (files: SubmitAttachment[] = []) => { + if (needsSetup) { + handleGoToSetup(); + return; + } + void createWorkspace(files); + }, + [createWorkspace, handleGoToSetup, needsSetup], + ); + const handlePromptSubmit = useCallback( + (message: { text?: string; files?: FileUIPart[] }) => { + // Library converts blob: → data: URLs before calling us; pass them + // through. We intentionally do not read attachments from the + // provider here — the library clears + revokes before onSubmit, so + // the provider's state is stale by this point. + const files = (message.files ?? []) + .filter((f) => typeof f.url === "string" && f.url.length > 0) + .map((f) => ({ + url: f.url, + mediaType: f.mediaType, + filename: f.filename, + })); + handleSubmit(files); + }, + [handleSubmit], + ); + + useEffect(() => { + if (!isNewWorkspaceModalOpen) return; + const handler = (e: KeyboardEvent) => { + if (!isEnterSubmit(e, { requireMod: true })) return; + e.preventDefault(); + // Keyboard fallback: submit without attachments. Inside the + // modal's form focus, PromptInput's own Enter handler fires + // instead and routes through handlePromptSubmit with files. + handleSubmit(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [isNewWorkspaceModalOpen, handleSubmit]); + + // ── Linked issues / PR ─────────────────────────────────────────── + const { + addLinkedIssue, + addLinkedGitHubIssue, + removeLinkedIssue, + setLinkedPR, + removeLinkedPR, + } = useLinkedContext(linkedIssues, updateDraft); + + // ── Render ──────────────────────────────────────────────────────── + return ( + <div className="p-3 space-y-2"> + {/* Workspace name + branch name */} + <div className="flex items-center"> + <Input + className="border-none bg-transparent dark:bg-transparent shadow-none text-base font-medium px-0 h-auto focus-visible:ring-0 placeholder:text-muted-foreground/40 min-w-0 flex-1" + placeholder="Workspace name (optional)" + value={workspaceName} + onChange={(e) => + updateDraft({ + workspaceName: e.target.value, + workspaceNameEdited: true, + }) + } + onBlur={() => { + if (!workspaceName.trim()) + updateDraft({ workspaceName: "", workspaceNameEdited: false }); + }} + /> + <div className="shrink min-w-0 ml-auto max-w-[50%]"> + <Input + className={cn( + "border-none bg-transparent dark:bg-transparent shadow-none text-xs font-mono text-muted-foreground/60 px-0 h-auto focus-visible:ring-0 placeholder:text-muted-foreground/30 focus:text-muted-foreground text-right placeholder:text-right overflow-hidden text-ellipsis", + )} + placeholder={branchPreview || "branch name"} + value={branchName} + onChange={(e) => + updateDraft({ + branchName: e.target.value.replace(/\s+/g, "-"), + branchNameEdited: true, + }) + } + onBlur={() => { + const sanitized = sanitizeUserBranchName(branchName.trim()); + if (!sanitized) + updateDraft({ branchName: "", branchNameEdited: false }); + else updateDraft({ branchName: sanitized }); + }} + /> + </div> + </div> + + {/* Prompt input */} + <PromptInput + onSubmit={handlePromptSubmit} + multiple + maxFiles={5} + maxFileSize={10 * 1024 * 1024} + className="[&>[data-slot=input-group]]:rounded-[13px] [&>[data-slot=input-group]]:border-[0.5px] [&>[data-slot=input-group]]:shadow-none [&>[data-slot=input-group]]:bg-foreground/[0.02]" + > + {(linkedPR || + linkedIssues.length > 0 || + attachments.files.length > 0) && ( + <div className="flex flex-wrap items-start gap-2 px-3 pt-3 self-stretch"> + <AnimatePresence initial={false}> + {linkedPR && ( + <motion.div + key="linked-pr" + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ duration: 0.15 }} + > + <LinkedPRPill + prNumber={linkedPR.prNumber} + title={linkedPR.title} + state={linkedPR.state} + onRemove={removeLinkedPR} + /> + </motion.div> + )} + {linkedIssues.map((issue) => ( + <motion.div + key={issue.url ?? issue.slug} + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ duration: 0.15 }} + > + {issue.source === "github" && issue.number != null ? ( + <LinkedGitHubIssuePill + issueNumber={issue.number} + title={issue.title} + state={issue.state ?? "open"} + onRemove={() => removeLinkedIssue(issue.slug)} + /> + ) : ( + <LinkedIssuePill + slug={issue.slug} + title={issue.title} + url={issue.url} + taskId={issue.taskId} + onRemove={() => removeLinkedIssue(issue.slug)} + /> + )} + </motion.div> + ))} + </AnimatePresence> + <PromptInputAttachments> + {(file) => <PromptInputAttachment data={file} />} + </PromptInputAttachments> + </div> + )} + <PromptInputTextarea + autoFocus + placeholder="What do you want to do?" + className="min-h-10" + value={prompt} + onChange={(e) => updateDraft({ prompt: e.target.value })} + /> + <PromptInputFooter> + <PromptInputTools className="gap-1.5"> + <AgentSelect<WorkspaceCreateAgent> + agents={enabledAgentPresets} + value={selectedAgent} + placeholder="No agent" + onValueChange={setSelectedAgent} + onBeforeConfigureAgents={closeModal} + triggerClassName={`${PILL_BUTTON_CLASS} px-1.5 gap-1 text-foreground w-auto max-w-[160px]`} + iconClassName="size-3 object-contain" + allowNone + noneLabel="No agent" + noneValue="none" + /> + </PromptInputTools> + <div className="flex items-center gap-2"> + <AttachmentButtons + linearIssueTrigger={ + <IssueLinkCommand + onSelect={addLinkedIssue} + tooltipLabel="Link issue" + > + <PromptInputButton + aria-label="Link issue" + className={`${PILL_BUTTON_CLASS} w-[22px]`} + > + <SiLinear className="size-3.5" /> + </PromptInputButton> + </IssueLinkCommand> + } + githubIssueTrigger={ + <GitHubIssueLinkCommand + onSelect={(issue) => + addLinkedGitHubIssue( + issue.issueNumber, + issue.title, + issue.url, + issue.state, + ) + } + projectId={projectId} + hostTarget={hostTarget} + tooltipLabel="Link GitHub issue" + > + <PromptInputButton + aria-label="Link GitHub issue" + className={`${PILL_BUTTON_CLASS} w-[22px]`} + > + <GoIssueOpened className="size-3.5" /> + </PromptInputButton> + </GitHubIssueLinkCommand> + } + prTrigger={ + <PRLinkCommand + onSelect={setLinkedPR} + projectId={projectId} + hostTarget={hostTarget} + tooltipLabel="Link pull request" + > + <PromptInputButton + aria-label="Link pull request" + className={`${PILL_BUTTON_CLASS} w-[22px]`} + > + <LuGitPullRequest className="size-3.5" /> + </PromptInputButton> + </PRLinkCommand> + } + /> + <PromptInputSubmit + className="size-[22px] rounded-full border border-transparent bg-foreground/10 shadow-none p-[5px] hover:bg-foreground/20" + disabled={needsSetup} + onClick={(e) => { + e.preventDefault(); + handleSubmit(); + }} + > + <ArrowUpIcon className="size-3.5 text-muted-foreground" /> + </PromptInputSubmit> + </div> + </PromptInputFooter> + </PromptInput> + + {/* Bottom bar */} + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-2 min-w-0 flex-1"> + <DevicePicker + hostTarget={hostTarget} + onSelectHostTarget={(t) => { + setLastHostTarget(t); + updateDraft({ hostTarget: t }); + }} + /> + <ProjectPickerPill + selectedProject={selectedProject} + projects={recentProjects} + onSelectProject={onSelectProject} + /> + <AnimatePresence mode="wait" initial={false}> + {linkedPR ? ( + <motion.span + key="linked-pr-label" + initial={{ opacity: 0, x: -8, filter: "blur(4px)" }} + animate={{ opacity: 1, x: 0, filter: "blur(0px)" }} + exit={{ opacity: 0, x: 8, filter: "blur(4px)" }} + transition={{ duration: 0.2, ease: "easeOut" }} + className="flex items-center gap-1 text-xs text-muted-foreground" + > + <LuGitPullRequest className="size-3 shrink-0" /> + based off PR #{linkedPR.prNumber} + </motion.span> + ) : ( + <motion.div + key="branch-picker" + className="min-w-0" + initial={{ opacity: 0, x: -8, filter: "blur(4px)" }} + animate={{ opacity: 1, x: 0, filter: "blur(0px)" }} + exit={{ opacity: 0, x: 8, filter: "blur(4px)" }} + transition={{ duration: 0.2, ease: "easeOut" }} + > + <CompareBaseBranchPicker {...pickerProps} /> + </motion.div> + )} + </AnimatePresence> + </div> + <div className="flex items-center gap-1.5"> + {needsSetup ? ( + <Button + type="button" + variant="outline" + size="sm" + className="h-6 px-2 text-[11px] text-amber-500 hover:text-amber-500" + onClick={handleGoToSetup} + > + Set up project… + </Button> + ) : ( + <span className="text-[11px] text-muted-foreground/50"> + {modKey}↵ + </span> + )} + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx new file mode 100644 index 00000000000..5b0fb24b63c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/AttachmentButtons.tsx @@ -0,0 +1,41 @@ +import { + PromptInputButton, + usePromptInputAttachments, +} from "@superset/ui/ai-elements/prompt-input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { PaperclipIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { PILL_BUTTON_CLASS } from "../../types"; + +interface AttachmentButtonsProps { + linearIssueTrigger: ReactNode; + githubIssueTrigger: ReactNode; + prTrigger: ReactNode; +} + +export function AttachmentButtons({ + linearIssueTrigger, + githubIssueTrigger, + prTrigger, +}: AttachmentButtonsProps) { + const attachments = usePromptInputAttachments(); + return ( + <div className="flex items-center gap-1"> + <Tooltip> + <TooltipTrigger asChild> + <PromptInputButton + aria-label="Add attachment" + className={`${PILL_BUTTON_CLASS} w-[22px]`} + onClick={() => attachments.openFileDialog()} + > + <PaperclipIcon className="size-3.5" /> + </PromptInputButton> + </TooltipTrigger> + <TooltipContent side="bottom">Add attachment</TooltipContent> + </Tooltip> + {linearIssueTrigger} + {githubIssueTrigger} + {prTrigger} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/index.ts new file mode 100644 index 00000000000..1bcf39bf3f0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/AttachmentButtons/index.ts @@ -0,0 +1 @@ +export { AttachmentButtons } from "./AttachmentButtons"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx new file mode 100644 index 00000000000..c01f0fe6ffb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/CompareBaseBranchPicker.tsx @@ -0,0 +1,277 @@ +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@superset/ui/tooltip"; +import { useEffect, useRef, useState } from "react"; +import { GoGitBranch } from "react-icons/go"; +import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; +import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; +import type { BranchFilter, BranchRow } from "../../../hooks/useBranchContext"; +import { FormPickerTrigger } from "../FormPickerTrigger"; + +interface CompareBaseBranchPickerProps { + effectiveCompareBaseBranch: string | null; + defaultBranch: string | null | undefined; + isBranchesLoading: boolean; + isBranchesError: boolean; + branches: BranchRow[]; + branchSearch: string; + onBranchSearchChange: (value: string) => void; + branchFilter: BranchFilter; + onBranchFilterChange: (filter: BranchFilter) => void; + isFetchingNextPage: boolean; + hasNextPage: boolean; + onLoadMore: () => void; + onSelectCompareBaseBranch: ( + branchName: string, + source: "local" | "remote-tracking", + ) => void; + onCheckoutBranch: (branchName: string) => void; + onOpenExisting: (branchName: string) => void; + onAdoptWorktree: (branchName: string) => void; + // Authoritative (cloud-synced) answer to "does a workspace row exist for + // this branch on this host?". Computed from the v2Workspaces collection + // so it stays in sync with soft-deletes. Trumps any server-side + // `hasWorkspace` snapshot, which can be stale after deletion. + hasWorkspaceForBranch: (branchName: string) => boolean; +} + +export function CompareBaseBranchPicker({ + effectiveCompareBaseBranch, + defaultBranch, + isBranchesLoading, + isBranchesError, + branches, + branchSearch, + onBranchSearchChange, + branchFilter, + onBranchFilterChange, + isFetchingNextPage, + hasNextPage, + onLoadMore, + onSelectCompareBaseBranch, + onCheckoutBranch, + onOpenExisting, + onAdoptWorktree, + hasWorkspaceForBranch, +}: CompareBaseBranchPickerProps) { + const [open, setOpen] = useState(false); + const sentinelRef = useRef<HTMLDivElement | null>(null); + + useEffect(() => { + if (!open || !hasNextPage || isFetchingNextPage) return; + const el = sentinelRef.current; + if (!el) return; + // Guard against cascade: when isFetchingNextPage flips false → effect + // re-runs → observer reattaches → if sentinel is still in the root + // margin (e.g. tall viewport, small page), the callback fires again + // immediately. Re-checking the latest fetch state avoids loading every + // remaining page in one chain. + let inFlight = false; + const observer = new IntersectionObserver( + (entries) => { + if (inFlight) return; + if (entries.some((e) => e.isIntersecting)) { + inFlight = true; + onLoadMore(); + } + }, + { rootMargin: "64px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [open, hasNextPage, isFetchingNextPage, onLoadMore]); + + if (isBranchesError) { + return ( + <span className="text-xs text-destructive">Failed to load branches</span> + ); + } + + return ( + <Popover + open={open} + onOpenChange={(v) => { + setOpen(v); + if (!v) onBranchSearchChange(""); + }} + > + <PopoverTrigger asChild> + <FormPickerTrigger + disabled={isBranchesLoading && branches.length === 0} + className="max-w-full" + > + <GoGitBranch className="size-3 shrink-0" /> + {isBranchesLoading && branches.length === 0 ? ( + <span className="h-2.5 w-14 rounded-sm bg-muted-foreground/15 animate-pulse" /> + ) : ( + <span className="font-mono truncate"> + {effectiveCompareBaseBranch || "..."} + </span> + )} + <HiChevronUpDown className="size-3 shrink-0" /> + </FormPickerTrigger> + </PopoverTrigger> + <PopoverContent + className="w-96 p-0" + align="start" + onWheel={(event) => event.stopPropagation()} + > + <Command shouldFilter={false}> + <CommandInput + placeholder="Search branches..." + value={branchSearch} + onValueChange={onBranchSearchChange} + /> + <Tabs + value={branchFilter} + onValueChange={(v) => onBranchFilterChange(v as BranchFilter)} + className="p-2" + > + <TabsList className="grid w-full grid-cols-2 h-7 bg-transparent"> + <TabsTrigger value="branch" className="text-[11px]"> + Branch + </TabsTrigger> + <TabsTrigger value="worktree" className="text-[11px]"> + Worktree + </TabsTrigger> + </TabsList> + </Tabs> + <TooltipProvider delayDuration={300}> + <CommandList className="max-h-[400px]"> + {!isBranchesLoading && branches.length === 0 && ( + <CommandEmpty>No branches found</CommandEmpty> + )} + {branches.map((branch) => { + const isRemoteOnly = branch.isRemote && !branch.isLocal; + return ( + <CommandItem + key={branch.name} + value={branch.name} + onSelect={() => { + // Carry the row's locality through so the server doesn't + // re-resolve and risk picking a stale cached remote ref. + onSelectCompareBaseBranch( + branch.name, + branch.isLocal ? "local" : "remote-tracking", + ); + setOpen(false); + }} + className="group h-11 flex items-center justify-between gap-3 px-3" + > + <span className="flex items-center gap-2.5 truncate flex-1 min-w-0"> + <GoGitBranch className="size-3.5 shrink-0 text-muted-foreground" /> + <span className="truncate font-mono text-xs"> + {branch.name} + </span> + <span className="flex items-center gap-1.5 shrink-0"> + {branch.name === defaultBranch && ( + <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> + default + </span> + )} + {isRemoteOnly && ( + <span className="text-[10px] text-muted-foreground/60 bg-muted/60 px-1.5 py-0.5 rounded"> + remote + </span> + )} + </span> + </span> + <span className="flex items-center gap-2 shrink-0"> + {branch.lastCommitDate > 0 && ( + <span className="text-[11px] text-muted-foreground/70 group-hover:hidden"> + {formatRelativeTime(branch.lastCommitDate * 1000)} + </span> + )} + {branchFilter === "worktree" ? ( + (() => { + // Authoritative check against the cloud-synced + // collection — a `server hasWorkspace:true` row + // may be stale after a delete. + const hasWorkspace = hasWorkspaceForBranch( + branch.name, + ); + return ( + <button + type="button" + className="hidden group-hover:inline-flex group-focus-within:inline-flex items-center rounded-sm bg-primary/10 hover:bg-primary/20 px-2 py-0.5 text-[11px] text-primary font-medium" + onClick={(e) => { + e.stopPropagation(); + if (hasWorkspace) { + onOpenExisting(branch.name); + } else { + onAdoptWorktree(branch.name); + } + }} + > + {hasWorkspace ? "Open" : "Create"} + </button> + ); + })() + ) : branch.isCheckedOut ? ( + <Tooltip> + <TooltipTrigger asChild> + {/* + Use aria-disabled, NOT the native `disabled` attribute. + Native disabled buttons don't fire pointer events, so the + Tooltip never sees hover/focus and never opens — defeating + the purpose of explaining why the button is unavailable. + */} + <button + type="button" + aria-disabled="true" + className="hidden group-hover:inline-flex group-focus-within:inline-flex items-center rounded-sm bg-muted px-2 py-0.5 text-[11px] text-muted-foreground/70 cursor-not-allowed" + onClick={(e) => e.stopPropagation()} + > + Check out + </button> + </TooltipTrigger> + <TooltipContent side="left"> + Already checked out in another worktree + </TooltipContent> + </Tooltip> + ) : ( + <button + type="button" + className="hidden group-hover:inline-flex group-focus-within:inline-flex items-center rounded-sm bg-primary/10 hover:bg-primary/20 px-2 py-0.5 text-[11px] text-primary font-medium" + onClick={(e) => { + e.stopPropagation(); + onCheckoutBranch(branch.name); + }} + > + Check out + </button> + )} + {effectiveCompareBaseBranch === branch.name && ( + <HiCheck className="size-4 text-primary" /> + )} + </span> + </CommandItem> + ); + })} + {hasNextPage && ( + <div + ref={sentinelRef} + className="py-2 text-center text-[11px] text-muted-foreground/60" + > + {isFetchingNextPage ? "Loading more..." : ""} + </div> + )} + </CommandList> + </TooltipProvider> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/index.ts new file mode 100644 index 00000000000..b2481b345b6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/CompareBaseBranchPicker/index.ts @@ -0,0 +1 @@ +export { CompareBaseBranchPicker } from "./CompareBaseBranchPicker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/FormPickerTrigger/FormPickerTrigger.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/FormPickerTrigger/FormPickerTrigger.tsx new file mode 100644 index 00000000000..e9a1b226eea --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/FormPickerTrigger/FormPickerTrigger.tsx @@ -0,0 +1,22 @@ +import { cn } from "@superset/ui/utils"; +import type { ComponentProps } from "react"; + +// Shared trigger for the top-of-modal pickers (Device / Project / Branch). +// No background; uniform icon size, text size, and text color so the three +// pickers read as one segmented control. +export function FormPickerTrigger({ + className, + type = "button", + ...props +}: ComponentProps<"button">) { + return ( + <button + type={type} + className={cn( + "inline-flex items-center gap-1 h-[22px] text-[11px] text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 min-w-0", + className, + )} + {...props} + /> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/FormPickerTrigger/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/FormPickerTrigger/index.ts new file mode 100644 index 00000000000..be3c251f6e2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/FormPickerTrigger/index.ts @@ -0,0 +1 @@ +export { FormPickerTrigger } from "./FormPickerTrigger"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx new file mode 100644 index 00000000000..bf488e7575b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/GitHubIssueLinkCommand.tsx @@ -0,0 +1,200 @@ +import { Checkbox } from "@superset/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useId, useState } from "react"; +import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; + +const MAX_RESULTS = 30; + +const normalizeIssueState = (state: string): IssueState => + state.toLowerCase() === "closed" ? "closed" : "open"; + +export interface SelectedIssue { + issueNumber: number; + title: string; + url: string; + state: string; +} + +interface GitHubIssueLinkCommandProps { + children: ReactNode; + tooltipLabel: string; + onSelect: (issue: SelectedIssue) => void; + projectId: string | null; + hostTarget: WorkspaceHostTarget; +} + +export function GitHubIssueLinkCommand({ + children, + tooltipLabel, + onSelect, + projectId, + hostTarget, +}: GitHubIssueLinkCommandProps) { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const hostUrl = useHostTargetUrl(hostTarget); + + const trimmedQuery = searchQuery.trim(); + const debouncedTrimmed = debouncedQuery.trim(); + const isPendingDebounce = trimmedQuery !== debouncedTrimmed; + + const { data, isFetching } = useQuery({ + queryKey: [ + "workspaceCreation", + "searchGitHubIssues", + projectId, + hostUrl, + debouncedTrimmed, + showClosed, + ], + queryFn: async () => { + if (!hostUrl || !projectId) return { issues: [] }; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchGitHubIssues.query({ + projectId, + query: debouncedTrimmed || undefined, + limit: MAX_RESULTS, + includeClosed: showClosed, + }); + }, + enabled: !!projectId && !!hostUrl && open, + }); + + const searchResults = data?.issues ?? []; + const repoMismatch = + data && "repoMismatch" in data ? data.repoMismatch : null; + + const isLoading = + debouncedTrimmed || trimmedQuery + ? isFetching || isPendingDebounce + : isFetching; + + const handleSelect = (issue: (typeof searchResults)[number]) => { + onSelect({ + issueNumber: issue.issueNumber, + title: issue.title, + url: issue.url, + state: issue.state, + }); + setSearchQuery(""); + setOpen(false); + }; + + return ( + <Popover + open={open} + onOpenChange={(next) => { + if (!next) setSearchQuery(""); + setOpen(next); + }} + > + <Tooltip> + <PopoverTrigger asChild> + <TooltipTrigger asChild>{children}</TooltipTrigger> + </PopoverTrigger> + <TooltipContent side="bottom">{tooltipLabel}</TooltipContent> + </Tooltip> + <PopoverContent + className="w-80 p-0" + align="start" + side="bottom" + onWheel={(event) => event.stopPropagation()} + > + <Command shouldFilter={false}> + <CommandInput + placeholder="Search issues..." + value={searchQuery} + onValueChange={setSearchQuery} + /> + <div className="flex items-center gap-2 border-b px-3 py-2"> + <Checkbox + id={showClosedId} + checked={showClosed} + onCheckedChange={(checked) => setShowClosed(checked === true)} + /> + <label + htmlFor={showClosedId} + className="cursor-pointer select-none text-xs text-muted-foreground" + > + Show closed + </label> + </div> + <CommandList className="max-h-[280px]"> + {searchResults.length === 0 && ( + <CommandEmpty> + {isLoading + ? debouncedTrimmed + ? "Searching..." + : "Loading..." + : repoMismatch + ? `Issue URL must match ${repoMismatch}.` + : debouncedTrimmed + ? showClosed + ? "No issues found." + : "No open issues found." + : showClosed + ? "No issues found." + : "No open issues found."} + </CommandEmpty> + )} + {searchResults.length > 0 && ( + <CommandGroup + heading={ + debouncedTrimmed + ? `${searchResults.length} result${searchResults.length === 1 ? "" : "s"}` + : showClosed + ? "Recent issues" + : "Open issues" + } + > + {searchResults.map((issue) => ( + <CommandItem + key={issue.issueNumber} + value={`${issue.issueNumber}-${issue.title}`} + onSelect={() => handleSelect(issue)} + className="group" + > + <IssueIcon + state={normalizeIssueState(issue.state)} + className="size-3.5 shrink-0" + /> + <span className="shrink-0 font-mono text-xs text-muted-foreground"> + #{issue.issueNumber} + </span> + <span className="min-w-0 flex-1 truncate text-xs"> + {issue.title} + </span> + <span className="shrink-0 hidden text-xs text-muted-foreground group-data-[selected=true]:inline"> + Link ↵ + </span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts new file mode 100644 index 00000000000..c7d5f8cdb50 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/GitHubIssueLinkCommand/index.ts @@ -0,0 +1 @@ +export { GitHubIssueLinkCommand } from "./GitHubIssueLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx new file mode 100644 index 00000000000..75ecb4b52d2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/LinkedGitHubIssuePill.tsx @@ -0,0 +1,59 @@ +import { Button } from "@superset/ui/button"; +import { XIcon } from "lucide-react"; +import { + IssueIcon, + type IssueState, +} from "renderer/screens/main/components/IssueIcon/IssueIcon"; + +interface LinkedGitHubIssuePillProps { + issueNumber: number; + title: string; + state: string; + onRemove: () => void; +} + +// Normalize issue state to valid IssueState type +const normalizeIssueState = (state: string): IssueState => + state.toLowerCase() === "closed" ? "closed" : "open"; + +export function LinkedGitHubIssuePill({ + issueNumber, + title, + state, + onRemove, +}: LinkedGitHubIssuePillProps) { + return ( + <div + title={title} + className="group flex items-center gap-2.5 rounded-md border border-border/50 bg-muted/60 px-3 py-2 text-sm transition-all select-none hover:bg-accent hover:ring-1 hover:ring-border dark:hover:bg-accent/50" + > + <div className="relative flex size-7 shrink-0 items-center justify-center rounded-md bg-foreground/10 p-0.5"> + <IssueIcon + state={normalizeIssueState(state)} + className="size-5 transition-opacity group-hover:opacity-0" + /> + <Button + aria-label="Remove linked issue" + className="pointer-events-none absolute inset-0 size-7 cursor-pointer rounded-md p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-3" + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + type="button" + variant="ghost" + > + <XIcon /> + <span className="sr-only">Remove</span> + </Button> + </div> + <div className="flex flex-col items-start leading-tight"> + <span className="max-w-[180px] truncate font-medium">{title}</span> + <div className="flex items-center gap-1.5 text-muted-foreground text-[10px] uppercase tracking-widest"> + <span>#{issueNumber}</span> + <span>·</span> + <span>GitHub</span> + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts new file mode 100644 index 00000000000..fe1657259a6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedGitHubIssuePill/index.ts @@ -0,0 +1 @@ +export { LinkedGitHubIssuePill } from "./LinkedGitHubIssuePill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx new file mode 100644 index 00000000000..9e2c4b35720 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/LinkedPRPill.tsx @@ -0,0 +1,55 @@ +import { Button } from "@superset/ui/button"; +import { XIcon } from "lucide-react"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; + +interface LinkedPRPillProps { + prNumber: number; + title: string; + state: string; + onRemove: () => void; +} + +export function LinkedPRPill({ + prNumber, + title, + state, + onRemove, +}: LinkedPRPillProps) { + return ( + <div + title={title} + className="group flex items-center gap-2.5 rounded-md border border-border/50 bg-muted/60 px-3 py-2 text-sm transition-all select-none hover:bg-accent hover:ring-1 hover:ring-border dark:hover:bg-accent/50" + > + <div className="relative flex size-7 shrink-0 items-center justify-center rounded-md bg-foreground/10 p-0.5"> + <PRIcon + state={state as PRState} + className="size-5 transition-opacity group-hover:opacity-0" + /> + <Button + aria-label="Remove linked PR" + className="pointer-events-none absolute inset-0 size-7 cursor-pointer rounded-md p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-3" + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + type="button" + variant="ghost" + > + <XIcon /> + <span className="sr-only">Remove</span> + </Button> + </div> + <div className="flex flex-col items-start leading-tight"> + <span className="max-w-[180px] truncate font-medium">{title}</span> + <div className="flex items-center gap-1.5 text-muted-foreground text-[10px] uppercase tracking-widest"> + <span>#{prNumber}</span> + <span>·</span> + <span>GitHub</span> + </div> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts new file mode 100644 index 00000000000..1042cfae4d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/LinkedPRPill/index.ts @@ -0,0 +1 @@ +export { LinkedPRPill } from "./LinkedPRPill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx new file mode 100644 index 00000000000..2603272f665 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/PRLinkCommand.tsx @@ -0,0 +1,201 @@ +import { Checkbox } from "@superset/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useQuery } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useId, useState } from "react"; +import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { + PRIcon, + type PRState, +} from "renderer/screens/main/components/PRIcon/PRIcon"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; + +export interface SelectedPR { + prNumber: number; + title: string; + url: string; + state: string; +} + +interface PRLinkCommandProps { + children: ReactNode; + tooltipLabel: string; + onSelect: (pr: SelectedPR) => void; + projectId: string | null; + hostTarget: WorkspaceHostTarget; +} + +function normalizeState(state: string, isDraft: boolean): string { + if (isDraft) return "draft"; + if (state === "OPEN" || state === "open") return "open"; + return state.toLowerCase(); +} + +export function PRLinkCommand({ + children, + tooltipLabel, + onSelect, + projectId, + hostTarget, +}: PRLinkCommandProps) { + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [showClosed, setShowClosed] = useState(false); + const showClosedId = useId(); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const hostUrl = useHostTargetUrl(hostTarget); + + const trimmedQuery = searchQuery.trim(); + const debouncedTrimmed = debouncedQuery.trim(); + const isPendingDebounce = trimmedQuery !== debouncedTrimmed; + + const { data, isFetching } = useQuery({ + queryKey: [ + "workspaceCreation", + "searchPullRequests", + projectId, + hostUrl, + debouncedTrimmed, + showClosed, + ], + queryFn: async () => { + if (!hostUrl || !projectId) return { pullRequests: [] }; + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchPullRequests.query({ + projectId, + query: debouncedTrimmed || undefined, + limit: 30, + includeClosed: showClosed, + }); + }, + enabled: !!projectId && !!hostUrl && open, + }); + + const pullRequests = data?.pullRequests ?? []; + const repoMismatch = + data && "repoMismatch" in data ? data.repoMismatch : null; + + const isLoading = + debouncedTrimmed || trimmedQuery + ? isFetching || isPendingDebounce + : isFetching; + + const handleSelect = (pr: (typeof pullRequests)[number]) => { + onSelect({ + prNumber: pr.prNumber, + title: pr.title, + url: pr.url, + state: normalizeState(pr.state, pr.isDraft), + }); + setSearchQuery(""); + setOpen(false); + }; + + return ( + <Popover + open={open} + onOpenChange={(next) => { + if (!next) setSearchQuery(""); + setOpen(next); + }} + > + <Tooltip> + <PopoverTrigger asChild> + <TooltipTrigger asChild>{children}</TooltipTrigger> + </PopoverTrigger> + <TooltipContent side="bottom">{tooltipLabel}</TooltipContent> + </Tooltip> + <PopoverContent + className="w-80 p-0" + align="start" + side="bottom" + onWheel={(event) => event.stopPropagation()} + > + <Command shouldFilter={false}> + <CommandInput + placeholder="Search pull requests..." + value={searchQuery} + onValueChange={setSearchQuery} + /> + <div className="flex items-center gap-2 border-b px-3 py-2"> + <Checkbox + id={showClosedId} + checked={showClosed} + onCheckedChange={(checked) => setShowClosed(checked === true)} + /> + <label + htmlFor={showClosedId} + className="cursor-pointer select-none text-xs text-muted-foreground" + > + Show closed + </label> + </div> + <CommandList className="max-h-[280px]"> + {pullRequests.length === 0 && ( + <CommandEmpty> + {isLoading + ? debouncedTrimmed + ? "Searching..." + : "Loading..." + : repoMismatch + ? `PR URL must match ${repoMismatch}.` + : debouncedTrimmed + ? showClosed + ? "No pull requests found." + : "No open pull requests found." + : showClosed + ? "No pull requests found." + : "No open pull requests."} + </CommandEmpty> + )} + {pullRequests.length > 0 && ( + <CommandGroup + heading={ + debouncedTrimmed + ? `${pullRequests.length} result${pullRequests.length === 1 ? "" : "s"}` + : showClosed + ? "Recent PRs" + : "Open PRs" + } + > + {pullRequests.map((pr) => ( + <CommandItem + key={pr.prNumber} + value={`${pr.prNumber}-${pr.title}`} + onSelect={() => handleSelect(pr)} + className="group" + > + <PRIcon + state={normalizeState(pr.state, pr.isDraft) as PRState} + className="size-3.5 shrink-0" + /> + <span className="shrink-0 font-mono text-xs text-muted-foreground"> + #{pr.prNumber} + </span> + <span className="min-w-0 flex-1 truncate text-xs"> + {pr.title} + </span> + <span className="shrink-0 hidden text-xs text-muted-foreground group-data-[selected=true]:inline"> + Link ↵ + </span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts new file mode 100644 index 00000000000..ba614340e89 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/PRLinkCommand/index.ts @@ -0,0 +1 @@ +export { PRLinkCommand } from "./PRLinkCommand"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx new file mode 100644 index 00000000000..d6dd0f948a3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/ProjectPickerPill.tsx @@ -0,0 +1,138 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@superset/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { HiCheck, HiChevronUpDown, HiMiniPlus } from "react-icons/hi2"; +import { LuFolderInput, LuTriangleAlert } from "react-icons/lu"; +import { useFolderFirstImport } from "renderer/routes/_authenticated/_dashboard/components/AddRepositoryModals/hooks/useFolderFirstImport"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import { useOpenNewProjectModal } from "renderer/stores/add-repository-modal"; +import type { ProjectOption } from "../../types"; +import { FormPickerTrigger } from "../FormPickerTrigger"; + +interface ProjectPickerPillProps { + selectedProject: ProjectOption | undefined; + projects: ProjectOption[]; + onSelectProject: (projectId: string) => void; +} + +export function ProjectPickerPill({ + selectedProject, + projects, + onSelectProject, +}: ProjectPickerPillProps) { + const [open, setOpen] = useState(false); + const openNewProject = useOpenNewProjectModal(); + const navigate = useNavigate(); + const folderImport = useFolderFirstImport({ + onError: (message) => { + toast.error(`Import failed: ${message}`); + }, + onMultipleProjects: ({ candidates }) => { + toast.error("Import failed", { + description: `Multiple projects use this repository (${candidates.length}). Choose the project in settings to set it up on this device.`, + action: { + label: "Open Projects", + onClick: () => navigate({ to: "/settings/projects" }), + }, + }); + }, + }); + + const handleCreateNewProject = async () => { + setOpen(false); + const result = await openNewProject(); + if (result) onSelectProject(result.projectId); + }; + + const handleImportProject = async () => { + setOpen(false); + const result = await folderImport.start(); + if (result) { + toast.success("Project imported and selected."); + onSelectProject(result.projectId); + } + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <FormPickerTrigger className="max-w-[140px]"> + {selectedProject && ( + <ProjectThumbnail + projectName={selectedProject.name} + githubOwner={selectedProject.githubOwner} + className="size-4" + /> + )} + <span className="truncate"> + {selectedProject?.name ?? "Select project"} + </span> + <HiChevronUpDown className="size-3 shrink-0" /> + </FormPickerTrigger> + </PopoverTrigger> + <PopoverContent + align="start" + className="w-60 p-0" + onWheel={(event) => event.stopPropagation()} + > + <Command> + <CommandInput placeholder="Search projects..." /> + <CommandList className="max-h-[min(280px,var(--radix-popover-content-available-height))]"> + <CommandEmpty>No projects found.</CommandEmpty> + <CommandGroup> + {projects.map((project) => ( + <CommandItem + key={project.id} + value={project.name} + onSelect={() => { + onSelectProject(project.id); + setOpen(false); + }} + > + <ProjectThumbnail + projectName={project.name} + githubOwner={project.githubOwner} + /> + <span className="flex-1 truncate">{project.name}</span> + {project.needsSetup === true && ( + <Tooltip> + <TooltipTrigger asChild> + <LuTriangleAlert className="size-3.5 shrink-0 text-amber-500" /> + </TooltipTrigger> + <TooltipContent>Not set up on this host</TooltipContent> + </Tooltip> + )} + {project.id === selectedProject?.id && ( + <HiCheck className="size-4 shrink-0" /> + )} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + <CommandSeparator alwaysRender /> + <CommandGroup forceMount> + <CommandItem forceMount onSelect={handleCreateNewProject}> + <HiMiniPlus className="size-4" /> + Create new project + </CommandItem> + <CommandItem forceMount onSelect={handleImportProject}> + <LuFolderInput className="size-4" /> + Import project + </CommandItem> + </CommandGroup> + </Command> + </PopoverContent> + </Popover> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/index.ts new file mode 100644 index 00000000000..5a501c182e3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/components/ProjectPickerPill/index.ts @@ -0,0 +1 @@ +export { ProjectPickerPill } from "./ProjectPickerPill"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/index.ts new file mode 100644 index 00000000000..19c87252914 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/index.ts @@ -0,0 +1 @@ +export { useBranchPickerController } from "./useBranchPickerController"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/useBranchPickerController.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/useBranchPickerController.ts new file mode 100644 index 00000000000..b3d54371850 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useBranchPickerController/useBranchPickerController.ts @@ -0,0 +1,226 @@ +import { toast } from "@superset/ui/sonner"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo, useState } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import type { BaseBranchSource } from "../../../../../DashboardNewWorkspaceDraftContext"; +import type { WorkspaceHostTarget } from "../../../components/DevicePicker"; +import { + type BranchFilter, + useBranchContext, +} from "../../../hooks/useBranchContext"; +import type { CompareBaseBranchPicker } from "../../components/CompareBaseBranchPicker"; + +type PickerProps = React.ComponentProps<typeof CompareBaseBranchPicker>; + +export interface UseBranchPickerControllerArgs { + projectId: string | null; + hostTarget: WorkspaceHostTarget; + baseBranch: string | null; + runSetupScript: boolean; + /** When set, used as the workspace name for picker actions; falls back to the branch name. */ + typedWorkspaceName: string; + onBaseBranchChange: ( + branch: string | null, + source: BaseBranchSource | null, + ) => void; + closeModal: () => void; +} + +/** + * Owns all state + handlers for the branch picker: the search/filter inputs, + * the branch-context query, the host-id resolution that gates Open/Create + * dispatch, and the three per-row action callbacks. Returns a single + * `pickerProps` object ready to spread into `<CompareBaseBranchPicker />`. + * + * See V2_WORKSPACE_CREATION.md §2 for the action model and §3 for the + * pending-row insert + navigate flow. + */ +export function useBranchPickerController(args: UseBranchPickerControllerArgs) { + const { + projectId, + hostTarget, + baseBranch, + runSetupScript, + typedWorkspaceName, + onBaseBranchChange, + closeModal, + } = args; + + const navigate = useNavigate(); + const collections = useCollections(); + const { machineId } = useLocalHostService(); + + // Branch list state — owned by the controller so the picker is purely + // presentational. + const [branchSearch, setBranchSearch] = useState(""); + const [branchFilter, setBranchFilter] = useState<BranchFilter>("branch"); + + const { + branches, + defaultBranch, + isLoading: isBranchesLoading, + isError: isBranchesError, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useBranchContext(projectId, hostTarget, branchSearch, branchFilter); + + const effectiveCompareBaseBranch = baseBranch || defaultBranch || null; + + // Authoritative "does a workspace already exist for this (project, + // branch, host)?" — driven by the cloud-synced collection rather than + // the server's per-row hasWorkspace snapshot, which can be stale after + // a delete. See V2_WORKSPACE_CREATION.md §2. + const { data: projectWorkspaces } = useLiveQuery( + (q) => q.from({ workspaces: collections.v2Workspaces }), + [collections], + ); + const { data: allHosts } = useLiveQuery( + (q) => q.from({ hosts: collections.v2Hosts }), + [collections], + ); + + // `v2Workspaces` rows are keyed by host id; collapsing by branch alone + // would collide across hosts that happen to share a branch. + const targetHostId = useMemo<string | null>(() => { + if (hostTarget.kind === "host") return hostTarget.hostId; + if (!machineId || !allHosts) return null; + return allHosts.find((h) => h.machineId === machineId)?.machineId ?? null; + }, [hostTarget, allHosts, machineId]); + + const workspaceByBranch = useMemo(() => { + const map = new Map<string, string>(); + if (!projectId || !projectWorkspaces || !targetHostId) return map; + for (const w of projectWorkspaces) { + if (w.projectId === projectId && w.hostId === targetHostId && w.branch) { + map.set(w.branch, w.id); + } + } + return map; + }, [projectId, projectWorkspaces, targetHostId]); + + const hasWorkspaceForBranch = useCallback( + (name: string) => workspaceByBranch.has(name), + [workspaceByBranch], + ); + + // Picker actions (Create / Check out) bypass the modal's submit, so they + // don't get the `resolveNames` pass — fall back to the branch name when + // the user hasn't typed a workspace name. + const resolveActionWorkspaceName = useCallback( + (branchName: string) => typedWorkspaceName.trim() || branchName, + [typedWorkspaceName], + ); + + const insertPendingAndNavigate = useCallback( + (row: { + pendingId: string; + intent: "checkout" | "adopt"; + workspaceName: string; + branchName: string; + }) => { + if (!projectId) { + toast.error("Select a project first"); + return; + } + collections.pendingWorkspaces.insert({ + id: row.pendingId, + projectId, + intent: row.intent, + name: row.workspaceName, + branchName: row.branchName, + prompt: "", + baseBranch: null, + baseBranchSource: null, + runSetupScript, + linkedIssues: [], + linkedPR: null, + hostTarget, + attachmentCount: 0, + status: "creating", + error: null, + workspaceId: null, + warnings: [], + createdAt: new Date(), + }); + closeModal(); + void navigate({ to: `/pending/${row.pendingId}` as string }); + }, + [projectId, collections, runSetupScript, hostTarget, closeModal, navigate], + ); + + const onAdoptWorktree = useCallback( + (branchName: string) => { + insertPendingAndNavigate({ + pendingId: crypto.randomUUID(), + intent: "adopt", + workspaceName: resolveActionWorkspaceName(branchName), + branchName, + }); + }, + [insertPendingAndNavigate, resolveActionWorkspaceName], + ); + + const onCheckoutBranch = useCallback( + (branchName: string) => { + insertPendingAndNavigate({ + pendingId: crypto.randomUUID(), + intent: "checkout", + workspaceName: resolveActionWorkspaceName(branchName), + branchName, + }); + }, + [insertPendingAndNavigate, resolveActionWorkspaceName], + ); + + const onOpenExisting = useCallback( + (branchName: string) => { + const workspaceId = workspaceByBranch.get(branchName); + if (!workspaceId) { + toast.error("Could not find existing workspace for this branch"); + return; + } + closeModal(); + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId }, + }); + }, + [workspaceByBranch, closeModal, navigate], + ); + + const onSelectCompareBaseBranch = useCallback( + (branch: string, source: BaseBranchSource) => { + onBaseBranchChange(branch, source); + }, + [onBaseBranchChange], + ); + + const onLoadMore = useCallback(() => { + void fetchNextPage(); + }, [fetchNextPage]); + + const pickerProps: PickerProps = { + effectiveCompareBaseBranch, + defaultBranch, + isBranchesLoading, + isBranchesError, + branches, + branchSearch, + onBranchSearchChange: setBranchSearch, + branchFilter, + onBranchFilterChange: setBranchFilter, + isFetchingNextPage, + hasNextPage: hasNextPage ?? false, + onLoadMore, + onSelectCompareBaseBranch, + onCheckoutBranch, + onOpenExisting, + onAdoptWorktree, + hasWorkspaceForBranch, + }; + + return { pickerProps }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useLinkedContext/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useLinkedContext/index.ts new file mode 100644 index 00000000000..560c108ecdc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useLinkedContext/index.ts @@ -0,0 +1 @@ +export { useLinkedContext } from "./useLinkedContext"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useLinkedContext/useLinkedContext.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useLinkedContext/useLinkedContext.ts new file mode 100644 index 00000000000..d7f79a43b7d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useLinkedContext/useLinkedContext.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; +import type { + DashboardNewWorkspaceDraft, + LinkedIssue, + LinkedPR, +} from "../../../../../DashboardNewWorkspaceDraftContext"; + +/** + * Bundle of handlers that mutate `linkedIssues` / `linkedPR` on the draft. + * Pure delegation — no state of its own. Co-located with PromptGroup + * because that's the only consumer. + * + * `linkedIssues` is needed to dedupe adds and to filter on remove; + * setLinkedPR / removeLinkedPR don't need to read the current PR, so the + * hook doesn't ask for it. + */ +export function useLinkedContext( + linkedIssues: LinkedIssue[], + updateDraft: (patch: Partial<DashboardNewWorkspaceDraft>) => void, +) { + const addLinkedIssue = useCallback( + (slug: string, title: string, taskId: string | undefined, url?: string) => { + if (linkedIssues.some((issue) => issue.slug === slug)) return; + updateDraft({ + linkedIssues: [ + ...linkedIssues, + { slug, title, source: "internal", taskId, url }, + ], + }); + }, + [linkedIssues, updateDraft], + ); + + const addLinkedGitHubIssue = useCallback( + (issueNumber: number, title: string, url: string, state: string) => { + if (linkedIssues.some((i) => i.url === url)) return; + updateDraft({ + linkedIssues: [ + ...linkedIssues, + { + slug: `#${issueNumber}`, + title, + source: "github", + url, + number: issueNumber, + state: state.toLowerCase() === "closed" ? "closed" : "open", + }, + ], + }); + }, + [linkedIssues, updateDraft], + ); + + const removeLinkedIssue = useCallback( + (slug: string) => { + updateDraft({ + linkedIssues: linkedIssues.filter((i) => i.slug !== slug), + }); + }, + [linkedIssues, updateDraft], + ); + + const setLinkedPR = useCallback( + (pr: LinkedPR) => updateDraft({ linkedPR: pr }), + [updateDraft], + ); + + const removeLinkedPR = useCallback( + () => updateDraft({ linkedPR: null }), + [updateDraft], + ); + + return { + addLinkedIssue, + addLinkedGitHubIssue, + removeLinkedIssue, + setLinkedPR, + removeLinkedPR, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts new file mode 100644 index 00000000000..0e4f4443d3e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/index.ts @@ -0,0 +1,4 @@ +export { + type SubmitAttachment, + useSubmitWorkspace, +} from "./useSubmitWorkspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/mapLinkedContext.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/mapLinkedContext.ts new file mode 100644 index 00000000000..4d8d25875c9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/mapLinkedContext.ts @@ -0,0 +1,30 @@ +import type { DashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; + +interface MappedLinkedContext { + internalIssueIds: string[] | undefined; + githubIssueUrls: string[] | undefined; + linkedPrUrl: string | undefined; +} + +/** + * Maps draft linked issues/PR into the API payload shape. + * Pure function — no side effects, no hooks. + */ +export function mapLinkedContext( + draft: DashboardNewWorkspaceDraft, +): MappedLinkedContext { + const internalIssueIds = draft.linkedIssues + .filter((i) => i.source === "internal" && i.taskId) + .map((i) => i.taskId as string); + + const githubIssueUrls = draft.linkedIssues + .filter((i) => i.source === "github" && i.url) + .map((i) => i.url as string); + + return { + internalIssueIds: + internalIssueIds.length > 0 ? internalIssueIds : undefined, + githubIssueUrls: githubIssueUrls.length > 0 ? githubIssueUrls : undefined, + linkedPrUrl: draft.linkedPR?.url, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts new file mode 100644 index 00000000000..05c4222f9b7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/resolveNames.ts @@ -0,0 +1,43 @@ +import { sanitizeUserBranchName } from "@superset/shared/workspace-launch"; +import type { DashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; + +interface ResolvedNames { + branchName: string; + workspaceName: string; + /** + * True when `workspaceName` came from the friendly-random fallback rather + * than a value the user typed. Host-service only runs the post-create AI + * rename when this is true — a user-typed name wins. + */ + workspaceNameWasAutoGenerated: boolean; +} + +/** + * Resolves the branch name and workspace display name from draft state. + * Pure function — no side effects, no hooks. + * + * Priority: + * - Branch: user-typed (sanitized) > draft's friendly random + * - Workspace: user-typed > draft's friendly random + * + * Prompt-based derivation is intentionally not used here — AI naming runs + * post-create in host-service for the workspace title. The friendly name + * lives on the draft so the picker preview matches what gets submitted. + */ +export function resolveNames(draft: DashboardNewWorkspaceDraft): ResolvedNames { + const branchName = + draft.branchNameEdited && draft.branchName.trim() + ? sanitizeUserBranchName(draft.branchName.trim()) + : draft.friendlyFallback; + + const userWorkspaceName = + draft.workspaceNameEdited && draft.workspaceName.trim() + ? draft.workspaceName.trim() + : null; + + return { + branchName, + workspaceName: userWorkspaceName ?? draft.friendlyFallback, + workspaceNameWasAutoGenerated: userWorkspaceName === null, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts new file mode 100644 index 00000000000..cf7c1bba346 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts @@ -0,0 +1,110 @@ +import { toast } from "@superset/ui/sonner"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { storeAttachments } from "renderer/lib/pending-attachment-store"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useDashboardNewWorkspaceDraft } from "../../../../../DashboardNewWorkspaceDraftContext"; +import type { WorkspaceCreateAgent } from "../../types"; +import { resolveNames } from "./resolveNames"; + +export interface SubmitAttachment { + url: string; // data: URL already (library converts blob→data before onSubmit) + mediaType: string; + filename?: string; +} + +/** + * Returns a callback that submits a fork (new branch from base): + * resolve names → store attachments → insert pending row → close modal → + * navigate to pending page. The page owns the host-service mutation — + * see V2_WORKSPACE_CREATION.md §3. + * + * Files come via the PromptInput's `onSubmit({ text, files })` payload + * (already converted from blob: → data: by the library before it calls + * us). We do not read from `useProviderAttachments().takeFiles()` here: + * the library clears provider state + revokes blob URLs *before* + * invoking onSubmit, so the ref is stale by the time we'd see it. + */ +export function useSubmitWorkspace( + projectId: string | null, + selectedAgent: WorkspaceCreateAgent, +) { + const navigate = useNavigate(); + const { closeAndResetDraft, draft } = useDashboardNewWorkspaceDraft(); + const collections = useCollections(); + + return useCallback( + async (files: SubmitAttachment[] = []) => { + if (!projectId) { + toast.error("Select a project first"); + return; + } + + const { branchName, workspaceName, workspaceNameWasAutoGenerated } = + resolveNames(draft); + const pendingId = crypto.randomUUID(); + + // PR mode: route to pr-checkout intent. Pending page fetches full + // PR details (getGitHubPullRequestContent) before firing the + // mutation, and derives the real branch name server-side from the + // resolved PR data. The `branchName` field here is a display + // placeholder; workspaceName similarly falls back to the PR title. + const isPrCheckout = draft.linkedPR !== null; + const prPlaceholderBranch = isPrCheckout + ? `pr-${draft.linkedPR?.prNumber}` + : null; + const prPlaceholderName = isPrCheckout + ? draft.linkedPR?.title || `PR #${draft.linkedPR?.prNumber}` + : null; + + if (files.length > 0) { + try { + await storeAttachments(pendingId, files); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to store attachments", + ); + return; + } + } + + collections.pendingWorkspaces.insert({ + id: pendingId, + projectId, + intent: isPrCheckout ? "pr-checkout" : "fork", + name: prPlaceholderName ?? workspaceName, + // PR-checkout names come from the PR title — never auto-rename. + // Fork names follow the user-typed-vs-friendly-fallback split. + workspaceNameWasAutoGenerated: isPrCheckout + ? false + : workspaceNameWasAutoGenerated, + branchName: prPlaceholderBranch ?? branchName, + prompt: draft.prompt, + baseBranch: draft.baseBranch ?? null, + baseBranchSource: draft.baseBranchSource ?? null, + runSetupScript: draft.runSetupScript, + linkedIssues: draft.linkedIssues, + linkedPR: draft.linkedPR, + hostTarget: draft.hostTarget, + attachmentCount: files.length, + agentId: selectedAgent, + status: "creating", + error: null, + workspaceId: null, + warnings: [], + createdAt: new Date(), + }); + + closeAndResetDraft(); + void navigate({ to: `/pending/${pendingId}` as string }); + }, + [ + closeAndResetDraft, + collections, + draft, + navigate, + projectId, + selectedAgent, + ], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts new file mode 100644 index 00000000000..41ee248568c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/types.ts @@ -0,0 +1,18 @@ +import type { AgentDefinitionId } from "@superset/shared/agent-settings"; + +export type WorkspaceCreateAgent = AgentDefinitionId | "none"; + +export const AGENT_STORAGE_KEY = "lastSelectedWorkspaceCreateAgent"; + +export const PILL_BUTTON_CLASS = + "!h-[22px] min-h-0 rounded-md border-[0.5px] border-border bg-foreground/[0.04] shadow-none text-[11px]"; + +export interface ProjectOption { + id: string; + name: string; + githubOwner: string | null; + githubRepoName: string | null; + // True when the currently-selected host doesn't yet have this project + // set up. null when we couldn't check (offline / unreachable host). + needsSetup: boolean | null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx deleted file mode 100644 index 49fca2ee850..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/BranchesGroup.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import Fuse from "fuse.js"; -import { useCallback, useMemo } from "react"; -import { GoArrowUpRight, GoGitBranch, GoGlobe } from "react-icons/go"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface BranchesGroupProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function BranchesGroup({ - projectId, - localProjectId, - hostTarget, -}: BranchesGroupProps) { - const navigate = useNavigate(); - const collections = useCollections(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - const hasLocalProject = !!localProjectId; - - const { data: localData, isLoading: isLocalLoading } = - electronTrpc.projects.getBranchesLocal.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - - const { data: remoteData } = electronTrpc.projects.getBranches.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - - const data = remoteData ?? localData; - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map<string, string>(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const defaultBranch = data?.defaultBranch ?? "main"; - - const branches = (data?.branches ?? []).sort((a, b) => { - if (a.name === defaultBranch) return -1; - if (b.name === defaultBranch) return 1; - if (a.isLocal !== b.isLocal) return a.isLocal ? -1 : 1; - return a.name.localeCompare(b.name); - }); - - const branchRows = useMemo(() => { - return branches.map((branch) => ({ - branch, - existingWorkspaceId: workspaceByBranch.get(branch.name), - })); - }, [branches, workspaceByBranch]); - - const debouncedQuery = useDebouncedValue(draft.branchesQuery, 150); - - const branchFuse = useMemo( - () => - new Fuse(branchRows, { - keys: ["branch.name"], - threshold: 0.3, - includeScore: true, - ignoreLocation: true, - }), - [branchRows], - ); - - const visibleBranchRows = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return branchRows.slice(0, 100); - } - return branchFuse - .search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, branchRows, branchFuse]); - - const handleCreate = useCallback( - (branchName: string) => { - if (!projectId) return; - void runAsyncAction( - createWorkspace({ - projectId, - name: branchName, - branch: branchName, - hostTarget, - }), - { - loading: "Creating workspace from branch...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - }, - [createWorkspace, hostTarget, projectId, runAsyncAction], - ); - - const handleOpen = useCallback( - (workspaceId: string) => { - closeAndResetDraft(); - navigateToV2Workspace(workspaceId, navigate); - }, - [closeAndResetDraft, navigate], - ); - - const handleBranchAction = useCallback( - (branchName: string) => { - const existingId = workspaceByBranch.get(branchName); - if (existingId) { - handleOpen(existingId); - return; - } - handleCreate(branchName); - }, - [handleCreate, handleOpen, workspaceByBranch], - ); - - if (!projectId) { - return ( - <CommandGroup> - <CommandEmpty>Select a project to view branches.</CommandEmpty> - </CommandGroup> - ); - } - - if (!hasLocalProject) { - return ( - <CommandGroup> - <CommandEmpty>No local repository linked to this project.</CommandEmpty> - </CommandGroup> - ); - } - - if (isLocalLoading) { - return ( - <CommandGroup> - <CommandEmpty>Loading branches...</CommandEmpty> - </CommandGroup> - ); - } - - return ( - <CommandGroup> - <CommandEmpty>No branches found.</CommandEmpty> - {visibleBranchRows.map(({ branch, existingWorkspaceId }) => { - const buttonLabel = existingWorkspaceId ? "Open" : "Create"; - return ( - <CommandItem - key={branch.name} - onSelect={() => handleBranchAction(branch.name)} - className="group h-12" - > - {existingWorkspaceId ? ( - <GoArrowUpRight className="size-4 shrink-0 text-muted-foreground" /> - ) : branch.isLocal ? ( - <GoGitBranch className="size-4 shrink-0 text-muted-foreground" /> - ) : ( - <GoGlobe className="size-4 shrink-0 text-muted-foreground" /> - )} - <span className="truncate flex-1">{branch.name}</span> - <Button - size="xs" - className="shrink-0 hidden group-data-[selected=true]:inline-flex" - onClick={(e) => { - e.stopPropagation(); - handleBranchAction(branch.name); - }} - > - {buttonLabel} ↵ - </Button> - </CommandItem> - ); - })} - </CommandGroup> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts deleted file mode 100644 index 75953e3d249..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/BranchesGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BranchesGroup } from "./BranchesGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx deleted file mode 100644 index d9d45c15bee..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/DashboardNewWorkspaceFormHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Tabs, TabsList, TabsTrigger } from "@superset/ui/tabs"; -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import type { DashboardNewWorkspaceTab } from "../../../../DashboardNewWorkspaceDraftContext"; -import { DevicePicker } from "../DevicePicker"; -import { ProjectSelector } from "../ProjectSelector"; - -interface DashboardNewWorkspaceFormHeaderProps { - activeTab: DashboardNewWorkspaceTab; - hostTarget: WorkspaceHostTarget; - selectedProjectId: string | null; - onSelectTab: (tab: DashboardNewWorkspaceTab) => void; - onSelectHostTarget: (hostTarget: WorkspaceHostTarget) => void; - onSelectProject: (projectId: string | null) => void; -} - -export function DashboardNewWorkspaceFormHeader({ - activeTab, - hostTarget, - selectedProjectId, - onSelectTab, - onSelectHostTarget, - onSelectProject, -}: DashboardNewWorkspaceFormHeaderProps) { - return ( - <div className="flex items-center justify-between border-b px-4 py-2.5"> - <Tabs - value={activeTab} - onValueChange={(value) => - onSelectTab(value as DashboardNewWorkspaceTab) - } - > - <TabsList> - <TabsTrigger value="prompt">Prompt</TabsTrigger> - <TabsTrigger value="issues">Issues</TabsTrigger> - <TabsTrigger value="pull-requests">Pull requests</TabsTrigger> - <TabsTrigger value="branches">Branches</TabsTrigger> - </TabsList> - </Tabs> - <div className="flex items-center gap-1"> - <DevicePicker - hostTarget={hostTarget} - onSelectHostTarget={onSelectHostTarget} - /> - <div className="mx-0.5 h-4 w-px bg-border" /> - <ProjectSelector - selectedProjectId={selectedProjectId} - onSelectProject={onSelectProject} - /> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts deleted file mode 100644 index f4469410524..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceFormHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceFormHeader } from "./DashboardNewWorkspaceFormHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx deleted file mode 100644 index d6584e9ecc9..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/DashboardNewWorkspaceListTabContent.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Command, CommandInput, CommandList } from "@superset/ui/command"; -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import type { DashboardNewWorkspaceTab } from "../../../../DashboardNewWorkspaceDraftContext"; -import { BranchesGroup } from "../BranchesGroup"; -import { IssuesGroup } from "../IssuesGroup"; -import { PullRequestsGroup } from "../PullRequestsGroup"; - -const COMMAND_CLASS_NAME = - "[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 flex h-full w-full flex-1 flex-col overflow-hidden rounded-none"; - -interface DashboardNewWorkspaceListTabContentProps { - activeTab: Exclude<DashboardNewWorkspaceTab, "prompt">; - projectId: string | null; - githubRepositoryId: string | null; - hostTarget: WorkspaceHostTarget; - localProjectId: string | null; - query: string; - onQueryChange: (value: string) => void; -} - -export function DashboardNewWorkspaceListTabContent({ - activeTab, - projectId, - githubRepositoryId, - hostTarget, - localProjectId, - query, - onQueryChange, -}: DashboardNewWorkspaceListTabContentProps) { - return ( - <Command shouldFilter={false} className={COMMAND_CLASS_NAME}> - <CommandInput - value={query} - onValueChange={onQueryChange} - placeholder={ - activeTab === "issues" - ? "Search by slug, title, or description" - : activeTab === "branches" - ? "Search by name" - : "Search by title, number, or author" - } - /> - - <CommandList className="!max-h-none flex-1 overflow-y-auto"> - {activeTab === "pull-requests" && ( - <PullRequestsGroup - projectId={projectId} - githubRepositoryId={githubRepositoryId} - hostTarget={hostTarget} - /> - )} - {activeTab === "branches" && ( - <BranchesGroup - projectId={projectId} - localProjectId={localProjectId} - hostTarget={hostTarget} - /> - )} - {activeTab === "issues" && ( - <IssuesGroup projectId={projectId} hostTarget={hostTarget} /> - )} - </CommandList> - </Command> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts deleted file mode 100644 index af9feb38a8c..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspaceListTabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceListTabContent } from "./DashboardNewWorkspaceListTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx deleted file mode 100644 index b1ff2609f7b..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/DashboardNewWorkspacePromptTabContent.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import { PromptGroup } from "../PromptGroup"; - -interface DashboardNewWorkspacePromptTabContentProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function DashboardNewWorkspacePromptTabContent({ - projectId, - localProjectId, - hostTarget, -}: DashboardNewWorkspacePromptTabContentProps) { - return ( - <div className="flex-1 overflow-y-auto"> - <PromptGroup - projectId={projectId} - localProjectId={localProjectId} - hostTarget={hostTarget} - /> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts deleted file mode 100644 index 0dd4c4cbf1a..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DashboardNewWorkspacePromptTabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspacePromptTabContent } from "./DashboardNewWorkspacePromptTabContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx index 7d084b9428d..a0754f1d187 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/DevicePicker.tsx @@ -1,4 +1,3 @@ -import { Button } from "@superset/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -13,49 +12,47 @@ import { cn } from "@superset/ui/utils"; import { HiCheck, HiChevronUpDown, - HiOutlineCloud, HiOutlineComputerDesktop, - HiOutlineGlobeAlt, HiOutlineServer, } from "react-icons/hi2"; -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; +import { FormPickerTrigger } from "../../PromptGroup/components/FormPickerTrigger"; import { useWorkspaceHostOptions, - type WorkspaceHostDeviceOption, + type WorkspaceHostOption, } from "./hooks/useWorkspaceHostOptions"; +import type { WorkspaceHostTarget } from "./types"; + +function OnlineDot({ online }: { online: boolean }) { + return ( + <span + role="img" + aria-label={online ? "online" : "offline"} + className={cn( + "inline-block size-1.5 shrink-0 rounded-full", + online ? "bg-emerald-500" : "bg-muted-foreground/60", + )} + /> + ); +} interface DevicePickerProps { hostTarget: WorkspaceHostTarget; onSelectHostTarget: (target: WorkspaceHostTarget) => void; -} - -function getDeviceIcon(type: WorkspaceHostDeviceOption["type"]) { - switch (type) { - case "cloud": - return HiOutlineCloud; - case "viewer": - return HiOutlineGlobeAlt; - default: - return HiOutlineComputerDesktop; - } + className?: string; } function getSelectedLabel( hostTarget: WorkspaceHostTarget, currentDeviceName: string | null, - otherDevices: WorkspaceHostDeviceOption[], + otherHosts: WorkspaceHostOption[], ) { if (hostTarget.kind === "local") { return currentDeviceName ?? "Local Device"; } - if (hostTarget.kind === "cloud") { - return "Cloud Workspace"; - } - return ( - otherDevices.find((device) => device.id === hostTarget.deviceId)?.name ?? - "Unknown Device" + otherHosts.find((host) => host.id === hostTarget.hostId)?.name ?? + "Unknown Host" ); } @@ -64,36 +61,45 @@ function getSelectedIcon(hostTarget: WorkspaceHostTarget) { return <HiOutlineComputerDesktop className="size-4 shrink-0" />; } - if (hostTarget.kind === "cloud") { - return <HiOutlineCloud className="size-4 shrink-0" />; - } - return <HiOutlineServer className="size-4 shrink-0" />; } export function DevicePicker({ hostTarget, onSelectHostTarget, + className, }: DevicePickerProps) { - const { currentDeviceName, otherDevices } = useWorkspaceHostOptions(); + const { currentDeviceName, otherHosts } = useWorkspaceHostOptions(); const selectedLabel = getSelectedLabel( hostTarget, currentDeviceName, - otherDevices, + otherHosts, ); + // Only remote hosts have a meaningful online indicator — the app itself + // is the local host, so it's tautologically online. + const selectedRemoteOnline = + hostTarget.kind === "host" + ? (otherHosts.find((host) => host.id === hostTarget.hostId)?.isOnline ?? + false) + : null; return ( <DropdownMenu> <DropdownMenuTrigger asChild> - <Button variant="ghost" size="sm" className="h-7 gap-1 px-2 text-xs"> - <span className="flex min-w-0 items-center gap-1.5"> - {getSelectedIcon(hostTarget)} - <span className="max-w-[140px] truncate">{selectedLabel}</span> - </span> + <FormPickerTrigger + className={cn("max-w-[140px]", className)} + aria-label={`Device: ${selectedLabel}`} + title={selectedLabel} + > + {getSelectedIcon(hostTarget)} + <span className="truncate">{selectedLabel}</span> + {selectedRemoteOnline !== null && ( + <OnlineDot online={selectedRemoteOnline} /> + )} <HiChevronUpDown className="size-3 shrink-0" /> - </Button> + </FormPickerTrigger> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-72"> + <DropdownMenuContent align="start" className="w-72"> <DropdownMenuItem onSelect={() => onSelectHostTarget({ kind: "local" })} > @@ -101,66 +107,42 @@ export function DevicePicker({ <span className="flex-1">Local Device</span> {hostTarget.kind === "local" && <HiCheck className="size-4" />} </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => onSelectHostTarget({ kind: "cloud" })} - > - <HiOutlineCloud className="size-4" /> - <span className="flex-1">Cloud Workspace</span> - {hostTarget.kind === "cloud" && <HiCheck className="size-4" />} - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuSub> - <DropdownMenuSubTrigger> - <HiOutlineServer className="size-4" /> - Other Devices - </DropdownMenuSubTrigger> - <DropdownMenuSubContent className="w-72"> - {otherDevices.length === 0 ? ( - <DropdownMenuItem disabled>No devices found</DropdownMenuItem> - ) : ( - otherDevices.map((device) => { - const DeviceIcon = getDeviceIcon(device.type); - const isSelected = - hostTarget.kind === "device" && - hostTarget.deviceId === device.id; + {otherHosts.length > 0 && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuSub> + <DropdownMenuSubTrigger> + <HiOutlineServer className="size-4" /> + Other Hosts + </DropdownMenuSubTrigger> + <DropdownMenuSubContent className="w-72"> + {otherHosts.map((host) => { + const isSelected = + hostTarget.kind === "host" && hostTarget.hostId === host.id; - return ( - <DropdownMenuItem - key={device.id} - onSelect={() => - onSelectHostTarget({ - kind: "device", - deviceId: device.id, - }) - } - > - <DeviceIcon className="size-4" /> - <div className="min-w-0 flex-1"> - <div className="truncate">{device.name}</div> - <div className="text-xs text-muted-foreground"> - {device.type} - </div> - </div> - <div className="flex items-center gap-2"> - <span - className={cn( - "size-2 rounded-full", - device.isOnline - ? "bg-emerald-500" - : "bg-muted-foreground/40", - )} - /> - <span className="text-xs text-muted-foreground"> - {device.isOnline ? "Online" : "Offline"} - </span> - {isSelected && <HiCheck className="size-4" />} - </div> - </DropdownMenuItem> - ); - }) - )} - </DropdownMenuSubContent> - </DropdownMenuSub> + return ( + <DropdownMenuItem + key={host.id} + onSelect={() => + onSelectHostTarget({ + kind: "host", + hostId: host.id, + }) + } + > + <HiOutlineServer className="size-4" /> + <span className="min-w-0 truncate">{host.name}</span> + <OnlineDot online={host.isOnline} /> + {isSelected && ( + <HiCheck className="ml-auto size-4 shrink-0" /> + )} + </DropdownMenuItem> + ); + })} + </DropdownMenuSubContent> + </DropdownMenuSub> + </> + )} </DropdownMenuContent> </DropdownMenu> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/index.ts index 06c290fd571..c029dd2553e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/index.ts @@ -1,2 +1,2 @@ -export type { WorkspaceHostDeviceOption } from "./useWorkspaceHostOptions"; +export type { WorkspaceHostOption } from "./useWorkspaceHostOptions"; export { useWorkspaceHostOptions } from "./useWorkspaceHostOptions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts index df827839382..b96c7534db5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions.ts @@ -3,97 +3,79 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useMemo } from "react"; import { env } from "renderer/env.renderer"; import { authClient } from "renderer/lib/auth-client"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { - type OrgService, - useHostService, -} from "renderer/routes/_authenticated/providers/HostServiceProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { MOCK_ORG_ID } from "shared/constants"; -const ONLINE_THRESHOLD_MS = 30_000; - -export interface WorkspaceHostDeviceOption { +export interface WorkspaceHostOption { id: string; name: string; - type: "host" | "cloud" | "viewer"; isOnline: boolean; } interface UseWorkspaceHostOptionsResult { currentDeviceName: string | null; - localHostService: OrgService | null; - otherDevices: WorkspaceHostDeviceOption[]; -} - -function isDeviceOnline(lastSeenAt: Date | null): boolean { - return ( - lastSeenAt !== null && - Date.now() - new Date(lastSeenAt).getTime() < ONLINE_THRESHOLD_MS - ); + /** machineId of the current device (the one running this desktop app). */ + localHostId: string | null; + activeHostUrl: string | null; + otherHosts: WorkspaceHostOption[]; } export function useWorkspaceHostOptions(): UseWorkspaceHostOptionsResult { const { data: session } = authClient.useSession(); const collections = useCollections(); - const { services } = useHostService(); - const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); + const { machineId, activeHostUrl } = useLocalHostService(); const activeOrganizationId = env.SKIP_ENV_VALIDATION ? MOCK_ORG_ID : (session?.session?.activeOrganizationId ?? null); const currentUserId = session?.user?.id ?? null; - const localHostService = - activeOrganizationId !== null - ? (services.get(activeOrganizationId) ?? null) - : null; - - const { data: accessibleDevices = [] } = useLiveQuery( + const { data: accessibleHosts = [] } = useLiveQuery( (q) => q - .from({ userDevices: collections.v2UsersDevices }) - .innerJoin( - { devices: collections.v2Devices }, - ({ userDevices, devices }) => eq(userDevices.deviceId, devices.id), - ) - .leftJoin( - { presence: collections.v2DevicePresence }, - ({ devices, presence }) => eq(devices.id, presence.deviceId), + .from({ userHosts: collections.v2UsersHosts }) + .innerJoin({ hosts: collections.v2Hosts }, ({ userHosts, hosts }) => + eq(userHosts.hostId, hosts.machineId), ) - .where(({ userDevices, devices }) => + .where(({ userHosts, hosts }) => and( - eq(userDevices.userId, currentUserId ?? ""), - eq(devices.organizationId, activeOrganizationId ?? ""), + eq(userHosts.userId, currentUserId ?? ""), + eq(hosts.organizationId, activeOrganizationId ?? ""), ), ) - .select(({ devices, presence }) => ({ - id: devices.id, - clientId: devices.clientId, - name: devices.name, - type: devices.type, - lastSeenAt: presence?.lastSeenAt ?? null, + .select(({ hosts }) => ({ + machineId: hosts.machineId, + name: hosts.name, + isOnline: hosts.isOnline, })), [activeOrganizationId, collections, currentUserId], ); - const otherDevices = useMemo( + const localHost = useMemo( + () => accessibleHosts.find((host) => host.machineId === machineId) ?? null, + [accessibleHosts, machineId], + ); + + const otherHosts = useMemo( () => - accessibleDevices - .filter((device) => device.clientId !== deviceInfo?.deviceId) - .map((device) => ({ - id: device.id, - name: device.name, - type: device.type, - isOnline: isDeviceOnline(device.lastSeenAt ?? null), + accessibleHosts + .filter((host) => host.machineId !== machineId) + .map((host) => ({ + id: host.machineId, + name: host.name, + isOnline: host.isOnline ?? false, })) .sort((a, b) => a.name.localeCompare(b.name)), - [accessibleDevices, deviceInfo?.deviceId], + [accessibleHosts, machineId], ); + // Always surface the local device, even if its v2Hosts row hasn't synced + // via Electric — the picker is useless without "this device" present. return { - currentDeviceName: deviceInfo?.deviceName ?? null, - localHostService, - otherDevices, + currentDeviceName: localHost?.name ?? (machineId ? "This device" : null), + localHostId: localHost?.machineId ?? machineId, + activeHostUrl, + otherHosts, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/index.ts index 7fca45abb81..3d709c110f4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/index.ts @@ -1 +1,2 @@ export { DevicePicker } from "./DevicePicker"; +export type { WorkspaceHostTarget } from "./types"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types.ts new file mode 100644 index 00000000000..f57a4966055 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/types.ts @@ -0,0 +1,3 @@ +export type WorkspaceHostTarget = + | { kind: "local" } + | { kind: "host"; hostId: string }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx deleted file mode 100644 index f685a4b950d..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/IssuesGroup.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Avatar } from "@superset/ui/atoms/Avatar"; -import { Button } from "@superset/ui/button"; -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { toast } from "@superset/ui/sonner"; -import { eq, isNull } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import { useMemo } from "react"; -import { GoArrowUpRight } from "react-icons/go"; -import { HiOutlineUserCircle } from "react-icons/hi2"; -import { SiLinear } from "react-icons/si"; -import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { getSlugColumnWidth } from "renderer/lib/slug-width"; -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import { - StatusIcon, - type StatusType, -} from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/components/shared/StatusIcon"; -import { useHybridSearch } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/hooks/useHybridSearch"; -import { compareTasks } from "renderer/routes/_authenticated/_dashboard/tasks/components/TasksView/utils/sorting"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface IssuesGroupProps { - projectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function IssuesGroup({ projectId, hostTarget }: IssuesGroupProps) { - const collections = useCollections(); - const navigate = useNavigate(); - const { gateFeature } = usePaywall(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - const { data: integrations } = useLiveQuery( - (q) => - q - .from({ - integrationConnections: collections.integrationConnections, - }) - .select(({ integrationConnections }) => ({ - ...integrationConnections, - })), - [collections], - ); - - const isLinearConnected = - integrations?.some((i) => i.provider === "linear") ?? false; - - const { data } = useLiveQuery( - (q) => - q - .from({ tasks: collections.tasks }) - .innerJoin({ status: collections.taskStatuses }, ({ tasks, status }) => - eq(tasks.statusId, status.id), - ) - .leftJoin({ assignee: collections.users }, ({ tasks, assignee }) => - eq(tasks.assigneeId, assignee.id), - ) - .select(({ tasks, status, assignee }) => ({ - ...tasks, - status, - assignee: assignee ?? null, - })) - .where(({ tasks }) => isNull(tasks.deletedAt)), - [collections], - ); - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map<string, string>(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const tasks = useMemo(() => data ?? [], [data]); - const sortedTasks = useMemo(() => [...tasks].sort(compareTasks), [tasks]); - - const debouncedQuery = useDebouncedValue(draft.issuesQuery, 150); - const { search } = useHybridSearch(sortedTasks); - - const visibleTasks = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return sortedTasks.slice(0, 100); - } - return search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, sortedTasks, search]); - - const slugWidth = useMemo( - () => getSlugColumnWidth(visibleTasks.map((t) => t.slug)), - [visibleTasks], - ); - - if (!isLinearConnected) { - return ( - <div className="flex flex-col items-center gap-3 py-8 px-4 text-center"> - <SiLinear className="size-6 text-muted-foreground" /> - <div className="space-y-1"> - <p className="text-sm font-medium">Connect Linear</p> - <p className="text-xs text-muted-foreground"> - Sync issues from Linear to create workspaces - </p> - </div> - <Button - size="sm" - variant="outline" - onClick={() => { - gateFeature(GATED_FEATURES.INTEGRATIONS, () => { - closeAndResetDraft(); - navigate({ to: "/settings/integrations" }); - }); - }} - > - Connect - </Button> - </div> - ); - } - - return ( - <CommandGroup> - <CommandEmpty>No issues found.</CommandEmpty> - {visibleTasks.map((task) => ( - <CommandItem - key={task.id} - onSelect={() => { - if (!projectId) { - toast.error("Select a project first"); - return; - } - const existingId = workspaceByBranch.get(task.slug.toLowerCase()); - if (existingId) { - closeAndResetDraft(); - navigateToV2Workspace(existingId, navigate); - return; - } - void runAsyncAction( - createWorkspace({ - projectId, - name: task.title, - branch: task.slug.toLowerCase(), - hostTarget, - }), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error - ? err.message - : "Failed to create workspace", - }, - ); - }} - className="group h-12" - > - {workspaceByBranch.has(task.slug.toLowerCase()) ? ( - <GoArrowUpRight className="size-4 shrink-0 text-muted-foreground" /> - ) : ( - <StatusIcon - type={task.status.type as StatusType} - color={task.status.color} - progress={task.status.progressPercent ?? undefined} - className="size-4 shrink-0" - /> - )} - <span - className="text-muted-foreground shrink-0 text-xs tabular-nums truncate" - style={{ width: slugWidth }} - > - {task.slug} - </span> - <span className="truncate flex-1">{task.title}</span> - <span className="shrink-0 group-data-[selected=true]:hidden"> - {task.assignee ? ( - <Avatar - size="xs" - fullName={task.assignee.name} - image={task.assignee.image} - /> - ) : ( - <HiOutlineUserCircle className="size-5 text-muted-foreground" /> - )} - </span> - <span className="text-xs text-muted-foreground shrink-0 hidden group-data-[selected=true]:inline"> - {workspaceByBranch.has(task.slug.toLowerCase()) ? "Open" : "Create"}{" "} - ↵ - </span> - </CommandItem> - ))} - </CommandGroup> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/index.ts deleted file mode 100644 index c0762c8495d..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/IssuesGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IssuesGroup } from "./IssuesGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx deleted file mode 100644 index 2ed6690d260..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/ProjectSelector.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@superset/ui/command"; -import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useMemo, useState } from "react"; -import { FaGithub } from "react-icons/fa"; -import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; -import { env } from "renderer/env.renderer"; -import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; - -interface ProjectSelectorProps { - selectedProjectId: string | null; - onSelectProject: (projectId: string) => void; -} - -export function ProjectSelector({ - selectedProjectId, - onSelectProject, -}: ProjectSelectorProps) { - const [open, setOpen] = useState(false); - const collections = useCollections(); - - const { data: v2Projects } = useLiveQuery( - (q) => - q - .from({ projects: collections.v2Projects }) - .select(({ projects }) => ({ ...projects })), - [collections], - ); - - const { data: githubRepositories } = useLiveQuery( - (q) => - q.from({ repos: collections.githubRepositories }).select(({ repos }) => ({ - id: repos.id, - owner: repos.owner, - })), - [collections], - ); - - const projects = useMemo(() => { - const ownerByRepoId = new Map( - (githubRepositories ?? []).map((repo) => [repo.id, repo.owner]), - ); - - return (v2Projects ?? []).map((project) => ({ - id: project.id, - name: project.name, - owner: ownerByRepoId.get(project.githubRepositoryId) ?? null, - })); - }, [githubRepositories, v2Projects]); - - const selectedProject = projects.find((p) => p.id === selectedProjectId); - - return ( - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild> - <Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1"> - {selectedProject ? ( - <ProjectThumbnail - projectName={selectedProject.name} - githubOwner={selectedProject.owner} - className="size-4" - /> - ) : null} - <span className="truncate max-w-[140px]"> - {selectedProject?.name ?? "Select project"} - </span> - <HiChevronUpDown className="size-3" /> - </Button> - </PopoverTrigger> - <PopoverContent align="end" className="w-60 p-0"> - <Command> - <CommandInput placeholder="Search projects..." /> - <CommandList className="max-h-72"> - <CommandEmpty>No projects found.</CommandEmpty> - <CommandGroup> - {projects.map((project) => ( - <CommandItem - key={project.id} - value={ - project.owner - ? `${project.owner}/${project.name}` - : project.name - } - onSelect={() => { - onSelectProject(project.id); - setOpen(false); - }} - > - <ProjectThumbnail - projectName={project.name} - githubOwner={project.owner} - /> - <div className="flex min-w-0 flex-col"> - <span className="truncate">{project.name}</span> - {project.owner ? ( - <span className="truncate text-xs text-muted-foreground"> - {project.owner} - </span> - ) : null} - </div> - {project.id === selectedProjectId && ( - <HiCheck className="ml-auto size-4" /> - )} - </CommandItem> - ))} - </CommandGroup> - </CommandList> - <CommandSeparator /> - <div className="p-1"> - <Button - variant="ghost" - className="w-full justify-start gap-2 px-2 py-1.5 text-sm font-normal" - onClick={() => { - setOpen(false); - window.open( - `${env.NEXT_PUBLIC_WEB_URL}/integrations/github`, - "_blank", - ); - }} - > - <FaGithub className="size-4" /> - Add from GitHub - </Button> - </div> - </Command> - </PopoverContent> - </Popover> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/index.ts deleted file mode 100644 index a524b03c166..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/ProjectSelector/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProjectSelector } from "./ProjectSelector"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx deleted file mode 100644 index 33b3c38b6bc..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/PromptGroup.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { Kbd, KbdGroup } from "@superset/ui/kbd"; -import { toast } from "@superset/ui/sonner"; -import { Textarea } from "@superset/ui/textarea"; -import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import { resolveEffectiveWorkspaceBaseBranch } from "renderer/lib/workspaceBaseBranch"; -import { useHotkeysStore } from "renderer/stores/hotkeys/store"; -import { - resolveBranchPrefix, - sanitizeBranchNameWithMaxLength, -} from "shared/utils/branch"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; -import { PromptGroupAdvancedOptions } from "./components/PromptGroupAdvancedOptions"; - -interface PromptGroupProps { - projectId: string | null; - localProjectId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function PromptGroup({ - projectId, - localProjectId, - hostTarget, -}: PromptGroupProps) { - const navigate = useNavigate(); - const platform = useHotkeysStore((state) => state.platform); - const modKey = platform === "darwin" ? "⌘" : "Ctrl"; - const textareaRef = useRef<HTMLTextAreaElement>(null); - const { closeModal, draft, runAsyncAction, updateDraft } = - useDashboardNewWorkspaceDraft(); - const [compareBaseBranchOpen, setCompareBaseBranchOpen] = useState(false); - const { - compareBaseBranch, - branchName, - branchNameEdited, - branchSearch, - prompt, - showAdvanced, - } = draft; - const { createWorkspace, isPending } = useCreateDashboardWorkspace(); - - const trimmedPrompt = prompt.trim(); - - const hasLocalProject = !!localProjectId; - - const { data: project } = electronTrpc.projects.get.useQuery( - { id: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - const { - data: localBranchData, - isLoading: isBranchesLoading, - isError: isBranchesError, - } = electronTrpc.projects.getBranchesLocal.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - const { data: remoteBranchData } = electronTrpc.projects.getBranches.useQuery( - { projectId: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - const branchData = remoteBranchData ?? localBranchData; - const { data: gitAuthor } = electronTrpc.projects.getGitAuthor.useQuery( - { id: localProjectId ?? "" }, - { enabled: hasLocalProject }, - ); - const { data: globalBranchPrefix } = - electronTrpc.settings.getBranchPrefix.useQuery(); - const { data: gitInfo } = electronTrpc.settings.getGitInfo.useQuery(); - - const resolvedPrefix = useMemo(() => { - const projectOverrides = project?.branchPrefixMode != null; - return resolveBranchPrefix({ - mode: projectOverrides - ? project?.branchPrefixMode - : (globalBranchPrefix?.mode ?? "none"), - customPrefix: projectOverrides - ? project?.branchPrefixCustom - : globalBranchPrefix?.customPrefix, - authorPrefix: gitAuthor?.prefix, - githubUsername: gitInfo?.githubUsername, - }); - }, [project, globalBranchPrefix, gitAuthor, gitInfo]); - - const filteredBranches = useMemo(() => { - if (!branchData?.branches) return []; - if (!branchSearch) return branchData.branches; - const searchLower = branchSearch.toLowerCase(); - return branchData.branches.filter((branch) => - branch.name.toLowerCase().includes(searchLower), - ); - }, [branchData?.branches, branchSearch]); - - const effectiveCompareBaseBranch = resolveEffectiveWorkspaceBaseBranch({ - explicitBaseBranch: compareBaseBranch, - workspaceBaseBranch: project?.workspaceBaseBranch, - defaultBranch: branchData?.defaultBranch, - branches: branchData?.branches, - }); - - const branchSlug = branchNameEdited - ? sanitizeBranchNameWithMaxLength(branchName, undefined, { - preserveFirstSegmentCase: true, - }) - : sanitizeBranchNameWithMaxLength(trimmedPrompt); - - const applyPrefix = !branchNameEdited; - - const branchPreview = - branchSlug && applyPrefix && resolvedPrefix - ? sanitizeBranchNameWithMaxLength(`${resolvedPrefix}/${branchSlug}`) - : branchSlug; - - const previousProjectIdRef = useRef(localProjectId); - - useEffect(() => { - if (previousProjectIdRef.current === localProjectId) { - return; - } - previousProjectIdRef.current = localProjectId; - updateDraft({ - compareBaseBranch: null, - branchSearch: "", - }); - setCompareBaseBranchOpen(false); - }, [localProjectId, updateDraft]); - - const handleCreate = () => { - if (!projectId) { - toast.error("Select a project first"); - return; - } - const name = branchSlug || trimmedPrompt || "workspace"; - const branch = branchPreview || "workspace"; - void runAsyncAction( - createWorkspace({ - projectId, - name, - branch, - hostTarget, - }), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - }; - - const handleBranchNameChange = (value: string) => { - updateDraft({ - branchName: value, - branchNameEdited: true, - }); - }; - - const handleBranchNameBlur = () => { - if (!branchName.trim()) { - updateDraft({ - branchName: "", - branchNameEdited: false, - }); - } - }; - - const handleCompareBaseBranchSelect = (selectedBaseBranch: string) => { - updateDraft({ - compareBaseBranch: selectedBaseBranch, - branchSearch: "", - }); - setCompareBaseBranchOpen(false); - }; - - return ( - <div className="px-4 py-4 space-y-3"> - <Textarea - ref={textareaRef} - className="min-h-24 max-h-48 text-sm resize-y field-sizing-fixed" - placeholder="What do you want to do?" - value={prompt} - onChange={(e) => updateDraft({ prompt: e.target.value })} - onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleCreate(); - } - }} - /> - - {hasLocalProject && ( - <PromptGroupAdvancedOptions - showAdvanced={showAdvanced} - onShowAdvancedChange={(showAdvanced) => updateDraft({ showAdvanced })} - branchInputValue={branchNameEdited ? branchName : branchPreview} - onBranchInputChange={handleBranchNameChange} - onBranchInputBlur={handleBranchNameBlur} - onEditPrefix={() => { - closeModal(); - navigate({ to: "/settings/behavior" }); - }} - isBranchesError={isBranchesError} - isBranchesLoading={isBranchesLoading} - compareBaseBranchOpen={compareBaseBranchOpen} - onCompareBaseBranchOpenChange={setCompareBaseBranchOpen} - effectiveCompareBaseBranch={effectiveCompareBaseBranch} - defaultBranch={branchData?.defaultBranch} - branchSearch={branchSearch} - onBranchSearchChange={(branchSearch) => updateDraft({ branchSearch })} - filteredBranches={filteredBranches} - onSelectCompareBaseBranch={handleCompareBaseBranchSelect} - runSetupScript={false} - onRunSetupScriptChange={() => {}} - hideSetupScript - /> - )} - - <Button - className="w-full h-8 text-sm" - onClick={handleCreate} - disabled={isPending} - > - Create Workspace - <KbdGroup className="ml-1.5 opacity-70"> - <Kbd className="bg-primary-foreground/15 text-primary-foreground h-4 min-w-4 text-[10px]"> - {modKey} - </Kbd> - <Kbd className="bg-primary-foreground/15 text-primary-foreground h-4 min-w-4 text-[10px]"> - ↵ - </Kbd> - </KbdGroup> - </Button> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/PromptGroupAdvancedOptions.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/PromptGroupAdvancedOptions.tsx deleted file mode 100644 index 07abe01f111..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/PromptGroupAdvancedOptions.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@superset/ui/collapsible"; -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList, -} from "@superset/ui/command"; -import { Input } from "@superset/ui/input"; -import { Label } from "@superset/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; -import { Switch } from "@superset/ui/switch"; -import { GoGitBranch } from "react-icons/go"; -import { - HiCheck, - HiChevronDown, - HiChevronUpDown, - HiOutlinePencil, -} from "react-icons/hi2"; -import { formatRelativeTime } from "renderer/lib/formatRelativeTime"; - -interface PromptGroupAdvancedOptionsProps { - showAdvanced: boolean; - onShowAdvancedChange: (open: boolean) => void; - branchInputValue: string; - onBranchInputChange: (value: string) => void; - onBranchInputBlur: () => void; - onEditPrefix: () => void; - runSetupScript: boolean; - onRunSetupScriptChange: (checked: boolean) => void; - shortcutHint?: string; - hideSetupScript?: boolean; - isBranchesError?: boolean; - isBranchesLoading?: boolean; - compareBaseBranchOpen?: boolean; - onCompareBaseBranchOpenChange?: (open: boolean) => void; - effectiveCompareBaseBranch?: string | null; - defaultBranch?: string; - branchSearch?: string; - onBranchSearchChange?: (search: string) => void; - filteredBranches?: Array<{ name: string; lastCommitDate: number }>; - onSelectCompareBaseBranch?: (branchName: string) => void; -} - -export function PromptGroupAdvancedOptions({ - showAdvanced, - onShowAdvancedChange, - branchInputValue, - onBranchInputChange, - onBranchInputBlur, - onEditPrefix, - runSetupScript, - onRunSetupScriptChange, - shortcutHint, - hideSetupScript, - isBranchesError, - isBranchesLoading, - compareBaseBranchOpen, - onCompareBaseBranchOpenChange, - effectiveCompareBaseBranch, - defaultBranch, - branchSearch, - onBranchSearchChange, - filteredBranches, - onSelectCompareBaseBranch, -}: PromptGroupAdvancedOptionsProps) { - const showCompareBaseBranch = onCompareBaseBranchOpenChange != null; - - return ( - <Collapsible open={showAdvanced} onOpenChange={onShowAdvancedChange}> - <div className="flex items-center justify-between"> - <CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"> - <HiChevronDown - className={`size-3 transition-transform ${showAdvanced ? "" : "-rotate-90"}`} - /> - Advanced options - </CollapsibleTrigger> - {shortcutHint && ( - <span className="text-[11px] text-muted-foreground/50"> - {shortcutHint} - </span> - )} - </div> - <CollapsibleContent className="pt-3 space-y-3"> - <div className="space-y-1.5"> - <div className="flex items-center justify-between"> - <label htmlFor="branch" className="text-xs text-muted-foreground"> - Branch name - </label> - <button - type="button" - onClick={onEditPrefix} - className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" - > - <HiOutlinePencil className="size-3" /> - <span>Edit prefix</span> - </button> - </div> - <Input - id="branch" - className="h-8 text-sm font-mono" - placeholder="auto-generated" - value={branchInputValue} - onChange={(event) => onBranchInputChange(event.target.value)} - onBlur={onBranchInputBlur} - /> - </div> - - {showCompareBaseBranch && ( - <div className="space-y-1.5"> - <span className="text-xs text-muted-foreground">Base branch</span> - {isBranchesError ? ( - <div className="flex items-center gap-2 h-8 px-3 rounded-md border border-destructive/50 bg-destructive/10 text-destructive text-xs"> - Failed to load branches - </div> - ) : ( - <Popover - open={compareBaseBranchOpen} - onOpenChange={onCompareBaseBranchOpenChange} - modal={false} - > - <PopoverTrigger asChild> - <Button - variant="outline" - size="sm" - className="w-full h-8 justify-between font-normal" - disabled={isBranchesLoading} - > - <span className="flex items-center gap-2 truncate"> - <GoGitBranch className="size-3.5 shrink-0 text-muted-foreground" /> - <span className="truncate font-mono text-sm"> - {effectiveCompareBaseBranch || "Select branch..."} - </span> - {effectiveCompareBaseBranch === defaultBranch && ( - <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> - default - </span> - )} - </span> - <HiChevronUpDown className="size-4 shrink-0 text-muted-foreground" /> - </Button> - </PopoverTrigger> - <PopoverContent - className="w-[--radix-popover-trigger-width] p-0" - align="start" - onWheel={(event) => event.stopPropagation()} - > - <Command shouldFilter={false}> - <CommandInput - placeholder="Search branches..." - value={branchSearch} - onValueChange={onBranchSearchChange} - /> - <CommandList className="max-h-[200px]"> - <CommandEmpty>No branches found</CommandEmpty> - {filteredBranches?.map((branch) => ( - <CommandItem - key={branch.name} - value={branch.name} - onSelect={() => - onSelectCompareBaseBranch?.(branch.name) - } - className="flex items-center justify-between" - > - <span className="flex items-center gap-2 truncate"> - <GoGitBranch className="size-3.5 shrink-0 text-muted-foreground" /> - <span className="truncate">{branch.name}</span> - {branch.name === defaultBranch && ( - <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> - default - </span> - )} - </span> - <span className="flex items-center gap-2 shrink-0"> - {branch.lastCommitDate > 0 && ( - <span className="text-xs text-muted-foreground"> - {formatRelativeTime(branch.lastCommitDate)} - </span> - )} - {effectiveCompareBaseBranch === branch.name && ( - <HiCheck className="size-4 text-primary" /> - )} - </span> - </CommandItem> - ))} - </CommandList> - </Command> - </PopoverContent> - </Popover> - )} - </div> - )} - - {!hideSetupScript && ( - <div className="flex items-center justify-between"> - <Label - htmlFor="run-setup-script" - className="text-xs text-muted-foreground" - > - Run setup script - </Label> - <Switch - id="run-setup-script" - checked={runSetupScript} - onCheckedChange={onRunSetupScriptChange} - /> - </div> - )} - </CollapsibleContent> - </Collapsible> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/index.ts deleted file mode 100644 index 56182b77e11..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PromptGroup/components/PromptGroupAdvancedOptions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PromptGroupAdvancedOptions } from "./PromptGroupAdvancedOptions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/PullRequestsGroup.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/PullRequestsGroup.tsx deleted file mode 100644 index e728e2373ad..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/PullRequestsGroup.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { CommandEmpty, CommandGroup, CommandItem } from "@superset/ui/command"; -import { toast } from "@superset/ui/sonner"; -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useNavigate } from "@tanstack/react-router"; -import Fuse from "fuse.js"; -import { useMemo } from "react"; -import { - GoArrowUpRight, - GoGitPullRequest, - GoGitPullRequestDraft, -} from "react-icons/go"; -import { SiGithub } from "react-icons/si"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import type { WorkspaceHostTarget } from "renderer/lib/v2-workspace-host"; -import { navigateToV2Workspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { useDashboardNewWorkspaceDraft } from "../../../../DashboardNewWorkspaceDraftContext"; -import { useCreateDashboardWorkspace } from "../../../../hooks/useCreateDashboardWorkspace"; - -interface PullRequestsGroupProps { - projectId: string | null; - githubRepositoryId: string | null; - hostTarget: WorkspaceHostTarget; -} - -export function PullRequestsGroup({ - projectId, - githubRepositoryId, - hostTarget, -}: PullRequestsGroupProps) { - const collections = useCollections(); - const navigate = useNavigate(); - const { createWorkspace } = useCreateDashboardWorkspace(); - const { draft, closeAndResetDraft, runAsyncAction } = - useDashboardNewWorkspaceDraft(); - - // Query open PRs for this repository using the v2 project's githubRepositoryId directly - const { data: pullRequests } = useLiveQuery( - (q) => - q - .from({ prs: collections.githubPullRequests }) - .where(({ prs }) => eq(prs.repositoryId, githubRepositoryId ?? "")) - .select(({ prs }) => ({ ...prs })), - [collections, githubRepositoryId], - ); - - // Check v2Workspaces for existing workspaces by branch - const { data: v2WorkspacesData } = useLiveQuery( - (q) => - q - .from({ ws: collections.v2Workspaces }) - .where(({ ws }) => eq(ws.projectId, projectId ?? "")) - .select(({ ws }) => ({ id: ws.id, branch: ws.branch })), - [collections, projectId], - ); - - const workspaceByBranch = useMemo(() => { - const map = new Map<string, string>(); - for (const w of v2WorkspacesData ?? []) { - map.set(w.branch, w.id); - } - return map; - }, [v2WorkspacesData]); - - const allOpenPrs = useMemo( - () => (pullRequests ?? []).filter((pr) => pr.state === "open"), - [pullRequests], - ); - - const debouncedQuery = useDebouncedValue(draft.pullRequestsQuery, 150); - - const prFuse = useMemo( - () => - new Fuse(allOpenPrs, { - keys: [ - { name: "title", weight: 2 }, - { name: "authorLogin", weight: 1 }, - { name: "prNumber", weight: 1 }, - ], - threshold: 0.3, - includeScore: true, - ignoreLocation: true, - }), - [allOpenPrs], - ); - - const openPrs = useMemo(() => { - const query = debouncedQuery.trim(); - if (!query) { - return allOpenPrs.slice(0, 100); - } - return prFuse - .search(query) - .slice(0, 100) - .map((result) => result.item); - }, [debouncedQuery, allOpenPrs, prFuse]); - - if (!projectId) { - return ( - <CommandGroup> - <CommandEmpty>Select a project to view pull requests.</CommandEmpty> - </CommandGroup> - ); - } - - if (!githubRepositoryId) { - return ( - <div className="flex flex-col items-center gap-3 py-8 px-4 text-center"> - <SiGithub className="size-6 text-muted-foreground" /> - <div className="space-y-1"> - <p className="text-sm font-medium">No GitHub repository linked</p> - <p className="text-xs text-muted-foreground"> - This project needs a GitHub repository to show pull requests - </p> - </div> - </div> - ); - } - - return ( - <CommandGroup> - <CommandEmpty>No pull requests found.</CommandEmpty> - {openPrs.map((pr) => ( - <CommandItem - key={pr.id} - onSelect={() => { - if (!projectId) { - toast.error("Select a project first"); - return; - } - const existingId = workspaceByBranch.get(pr.headBranch); - if (existingId) { - closeAndResetDraft(); - navigateToV2Workspace(existingId, navigate); - return; - } - void runAsyncAction( - createWorkspace({ - projectId, - name: pr.title, - branch: pr.headBranch, - hostTarget, - }), - { - loading: "Creating workspace from PR...", - success: "Workspace created", - error: (err) => - err instanceof Error - ? err.message - : "Failed to create workspace", - }, - ); - }} - className="group h-12" - > - {workspaceByBranch.has(pr.headBranch) ? ( - <GoArrowUpRight className="size-4 shrink-0 text-muted-foreground" /> - ) : pr.isDraft ? ( - <GoGitPullRequestDraft className="size-4 shrink-0 text-muted-foreground" /> - ) : ( - <GoGitPullRequest className="size-4 shrink-0 text-emerald-500" /> - )} - <span - className="text-muted-foreground shrink-0 text-xs tabular-nums truncate" - style={{ width: "2.8rem" }} - > - #{pr.prNumber} - </span> - <span className="truncate flex-1">{pr.title}</span> - <span className="text-xs text-muted-foreground shrink-0 group-data-[selected=true]:hidden"> - {pr.authorLogin} - </span> - <span className="text-xs text-muted-foreground shrink-0 hidden group-data-[selected=true]:inline"> - {workspaceByBranch.has(pr.headBranch) ? "Open" : "Create"} ↵ - </span> - </CommandItem> - ))} - </CommandGroup> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/index.ts deleted file mode 100644 index 51ab880114f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/PullRequestsGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PullRequestsGroup } from "./PullRequestsGroup"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/index.ts new file mode 100644 index 00000000000..3de8ec797fd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/index.ts @@ -0,0 +1,5 @@ +export { + type BranchFilter, + type BranchRow, + useBranchContext, +} from "./useBranchContext"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/useBranchContext.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/useBranchContext.ts new file mode 100644 index 00000000000..96c4dfa879b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useBranchContext/useBranchContext.ts @@ -0,0 +1,78 @@ +import type { AppRouter } from "@superset/host-service"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; +import { useMemo } from "react"; +import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import type { WorkspaceHostTarget } from "../../components/DevicePicker"; + +type SearchBranchesInput = + inferRouterInputs<AppRouter>["workspaceCreation"]["searchBranches"]; +type SearchBranchesOutput = + inferRouterOutputs<AppRouter>["workspaceCreation"]["searchBranches"]; + +export type BranchFilter = NonNullable<SearchBranchesInput["filter"]>; +export type BranchRow = SearchBranchesOutput["items"][number]; +type BranchPage = SearchBranchesOutput; + +const PAGE_SIZE = 50; + +/** + * Paginated branch search via host-service. First page of a + * (projectId, host, query, filter) tuple asks to refresh remote refs; + * the host-service enforces a TTL so rapid typing doesn't thrash `git fetch`. + */ +export function useBranchContext( + projectId: string | null, + hostTarget: WorkspaceHostTarget, + query: string, + filter: BranchFilter = "branch", +) { + const hostUrl = useHostTargetUrl(hostTarget); + + const q = useInfiniteQuery({ + queryKey: [ + "workspaceCreation", + "searchBranches", + projectId, + hostUrl, + query, + filter, + ], + enabled: !!projectId && !!hostUrl, + initialPageParam: undefined as string | undefined, + getNextPageParam: (last: BranchPage) => last.nextCursor ?? undefined, + queryFn: async ({ pageParam }): Promise<BranchPage> => { + if (!hostUrl || !projectId) { + return { defaultBranch: null, items: [], nextCursor: null }; + } + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.searchBranches.query({ + projectId, + query: query || undefined, + cursor: pageParam, + limit: PAGE_SIZE, + refresh: pageParam === undefined, + filter, + }); + }, + }); + + const pages = q.data?.pages as BranchPage[] | undefined; + const branches = useMemo<BranchRow[]>( + () => pages?.flatMap((p) => p.items) ?? [], + [pages], + ); + + const defaultBranch = pages?.[0]?.defaultBranch ?? null; + + return { + branches, + defaultBranch, + isLoading: q.isLoading, + isError: q.isError, + isFetchingNextPage: q.isFetchingNextPage, + hasNextPage: q.hasNextPage, + fetchNextPage: q.fetchNextPage, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/index.ts deleted file mode 100644 index 0119a16a1b6..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useDashboardNewWorkspaceProjectSelection } from "./useDashboardNewWorkspaceProjectSelection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/useDashboardNewWorkspaceProjectSelection.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/useDashboardNewWorkspaceProjectSelection.ts deleted file mode 100644 index fcd87eb2a64..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useDashboardNewWorkspaceProjectSelection/useDashboardNewWorkspaceProjectSelection.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useEffect, useMemo, useRef } from "react"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; - -interface UseDashboardNewWorkspaceProjectSelectionOptions { - isOpen: boolean; - preSelectedProjectId: string | null; - selectedProjectId: string | null; - onSelectProject: (projectId: string | null) => void; -} - -export function useDashboardNewWorkspaceProjectSelection({ - isOpen, - preSelectedProjectId, - selectedProjectId, - onSelectProject, -}: UseDashboardNewWorkspaceProjectSelectionOptions) { - const collections = useCollections(); - - const { data: v2ProjectsData } = useLiveQuery( - (q) => - q - .from({ projects: collections.v2Projects }) - .select(({ projects }) => ({ ...projects })), - [collections], - ); - const v2Projects = useMemo(() => v2ProjectsData ?? [], [v2ProjectsData]); - const areV2ProjectsReady = v2ProjectsData !== undefined; - - const appliedPreSelectionRef = useRef<string | null>(null); - - useEffect(() => { - if (!isOpen) { - appliedPreSelectionRef.current = null; - } - }, [isOpen]); - - useEffect(() => { - if (!isOpen) return; - - if ( - preSelectedProjectId && - preSelectedProjectId !== appliedPreSelectionRef.current - ) { - if (!areV2ProjectsReady) return; - const hasPreSelectedProject = v2Projects.some( - (project) => project.id === preSelectedProjectId, - ); - if (hasPreSelectedProject) { - appliedPreSelectionRef.current = preSelectedProjectId; - if (preSelectedProjectId !== selectedProjectId) { - onSelectProject(preSelectedProjectId); - } - return; - } - } - - if (!areV2ProjectsReady) return; - - const hasSelectedProject = v2Projects.some( - (project) => project.id === selectedProjectId, - ); - if (!hasSelectedProject) { - const nextProjectId = v2Projects[0]?.id ?? null; - if (nextProjectId !== selectedProjectId) { - onSelectProject(nextProjectId); - } - } - }, [ - selectedProjectId, - areV2ProjectsReady, - isOpen, - onSelectProject, - preSelectedProjectId, - v2Projects, - ]); - - const selectedProject = - v2Projects.find((project) => project.id === selectedProjectId) ?? null; - const githubRepositoryId = selectedProject?.githubRepositoryId ?? null; - - const { data: githubRepoData } = useLiveQuery( - (q) => - q - .from({ repos: collections.githubRepositories }) - .where(({ repos }) => eq(repos.id, githubRepositoryId ?? "")) - .select(({ repos }) => ({ - id: repos.id, - owner: repos.owner, - name: repos.name, - })), - [collections, githubRepositoryId], - ); - - return { - githubRepository: githubRepoData?.[0] ?? null, - githubRepositoryId, - selectedProject, - v2Projects, - }; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useResolvedLocalProject/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useResolvedLocalProject/index.ts deleted file mode 100644 index dfde2a3b4c3..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useResolvedLocalProject/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useResolvedLocalProject } from "./useResolvedLocalProject"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useResolvedLocalProject/useResolvedLocalProject.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useResolvedLocalProject/useResolvedLocalProject.ts deleted file mode 100644 index f88ca411a3f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/hooks/useResolvedLocalProject/useResolvedLocalProject.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useMemo } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; - -interface ResolvedGithubRepository { - owner: string; - name: string; -} - -export function useResolvedLocalProject( - githubRepository: ResolvedGithubRepository | null, -) { - const { data: localProjects = [] } = - electronTrpc.projects.getRecents.useQuery(); - - return useMemo(() => { - if (!githubRepository) return null; - const match = localProjects.find((localProject) => { - if (localProject.githubOwner !== githubRepository.owner) return false; - if (localProject.name === githubRepository.name) return true; - - const directoryName = localProject.mainRepoPath?.split("/").pop(); - return directoryName === githubRepository.name; - }); - - return match?.id ?? null; - }, [githubRepository, localProjects]); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/index.ts deleted file mode 100644 index 5a7fbfd5e5e..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DashboardNewWorkspaceForm } from "./DashboardNewWorkspaceForm"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx new file mode 100644 index 00000000000..732d237d44d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/DashboardNewWorkspaceModalContent.tsx @@ -0,0 +1,183 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useMemo, useRef } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useV2WorkspaceCreateDefaultsStore } from "renderer/stores/v2-workspace-create-defaults"; +import { MOCK_ORG_ID } from "shared/constants"; +import { useDashboardNewWorkspaceDraft } from "../../DashboardNewWorkspaceDraftContext"; +import { PromptGroup } from "../DashboardNewWorkspaceForm/PromptGroup"; +import { useSelectedHostProjectIds } from "./hooks/useSelectedHostProjectIds"; + +interface DashboardNewWorkspaceModalContentProps { + isOpen: boolean; + preSelectedProjectId: string | null; +} + +/** + * Content pane for the Dashboard new-workspace modal. + * + * Resolves the project list from V2 collections (`v2Projects` + + * `githubRepositories`) and handles the initial project selection when the + * modal opens. Delegates the composer itself to PromptGroup. + */ +export function DashboardNewWorkspaceModalContent({ + isOpen, + preSelectedProjectId, +}: DashboardNewWorkspaceModalContentProps) { + const { draft, updateDraft } = useDashboardNewWorkspaceDraft(); + const setLastProjectId = useV2WorkspaceCreateDefaultsStore( + (state) => state.setLastProjectId, + ); + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + + const { data: v2Projects } = useLiveQuery( + (q) => + q + .from({ projects: collections.v2Projects }) + .where(({ projects }) => + eq(projects.organizationId, activeOrganizationId ?? ""), + ) + .select(({ projects }) => ({ ...projects })), + [collections, activeOrganizationId], + ); + + const { data: githubRepositories } = useLiveQuery( + (q) => + q.from({ repos: collections.githubRepositories }).select(({ repos }) => ({ + id: repos.id, + owner: repos.owner, + name: repos.name, + })), + [collections], + ); + + const setUpProjectIds = useSelectedHostProjectIds(draft.hostTarget); + + const recentProjects = useMemo(() => { + const repoById = new Map( + (githubRepositories ?? []).map((repo) => [repo.id, repo]), + ); + return (v2Projects ?? []).map((project) => { + const repo = project.githubRepositoryId + ? (repoById.get(project.githubRepositoryId) ?? null) + : null; + return { + id: project.id, + name: project.name, + githubOwner: repo?.owner ?? null, + githubRepoName: repo?.name ?? null, + needsSetup: + setUpProjectIds === null ? null : !setUpProjectIds.has(project.id), + }; + }); + }, [githubRepositories, setUpProjectIds, v2Projects]); + + const areProjectsReady = v2Projects !== undefined; + const appliedPreSelectionRef = useRef<string | null>(null); + const appliedHostTargetRef = useRef(false); + const hasInitializedSelectionRef = useRef(false); + + useEffect(() => { + if (!isOpen) { + appliedPreSelectionRef.current = null; + appliedHostTargetRef.current = false; + hasInitializedSelectionRef.current = false; + return; + } + if (appliedHostTargetRef.current) return; + appliedHostTargetRef.current = true; + const persistedHostTarget = + useV2WorkspaceCreateDefaultsStore.getState().lastHostTarget; + const validHostTarget = + persistedHostTarget?.kind === "local" + ? persistedHostTarget + : persistedHostTarget?.kind === "host" && + typeof persistedHostTarget.hostId === "string" + ? persistedHostTarget + : null; + if (validHostTarget) { + updateDraft({ hostTarget: validHostTarget }); + } + }, [isOpen, updateDraft]); + + useEffect(() => { + if (!isOpen) return; + + if ( + preSelectedProjectId && + preSelectedProjectId !== appliedPreSelectionRef.current + ) { + if (!areProjectsReady) return; + const hasPreSelectedProject = recentProjects.some( + (project) => project.id === preSelectedProjectId, + ); + if (hasPreSelectedProject) { + appliedPreSelectionRef.current = preSelectedProjectId; + hasInitializedSelectionRef.current = true; + if (preSelectedProjectId !== draft.selectedProjectId) { + updateDraft({ selectedProjectId: preSelectedProjectId }); + } + return; + } + } + + if (!areProjectsReady) return; + // Wait for org context. Without it, v2Projects is filtered by an empty + // org id and resolves to []; initializing here would lock in a null + // selection before the real project list arrives. + if (activeOrganizationId === null) return; + + // Only auto-pick a default once. After init, leave the user's selection + // alone — including freshly created projects that may not be in the live + // query yet (they'll appear momentarily and the picker will show them). + if (hasInitializedSelectionRef.current) return; + + const hasSelectedProject = recentProjects.some( + (project) => project.id === draft.selectedProjectId, + ); + if (!hasSelectedProject) { + const { lastProjectId } = useV2WorkspaceCreateDefaultsStore.getState(); + const persistedProjectId = + lastProjectId && + recentProjects.some((project) => project.id === lastProjectId) + ? lastProjectId + : null; + updateDraft({ + selectedProjectId: persistedProjectId ?? recentProjects[0]?.id ?? null, + }); + } + hasInitializedSelectionRef.current = true; + }, [ + draft.selectedProjectId, + areProjectsReady, + activeOrganizationId, + isOpen, + preSelectedProjectId, + recentProjects, + updateDraft, + ]); + + const selectedProject = recentProjects.find( + (project) => project.id === draft.selectedProjectId, + ); + + return ( + <div className="flex-1 overflow-y-auto"> + <PromptGroup + projectId={draft.selectedProjectId} + selectedProject={selectedProject} + recentProjects={recentProjects.filter((project) => Boolean(project.id))} + onSelectProject={(selectedProjectId) => { + setLastProjectId(selectedProjectId); + updateDraft({ selectedProjectId }); + }} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/index.ts new file mode 100644 index 00000000000..d785220c21b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/index.ts @@ -0,0 +1 @@ +export { useSelectedHostProjectIds } from "./useSelectedHostProjectIds"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts new file mode 100644 index 00000000000..b35d0c79b18 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/hooks/useSelectedHostProjectIds/useSelectedHostProjectIds.ts @@ -0,0 +1,9 @@ +import { useHostTargetUrl } from "renderer/hooks/host-service/useHostTargetUrl"; +import { useHostProjectIds } from "renderer/react-query/projects"; +import type { WorkspaceHostTarget } from "../../../DashboardNewWorkspaceForm/components/DevicePicker/types"; + +export function useSelectedHostProjectIds( + hostTarget: WorkspaceHostTarget, +): Set<string> | null { + return useHostProjectIds(useHostTargetUrl(hostTarget)); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/index.ts new file mode 100644 index 00000000000..67a1b5e21e5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceModalContent/index.ts @@ -0,0 +1 @@ +export { DashboardNewWorkspaceModalContent } from "./DashboardNewWorkspaceModalContent"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/index.ts new file mode 100644 index 00000000000..ee3dc065fd5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/index.ts @@ -0,0 +1 @@ +export { useAdoptWorktree } from "./useAdoptWorktree"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree.ts new file mode 100644 index 00000000000..b3415730c9e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useAdoptWorktree/useAdoptWorktree.ts @@ -0,0 +1,43 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { useCallback } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import type { WorkspaceHostTarget } from "../../components/DashboardNewWorkspaceForm/components/DevicePicker"; + +export interface AdoptWorktreeInput { + projectId: string; + hostTarget: WorkspaceHostTarget; + workspaceName: string; + branch: string; +} + +/** + * Registers a workspace row for an existing `.worktrees/<branch>` directory + * that has no matching workspaces row. No git ops — just cloud + local DB. + */ +export function useAdoptWorktree() { + const { activeHostUrl } = useLocalHostService(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = session?.session?.activeOrganizationId ?? null; + + return useCallback( + async (input: AdoptWorktreeInput) => { + const hostUrl = + input.hostTarget.kind === "local" + ? activeHostUrl + : activeOrganizationId + ? `${env.RELAY_URL}/hosts/${buildHostRoutingKey(activeOrganizationId, input.hostTarget.hostId)}` + : null; + if (!hostUrl) throw new Error("Host service not available"); + const client = getHostServiceClientByUrl(hostUrl); + return client.workspaceCreation.adopt.mutate({ + projectId: input.projectId, + workspaceName: input.workspaceName, + branch: input.branch, + }); + }, + [activeHostUrl, activeOrganizationId], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/index.ts new file mode 100644 index 00000000000..9aa66b1b71a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/index.ts @@ -0,0 +1 @@ +export { useCheckoutDashboardWorkspace } from "./useCheckoutDashboardWorkspace"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace.ts new file mode 100644 index 00000000000..f0718e1f921 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCheckoutDashboardWorkspace/useCheckoutDashboardWorkspace.ts @@ -0,0 +1,87 @@ +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { useCallback } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import type { WorkspaceHostTarget } from "../../components/DashboardNewWorkspaceForm/components/DevicePicker"; + +export interface CheckoutWorkspaceInput { + pendingId: string; + projectId: string; + hostTarget: WorkspaceHostTarget; + workspaceName: string; + // Exactly one of `branch` or `pr` must be set — enforced server-side + // via zod refine. Branch mode: materialize an existing local/remote + // branch. PR mode: materialize a PR's branch via `gh pr checkout`. + branch?: string; + pr?: { + number: number; + url: string; + title: string; + headRefName: string; + baseRefName: string; + headRepositoryOwner: string; + isCrossRepository: boolean; + state: "open" | "closed" | "merged"; + }; + composer: { + prompt?: string; + // Written to `branch.<name>.base` for the Changes tab. Filled from + // picker selection in branch mode, `pr.baseRefName` in PR mode. + baseBranch?: string; + runSetupScript?: boolean; + }; + linkedContext?: { + internalIssueIds?: string[]; + githubIssueUrls?: string[]; + linkedPrUrl?: string; + attachments?: Array<{ + data: string; + mediaType: string; + filename?: string; + }>; + }; +} + +/** + * Thin wrapper around the host-service `workspaceCreation.checkout` mutation. + * Two modes: + * - Branch mode (`branch` set): reuse an existing local/remote branch. + * - PR mode (`pr` set): materialize a PR's branch via `gh pr checkout`; + * idempotent (returns `alreadyExists: true` if a workspace already exists + * for the derived branch). + */ +export function useCheckoutDashboardWorkspace() { + const { activeHostUrl } = useLocalHostService(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = session?.session?.activeOrganizationId ?? null; + + return useCallback( + async (input: CheckoutWorkspaceInput) => { + const hostUrl = + input.hostTarget.kind === "local" + ? activeHostUrl + : activeOrganizationId + ? `${env.RELAY_URL}/hosts/${buildHostRoutingKey(activeOrganizationId, input.hostTarget.hostId)}` + : null; + + if (!hostUrl) { + throw new Error("Host service not available"); + } + + const client = getHostServiceClientByUrl(hostUrl); + + return client.workspaceCreation.checkout.mutate({ + pendingId: input.pendingId, + projectId: input.projectId, + workspaceName: input.workspaceName, + branch: input.branch, + pr: input.pr, + composer: input.composer, + linkedContext: input.linkedContext, + }); + }, + [activeHostUrl, activeOrganizationId], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts index 23c28726008..ed890338300 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/hooks/useCreateDashboardWorkspace/useCreateDashboardWorkspace.ts @@ -1,57 +1,75 @@ -import { useCallback, useState } from "react"; -import { - getHostServiceClientByUrl, - type HostServiceClient, -} from "renderer/lib/host-service-client"; -import { - resolveCreateWorkspaceHostUrl, - type WorkspaceHostTarget, -} from "renderer/lib/v2-workspace-host"; -import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; -import { useWorkspaceHostOptions } from "../../components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { useCallback } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import type { WorkspaceHostTarget } from "../../components/DashboardNewWorkspaceForm/components/DevicePicker"; -interface CreateDashboardWorkspaceInput { +export interface CreateWorkspaceInput { + pendingId: string; projectId: string; - name: string; - branch: string; hostTarget: WorkspaceHostTarget; + names: { + workspaceName: string; + branchName: string; + /** + * When true, host-service may replace the workspace title with an + * AI-generated name post-create. Default true preserves the rename + * path for any legacy caller that omits the field. + */ + workspaceNameWasAutoGenerated?: boolean; + }; + composer: { + prompt?: string; + baseBranch?: string; + baseBranchSource?: "local" | "remote-tracking"; + runSetupScript?: boolean; + }; + linkedContext?: { + internalIssueIds?: string[]; + githubIssueUrls?: string[]; + linkedPrUrl?: string; + attachments?: Array<{ + data: string; + mediaType: string; + filename?: string; + }>; + }; } +/** + * Thin wrapper around the host-service `workspaceCreation.create` mutation. + * The caller is responsible for pending state, toasts, and draft management. + */ export function useCreateDashboardWorkspace() { - const [isPending, setIsPending] = useState(false); - const { localHostService } = useWorkspaceHostOptions(); - const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); + const { activeHostUrl } = useLocalHostService(); + const { data: session } = authClient.useSession(); + const activeOrganizationId = session?.session?.activeOrganizationId ?? null; - const createWorkspace = useCallback( - async (input: CreateDashboardWorkspaceInput) => { - setIsPending(true); - try { - const hostUrl = resolveCreateWorkspaceHostUrl( - input.hostTarget, - localHostService?.url ?? null, - ); - if (!hostUrl) { - throw new Error("Host service not available"); - } + return useCallback( + async (input: CreateWorkspaceInput) => { + const hostUrl = + input.hostTarget.kind === "local" + ? activeHostUrl + : activeOrganizationId + ? `${env.RELAY_URL}/hosts/${buildHostRoutingKey(activeOrganizationId, input.hostTarget.hostId)}` + : null; - const client: HostServiceClient = - input.hostTarget.kind === "local" && localHostService - ? localHostService.client - : getHostServiceClientByUrl(hostUrl); - - const workspace = await client.workspace.create.mutate({ - projectId: input.projectId, - name: input.name, - branch: input.branch, - }); - ensureWorkspaceInSidebar(workspace.id, input.projectId); - return workspace; - } finally { - setIsPending(false); + if (!hostUrl) { + throw new Error("Host service not available"); } + + const client = getHostServiceClientByUrl(hostUrl); + + return client.workspaceCreation.create.mutate({ + pendingId: input.pendingId, + projectId: input.projectId, + names: input.names, + composer: input.composer, + linkedContext: input.linkedContext, + }); }, - [ensureWorkspaceInSidebar, localHostService], + [activeHostUrl, activeOrganizationId], ); - - return { createWorkspace, isPending }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/GlobalBrowserLifecycle.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/GlobalBrowserLifecycle.tsx new file mode 100644 index 00000000000..e163a5b9ace --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/GlobalBrowserLifecycle.tsx @@ -0,0 +1,6 @@ +import { useGlobalBrowserLifecycle } from "./hooks/useGlobalBrowserLifecycle"; + +export function GlobalBrowserLifecycle() { + useGlobalBrowserLifecycle(); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/index.ts new file mode 100644 index 00000000000..e41d3db35b0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/index.ts @@ -0,0 +1 @@ +export { useGlobalBrowserLifecycle } from "./useGlobalBrowserLifecycle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts new file mode 100644 index 00000000000..0a2ef7b3471 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts @@ -0,0 +1,133 @@ +import type { WorkspaceState } from "@superset/panes"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useRef } from "react"; +import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "../../../utils/paneLifecycleRows"; + +/** + * Grace period for cross-workspace pane moves / renames before destroying. + * Matches the terminal-side timing so the two runtimes behave consistently. + */ +const DESTROY_DELAY_MS = 500; + +interface PendingBrowserDestruction { + workspaceId: string; + timer: ReturnType<typeof setTimeout> | null; +} + +function getBrowserPaneId( + pane: WorkspaceState<unknown>["tabs"][number]["panes"][string], +): string | null { + return pane.kind === "browser" ? pane.id : null; +} + +function extractBrowserLocations( + rows: PaneLifecycleRow[], +): Map<string, string> { + return extractPaneLocations(rows, getBrowserPaneId); +} + +/** + * Destroys browser registry entries whose paneId is no longer present in + * any workspace's persisted layout. + */ +export function useGlobalBrowserLifecycle() { + const collections = useCollections(); + const prevBrowserLocationsRef = useRef<Map<string, string>>(new Map()); + const pendingDestruction = useRef<Map<string, PendingBrowserDestruction>>( + new Map(), + ); + + const { data: allWorkspaceRows = [] } = useLiveQuery( + (query) => + query.from({ + v2WorkspaceLocalState: collections.v2WorkspaceLocalState, + }), + [collections], + ); + + useEffect(() => { + const rows = allWorkspaceRows as PaneLifecycleRow[]; + const currentBrowserLocations = extractBrowserLocations(rows); + const currentWorkspaceIds = extractWorkspaceIds(rows); + const prevBrowserLocations = prevBrowserLocationsRef.current; + + // Cancel any pending destruction for ids that reappeared (e.g. pane + // moved between workspaces, user undo, or the transient replaceState + // churn we were fighting in the first place). + for (const browserId of currentBrowserLocations.keys()) { + const pending = pendingDestruction.current.get(browserId); + if (pending?.timer) { + clearTimeout(pending.timer); + } + pendingDestruction.current.delete(browserId); + } + + // If a pane was authoritatively removed but the owner row disappeared + // before the grace timer fired, keep waiting until that row is present + // again. That avoids destroying webviews during sleep/wake while still + // cleaning up when the post-removal layout comes back. + for (const [browserId, pending] of pendingDestruction.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { + pendingDestruction.current.delete(browserId); + browserRuntimeRegistry.destroy(browserId); + } + } + + const removedLocations = getRemovedPaneLocations({ + previousLocations: prevBrowserLocations, + currentLocations: currentBrowserLocations, + currentWorkspaceIds, + }); + + for (const { id: browserId, workspaceId } of removedLocations) { + if (pendingDestruction.current.has(browserId)) continue; + + const timer = setTimeout(() => { + const freshRows = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) as PaneLifecycleRow[]; + const freshLocations = extractBrowserLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); + + if (freshLocations.has(browserId)) { + pendingDestruction.current.delete(browserId); + return; + } + + if (freshWorkspaceIds.has(workspaceId)) { + pendingDestruction.current.delete(browserId); + browserRuntimeRegistry.destroy(browserId); + return; + } + + const pending = pendingDestruction.current.get(browserId); + if (pending) { + pending.timer = null; + } + }, DESTROY_DELAY_MS); + + pendingDestruction.current.set(browserId, { workspaceId, timer }); + } + + prevBrowserLocationsRef.current = currentBrowserLocations; + }, [allWorkspaceRows, collections]); + + useEffect(() => { + return () => { + for (const pending of pendingDestruction.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } + } + pendingDestruction.current.clear(); + }; + }, []); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/index.ts new file mode 100644 index 00000000000..0620ae9408e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/index.ts @@ -0,0 +1 @@ +export { GlobalBrowserLifecycle } from "./GlobalBrowserLifecycle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/V1MigrationSummaryModal.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/V1MigrationSummaryModal.tsx new file mode 100644 index 00000000000..69ae7273511 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/V1MigrationSummaryModal.tsx @@ -0,0 +1,634 @@ +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@superset/ui/dialog"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { Link } from "@tanstack/react-router"; +import { lazy, Suspense, useEffect, useState } from "react"; +import { + LuChevronDown, + LuChevronRight, + LuFolder, + LuLayoutGrid, + LuTriangle, +} from "react-icons/lu"; +import { env } from "renderer/env.renderer"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { V1_MIGRATION_SUMMARY_EVENT } from "renderer/routes/_authenticated/hooks/useMigrateV1DataToV2"; +import { MOCK_ORG_ID } from "shared/constants"; + +const Dithering = lazy(() => + import("@paper-design/shaders-react").then((mod) => ({ + default: mod.Dithering, + })), +); + +type MigrationPage = "welcome" | "results"; +type ProjectStatus = "created" | "linked" | "synced" | "error"; +type WorkspaceStatus = "adopted" | "synced" | "skipped" | "error"; + +interface ProjectEntry { + name: string; + status: ProjectStatus; + reason?: string; +} + +interface WorkspaceEntry { + name: string; + branch: string; + status: WorkspaceStatus; + reason?: string; +} + +interface MigrationSummary { + projectsCreated: number; + projectsLinked: number; + projectsErrored: number; + workspacesCreated: number; + workspacesSkipped: number; + workspacesErrored: number; + projects: ProjectEntry[]; + workspaces: WorkspaceEntry[]; + errors: Array<{ kind: string; name: string; message: string }>; +} + +interface StoredEntry { + summary: MigrationSummary; + createdAt: number; +} + +interface ModalUiState { + page: MigrationPage; + isTransitioning: boolean; + expandedSection: "projects" | "workspaces" | "errors" | null; +} + +const INITIAL_MODAL_UI_STATE: ModalUiState = { + page: "welcome", + isTransitioning: false, + expandedSection: null, +}; + +const GRADIENT_COLORS = [ + "#f97316", + "#fb923c", + "#f59e0b", + "#431407", +] as const satisfies readonly [string, string, string, string]; + +function summaryKey(organizationId: string): string { + return `v1-migration-summary-${organizationId}`; +} + +function readSummary(organizationId: string): MigrationSummary | null { + const raw = localStorage.getItem(summaryKey(organizationId)); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as StoredEntry; + return parsed.summary ?? null; + } catch { + localStorage.removeItem(summaryKey(organizationId)); + return null; + } +} + +export function V1MigrationSummaryModal() { + const { data: session } = authClient.useSession(); + const organizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + const [summary, setSummary] = useState<MigrationSummary | null>(null); + const [modalUiState, setModalUiState] = useState<ModalUiState>( + INITIAL_MODAL_UI_STATE, + ); + const { page, isTransitioning, expandedSection } = modalUiState; + + useEffect(() => { + if (!organizationId) { + setSummary(null); + setModalUiState(INITIAL_MODAL_UI_STATE); + return; + } + setSummary(readSummary(organizationId)); + + const onUpdate = (event: Event) => { + const detail = (event as CustomEvent<{ organizationId: string }>).detail; + if (detail?.organizationId === organizationId) { + setSummary(readSummary(organizationId)); + setModalUiState(INITIAL_MODAL_UI_STATE); + } + }; + window.addEventListener(V1_MIGRATION_SUMMARY_EVENT, onUpdate); + + return () => { + window.removeEventListener(V1_MIGRATION_SUMMARY_EVENT, onUpdate); + }; + }, [organizationId]); + + const dismiss = () => { + if (organizationId) localStorage.removeItem(summaryKey(organizationId)); + setSummary(null); + setModalUiState(INITIAL_MODAL_UI_STATE); + }; + + const transitionToPage = (nextPage: MigrationPage) => { + if (page === nextPage || isTransitioning) return; + setModalUiState((current) => ({ ...current, isTransitioning: true })); + window.setTimeout(() => { + setModalUiState((current) => ({ + ...current, + page: nextPage, + isTransitioning: false, + })); + }, 160); + }; + + const toggleSection = (section: "projects" | "workspaces" | "errors") => { + setModalUiState((current) => ({ + ...current, + expandedSection: current.expandedSection === section ? null : section, + })); + }; + + if (!summary) return null; + + return ( + <Dialog open={!!summary}> + <DialogContent + className="!w-[744px] !max-w-[744px] p-0 gap-0 overflow-hidden !rounded-none" + showCloseButton={false} + onEscapeKeyDown={(event) => event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + > + <DialogTitle className="sr-only"> + {page === "welcome" + ? "Welcome to Superset v2" + : "V1 migration results"} + </DialogTitle> + <DialogDescription className="sr-only"> + Review the migration summary and click Done to continue. + </DialogDescription> + + <div + className={cn( + "transition-opacity duration-200 ease-out", + isTransitioning ? "opacity-0" : "opacity-100", + )} + > + {page === "welcome" ? ( + <WelcomePage /> + ) : ( + <ResultsPage + summary={summary} + expandedSection={expandedSection} + onToggleSection={toggleSection} + onDismiss={dismiss} + /> + )} + </div> + + <div className="box-border flex items-center justify-between border-t bg-background px-5 py-4"> + {page === "results" ? ( + <Button + variant="outline" + disabled={isTransitioning} + onClick={() => transitionToPage("welcome")} + > + Back + </Button> + ) : ( + <div /> + )} + <Button + disabled={isTransitioning} + onClick={() => { + if (page === "welcome") { + transitionToPage("results"); + } else { + dismiss(); + } + }} + > + {page === "welcome" ? "Okay" : "Done"} + </Button> + </div> + </DialogContent> + </Dialog> + ); +} + +function WelcomePage() { + return ( + <div className="relative h-[454px] overflow-hidden bg-[#080a12]"> + <DitheredBackground + colors={GRADIENT_COLORS} + className="absolute inset-0 h-full w-full" + /> + <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(255,255,255,0.14),transparent_34%),linear-gradient(to_bottom,rgba(0,0,0,0.04),rgba(0,0,0,0.5))]" /> + <div className="absolute inset-0 flex flex-col items-center justify-center px-14 text-center"> + <div className="text-3xl font-semibold text-white"> + Welcome to Superset v2 + </div> + </div> + </div> + ); +} + +interface DitheredBackgroundProps { + colors: readonly [string, string, string, string]; + className?: string; +} + +function DitheredBackground({ + colors, + className = "", +}: DitheredBackgroundProps) { + return ( + <div + className={cn( + "pointer-events-none opacity-40 mix-blend-screen", + className, + )} + > + <Suspense fallback={null}> + <Dithering + colorBack="#00000000" + colorFront={colors[0]} + shape="warp" + type="4x4" + speed={0.15} + className="size-full" + minPixelRatio={1} + /> + </Suspense> + </div> + ); +} + +interface ResultsPageProps { + summary: MigrationSummary; + expandedSection: "projects" | "workspaces" | "errors" | null; + onToggleSection: (section: "projects" | "workspaces" | "errors") => void; + onDismiss: () => void; +} + +function ResultsPage({ + summary, + expandedSection, + onToggleSection, + onDismiss, +}: ResultsPageProps) { + const copyText = electronTrpc.external.copyText.useMutation(); + const [isSendingSupportReport, setIsSendingSupportReport] = useState(false); + const projectsTotal = summary.projects.filter( + (project) => project.status !== "error", + ).length; + const workspacesTotal = summary.workspaces.filter( + (workspace) => workspace.status !== "error", + ).length; + const hasErrors = summary.errors.length > 0; + const projectDetail = [ + countByStatus(summary.projects, "synced", "synced"), + countByStatus(summary.projects, "linked", "linked"), + countByStatus(summary.projects, "created", "created"), + ] + .filter(Boolean) + .join(" · "); + const workspaceDetail = [ + countByStatus(summary.workspaces, "synced", "synced"), + countByStatus(summary.workspaces, "adopted", "adopted"), + countByStatus(summary.workspaces, "skipped", "skipped"), + ] + .filter(Boolean) + .join(" · "); + const contactSupport = async () => { + const report = buildMigrationSupportReport(summary); + setIsSendingSupportReport(true); + try { + await apiTrpcClient.support.sendMigrationReport.mutate({ report }); + toast.success("Migration details sent to support"); + } catch (error) { + console.warn("[v1-migration] Failed to send support report:", error); + try { + await copyText.mutateAsync(report); + toast.success("Migration details copied to clipboard"); + } catch (copyError) { + console.warn( + "[v1-migration] Failed to copy support report:", + copyError, + ); + toast.error("Could not send migration details"); + } + } finally { + setIsSendingSupportReport(false); + } + }; + + return ( + <div className="flex h-[454px] flex-col bg-background"> + <div className="border-b px-8 py-6"> + <div className="text-2xl font-semibold text-foreground"> + Migration results + </div> + <p className="mt-2 text-sm text-muted-foreground"> + Ran into issues?{" "} + <button + type="button" + disabled={isSendingSupportReport || copyText.isPending} + onClick={() => { + void contactSupport(); + }} + className="font-medium text-primary hover:underline disabled:cursor-not-allowed disabled:opacity-60" + > + Contact us + </button> + . You can go back to V1 in{" "} + <Link + to="/settings/experimental" + onClick={onDismiss} + className="font-medium text-primary hover:underline" + > + settings + </Link> + . + </p> + </div> + + <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-1 overflow-y-auto px-5 py-4"> + <ExpandableSummaryRow + icon={LuFolder} + label="Projects" + count={projectsTotal} + detail={projectDetail} + expanded={expandedSection === "projects"} + onToggle={ + summary.projects.length > 0 + ? () => onToggleSection("projects") + : undefined + } + > + <EntryList> + {summary.projects.map((p) => ( + <Entry + key={`project-${p.name}-${p.status}-${p.reason ?? ""}`} + primary={p.name} + statusLabel={p.status} + statusTone={entryTone(p.status)} + detail={p.reason} + /> + ))} + </EntryList> + </ExpandableSummaryRow> + <ExpandableSummaryRow + icon={LuLayoutGrid} + label="Workspaces" + count={workspacesTotal} + detail={workspaceDetail} + expanded={expandedSection === "workspaces"} + onToggle={ + summary.workspaces.length > 0 + ? () => onToggleSection("workspaces") + : undefined + } + > + <EntryList> + {summary.workspaces.map((w) => ( + <Entry + key={`workspace-${w.name}-${w.branch}-${w.status}-${w.reason ?? ""}`} + primary={w.name} + secondary={w.branch} + statusLabel={w.status} + statusTone={entryTone(w.status)} + detail={w.reason} + /> + ))} + </EntryList> + </ExpandableSummaryRow> + {hasErrors && ( + <ExpandableSummaryRow + icon={LuTriangle} + label="Errors" + count={summary.errors.length} + detail={summary.errors[0]?.message} + variant="error" + expanded={expandedSection === "errors"} + onToggle={() => onToggleSection("errors")} + > + <EntryList> + {summary.errors.map((error) => ( + <Entry + key={`error-${error.kind}-${error.name}-${error.message}`} + primary={error.name} + secondary={error.kind} + statusLabel="error" + statusTone="error" + detail={error.message} + /> + ))} + </EntryList> + </ExpandableSummaryRow> + )} + </div> + </div> + ); +} + +function entryTone( + status: ProjectStatus | WorkspaceStatus, +): "success" | "muted" | "error" { + if (status === "error") return "error"; + if (status === "skipped") return "muted"; + return "success"; +} + +function countByStatus<T extends { status: string }>( + entries: T[], + status: T["status"], + label: string, +): string | null { + const count = entries.filter((entry) => entry.status === status).length; + if (count === 0) return null; + return `${count} ${label}`; +} + +function buildMigrationSupportReport(summary: MigrationSummary): string { + const lines = [ + "Hi Superset team,", + "", + "I ran into an issue with the V1 to V2 migration.", + "", + "Migration summary:", + `- Projects: ${summary.projectsCreated} created, ${summary.projectsLinked} linked, ${summary.projectsErrored} errored`, + `- Workspaces: ${summary.workspacesCreated} created, ${summary.workspacesSkipped} skipped, ${summary.workspacesErrored} errored`, + ]; + + const relevantEntries = [ + ...summary.errors.map( + (error) => `${error.kind}: ${error.name} - ${error.message}`, + ), + ...summary.workspaces + .filter((workspace) => workspace.status === "skipped") + .map( + (workspace) => + `workspace: ${workspace.name} (${workspace.branch}) - ${workspace.reason ?? workspace.status}`, + ), + ]; + + if (relevantEntries.length > 0) { + lines.push( + "", + "Migration errors and skipped items:", + ...relevantEntries + .slice(0, 20) + .map((entry) => `- ${truncateSupportLine(entry)}`), + ); + if (relevantEntries.length > 20) { + lines.push(`- ${relevantEntries.length - 20} more item(s) not included`); + } + } + + return lines.join("\n"); +} + +function truncateSupportLine(value: string): string { + if (value.length <= 240) return value; + return `${value.slice(0, 237)}...`; +} + +interface SummaryRowProps { + icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; + label: string; + count: number; + detail?: string; + variant?: "default" | "error"; +} + +interface ExpandableSummaryRowProps extends SummaryRowProps { + expanded: boolean; + onToggle?: () => void; + children: React.ReactNode; +} + +function ExpandableSummaryRow({ + icon: Icon, + label, + count, + detail, + variant = "default", + expanded, + onToggle, + children, +}: ExpandableSummaryRowProps) { + const clickable = onToggle !== undefined; + return ( + <div className="flex min-w-0 flex-col"> + <button + type="button" + disabled={!clickable} + onClick={onToggle} + className={cn( + "flex items-center gap-3 rounded-md px-3 py-2 text-left", + clickable && "cursor-pointer hover:bg-muted/50", + !clickable && "cursor-default", + )} + > + <div + className={cn( + "flex h-8 w-8 items-center justify-center rounded-md", + variant === "error" + ? "bg-destructive/10 text-destructive" + : "bg-muted text-foreground", + )} + > + <Icon className="h-4 w-4" strokeWidth={2} /> + </div> + <div className="flex flex-1 items-center justify-between"> + <div className="flex items-baseline gap-2"> + <span className="text-sm font-medium text-foreground">{label}</span> + <Badge variant="secondary" className="px-1.5 py-0 text-xs"> + {count} + </Badge> + </div> + <div className="flex items-center gap-2"> + {detail && ( + <span className="max-w-[180px] truncate text-xs text-muted-foreground"> + {detail} + </span> + )} + {clickable && + (expanded ? ( + <LuChevronDown + className="h-3.5 w-3.5 text-muted-foreground" + strokeWidth={2} + /> + ) : ( + <LuChevronRight + className="h-3.5 w-3.5 text-muted-foreground" + strokeWidth={2} + /> + ))} + </div> + </div> + </button> + {expanded && <div className="pb-1 pl-14 pr-3">{children}</div>} + </div> + ); +} + +function EntryList({ children }: { children: React.ReactNode }) { + return <div className="flex flex-col gap-0.5 py-1">{children}</div>; +} + +interface EntryProps { + primary: string; + secondary?: string; + statusLabel: string; + statusTone: "success" | "muted" | "error"; + detail?: string; +} + +function Entry({ + primary, + secondary, + statusLabel, + statusTone, + detail, +}: EntryProps) { + return ( + <div className="flex min-w-0 items-center justify-between gap-3 py-1"> + <div className="flex min-w-0 flex-1 flex-col"> + <span className="truncate text-xs font-medium text-foreground"> + {primary} + </span> + {secondary && ( + <span className="truncate font-mono text-[10px] text-muted-foreground"> + {secondary} + </span> + )} + {detail && ( + <span className="truncate text-[10px] text-muted-foreground"> + {detail} + </span> + )} + </div> + <span + className={cn( + "shrink-0 text-[10px] font-medium uppercase tracking-wide", + statusTone === "success" && "text-emerald-600 dark:text-emerald-400", + statusTone === "muted" && "text-muted-foreground", + statusTone === "error" && "text-destructive", + )} + > + {statusLabel} + </span> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/index.ts new file mode 100644 index 00000000000..6e5171b5f5e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1MigrationSummaryModal/index.ts @@ -0,0 +1 @@ +export { V1MigrationSummaryModal } from "./V1MigrationSummaryModal"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx new file mode 100644 index 00000000000..0978c0e23b3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/V2NotificationController.tsx @@ -0,0 +1,142 @@ +import type { WorkspaceState } from "@superset/panes"; +import { buildHostRoutingKey } from "@superset/shared/host-routing"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { + HostNotificationSubscriber, + type HostNotificationWorkspaceState, +} from "./components/HostNotificationSubscriber"; + +interface WorkspaceHostRow { + workspaceId: string; + organizationId: string; + hostId: string; +} + +interface HostNotificationSubscriberGroup { + hostUrl: string; + workspaces: HostNotificationWorkspaceState[]; +} + +/** + * Mounts one v2 notification listener per host-service URL so backgrounded + * workspaces update their sidebar status indicator and play the finish sound. + * Sibling to `AgentHooks`; rendered at the authenticated layout level. + * + * A host subscriber subscribes with workspaceId `*` and filters against the + * workspaces assigned to that host. This keeps the topology O(1 listener per + * host), not O(1 listener and settings observer per workspace). + */ +export function V2NotificationController() { + const collections = useCollections(); + const { machineId, activeHostUrl } = useLocalHostService(); + const { data: workspaceHosts = [] } = useLiveQuery( + (q) => + q + .from({ v2Workspaces: collections.v2Workspaces }) + .select(({ v2Workspaces }) => ({ + workspaceId: v2Workspaces.id, + organizationId: v2Workspaces.organizationId, + hostId: v2Workspaces.hostId, + })), + [collections], + ); + const { data: localWorkspaceRows = [] } = useLiveQuery( + (q) => + q + .from({ v2WorkspaceLocalState: collections.v2WorkspaceLocalState }) + .select(({ v2WorkspaceLocalState }) => ({ + workspaceId: v2WorkspaceLocalState.workspaceId, + paneLayout: v2WorkspaceLocalState.paneLayout, + })), + [collections], + ); + const hostGroups = useMemo( + () => + groupWorkspacesByHostUrl({ + workspaceHosts, + localWorkspaceRows, + machineId, + activeHostUrl, + }), + [workspaceHosts, localWorkspaceRows, machineId, activeHostUrl], + ); + + return ( + <> + {hostGroups.map((group) => ( + <HostNotificationSubscriber + key={group.hostUrl} + hostUrl={group.hostUrl} + workspaces={group.workspaces} + /> + ))} + </> + ); +} + +function groupWorkspacesByHostUrl({ + workspaceHosts, + localWorkspaceRows, + machineId, + activeHostUrl, +}: { + workspaceHosts: WorkspaceHostRow[]; + localWorkspaceRows: Array<{ + workspaceId: string; + paneLayout: unknown; + }>; + machineId: string | null; + activeHostUrl: string | null; +}): HostNotificationSubscriberGroup[] { + const paneLayoutsByWorkspaceId = new Map( + localWorkspaceRows.map((row) => [ + row.workspaceId, + row.paneLayout as WorkspaceState<PaneViewerData>, + ]), + ); + const groups = new Map<string, HostNotificationWorkspaceState[]>(); + + for (const workspace of workspaceHosts) { + const hostUrl = getHostUrlForWorkspace({ + organizationId: workspace.organizationId, + hostId: workspace.hostId, + machineId, + activeHostUrl, + }); + if (!hostUrl) continue; + + const group = groups.get(hostUrl) ?? []; + group.push({ + workspaceId: workspace.workspaceId, + paneLayout: paneLayoutsByWorkspaceId.get(workspace.workspaceId) ?? null, + }); + groups.set(hostUrl, group); + } + + return [...groups.entries()].map(([hostUrl, workspaces]) => ({ + hostUrl, + workspaces, + })); +} + +function getHostUrlForWorkspace({ + organizationId, + hostId, + machineId, + activeHostUrl, +}: { + organizationId: string; + hostId: string; + machineId: string | null; + activeHostUrl: string | null; +}): string | null { + if (machineId && hostId === machineId) { + return activeHostUrl; + } + return `${env.RELAY_URL}/hosts/${buildHostRoutingKey(organizationId, hostId)}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx new file mode 100644 index 00000000000..f8e7cbdf85e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx @@ -0,0 +1,87 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { + AgentLifecyclePayload, + TerminalLifecyclePayload, +} from "@superset/workspace-client"; +import { getEventBus } from "@superset/workspace-client"; +import { useEffect, useEffectEvent, useMemo } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { + handleV2AgentLifecycleEvent, + handleV2TerminalLifecycleEvent, +} from "../../lib/lifecycleEvents"; + +export interface HostNotificationWorkspaceState { + workspaceId: string; + paneLayout: WorkspaceState<PaneViewerData> | null; +} + +export function HostNotificationSubscriber({ + hostUrl, + workspaces, +}: { + hostUrl: string; + workspaces: HostNotificationWorkspaceState[]; +}): null { + const { data: volume = 100 } = + electronTrpc.settings.getNotificationVolume.useQuery(); + const { data: muted = false } = + electronTrpc.settings.getNotificationSoundsMuted.useQuery(); + const workspacesById = useMemo( + () => + new Map( + workspaces.map((workspace) => [workspace.workspaceId, workspace]), + ), + [workspaces], + ); + + const handleAgentLifecycle = useEffectEvent( + (workspaceId: string, payload: AgentLifecyclePayload) => { + const workspace = workspacesById.get(workspaceId); + if (!workspace) return; + handleV2AgentLifecycleEvent({ + workspaceId, + payload, + paneLayout: workspace.paneLayout, + volume, + muted, + }); + }, + ); + + const handleTerminalLifecycle = useEffectEvent( + (workspaceId: string, payload: TerminalLifecyclePayload) => { + const workspace = workspacesById.get(workspaceId); + if (!workspace) return; + handleV2TerminalLifecycleEvent({ + workspaceId, + payload, + }); + }, + ); + + useEffect(() => { + const bus = getEventBus(hostUrl, () => getHostServiceWsToken(hostUrl)); + const removeAgentListener = bus.on( + "agent:lifecycle", + "*", + handleAgentLifecycle, + ); + const removeTerminalListener = bus.on( + "terminal:lifecycle", + "*", + handleTerminalLifecycle, + ); + const release = bus.retain(); + + return () => { + removeAgentListener(); + removeTerminalListener(); + release(); + }; + }, [hostUrl]); + + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/index.ts new file mode 100644 index 00000000000..f765167566a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/index.ts @@ -0,0 +1,2 @@ +export type { HostNotificationWorkspaceState } from "./HostNotificationSubscriber"; +export { HostNotificationSubscriber } from "./HostNotificationSubscriber"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/index.ts new file mode 100644 index 00000000000..53c2fd5468a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/index.ts @@ -0,0 +1 @@ +export { V2NotificationController } from "./V2NotificationController"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts new file mode 100644 index 00000000000..23fafcd9f70 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/lifecycleEvents.ts @@ -0,0 +1,187 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { + AgentLifecyclePayload, + TerminalLifecyclePayload, +} from "@superset/workspace-client"; +import { playRingtone } from "renderer/lib/ringtones/play"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { useRingtoneStore } from "renderer/stores/ringtone"; +import { + getV2TerminalNotificationSource, + useV2NotificationStore, + type V2NotificationSourceInput, +} from "renderer/stores/v2-notifications"; +import { + isV2NotificationTargetVisible, + resolveV2NotificationTarget, + type V2NotificationTarget, +} from "./resolveV2NotificationTarget"; +import { resolveV2AgentStatusTransition } from "./statusTransitions"; + +/** + * Handles v2 lifecycle events received by V2NotificationController. Updates + * pane status indicators (working/review/permission/idle) and plays the + * selected ringtone in the renderer. + * + * Mirrors the v1 electron-main playback path + * (apps/desktop/src/main/lib/notifications/notification-manager.ts) plus the + * v1 sidebar-status path (renderer/stores/tabs/useAgentHookListener.ts), but + * runs client-side so it works when host-service is off-machine. + * + * Keeps v1 behavior: skip `Start` for sound, suppress when the event's + * pane is visible and the window is focused, and honor the existing + * mute/volume settings. + */ +export function handleV2AgentLifecycleEvent({ + workspaceId, + payload, + paneLayout, + volume, + muted, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + paneLayout: WorkspaceState<PaneViewerData> | null | undefined; + volume: number; + muted: boolean; +}): void { + const target = resolveV2NotificationTarget({ + workspaceId, + payload, + paneLayout, + }); + updatePaneStatus(workspaceId, payload, target, paneLayout); + + if (payload.eventType === "Start") return; + if (shouldSuppress(target, paneLayout)) return; + + const ringtoneId = useRingtoneStore.getState().selectedRingtoneId; + void playRingtone({ ringtoneId, volume, muted }); + + showNativeNotification({ payload, workspaceId, target }); +} + +export function handleV2TerminalLifecycleEvent({ + workspaceId, + payload, +}: { + workspaceId: string; + payload: TerminalLifecyclePayload; +}): void { + if (payload.eventType !== "exit") return; + clearSources(workspaceId, [ + getV2TerminalNotificationSource(payload.terminalId), + ]); +} + +/** + * Writes agent-lifecycle status into the v2 notification store so workspace, + * tab, and pane UI can derive attention from the same terminal source. + * + * The Stop transition mirrors v1 (useAgentHookListener.ts), but uses the v2 + * pane layout instead of workspace-level guessing: clear to idle when the + * exact target pane is visible, otherwise mark review so the sidebar surfaces + * it. + */ +function updatePaneStatus( + workspaceId: string, + payload: AgentLifecyclePayload, + target: V2NotificationTarget, + paneLayout: WorkspaceState<PaneViewerData> | null | undefined, +): void { + const store = useV2NotificationStore.getState(); + const targetVisible = isV2NotificationTargetVisible({ + currentWorkspaceId: getCurrentWorkspaceId(), + paneLayout, + target, + }); + const transition = resolveV2AgentStatusTransition({ + workspaceId, + payload, + statuses: store.sources, + targetVisible, + }); + + clearSources(workspaceId, transition.clearSources); + if (transition.setStatus) { + store.setSourceStatus( + transition.setStatus.source, + workspaceId, + transition.setStatus.status, + payload.occurredAt, + ); + } +} + +function getCurrentWorkspaceId(): string | null { + try { + // Matches both v1 `/workspace/<id>` and v2 `/v2-workspace/<id>` + // routes. Notifications are layout-level, so either can be active + // while an event arrives. + const match = window.location.hash.match(/\/(?:v2-)?workspace\/([^/?#]+)/); + return match ? decodeURIComponent(match[1] ?? "") : null; + } catch { + return null; + } +} + +function shouldSuppress( + target: V2NotificationTarget, + paneLayout: WorkspaceState<PaneViewerData> | null | undefined, +): boolean { + if (typeof document !== "undefined" && document.hidden) return false; + if (typeof window !== "undefined" && !document.hasFocus()) return false; + + return isV2NotificationTargetVisible({ + currentWorkspaceId: getCurrentWorkspaceId(), + paneLayout, + target, + }); +} + +function showNativeNotification({ + payload, + workspaceId, + target, +}: { + payload: AgentLifecyclePayload; + workspaceId: string; + target: V2NotificationTarget; +}): void { + const isPermission = payload.eventType === "PermissionRequest"; + const title = isPermission ? "Awaiting Response" : "Agent Complete"; + const body = isPermission + ? "Your agent needs input" + : "Your agent has finished"; + + void electronTrpcClient.notifications.showNative + .mutate({ + title, + body, + silent: true, + clickTarget: { + workspaceId, + source: { type: "terminal", id: target.terminalId }, + }, + }) + .catch((error) => { + console.warn( + "[notifications] failed to show native notification:", + error, + ); + }); +} + +function clearSources( + workspaceId: string, + sources: Array<V2NotificationSourceInput | null | undefined>, +): void { + const store = useV2NotificationStore.getState(); + store.clearSourceStatuses( + sources.filter((source): source is V2NotificationSourceInput => + Boolean(source), + ), + workspaceId, + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts new file mode 100644 index 00000000000..2dffbbd62e6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "bun:test"; +import type { WorkspaceState } from "@superset/panes"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { + isV2NotificationTargetVisible, + resolveTerminalTarget, + resolveV2NotificationTarget, +} from "./resolveV2NotificationTarget"; + +const WORKSPACE_ID = "workspace-1"; + +const layout: WorkspaceState<PaneViewerData> = { + version: 1, + activeTabId: "tab-active", + tabs: [ + { + id: "tab-active", + createdAt: 1, + activePaneId: "pane-terminal", + layout: { type: "pane", paneId: "pane-terminal" }, + panes: { + "pane-terminal": { + id: "pane-terminal", + kind: "terminal", + data: { terminalId: "terminal-1" }, + }, + "pane-terminal-hidden": { + id: "pane-terminal-hidden", + kind: "terminal", + data: { terminalId: "terminal-hidden" }, + }, + }, + }, + { + id: "tab-background", + createdAt: 2, + activePaneId: "pane-terminal-background", + layout: { type: "pane", paneId: "pane-terminal-background" }, + panes: { + "pane-terminal-background": { + id: "pane-terminal-background", + kind: "terminal", + data: { terminalId: "terminal-2" }, + }, + }, + }, + ], +}; + +function payload( + overrides: Partial<AgentLifecyclePayload>, +): AgentLifecyclePayload { + return { + eventType: "Stop", + terminalId: "terminal-1", + occurredAt: 1, + ...overrides, + }; +} + +describe("resolveV2NotificationTarget", () => { + it("uses terminal ids to find the owning v2 pane", () => { + const target = resolveV2NotificationTarget({ + workspaceId: WORKSPACE_ID, + payload: payload({ terminalId: "terminal-1" }), + paneLayout: layout, + }); + + expect(target).toMatchObject({ + workspaceId: WORKSPACE_ID, + tabId: "tab-active", + paneId: "pane-terminal", + terminalId: "terminal-1", + }); + }); + + it("falls back to a terminal-only target when no pane matches", () => { + const target = resolveV2NotificationTarget({ + workspaceId: WORKSPACE_ID, + payload: payload({ terminalId: "terminal-missing" }), + paneLayout: layout, + }); + + expect(target).toEqual({ + workspaceId: WORKSPACE_ID, + terminalId: "terminal-missing", + }); + }); + + it("only reports visible for the active tab and active pane", () => { + const terminalTarget = resolveTerminalTarget({ + workspaceId: WORKSPACE_ID, + terminalId: "terminal-1", + paneLayout: layout, + }); + const backgroundTarget = resolveV2NotificationTarget({ + workspaceId: WORKSPACE_ID, + payload: payload({ terminalId: "terminal-2" }), + paneLayout: layout, + }); + + expect(terminalTarget).not.toBeNull(); + if (!terminalTarget) return; + + expect( + isV2NotificationTargetVisible({ + currentWorkspaceId: WORKSPACE_ID, + paneLayout: layout, + target: terminalTarget, + }), + ).toBe(true); + expect( + isV2NotificationTargetVisible({ + currentWorkspaceId: WORKSPACE_ID, + paneLayout: layout, + target: backgroundTarget, + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts new file mode 100644 index 00000000000..f520ed68270 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/resolveV2NotificationTarget.ts @@ -0,0 +1,84 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; + +export interface V2NotificationTarget { + workspaceId: string; + tabId?: string; + paneId?: string; + terminalId: string; +} + +export function resolveV2NotificationTarget({ + workspaceId, + payload, + paneLayout, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + paneLayout: WorkspaceState<PaneViewerData> | null | undefined; +}): V2NotificationTarget { + return ( + resolveTerminalTarget({ + workspaceId, + terminalId: payload.terminalId, + paneLayout, + }) ?? { + workspaceId, + terminalId: payload.terminalId, + } + ); +} + +export function resolveTerminalTarget({ + workspaceId, + terminalId, + paneLayout, +}: { + workspaceId: string; + terminalId: string; + paneLayout: WorkspaceState<PaneViewerData> | null | undefined; +}): V2NotificationTarget | null { + if (!paneLayout?.tabs) return null; + + for (const tab of paneLayout.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "terminal") continue; + const data = pane.data as Partial<TerminalPaneData>; + if (data.terminalId !== terminalId) continue; + return { + workspaceId, + tabId: tab.id, + paneId: pane.id, + terminalId, + }; + } + } + + return null; +} + +export function isV2NotificationTargetVisible({ + currentWorkspaceId, + paneLayout, + target, +}: { + currentWorkspaceId: string | null; + paneLayout: WorkspaceState<PaneViewerData> | null | undefined; + target: V2NotificationTarget; +}): boolean { + if (!currentWorkspaceId || currentWorkspaceId !== target.workspaceId) { + return false; + } + if (!target.tabId || !target.paneId || !paneLayout?.tabs) return false; + + const tab = paneLayout.tabs.find( + (candidate) => candidate.id === target.tabId, + ); + return ( + tab?.activePaneId === target.paneId && paneLayout.activeTabId === tab.id + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts new file mode 100644 index 00000000000..c00073f6814 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import { resolveV2AgentStatusTransition } from "./statusTransitions"; + +const WORKSPACE_ID = "workspace-1"; + +function payload( + overrides: Partial<AgentLifecyclePayload>, +): AgentLifecyclePayload { + return { + eventType: "Stop", + terminalId: "terminal-1", + occurredAt: 1, + ...overrides, + }; +} + +describe("resolveV2AgentStatusTransition", () => { + it("marks start as working on the terminal source", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ + eventType: "Start", + terminalId: "terminal-1", + }), + statuses: {}, + targetVisible: false, + }), + ).toEqual({ + clearSources: [], + setStatus: { + source: { type: "terminal", id: "terminal-1" }, + status: "working", + }, + }); + }); + + it("clears permission state on stop", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ + eventType: "Stop", + terminalId: "terminal-1", + }), + statuses: { + "terminal:terminal-1": { + workspaceId: WORKSPACE_ID, + status: "permission", + }, + }, + targetVisible: false, + }), + ).toEqual({ + clearSources: [{ type: "terminal", id: "terminal-1" }], + setStatus: null, + }); + }); + + it("clears stop when the exact target pane is visible", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), + statuses: {}, + targetVisible: true, + }), + ).toEqual({ + clearSources: [{ type: "terminal", id: "terminal-1" }], + setStatus: null, + }); + }); + + it("marks background stop as review", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), + statuses: {}, + targetVisible: false, + }), + ).toEqual({ + clearSources: [], + setStatus: { + source: { type: "terminal", id: "terminal-1" }, + status: "review", + }, + }); + }); + + it("ignores permission state from a different workspace", () => { + expect( + resolveV2AgentStatusTransition({ + workspaceId: WORKSPACE_ID, + payload: payload({ eventType: "Stop", terminalId: "terminal-1" }), + statuses: { + "terminal:terminal-1": { + workspaceId: "workspace-2", + status: "permission", + }, + }, + targetVisible: false, + }), + ).toEqual({ + clearSources: [], + setStatus: { + source: { type: "terminal", id: "terminal-1" }, + status: "review", + }, + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts new file mode 100644 index 00000000000..d6c9d614947 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/lib/statusTransitions.ts @@ -0,0 +1,59 @@ +import type { AgentLifecyclePayload } from "@superset/workspace-client"; +import { + getV2NotificationSourceKey, + getV2TerminalNotificationSource, + type V2NotificationSource, + type V2NotificationSourceInput, +} from "renderer/stores/v2-notifications"; +import type { ActivePaneStatus, PaneStatus } from "shared/tabs-types"; + +interface StatusEntry { + workspaceId: string; + status: PaneStatus; +} + +export interface V2AgentStatusTransition { + clearSources: V2NotificationSourceInput[]; + setStatus: { source: V2NotificationSource; status: ActivePaneStatus } | null; +} + +export function resolveV2AgentStatusTransition({ + workspaceId, + payload, + statuses, + targetVisible, +}: { + workspaceId: string; + payload: AgentLifecyclePayload; + statuses: Record<string, StatusEntry | undefined>; + targetVisible: boolean; +}): V2AgentStatusTransition { + const terminalSource = getV2TerminalNotificationSource(payload.terminalId); + const terminalSourceKey = getV2NotificationSourceKey(terminalSource); + + if (payload.eventType === "Start") { + return { + clearSources: [], + setStatus: { source: terminalSource, status: "working" }, + }; + } + + if (payload.eventType === "PermissionRequest") { + return { + clearSources: [], + setStatus: { source: terminalSource, status: "permission" }, + }; + } + + const entry = statuses[terminalSourceKey]; + const wasAwaitingPermission = + entry?.workspaceId === workspaceId && entry.status === "permission"; + if (wasAwaitingPermission || targetVisible) { + return { clearSources: [terminalSource], setStatus: null }; + } + + return { + clearSources: [], + setStatus: { source: terminalSource, status: "review" }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts new file mode 100644 index 00000000000..bd0002dcfd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts @@ -0,0 +1,8 @@ +export { + extractPaneIds, + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, + type RemovedPaneLocation, +} from "./paneLifecycleRows"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts new file mode 100644 index 00000000000..2f87536559a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { + extractPaneIds, + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "./paneLifecycleRows"; + +function row( + workspaceId: string, + panes: Record<string, unknown>, +): PaneLifecycleRow { + return { + workspaceId, + paneLayout: { + tabs: [ + { + id: `${workspaceId}-tab`, + title: "Tab", + panes, + layout: null, + activePaneId: null, + }, + ], + activeTabId: `${workspaceId}-tab`, + }, + }; +} + +function terminalPane(id: string) { + return { + id: `pane-${id}`, + kind: "terminal", + data: { terminalId: id }, + }; +} + +function terminalIdForPane(pane: { + kind: string; + data: unknown; +}): string | null { + if (pane.kind !== "terminal") return null; + const data = pane.data as { terminalId?: unknown }; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +describe("paneLifecycleRows", () => { + test("extracts workspace IDs and tracked pane locations", () => { + const rows = [ + row("workspace-a", { + "pane-term-1": terminalPane("term-1"), + "pane-file-1": { id: "pane-file-1", kind: "file", data: {} }, + }), + row("workspace-b", { + "pane-term-2": terminalPane("term-2"), + }), + ]; + + expect([...extractWorkspaceIds(rows)]).toEqual([ + "workspace-a", + "workspace-b", + ]); + expect([ + ...extractPaneLocations(rows, terminalIdForPane).entries(), + ]).toEqual([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + expect([...extractPaneIds(rows, terminalIdForPane)]).toEqual([ + "term-1", + "term-2", + ]); + }); + + test("marks a pane removed only when its owner workspace row is present", () => { + const previousLocations = new Map([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + const currentLocations = new Map([["term-2", "workspace-b"]]); + const currentWorkspaceIds = new Set(["workspace-a", "workspace-b"]); + + expect( + getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, + }), + ).toEqual([{ id: "term-1", workspaceId: "workspace-a" }]); + }); + + test("ignores panes whose owner workspace row disappeared", () => { + const previousLocations = new Map([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + const currentLocations = new Map([["term-2", "workspace-b"]]); + const currentWorkspaceIds = new Set(["workspace-b"]); + + expect( + getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, + }), + ).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts new file mode 100644 index 00000000000..e5873152b24 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts @@ -0,0 +1,93 @@ +import type { Pane, WorkspaceState } from "@superset/panes"; + +export interface PaneLifecycleRow { + workspaceId: unknown; + paneLayout: unknown; +} + +export interface RemovedPaneLocation { + id: string; + workspaceId: string; +} + +export function extractWorkspaceIds(rows: PaneLifecycleRow[]): Set<string> { + const workspaceIds = new Set<string>(); + for (const row of rows) { + if (typeof row.workspaceId === "string") { + workspaceIds.add(row.workspaceId); + } + } + return workspaceIds; +} + +export function extractPaneLocations( + rows: PaneLifecycleRow[], + getTrackedPaneId: (pane: Pane<unknown>) => string | null, +): Map<string, string> { + const locations = new Map<string, string>(); + + for (const row of rows) { + if (typeof row.workspaceId !== "string") continue; + + const layout = row.paneLayout as WorkspaceState<unknown> | undefined; + if (!layout?.tabs) continue; + + for (const tab of layout.tabs) { + for (const pane of Object.values(tab.panes)) { + const trackedPaneId = getTrackedPaneId(pane); + if (trackedPaneId) { + locations.set(trackedPaneId, row.workspaceId); + } + } + } + } + + return locations; +} + +export function extractPaneIds( + rows: PaneLifecycleRow[], + getTrackedPaneId: (pane: Pane<unknown>) => string | null, +): Set<string> { + const ids = new Set<string>(); + + for (const row of rows) { + const layout = row.paneLayout as WorkspaceState<unknown> | undefined; + if (!layout?.tabs) continue; + + for (const tab of layout.tabs) { + for (const pane of Object.values(tab.panes)) { + const trackedPaneId = getTrackedPaneId(pane); + if (trackedPaneId) { + ids.add(trackedPaneId); + } + } + } + } + + return ids; +} + +export function getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, +}: { + previousLocations: Map<string, string>; + currentLocations: Map<string, string>; + currentWorkspaceIds: Set<string>; +}): RemovedPaneLocation[] { + const removed: RemovedPaneLocation[] = []; + + for (const [id, workspaceId] of previousLocations) { + if (currentLocations.has(id)) continue; + // A missing owner row means the collection snapshot is not authoritative + // for this pane. This happens during org/provider churn and can happen + // briefly after laptop sleep/wake. Intentional sidebar-row removals clean + // up their pane runtimes before deleting the row. + if (!currentWorkspaceIds.has(workspaceId)) continue; + removed.push({ id, workspaceId }); + } + + return removed; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 45dce8cf6ea..108ea0afb1f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -1,7 +1,15 @@ -import { createPaneWorkspaceState } from "@superset/pane-layout"; +import type { Pane, WorkspaceState } from "@superset/panes"; import { useCallback } from "react"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; +import { + extractPaneIds, + type PaneLifecycleRow, +} from "renderer/routes/_authenticated/components/utils/paneLifecycleRows"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; +import { isSidebarWorkspaceVisible } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import { PROJECT_CUSTOM_COLORS } from "shared/constants/project-colors"; function getNextTabOrder(items: Array<{ tabOrder: number }>): number { const maxTabOrder = items.reduce( @@ -11,6 +19,111 @@ function getNextTabOrder(items: Array<{ tabOrder: number }>): number { return maxTabOrder + 1; } +function getPrependTabOrder(items: Array<{ tabOrder: number }>): number { + if (items.length === 0) return 1; + const minTabOrder = items.reduce( + (minValue, item) => Math.min(minValue, item.tabOrder), + Number.POSITIVE_INFINITY, + ); + return minTabOrder - 1; +} + +type ProjectTopLevelItem = { + type: "workspace" | "section"; + id: string; + tabOrder: number; +}; + +type ProjectTopLevelCollections = Pick< + AppCollections, + "v2SidebarSections" | "v2WorkspaceLocalState" +>; + +function compareProjectTopLevelItems( + left: ProjectTopLevelItem, + right: ProjectTopLevelItem, +): number { + const orderDelta = left.tabOrder - right.tabOrder; + if (orderDelta !== 0) return orderDelta; + if (left.type === right.type) return 0; + return left.type === "section" ? -1 : 1; +} + +function getProjectTopLevelItems( + collections: ProjectTopLevelCollections, + projectId: string, + options: { excludeWorkspaceId?: string; excludeSectionId?: string } = {}, +): ProjectTopLevelItem[] { + return [ + ...Array.from(collections.v2WorkspaceLocalState.state.values()) + .filter( + (item) => + item.sidebarState.projectId === projectId && + isSidebarWorkspaceVisible(item) && + item.sidebarState.sectionId === null && + item.workspaceId !== options.excludeWorkspaceId, + ) + .map((item) => ({ + type: "workspace" as const, + id: item.workspaceId, + tabOrder: item.sidebarState.tabOrder, + })), + ...Array.from(collections.v2SidebarSections.state.values()) + .filter( + (item) => + item.projectId === projectId && + item.sectionId !== options.excludeSectionId, + ) + .map((item) => ({ + type: "section" as const, + id: item.sectionId, + tabOrder: item.tabOrder, + })), + ].sort(compareProjectTopLevelItems); +} + +function getFirstSectionIndex(items: ProjectTopLevelItem[]): number { + const firstSectionIndex = items.findIndex((item) => item.type === "section"); + return firstSectionIndex === -1 ? items.length : firstSectionIndex; +} + +function createEmptyPaneLayout(): WorkspaceState<unknown> { + return { + version: 1, + tabs: [], + activeTabId: null, + } satisfies WorkspaceState<unknown>; +} + +/** + * Rewrites the flat top-level project lane. Workspace items are explicitly + * ungrouped by setting sidebarState.projectId and clearing sidebarState.sectionId. + */ +function writeProjectTopLevelOrder( + collections: ProjectTopLevelCollections, + projectId: string, + items: ProjectTopLevelItem[], +): void { + items.forEach((item, index) => { + const tabOrder = index + 1; + if (item.type === "workspace") { + if (!collections.v2WorkspaceLocalState.get(item.id)) return; + collections.v2WorkspaceLocalState.update(item.id, (draft) => { + draft.sidebarState.projectId = projectId; + draft.sidebarState.sectionId = null; + draft.sidebarState.tabOrder = tabOrder; + draft.sidebarState.isHidden = false; + }); + return; + } + + if (!collections.v2SidebarSections.get(item.id)) return; + collections.v2SidebarSections.update(item.id, (draft) => { + draft.tabOrder = tabOrder; + }); + }); +} + function ensureSidebarProjectRecord( collections: Pick<AppCollections, "v2SidebarProjects">, projectId: string, @@ -37,35 +150,56 @@ function ensureSidebarWorkspaceRecord( workspaceId: string, projectId: string, ): void { - if (collections.v2WorkspaceLocalState.get(workspaceId)) { + const existing = collections.v2WorkspaceLocalState.get(workspaceId); + if (existing && isSidebarWorkspaceVisible(existing)) { return; } - const topLevelOrders = [ - ...Array.from(collections.v2WorkspaceLocalState.state.values()) - .filter( - (item) => - item.sidebarState.projectId === projectId && - item.sidebarState.sectionId === null, - ) - .map((item) => ({ tabOrder: item.sidebarState.tabOrder })), - ...Array.from(collections.v2SidebarSections.state.values()).filter( - (item) => item.projectId === projectId, - ), - ]; + const topLevelItems = getProjectTopLevelItems(collections, projectId); + + if (existing) { + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.projectId = projectId; + draft.sidebarState.tabOrder = getPrependTabOrder(topLevelItems); + draft.sidebarState.sectionId = null; + draft.sidebarState.isHidden = false; + }); + return; + } collections.v2WorkspaceLocalState.insert({ workspaceId, createdAt: new Date(), sidebarState: { projectId, - tabOrder: getNextTabOrder(topLevelOrders), + tabOrder: getPrependTabOrder(topLevelItems), sectionId: null, + isHidden: false, }, - paneLayout: createPaneWorkspaceState({ roots: [] }), + paneLayout: createEmptyPaneLayout(), }); } +function getTerminalRuntimeId(pane: Pane<unknown>): string | null { + if (pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const data = pane.data as { terminalId?: unknown }; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +function getBrowserRuntimeId(pane: Pane<unknown>): string | null { + return pane.kind === "browser" ? pane.id : null; +} + +function cleanupWorkspacePaneRuntimes(rows: PaneLifecycleRow[]): void { + for (const terminalId of extractPaneIds(rows, getTerminalRuntimeId)) { + terminalRuntimeRegistry.release(terminalId); + } + for (const browserId of extractPaneIds(rows, getBrowserRuntimeId)) { + browserRuntimeRegistry.destroy(browserId); + } +} + export function useDashboardSidebarState() { const collections = useCollections(); @@ -113,6 +247,67 @@ export function useDashboardSidebarState() { if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { draft.sidebarState.tabOrder = index + 1; + draft.sidebarState.isHidden = false; + }); + }); + }, + [collections], + ); + + const reorderProjectChildren = useCallback( + ( + projectId: string, + orderedItems: Array<{ type: "workspace" | "section"; id: string }>, + ) => { + orderedItems.forEach((item, index) => { + const tabOrder = index + 1; + if (item.type === "workspace") { + if (!collections.v2WorkspaceLocalState.get(item.id)) return; + collections.v2WorkspaceLocalState.update(item.id, (draft) => { + draft.sidebarState.tabOrder = tabOrder; + draft.sidebarState.sectionId = null; + draft.sidebarState.projectId = projectId; + draft.sidebarState.isHidden = false; + }); + } else { + if (!collections.v2SidebarSections.get(item.id)) return; + collections.v2SidebarSections.update(item.id, (draft) => { + draft.tabOrder = tabOrder; + }); + } + }); + }, + [collections], + ); + + const moveWorkspaceToSectionAtIndex = useCallback( + ( + workspaceId: string, + projectId: string, + sectionId: string, + index: number, + ) => { + const existing = collections.v2WorkspaceLocalState.get(workspaceId); + if (!existing) return; + const siblings = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) + .filter( + (item) => + item.sidebarState.projectId === projectId && + isSidebarWorkspaceVisible(item) && + item.workspaceId !== workspaceId && + item.sidebarState.sectionId === sectionId, + ) + .sort((a, b) => a.sidebarState.tabOrder - b.sidebarState.tabOrder); + const reordered = [...siblings]; + reordered.splice(index, 0, existing); + reordered.forEach((item, i) => { + collections.v2WorkspaceLocalState.update(item.workspaceId, (draft) => { + draft.sidebarState.tabOrder = i + 1; + draft.sidebarState.sectionId = sectionId; + draft.sidebarState.projectId = projectId; + draft.sidebarState.isHidden = false; }); }); }, @@ -120,22 +315,28 @@ export function useDashboardSidebarState() { ); const createSection = useCallback( - (projectId: string, name = "New Section") => { + (projectId: string, options: { name?: string } = {}) => { + const { name = "New group" } = options; ensureSidebarProjectRecord(collections, projectId); const sectionId = crypto.randomUUID(); - const sectionOrders = Array.from( - collections.v2SidebarSections.state.values(), - ).filter((item) => item.projectId === projectId); + const randomColor = + PROJECT_CUSTOM_COLORS[ + Math.floor(Math.random() * PROJECT_CUSTOM_COLORS.length) + ].value; + + const tabOrder = getNextTabOrder( + getProjectTopLevelItems(collections, projectId), + ); collections.v2SidebarSections.insert({ sectionId, projectId, name, createdAt: new Date(), - tabOrder: getNextTabOrder(sectionOrders), + tabOrder, isCollapsed: false, - color: null, + color: randomColor, }); return sectionId; @@ -178,20 +379,37 @@ export function useDashboardSidebarState() { const existing = collections.v2WorkspaceLocalState.get(workspaceId); if (!existing) return; + if (sectionId === null) { + const topLevelItems = getProjectTopLevelItems(collections, projectId, { + excludeWorkspaceId: workspaceId, + }); + const insertIndex = getFirstSectionIndex(topLevelItems); + topLevelItems.splice(insertIndex, 0, { + type: "workspace", + id: workspaceId, + tabOrder: 0, + }); + writeProjectTopLevelOrder(collections, projectId, topLevelItems); + return; + } + const siblingRows = Array.from( collections.v2WorkspaceLocalState.state.values(), ) .filter( (item) => item.sidebarState.projectId === projectId && + isSidebarWorkspaceVisible(item) && item.workspaceId !== workspaceId && item.sidebarState.sectionId === sectionId, ) .map((item) => ({ tabOrder: item.sidebarState.tabOrder })); collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.projectId = projectId; draft.sidebarState.sectionId = sectionId; draft.sidebarState.tabOrder = getNextTabOrder(siblingRows); + draft.sidebarState.isHidden = false; }); }, [collections], @@ -202,28 +420,36 @@ export function useDashboardSidebarState() { const section = collections.v2SidebarSections.get(sectionId); if (!section) return; - const siblingTopLevelRows = Array.from( + const topLevelItems = getProjectTopLevelItems( + collections, + section.projectId, + { excludeSectionId: sectionId }, + ); + const sectionWorkspaces = Array.from( collections.v2WorkspaceLocalState.state.values(), ) .filter( (item) => item.sidebarState.projectId === section.projectId && - item.sidebarState.sectionId === null, + isSidebarWorkspaceVisible(item) && + item.sidebarState.sectionId === sectionId, ) - .map((item) => ({ tabOrder: item.sidebarState.tabOrder })); - - let nextOrder = getNextTabOrder(siblingTopLevelRows); - for (const workspace of collections.v2WorkspaceLocalState.state.values()) { - if (workspace.sidebarState.sectionId !== sectionId) continue; - collections.v2WorkspaceLocalState.update( - workspace.workspaceId, - (draft) => { - draft.sidebarState.sectionId = null; - draft.sidebarState.tabOrder = nextOrder; - }, + .sort( + (left, right) => + left.sidebarState.tabOrder - right.sidebarState.tabOrder, ); - nextOrder += 1; - } + + const insertIndex = getFirstSectionIndex(topLevelItems); + topLevelItems.splice( + insertIndex, + 0, + ...sectionWorkspaces.map((workspace) => ({ + type: "workspace" as const, + id: workspace.workspaceId, + tabOrder: 0, + })), + ); + writeProjectTopLevelOrder(collections, section.projectId, topLevelItems); collections.v2SidebarSections.delete(sectionId); }, @@ -232,19 +458,49 @@ export function useDashboardSidebarState() { const removeWorkspaceFromSidebar = useCallback( (workspaceId: string) => { - if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + const workspace = collections.v2WorkspaceLocalState.get(workspaceId); + if (!workspace) return; + cleanupWorkspacePaneRuntimes([workspace]); collections.v2WorkspaceLocalState.delete(workspaceId); }, [collections], ); + const hideWorkspaceInSidebar = useCallback( + (workspaceId: string, projectId: string) => { + const workspace = collections.v2WorkspaceLocalState.get(workspaceId); + if (!workspace) { + collections.v2WorkspaceLocalState.insert({ + workspaceId, + createdAt: new Date(), + sidebarState: { + projectId, + tabOrder: 0, + sectionId: null, + isHidden: true, + }, + paneLayout: createEmptyPaneLayout(), + }); + return; + } + + cleanupWorkspacePaneRuntimes([workspace]); + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.projectId = projectId; + draft.sidebarState.sectionId = null; + draft.sidebarState.isHidden = true; + draft.paneLayout = createEmptyPaneLayout(); + }); + }, + [collections], + ); + const removeProjectFromSidebar = useCallback( (projectId: string) => { - const workspaceIds = Array.from( + const workspaceRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ) - .filter((item) => item.sidebarState.projectId === projectId) - .map((item) => item.workspaceId); + ).filter((item) => item.sidebarState.projectId === projectId); + const workspaceIds = workspaceRows.map((item) => item.workspaceId); const sectionIds = Array.from( collections.v2SidebarSections.state.values(), ) @@ -252,6 +508,7 @@ export function useDashboardSidebarState() { .map((item) => item.sectionId); if (workspaceIds.length > 0) { + cleanupWorkspacePaneRuntimes(workspaceRows); collections.v2WorkspaceLocalState.delete(workspaceIds); } if (sectionIds.length > 0) { @@ -269,8 +526,11 @@ export function useDashboardSidebarState() { deleteSection, ensureProjectInSidebar, ensureWorkspaceInSidebar, + hideWorkspaceInSidebar, moveWorkspaceToSection, + moveWorkspaceToSectionAtIndex, removeProjectFromSidebar, + reorderProjectChildren, removeWorkspaceFromSidebar, reorderProjects, reorderWorkspaces, diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/index.ts new file mode 100644 index 00000000000..e8aea8edc06 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/index.ts @@ -0,0 +1 @@ +export { useDevSeedV2Sidebar } from "./useDevSeedV2Sidebar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts new file mode 100644 index 00000000000..485d3791dfb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDevSeedV2Sidebar/useDevSeedV2Sidebar.ts @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { env } from "renderer/env.renderer"; +import { useAccessibleV2Workspaces } from "renderer/routes/_authenticated/_dashboard/v2-workspaces/hooks/useAccessibleV2Workspaces"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +const SEED_FLAG_KEY = "superset:dev:v2-sidebar-seeded"; + +/** + * Auto-pins accessible v2 workspaces in dev so a fresh worktree's sidebar + * isn't blank. Chromium's localStorage is per-origin: the dev Vite origin + * (`http://localhost:<port>`) can't share data with the packaged `file://` + * origin, so copying prod's leveldb seeds the wrong namespace. We pin at + * runtime instead. The flag prevents re-pinning workspaces the user later + * unpins. + */ +export function useDevSeedV2Sidebar(): void { + const collections = useCollections(); + const { ensureWorkspaceInSidebar } = useDashboardSidebarState(); + const { all: accessibleWorkspaces } = useAccessibleV2Workspaces(); + + useEffect(() => { + if (env.NODE_ENV !== "development") return; + if (window.localStorage.getItem(SEED_FLAG_KEY) === "1") return; + if (accessibleWorkspaces.length === 0) return; + if (collections.v2WorkspaceLocalState.state.size > 0) { + window.localStorage.setItem(SEED_FLAG_KEY, "1"); + return; + } + + for (const workspace of accessibleWorkspaces) { + ensureWorkspaceInSidebar(workspace.id, workspace.projectId); + } + window.localStorage.setItem(SEED_FLAG_KEY, "1"); + }, [accessibleWorkspaces, collections, ensureWorkspaceInSidebar]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/index.ts new file mode 100644 index 00000000000..d3c07f2c7e3 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/index.ts @@ -0,0 +1,4 @@ +export { + useMigrateV1DataToV2, + V1_MIGRATION_SUMMARY_EVENT, +} from "./useMigrateV1DataToV2"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts new file mode 100644 index 00000000000..2c8eba026a2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts @@ -0,0 +1,1110 @@ +import { describe, expect, test } from "bun:test"; +import type { HostServiceClient } from "renderer/lib/host-service-client"; +import type { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { OrgCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; +import { migrateV1DataToV2 } from "./migrate"; + +type ElectronTrpcClient = typeof electronTrpcClient; + +interface V1ProjectRow { + id: string; + name: string; + mainRepoPath: string; + tabOrder: number | null; + defaultApp: string | null; +} + +interface V1WorkspaceRow { + id: string; + projectId: string; + worktreeId: string | null; + type: "branch" | "worktree"; + branch: string; + name: string; + sectionId: string | null; + tabOrder: number; +} + +interface V1WorktreeRow { + id: string; + path: string; + baseBranch?: string | null; +} + +interface V1SectionRow { + id: string; + projectId: string; + name: string; + tabOrder: number; + isCollapsed: boolean | null; + color: string | null; +} + +interface StateRow { + v1Id: string; + v2Id: string | null; + organizationId: string; + kind: "project" | "workspace"; + status: "success" | "linked" | "error" | "skipped"; + reason: string | null; +} + +type PathResponse = + | { candidates: Array<{ id: string }> } + | { err: "path-missing" }; + +interface FakeEnv { + v1Projects: V1ProjectRow[]; + v1Workspaces: V1WorkspaceRow[]; + v1Worktrees: V1WorktreeRow[]; + v1Sections: V1SectionRow[]; + state: Map<string, StateRow>; + findByPath: Map<string, PathResponse>; + failNextStateWriteFor: Set<string>; + hostProjectsByPath: Map<string, string>; + hostWorkspacesByKey: Map<string, string>; + setupThrowsFor: Set<string>; + createThrowsFor: Set<string>; + adoptThrowsFor: Map<string, { code: string; message?: string }>; + adoptThrowsForPath: Map<string, { code: string; message?: string }>; + createCalls: Array<{ name: string; repoPath: string }>; + createdProjectIds: string[]; + setupCalls: Array<{ projectId: string; repoPath?: string }>; + adoptCalls: Array<{ + projectId: string; + branch: string; + worktreePath?: string; + baseBranch?: string; + existingWorkspaceId?: string; + }>; + createdWorkspaceIds: string[]; +} + +function makeFakeEnv(overrides: Partial<FakeEnv> = {}): FakeEnv { + return { + v1Projects: [], + v1Workspaces: [], + v1Worktrees: [], + v1Sections: [], + state: new Map(), + findByPath: new Map(), + failNextStateWriteFor: new Set(), + hostProjectsByPath: new Map(), + hostWorkspacesByKey: new Map(), + setupThrowsFor: new Set(), + createThrowsFor: new Set(), + adoptThrowsFor: new Map(), + adoptThrowsForPath: new Map(), + createCalls: [], + createdProjectIds: [], + setupCalls: [], + adoptCalls: [], + createdWorkspaceIds: [], + ...overrides, + }; +} + +function trpcErr(code: string, message = code) { + return Object.assign(new Error(message), { data: { code } }); +} + +function makeElectronTrpc(env: FakeEnv): ElectronTrpcClient { + const createdV2s: string[] = []; + const hostProjects = new Set<string>(); + const hostWorkspaces = new Set<string>(); + void createdV2s; + void hostProjects; + void hostWorkspaces; + + const stub = { + migration: { + readV1Projects: { query: async () => env.v1Projects }, + readV1Workspaces: { query: async () => env.v1Workspaces }, + readV1Worktrees: { query: async () => env.v1Worktrees }, + readV1WorkspaceSections: { query: async () => env.v1Sections }, + listState: { + query: async ({ organizationId }: { organizationId: string }) => + Array.from(env.state.values()).filter( + (r) => r.organizationId === organizationId, + ), + }, + upsertState: { + mutate: async (row: Omit<StateRow, "migratedAt">) => { + const key = `${row.kind}:${row.v1Id}`; + if (env.failNextStateWriteFor.delete(key)) { + throw new Error(`failed to write migration state for ${key}`); + } + env.state.set(key, { + v1Id: row.v1Id, + v2Id: row.v2Id, + organizationId: row.organizationId, + kind: row.kind, + status: row.status, + reason: row.reason ?? null, + }); + }, + }, + }, + }; + return stub as unknown as ElectronTrpcClient; +} + +function makeHostService(env: FakeEnv): HostServiceClient { + const idCounter = { n: 0 }; + const nextId = (prefix: string) => `${prefix}-${++idCounter.n}`; + + const stub = { + project: { + findByPath: { + query: async ({ repoPath }: { repoPath: string }) => { + const result = env.findByPath.get(repoPath); + if (result) { + if ("err" in result) { + throw new Error(`path not a git repo: ${repoPath}`); + } + return result; + } + const existingId = env.hostProjectsByPath.get(repoPath); + if (existingId) return { candidates: [{ id: existingId }] }; + return { candidates: [] }; + }, + }, + setup: { + mutate: async ({ + projectId, + mode, + }: { + projectId: string; + mode: { repoPath: string; allowRelocate?: boolean }; + }) => { + env.setupCalls.push({ projectId, repoPath: mode.repoPath }); + if (env.setupThrowsFor.has(projectId)) { + throw trpcErr("CONFLICT", "already set up elsewhere"); + } + env.hostProjectsByPath.set(mode.repoPath, projectId); + return { repoPath: "/fake" }; + }, + }, + create: { + mutate: async ({ + name, + mode, + }: { + name: string; + mode: { repoPath: string }; + }) => { + if (env.createThrowsFor.has(name)) { + throw new Error(`cloud create failed for ${name}`); + } + env.createCalls.push({ name, repoPath: mode.repoPath }); + const projectId = nextId("v2-proj"); + env.createdProjectIds.push(projectId); + env.hostProjectsByPath.set(mode.repoPath, projectId); + return { projectId, repoPath: mode.repoPath }; + }, + }, + }, + workspace: { + get: { + query: async ({ id }: { id: string }) => { + if (![...env.hostWorkspacesByKey.values()].includes(id)) { + throw trpcErr("NOT_FOUND", "Workspace not found"); + } + return { id }; + }, + }, + }, + workspaceCreation: { + adopt: { + mutate: async ({ + projectId, + branch, + worktreePath, + baseBranch, + existingWorkspaceId, + }: { + projectId: string; + branch: string; + worktreePath?: string; + baseBranch?: string; + existingWorkspaceId?: string; + }) => { + const call = { + projectId, + branch, + worktreePath, + baseBranch, + } as (typeof env.adoptCalls)[number]; + if (existingWorkspaceId) + call.existingWorkspaceId = existingWorkspaceId; + env.adoptCalls.push(call); + const pathBehavior = worktreePath + ? env.adoptThrowsForPath.get(worktreePath) + : undefined; + if (pathBehavior) + throw trpcErr(pathBehavior.code, pathBehavior.message); + const behavior = env.adoptThrowsFor.get(branch); + if (behavior) throw trpcErr(behavior.code, behavior.message); + const key = `${projectId}:${worktreePath ?? branch}`; + const existingId = env.hostWorkspacesByKey.get(key); + if (existingId) { + return { + workspace: { id: existingId, branch }, + terminals: [], + warnings: [], + }; + } + if (existingWorkspaceId) { + env.hostWorkspacesByKey.set(key, existingWorkspaceId); + return { + workspace: { id: existingWorkspaceId, branch }, + terminals: [], + warnings: [], + }; + } + const workspaceId = nextId("v2-ws"); + env.createdWorkspaceIds.push(workspaceId); + env.hostWorkspacesByKey.set(key, workspaceId); + return { + workspace: { id: workspaceId, branch }, + terminals: [], + warnings: [], + }; + }, + }, + }, + }; + return stub as unknown as HostServiceClient; +} + +function makeCollections(): OrgCollections { + const make = <T extends Record<string, unknown>>(keyOf: (v: T) => string) => { + const store = new Map<string, T>(); + return { + get: (k: string) => store.get(k), + insert: (v: T) => { + store.set(keyOf(v), v); + }, + }; + }; + return { + // Only the 3 collections migrate.ts + writeSidebarState touch matter. + v2SidebarProjects: make((v: { projectId: string }) => v.projectId), + v2SidebarSections: make((v: { sectionId: string }) => v.sectionId), + v2WorkspaceLocalState: make((v: { workspaceId: string }) => v.workspaceId), + } as unknown as OrgCollections; +} + +const ORG = "org-1"; + +function project( + id: string, + overrides: Partial<V1ProjectRow> = {}, +): V1ProjectRow { + return { + id, + name: `project-${id}`, + mainRepoPath: `/repos/${id}`, + tabOrder: 0, + defaultApp: null, + ...overrides, + }; +} + +function workspace( + id: string, + projectId: string, + overrides: Partial<V1WorkspaceRow> = {}, +): V1WorkspaceRow { + return { + id, + projectId, + worktreeId: null, + type: "branch", + branch: `branch-${id}`, + name: `workspace-${id}`, + sectionId: null, + tabOrder: 0, + ...overrides, + }; +} + +describe("migrateV1DataToV2", () => { + test("happy path: creates projects and adopts workspaces", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1"), project("p2")], + v1Workspaces: [ + workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), + workspace("w2", "p2", { worktreeId: "wt2", type: "worktree" }), + ], + v1Worktrees: [ + { id: "wt1", path: "/worktrees/w1" }, + { id: "wt2", path: "/worktrees/w2" }, + ], + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.projectsCreated).toBe(2); + expect(summary.projectsLinked).toBe(0); + expect(summary.workspacesCreated).toBe(2); + expect(summary.workspacesSkipped).toBe(0); + expect(summary.errors).toHaveLength(0); + expect(env.state.size).toBe(4); + }); + + test("findByPath hit links to existing v2 project", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [], + findByPath: new Map([ + ["/repos/p1", { candidates: [{ id: "v2-existing" }] }], + ]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.projectsLinked).toBe(1); + expect(summary.projectsCreated).toBe(0); + expect(env.state.get("project:p1")?.v2Id).toBe("v2-existing"); + expect(env.state.get("project:p1")?.status).toBe("linked"); + }); + + test("CONFLICT on setup after link records an error", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + findByPath: new Map([ + ["/repos/p1", { candidates: [{ id: "v2-existing" }] }], + ]), + setupThrowsFor: new Set(["v2-existing"]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.projectsLinked).toBe(0); + expect(summary.projectsErrored).toBe(1); + expect(summary.errors).toHaveLength(1); + expect(env.state.get("project:p1")?.status).toBe("error"); + }); + + test("project create failure records error and skips its workspaces", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p-bad"), project("p-good")], + v1Workspaces: [ + workspace("w1", "p-bad", { worktreeId: "wt1", type: "worktree" }), + workspace("w2", "p-good", { worktreeId: "wt2", type: "worktree" }), + ], + v1Worktrees: [ + { id: "wt1", path: "/a" }, + { id: "wt2", path: "/b" }, + ], + createThrowsFor: new Set(["project-p-bad"]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.projectsErrored).toBe(1); + expect(summary.projectsCreated).toBe(1); + expect(summary.workspacesCreated).toBe(1); // w2 only + expect(summary.workspacesSkipped).toBe(1); // w1 skipped (parent error) + expect(env.state.get("workspace:w1")?.reason).toBe( + "parent_project_unresolved", + ); + }); + + test("orphan workspace (missing worktree row) is skipped", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w-orphan", "p1", { + type: "worktree", + worktreeId: "missing", + }), + ], + v1Worktrees: [], + adoptThrowsFor: new Map([["branch-w-orphan", { code: "NOT_FOUND" }]]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesSkipped).toBe(1); + expect(summary.workspacesCreated).toBe(0); + expect(env.state.get("workspace:w-orphan")?.reason).toBe( + "worktree_not_registered", + ); + }); + + test("missing v1 worktree row falls back to branch adoption", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { + type: "worktree", + worktreeId: "missing", + }), + ], + v1Worktrees: [], + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesCreated).toBe(1); + expect(summary.workspacesSkipped).toBe(0); + expect(env.adoptCalls).toContainEqual({ + projectId: "v2-proj-1", + branch: "branch-w1", + worktreePath: undefined, + baseBranch: undefined, + }); + expect(env.state.get("workspace:w1")?.status).toBe("success"); + }); + + test("adopt NOT_FOUND is skipped, not errored", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { + branch: "gone", + worktreeId: "wt1", + type: "worktree", + }), + ], + v1Worktrees: [{ id: "wt1", path: "/gone" }], + adoptThrowsFor: new Map([["gone", { code: "NOT_FOUND" }]]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesSkipped).toBe(1); + expect(summary.workspacesErrored).toBe(0); + expect(env.state.get("workspace:w1")?.reason).toBe( + "worktree_not_registered", + ); + }); + + test("stale v1 worktree path falls back to branch adoption", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { + worktreeId: "wt1", + type: "worktree", + }), + ], + v1Worktrees: [{ id: "wt1", path: "/stale-worktree" }], + adoptThrowsForPath: new Map([["/stale-worktree", { code: "NOT_FOUND" }]]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesCreated).toBe(1); + expect(summary.workspacesSkipped).toBe(0); + expect(env.adoptCalls).toEqual([ + { + projectId: "v2-proj-1", + branch: "branch-w1", + worktreePath: "/stale-worktree", + baseBranch: undefined, + }, + { + projectId: "v2-proj-1", + branch: "branch-w1", + worktreePath: undefined, + baseBranch: undefined, + }, + ]); + expect(env.state.get("workspace:w1")?.status).toBe("success"); + }); + + test("adopt non-NOT_FOUND error is recorded as error", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { + branch: "boom", + worktreeId: "wt1", + type: "worktree", + }), + ], + v1Worktrees: [{ id: "wt1", path: "/x" }], + adoptThrowsFor: new Map([ + ["boom", { code: "INTERNAL_SERVER_ERROR", message: "cloud down" }], + ]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesErrored).toBe(1); + expect(summary.errors).toHaveLength(1); + expect(env.state.get("workspace:w1")?.status).toBe("error"); + }); + + test("other-org state does not block migration for the active organization", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + state: new Map([ + [ + "project:p1", + { + v1Id: "p1", + v2Id: "v2-other-org-project", + organizationId: "some-other-org", + kind: "project", + status: "success", + reason: null, + }, + ], + ]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.projectsCreated).toBe(1); + expect(summary.errors).toHaveLength(0); + expect(env.state.get("project:p1")?.organizationId).toBe(ORG); + expect(env.state.get("project:p1")?.status).toBe("success"); + }); + + test("rerun skips rows already in success/linked state, retries error rows and skipped workspaces", async () => { + // Pre-populate state as if a prior run completed p1 but errored on p2. + const prior = new Map<string, StateRow>([ + [ + "project:p1", + { + v1Id: "p1", + v2Id: "v2-p1", + organizationId: ORG, + kind: "project", + status: "success", + reason: null, + }, + ], + [ + "project:p2", + { + v1Id: "p2", + v2Id: null, + organizationId: ORG, + kind: "project", + status: "error", + reason: "prior failure", + }, + ], + [ + "workspace:w2", + { + v1Id: "w2", + v2Id: null, + organizationId: ORG, + kind: "workspace", + status: "skipped", + reason: "parent_project_unresolved", + }, + ], + ]); + const env = makeFakeEnv({ + v1Projects: [project("p1"), project("p2")], + v1Workspaces: [ + workspace("w2", "p2", { worktreeId: "wt2", type: "worktree" }), + ], + v1Worktrees: [{ id: "wt2", path: "/worktrees/w2" }], + state: prior, + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + // p1 was success → skipped. p2 was error → retried and succeeded this run. + expect(summary.projectsCreated).toBe(1); + expect(summary.workspacesCreated).toBe(1); + expect(env.state.get("project:p2")?.status).toBe("success"); + expect(env.state.get("workspace:w2")?.status).toBe("success"); + expect(env.state.get("project:p1")?.status).toBe("success"); // unchanged + expect(env.setupCalls).toContainEqual({ + projectId: "v2-p1", + repoPath: "/repos/p1", + }); + }); + + test("idempotent rerun reports already synced rows without counting them as changes", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), + ], + v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], + hostWorkspacesByKey: new Map([["v2-p1:/worktrees/w1", "v2-w1"]]), + state: new Map([ + [ + "project:p1", + { + v1Id: "p1", + v2Id: "v2-p1", + organizationId: ORG, + kind: "project", + status: "success", + reason: null, + }, + ], + [ + "workspace:w1", + { + v1Id: "w1", + v2Id: "v2-w1", + organizationId: ORG, + kind: "workspace", + status: "success", + reason: null, + }, + ], + ]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.projectsCreated).toBe(0); + expect(summary.projectsLinked).toBe(0); + expect(summary.workspacesCreated).toBe(0); + expect(summary.workspacesSkipped).toBe(0); + expect(summary.projects).toEqual([ + { name: "project-p1", status: "synced", reason: "Already imported" }, + ]); + expect(summary.workspaces).toEqual([ + { + name: "workspace-w1", + branch: "branch-w1", + status: "synced", + reason: "Already imported", + }, + ]); + expect(env.adoptCalls).toHaveLength(0); + }); + + test("running a completed migration again does not create duplicate projects or workspaces", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), + ], + v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], + }); + const electronTrpc = makeElectronTrpc(env); + const hostService = makeHostService(env); + const collections = makeCollections(); + + const first = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc, + hostService, + collections, + }); + + expect(first.projectsCreated).toBe(1); + expect(first.workspacesCreated).toBe(1); + expect(env.createCalls).toHaveLength(1); + expect(env.adoptCalls).toHaveLength(1); + expect(env.createdWorkspaceIds).toHaveLength(1); + + const second = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc, + hostService, + collections, + }); + + expect(second.projectsCreated).toBe(0); + expect(second.projectsLinked).toBe(0); + expect(second.workspacesCreated).toBe(0); + expect(second.workspacesErrored).toBe(0); + expect(second.projects).toEqual([ + { name: "project-p1", status: "synced", reason: "Already imported" }, + ]); + expect(second.workspaces).toEqual([ + { + name: "workspace-w1", + branch: "branch-w1", + status: "synced", + reason: "Already imported", + }, + ]); + expect(env.createCalls).toHaveLength(1); + expect(env.adoptCalls).toHaveLength(1); + expect(env.createdWorkspaceIds).toHaveLength(1); + expect(env.setupCalls).toEqual([ + { projectId: "v2-proj-1", repoPath: "/repos/p1" }, + ]); + }); + + test("project state write failure does not migrate child workspaces until rerun reconciles the project", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), + ], + v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], + failNextStateWriteFor: new Set(["project:p1"]), + }); + const electronTrpc = makeElectronTrpc(env); + const hostService = makeHostService(env); + + const first = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc, + hostService, + collections: makeCollections(), + }); + + expect(first.projectsErrored).toBe(1); + expect(first.workspacesSkipped).toBe(1); + expect(env.createCalls).toHaveLength(1); + expect(env.createdProjectIds).toEqual(["v2-proj-1"]); + expect(env.adoptCalls).toHaveLength(0); + expect(env.state.get("project:p1")?.status).toBe("error"); + expect(env.state.get("workspace:w1")?.reason).toBe( + "parent_project_unresolved", + ); + + const second = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc, + hostService, + collections: makeCollections(), + }); + + expect(second.projectsLinked).toBe(1); + expect(second.workspacesCreated).toBe(1); + expect(env.createCalls).toHaveLength(1); + expect(env.createdProjectIds).toEqual(["v2-proj-1"]); + expect(env.state.get("project:p1")?.v2Id).toBe("v2-proj-1"); + expect(env.state.get("project:p1")?.status).toBe("linked"); + expect(env.state.get("workspace:w1")?.status).toBe("success"); + }); + + test("workspace state write failure reruns adoption without creating a duplicate workspace", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), + ], + v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], + failNextStateWriteFor: new Set(["workspace:w1"]), + }); + const electronTrpc = makeElectronTrpc(env); + const hostService = makeHostService(env); + + const first = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc, + hostService, + collections: makeCollections(), + }); + + expect(first.workspacesErrored).toBe(1); + expect(env.createdWorkspaceIds).toEqual(["v2-ws-2"]); + expect(env.state.get("workspace:w1")?.status).toBe("error"); + + const second = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc, + hostService, + collections: makeCollections(), + }); + + expect(second.workspacesCreated).toBe(1); + expect(env.createdWorkspaceIds).toEqual(["v2-ws-2"]); + expect(env.state.get("workspace:w1")?.status).toBe("success"); + expect(env.state.get("workspace:w1")?.v2Id).toBe("v2-ws-2"); + }); + + test("completed workspace with missing host db row is relinked to existing cloud workspace", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), + ], + v1Worktrees: [{ id: "wt1", path: "/worktrees/w1" }], + state: new Map([ + [ + "project:p1", + { + v1Id: "p1", + v2Id: "v2-p1", + organizationId: ORG, + kind: "project", + status: "success", + reason: null, + }, + ], + [ + "workspace:w1", + { + v1Id: "w1", + v2Id: "v2-ws-existing", + organizationId: ORG, + kind: "workspace", + status: "success", + reason: null, + }, + ], + ]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesCreated).toBe(1); + expect(env.createdWorkspaceIds).toEqual([]); + expect(env.adoptCalls).toContainEqual({ + projectId: "v2-p1", + branch: "branch-w1", + worktreePath: "/worktrees/w1", + baseBranch: undefined, + existingWorkspaceId: "v2-ws-existing", + }); + expect(env.state.get("workspace:w1")?.status).toBe("success"); + expect(env.state.get("workspace:w1")?.v2Id).toBe("v2-ws-existing"); + }); + + test("rerun retries previous worktree skips so old skipped state can recover", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w-orphan", "p1", { + type: "worktree", + worktreeId: "missing", + }), + ], + v1Worktrees: [], + state: new Map([ + [ + "project:p1", + { + v1Id: "p1", + v2Id: "v2-p1", + organizationId: ORG, + kind: "project", + status: "success", + reason: null, + }, + ], + [ + "workspace:w-orphan", + { + v1Id: "w-orphan", + v2Id: null, + organizationId: ORG, + kind: "workspace", + status: "skipped", + reason: "orphan_worktree", + }, + ], + ]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesCreated).toBe(1); + expect(summary.workspacesSkipped).toBe(0); + expect(env.adoptCalls).toContainEqual({ + projectId: "v2-p1", + branch: "branch-w-orphan", + worktreePath: undefined, + baseBranch: undefined, + }); + expect(env.state.get("workspace:w-orphan")?.status).toBe("success"); + }); + + test("failed retry of previous missing-worktree skip does not count as new work", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w-orphan", "p1", { + type: "worktree", + worktreeId: "missing", + }), + ], + v1Worktrees: [], + adoptThrowsFor: new Map([["branch-w-orphan", { code: "NOT_FOUND" }]]), + state: new Map([ + [ + "project:p1", + { + v1Id: "p1", + v2Id: "v2-p1", + organizationId: ORG, + kind: "project", + status: "success", + reason: null, + }, + ], + [ + "workspace:w-orphan", + { + v1Id: "w-orphan", + v2Id: null, + organizationId: ORG, + kind: "workspace", + status: "skipped", + reason: "worktree_not_registered", + }, + ], + ]), + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesCreated).toBe(0); + expect(summary.workspacesSkipped).toBe(0); + expect(env.adoptCalls).toHaveLength(1); + expect(summary.workspaces).toHaveLength(1); + expect(summary.workspaces).toContainEqual({ + name: "workspace-w-orphan", + branch: "branch-w-orphan", + status: "skipped", + reason: "worktree no longer exists", + }); + expect(env.state.get("workspace:w-orphan")?.status).toBe("skipped"); + expect(env.state.get("workspace:w-orphan")?.reason).toBe( + "worktree_not_registered", + ); + }); + + test("passes v1 worktree base branch into adoption", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { worktreeId: "wt1", type: "worktree" }), + ], + v1Worktrees: [ + { id: "wt1", path: "/worktrees/w1", baseBranch: "develop" }, + ], + }); + + await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(env.adoptCalls).toContainEqual({ + projectId: "v2-proj-1", + branch: "branch-w1", + worktreePath: "/worktrees/w1", + baseBranch: "develop", + }); + }); + + test("workspace with sectionId that lacks a v1 section record lands at top level", async () => { + const env = makeFakeEnv({ + v1Projects: [project("p1")], + v1Workspaces: [ + workspace("w1", "p1", { + worktreeId: "wt1", + type: "worktree", + sectionId: "sec-missing", + }), + ], + v1Worktrees: [{ id: "wt1", path: "/a" }], + v1Sections: [], + }); + + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.workspacesCreated).toBe(1); + expect(env.state.get("workspace:w1")?.status).toBe("success"); + }); + + test("no v1 data → no-op, no errors, empty summary", async () => { + const env = makeFakeEnv({}); + const summary = await migrateV1DataToV2({ + organizationId: ORG, + electronTrpc: makeElectronTrpc(env), + hostService: makeHostService(env), + collections: makeCollections(), + }); + + expect(summary.projectsCreated).toBe(0); + expect(summary.projectsLinked).toBe(0); + expect(summary.workspacesCreated).toBe(0); + expect(summary.errors).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts new file mode 100644 index 00000000000..2c4220359b4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts @@ -0,0 +1,503 @@ +import type { HostServiceClient } from "renderer/lib/host-service-client"; +import type { electronTrpcClient } from "renderer/lib/trpc-client"; +import type { OrgCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; +import { writeV2SidebarState } from "./writeSidebarState"; + +type ElectronTrpcClient = typeof electronTrpcClient; + +export type ProjectStatus = "created" | "linked" | "synced" | "error"; +export type WorkspaceStatus = "adopted" | "synced" | "skipped" | "error"; + +export interface ProjectEntry { + name: string; + status: ProjectStatus; + reason?: string; +} + +export interface WorkspaceEntry { + name: string; + branch: string; + status: WorkspaceStatus; + reason?: string; +} + +export interface MigrationSummary { + projectsCreated: number; + projectsLinked: number; + projectsErrored: number; + workspacesCreated: number; + workspacesSkipped: number; + workspacesErrored: number; + projects: ProjectEntry[]; + workspaces: WorkspaceEntry[]; + errors: Array<{ + kind: "project" | "workspace"; + name: string; + message: string; + }>; +} + +const emptySummary = (): MigrationSummary => ({ + projectsCreated: 0, + projectsLinked: 0, + projectsErrored: 0, + workspacesCreated: 0, + workspacesSkipped: 0, + workspacesErrored: 0, + projects: [], + workspaces: [], + errors: [], +}); + +function trpcCode(err: unknown): string | null { + if (typeof err !== "object" || err === null) return null; + const data = (err as { data?: unknown }).data; + if (typeof data !== "object" || data === null) return null; + const code = (data as { code?: unknown }).code; + return typeof code === "string" ? code : null; +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +async function setupProjectImport( + hostService: HostServiceClient, + projectId: string, + repoPath: string, +): Promise<void> { + await hostService.project.setup.mutate({ + projectId, + mode: { kind: "import", repoPath }, + }); +} + +function shouldRetryWorkspace( + existing: { status: string; reason?: string | null } | undefined, +): boolean { + if (!existing) return true; + if (existing.status === "success") return false; + if (existing.status === "error") return true; + return ( + existing.status === "skipped" && + (existing.reason === "parent_project_unresolved" || + existing.reason === "orphan_worktree" || + existing.reason === "worktree_not_registered") + ); +} + +async function hasLocalWorkspace( + hostService: HostServiceClient, + workspaceId: string, +): Promise<boolean> { + try { + await hostService.workspace.get.query({ id: workspaceId }); + return true; + } catch (err) { + if (trpcCode(err) === "NOT_FOUND") return false; + throw err; + } +} + +function addProjectError( + summary: MigrationSummary, + name: string, + message: string, +): void { + summary.projectsErrored += 1; + summary.projects.push({ + name, + status: "error", + reason: message, + }); + summary.errors.push({ + kind: "project", + name, + message, + }); +} + +function addWorkspaceSkip( + summary: MigrationSummary, + name: string, + branch: string, + reason: string, +): void { + summary.workspacesSkipped += 1; + summary.workspaces.push({ + name, + branch, + status: "skipped", + reason, + }); +} + +function skippedWorkspaceReason(reason: string | null | undefined): string { + switch (reason) { + case "orphan_worktree": + return "worktree record missing"; + case "worktree_not_registered": + return "worktree no longer exists"; + case "parent_project_unresolved": + return "parent project did not migrate"; + default: + return reason ?? "skipped"; + } +} + +function wasAlreadyMissingWorktreeSkip( + existing: { status: string; reason?: string | null } | undefined, +): boolean { + return ( + existing?.status === "skipped" && + (existing.reason === "orphan_worktree" || + existing.reason === "worktree_not_registered") + ); +} + +function addWorkspaceError( + summary: MigrationSummary, + name: string, + branch: string, + message: string, +): void { + summary.workspacesErrored += 1; + summary.workspaces.push({ + name, + branch, + status: "error", + reason: message, + }); + summary.errors.push({ + kind: "workspace", + name, + message, + }); +} + +interface Args { + organizationId: string; + electronTrpc: ElectronTrpcClient; + hostService: HostServiceClient; + collections: OrgCollections; +} + +export async function migrateV1DataToV2(args: Args): Promise<MigrationSummary> { + const { organizationId, electronTrpc, hostService, collections } = args; + const summary = emptySummary(); + + const [v1Projects, v1Workspaces, v1Worktrees, v1Sections, existingState] = + await Promise.all([ + electronTrpc.migration.readV1Projects.query(), + electronTrpc.migration.readV1Workspaces.query(), + electronTrpc.migration.readV1Worktrees.query(), + electronTrpc.migration.readV1WorkspaceSections.query(), + electronTrpc.migration.listState.query({ organizationId }), + ]); + + const stateByKey = new Map<string, (typeof existingState)[number]>(); + for (const row of existingState) { + stateByKey.set(`${row.kind}:${row.v1Id}`, row); + } + + const worktreesById = new Map<string, (typeof v1Worktrees)[number]>(); + for (const wt of v1Worktrees) worktreesById.set(wt.id, wt); + + const projectV1ToV2 = new Map<string, string>(); + for (const row of existingState) { + if ( + row.kind === "project" && + row.v2Id && + (row.status === "success" || row.status === "linked") + ) { + projectV1ToV2.set(row.v1Id, row.v2Id); + } + } + + const workspaceV1ToV2 = new Map<string, string>(); + for (const row of existingState) { + if (row.kind === "workspace" && row.v2Id && row.status === "success") { + workspaceV1ToV2.set(row.v1Id, row.v2Id); + } + } + + for (const project of v1Projects) { + const key = `project:${project.id}`; + const existing = stateByKey.get(key); + if ( + existing?.v2Id && + (existing.status === "success" || existing.status === "linked") + ) { + try { + await setupProjectImport( + hostService, + existing.v2Id, + project.mainRepoPath, + ); + projectV1ToV2.set(project.id, existing.v2Id); + summary.projects.push({ + name: project.name, + status: "synced", + reason: "Already imported", + }); + } catch (err) { + const message = errorMessage(err); + await electronTrpc.migration.upsertState.mutate({ + v1Id: project.id, + kind: "project", + v2Id: existing.v2Id, + organizationId, + status: "error", + reason: message, + }); + projectV1ToV2.delete(project.id); + addProjectError(summary, project.name, message); + console.error( + "[v1-migration] existing project setup failed", + project.name, + err, + ); + } + continue; + } + + try { + const found = await hostService.project.findByPath.query({ + repoPath: project.mainRepoPath, + }); + + let v2ProjectId: string; + let status: "success" | "linked"; + + if (found.candidates.length > 0) { + const candidate = found.candidates[0]; + if (!candidate) throw new Error("findByPath returned empty candidate"); + if (found.candidates.length > 1) { + console.warn( + `[v1-migration] findByPath for ${project.mainRepoPath} returned ${found.candidates.length} candidates; migration has no project picker, linking to first (${candidate.id})`, + ); + } + v2ProjectId = candidate.id; + status = "linked"; + await setupProjectImport( + hostService, + candidate.id, + project.mainRepoPath, + ); + } else { + const created = await hostService.project.create.mutate({ + name: project.name, + mode: { + kind: "importLocal", + repoPath: project.mainRepoPath, + }, + }); + v2ProjectId = created.projectId; + status = "success"; + } + + await electronTrpc.migration.upsertState.mutate({ + v1Id: project.id, + kind: "project", + v2Id: v2ProjectId, + organizationId, + status, + reason: null, + }); + projectV1ToV2.set(project.id, v2ProjectId); + if (status === "success") { + summary.projectsCreated += 1; + summary.projects.push({ name: project.name, status: "created" }); + } else { + summary.projectsLinked += 1; + summary.projects.push({ name: project.name, status: "linked" }); + } + } catch (err) { + const message = errorMessage(err); + projectV1ToV2.delete(project.id); + await electronTrpc.migration.upsertState.mutate({ + v1Id: project.id, + kind: "project", + v2Id: null, + organizationId, + status: "error", + reason: message, + }); + addProjectError(summary, project.name, message); + console.error("[v1-migration] project failed", project.name, err); + } + } + + for (const workspace of v1Workspaces) { + const key = `workspace:${workspace.id}`; + const existing = stateByKey.get(key); + let recoverCompletedWorkspace = false; + if (existing?.status === "success" && existing.v2Id) { + try { + if (await hasLocalWorkspace(hostService, existing.v2Id)) { + workspaceV1ToV2.set(workspace.id, existing.v2Id); + summary.workspaces.push({ + name: workspace.name, + branch: workspace.branch, + status: "synced", + reason: "Already imported", + }); + continue; + } + recoverCompletedWorkspace = true; + } catch (err) { + const message = errorMessage(err); + await electronTrpc.migration.upsertState.mutate({ + v1Id: workspace.id, + kind: "workspace", + v2Id: existing.v2Id, + organizationId, + status: "error", + reason: message, + }); + addWorkspaceError(summary, workspace.name, workspace.branch, message); + console.error( + "[v1-migration] workspace local reconciliation failed", + workspace.name, + err, + ); + continue; + } + } + if (!recoverCompletedWorkspace && !shouldRetryWorkspace(existing)) { + if (existing?.status === "skipped") { + summary.workspaces.push({ + name: workspace.name, + branch: workspace.branch, + status: "skipped", + reason: skippedWorkspaceReason(existing.reason), + }); + } + continue; + } + + const v2ProjectId = projectV1ToV2.get(workspace.projectId); + if (!v2ProjectId) { + await electronTrpc.migration.upsertState.mutate({ + v1Id: workspace.id, + kind: "workspace", + v2Id: null, + organizationId, + status: "skipped", + reason: "parent_project_unresolved", + }); + addWorkspaceSkip( + summary, + workspace.name, + workspace.branch, + "parent project did not migrate", + ); + continue; + } + + const v1Worktree = workspace.worktreeId + ? worktreesById.get(workspace.worktreeId) + : undefined; + const v1WorktreePath = v1Worktree?.path; + const v1BaseBranch = v1Worktree?.baseBranch; + + const adoptWorkspace = (worktreePath: string | undefined) => + hostService.workspaceCreation.adopt.mutate({ + projectId: v2ProjectId, + workspaceName: workspace.name, + branch: workspace.branch, + baseBranch: v1BaseBranch ?? undefined, + existingWorkspaceId: existing?.v2Id ?? undefined, + worktreePath, + }); + + const recordAdoptFailure = async (err: unknown) => { + if (trpcCode(err) === "NOT_FOUND") { + const reason = "worktree_not_registered"; + await electronTrpc.migration.upsertState.mutate({ + v1Id: workspace.id, + kind: "workspace", + v2Id: null, + organizationId, + status: "skipped", + reason, + }); + if (wasAlreadyMissingWorktreeSkip(existing)) { + summary.workspaces.push({ + name: workspace.name, + branch: workspace.branch, + status: "skipped", + reason: skippedWorkspaceReason(reason), + }); + return; + } + addWorkspaceSkip( + summary, + workspace.name, + workspace.branch, + "worktree no longer exists", + ); + return; + } + const message = errorMessage(err); + await electronTrpc.migration.upsertState.mutate({ + v1Id: workspace.id, + kind: "workspace", + v2Id: null, + organizationId, + status: "error", + reason: message, + }); + addWorkspaceError(summary, workspace.name, workspace.branch, message); + console.error("[v1-migration] workspace failed", workspace.name, err); + }; + + try { + let result: Awaited<ReturnType<typeof adoptWorkspace>>; + try { + result = await adoptWorkspace(v1WorktreePath); + } catch (err) { + if (trpcCode(err) !== "NOT_FOUND" || !v1WorktreePath) { + throw err; + } + + // v1 worktree rows can be stale while git still has the branch + // registered at a different path. Retry by branch before giving up. + result = await adoptWorkspace(undefined); + } + + await electronTrpc.migration.upsertState.mutate({ + v1Id: workspace.id, + kind: "workspace", + v2Id: result.workspace.id, + organizationId, + status: "success", + reason: null, + }); + workspaceV1ToV2.set(workspace.id, result.workspace.id); + summary.workspacesCreated += 1; + summary.workspaces.push({ + name: workspace.name, + branch: workspace.branch, + status: "adopted", + }); + } catch (err) { + await recordAdoptFailure(err); + } + } + + // Translate all sidebar state (project order, sections, workspace order + + // section membership) in one pass. Main loop above only handles cloud + + // host-service creates and records migration_state; renderer-side + // collection writes live entirely in writeV2SidebarState. + writeV2SidebarState(collections, { + projectV1ToV2, + workspaceV1ToV2, + v1Projects, + v1Sections, + v1Workspaces, + }); + + return summary; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.test.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.test.ts new file mode 100644 index 00000000000..321282873cb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { computeNormalizedOrders } from "./normalize"; + +const project = "p-1"; + +function workspace( + id: string, + tabOrder: number, + sectionId: string | null = null, + projectId: string = project, +) { + return { id, projectId, sectionId, tabOrder }; +} + +function section(id: string, tabOrder: number, projectId: string = project) { + return { id, projectId, tabOrder }; +} + +describe("computeNormalizedOrders", () => { + test("empty input returns empty maps", () => { + const result = computeNormalizedOrders({ workspaces: [], sections: [] }); + expect(result.workspaceTabOrder.size).toBe(0); + expect(result.sectionTabOrder.size).toBe(0); + }); + + test("top-level workspaces only — preserve relative order", () => { + const { workspaceTabOrder } = computeNormalizedOrders({ + workspaces: [workspace("a", 5), workspace("b", 2), workspace("c", 9)], + sections: [], + }); + expect(workspaceTabOrder.get("b")).toBe(0); + expect(workspaceTabOrder.get("a")).toBe(1); + expect(workspaceTabOrder.get("c")).toBe(2); + }); + + test("sections only — preserve relative order", () => { + const { sectionTabOrder } = computeNormalizedOrders({ + workspaces: [], + sections: [section("s1", 10), section("s2", 3)], + }); + expect(sectionTabOrder.get("s2")).toBe(0); + expect(sectionTabOrder.get("s1")).toBe(1); + }); + + test("workspaces placed before sections in combined space", () => { + const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ + workspaces: [workspace("a", 0), workspace("b", 1)], + sections: [section("s1", 2)], + }); + expect(workspaceTabOrder.get("a")).toBe(0); + expect(workspaceTabOrder.get("b")).toBe(1); + expect(sectionTabOrder.get("s1")).toBe(2); + }); + + test("interleaved v1 layout flattens to workspaces-then-sections", () => { + // v1: [Section A (0), Workspace X (1), Section B (2), Workspace Y (3)] + const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ + workspaces: [workspace("X", 1), workspace("Y", 3)], + sections: [section("A", 0), section("B", 2)], + }); + // Top-level workspaces first: X (was 1), Y (was 3) → 0, 1 + expect(workspaceTabOrder.get("X")).toBe(0); + expect(workspaceTabOrder.get("Y")).toBe(1); + // Sections after: A (was 0), B (was 2) → 2, 3 + expect(sectionTabOrder.get("A")).toBe(2); + expect(sectionTabOrder.get("B")).toBe(3); + }); + + test("workspaces inside a section keep within-section order", () => { + const { workspaceTabOrder } = computeNormalizedOrders({ + workspaces: [ + workspace("inner-a", 5, "sec-1"), + workspace("inner-b", 1, "sec-1"), + workspace("inner-c", 3, "sec-1"), + ], + sections: [section("sec-1", 0)], + }); + expect(workspaceTabOrder.get("inner-b")).toBe(0); + expect(workspaceTabOrder.get("inner-c")).toBe(1); + expect(workspaceTabOrder.get("inner-a")).toBe(2); + }); + + test("mixed top-level + in-section workspaces are independent", () => { + const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ + workspaces: [ + workspace("top-1", 0), + workspace("top-2", 1), + workspace("in-a", 7, "sec-1"), + workspace("in-b", 2, "sec-1"), + ], + sections: [section("sec-1", 5)], + }); + // Top-level: top-1=0, top-2=1 + expect(workspaceTabOrder.get("top-1")).toBe(0); + expect(workspaceTabOrder.get("top-2")).toBe(1); + // Section tabOrder = 2 (after the 2 top-level workspaces) + expect(sectionTabOrder.get("sec-1")).toBe(2); + // In-section: in-b (v1 tabOrder=2) before in-a (v1 tabOrder=7) + expect(workspaceTabOrder.get("in-b")).toBe(0); + expect(workspaceTabOrder.get("in-a")).toBe(1); + }); + + test("multiple projects are independent", () => { + const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ + workspaces: [ + workspace("p1-w1", 0, null, "p1"), + workspace("p2-w1", 0, null, "p2"), + ], + sections: [section("p1-sec", 1, "p1"), section("p2-sec", 1, "p2")], + }); + expect(workspaceTabOrder.get("p1-w1")).toBe(0); + expect(sectionTabOrder.get("p1-sec")).toBe(1); + expect(workspaceTabOrder.get("p2-w1")).toBe(0); + expect(sectionTabOrder.get("p2-sec")).toBe(1); + }); + + test("sparse/gapped v1 values still produce contiguous output", () => { + const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ + workspaces: [workspace("a", 0), workspace("b", 100), workspace("c", 250)], + sections: [section("s1", 500)], + }); + expect(workspaceTabOrder.get("a")).toBe(0); + expect(workspaceTabOrder.get("b")).toBe(1); + expect(workspaceTabOrder.get("c")).toBe(2); + expect(sectionTabOrder.get("s1")).toBe(3); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.ts new file mode 100644 index 00000000000..c08d41728f0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/normalize.ts @@ -0,0 +1,79 @@ +/** + * v1 → v2 sidebar order translation. + * + * v1 allowed arbitrary interleaving of top-level workspaces and sections. + * v2 doesn't — if a top-level workspace appears after a section in sort + * order, v2's render absorbs it into that section's display group + * (useDashboardSidebarData.ts:343-357). A direct copy of v1 tab_order + * values would surprise users whose v1 layout had post-section orphans. + * + * Translation: put all top-level workspaces first (in their original + * v1 order), then sections (in their original v1 order). Preserves + * relative ordering within each group; sacrifices interleaving (which + * v2 can't express anyway). Workspaces inside a section keep their + * within-section order. + */ +export interface V1TabOrderInput { + workspaces: Array<{ + id: string; + projectId: string; + sectionId: string | null; + tabOrder: number; + }>; + sections: Array<{ id: string; projectId: string; tabOrder: number }>; +} + +export interface V1TabOrderOutput { + workspaceTabOrder: Map<string, number>; + sectionTabOrder: Map<string, number>; +} + +export function computeNormalizedOrders( + input: V1TabOrderInput, +): V1TabOrderOutput { + const workspaceTabOrder = new Map<string, number>(); + const sectionTabOrder = new Map<string, number>(); + + const projectIds = new Set<string>(); + for (const w of input.workspaces) projectIds.add(w.projectId); + for (const s of input.sections) projectIds.add(s.projectId); + + for (const projectId of projectIds) { + const topLevelWorkspaces = input.workspaces + .filter((w) => w.projectId === projectId && w.sectionId === null) + .sort((a, b) => a.tabOrder - b.tabOrder); + + const sections = input.sections + .filter((s) => s.projectId === projectId) + .sort((a, b) => a.tabOrder - b.tabOrder); + + topLevelWorkspaces.forEach((w, index) => { + workspaceTabOrder.set(w.id, index); + }); + + sections.forEach((s, index) => { + sectionTabOrder.set(s.id, topLevelWorkspaces.length + index); + }); + + // Workspaces inside sections: keep order relative to their section peers. + const workspacesBySection = new Map< + string, + Array<(typeof input.workspaces)[number]> + >(); + for (const w of input.workspaces) { + if (w.projectId !== projectId || w.sectionId === null) continue; + const group = workspacesBySection.get(w.sectionId) ?? []; + group.push(w); + workspacesBySection.set(w.sectionId, group); + } + for (const [, group] of workspacesBySection) { + group + .sort((a, b) => a.tabOrder - b.tabOrder) + .forEach((w, index) => { + workspaceTabOrder.set(w.id, index); + }); + } + } + + return { workspaceTabOrder, sectionTabOrder }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/useMigrateV1DataToV2.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/useMigrateV1DataToV2.ts new file mode 100644 index 00000000000..2228cf0c47b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/useMigrateV1DataToV2.ts @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; +import { env } from "renderer/env.renderer"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; +import { authClient } from "renderer/lib/auth-client"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { MOCK_ORG_ID } from "shared/constants"; +import { type MigrationSummary, migrateV1DataToV2 } from "./migrate"; + +export type MigrationRunResult = + | { completed: true; summary: MigrationSummary } + | { completed: false; reason: string }; + +function getAttemptKey(organizationId: string): string { + return `v1-migration-attempted-${organizationId}`; +} + +function getSummaryKey(organizationId: string): string { + return `v1-migration-summary-${organizationId}`; +} + +function getShownKey(organizationId: string): string { + return `v1-migration-modal-shown-${organizationId}`; +} + +export const V1_MIGRATION_SUMMARY_EVENT = "v1-migration-summary-updated"; + +function persistSummary(organizationId: string, summary: MigrationSummary) { + localStorage.setItem( + getSummaryKey(organizationId), + JSON.stringify({ summary, createdAt: Date.now() }), + ); + localStorage.setItem(getShownKey(organizationId), "1"); + window.dispatchEvent( + new CustomEvent(V1_MIGRATION_SUMMARY_EVENT, { detail: { organizationId } }), + ); +} + +// Module-level singleton so every hook instance shares the same isRunning value. +// Without this, the auto-run from the dashboard layout and the manual rerun +// from settings each have their own isRunning ref, letting the user start a +// concurrent migration from settings while the auto-run is still in flight. +let activeMigrationCount = 0; +const migrationRunningSubscribers = new Set<() => void>(); + +function subscribeMigrationRunning(notify: () => void) { + migrationRunningSubscribers.add(notify); + return () => { + migrationRunningSubscribers.delete(notify); + }; +} + +function getMigrationRunningSnapshot() { + return activeMigrationCount > 0; +} + +function setMigrationRunning(running: boolean) { + activeMigrationCount = Math.max(0, activeMigrationCount + (running ? 1 : -1)); + for (const notify of migrationRunningSubscribers) notify(); +} + +/** + * Fires v1→v2 migration once per app launch when the dashboard first mounts + * with v2 enabled. Idempotent by design: + * - sessionStorage marker dedups within a session (blocks strict-mode double-invoke) + * - migration_state in the local DB tracks completed rows; subsequent runs + * reconcile success/linked project rows, skip completed workspace rows, and + * retry error rows plus parent-dependent workspace skips + * + * Reruns happen implicitly on app relaunch. No automatic retry timer or online + * listener — v2 requires the cloud to be reachable anyway, so a transient + * offline error resolves on the next launch. + */ +export function useMigrateV1DataToV2({ + autoRun = true, +}: { + autoRun?: boolean; +} = {}) { + const { data: session } = authClient.useSession(); + const { activeHostUrl } = useLocalHostService(); + const { isV2CloudEnabled } = useIsV2CloudEnabled(); + const collections = useCollections(); + const isRunning = useSyncExternalStore( + subscribeMigrationRunning, + getMigrationRunningSnapshot, + getMigrationRunningSnapshot, + ); + const organizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + const attemptedRef = useRef<string | null>(null); + + const runMigration = useCallback( + async ({ manual }: { manual: boolean }): Promise<MigrationRunResult> => { + if (!isV2CloudEnabled) { + return { completed: false, reason: "Superset v2 is not enabled" }; + } + if (!organizationId) { + return { completed: false, reason: "No active organization" }; + } + if (!activeHostUrl) { + return { completed: false, reason: "Host service is not ready" }; + } + if (activeMigrationCount > 0) { + return { completed: false, reason: "Migration is already running" }; + } + + const attemptKey = getAttemptKey(organizationId); + if (!manual) { + if (attemptedRef.current === organizationId) { + return { + completed: false, + reason: "Migration already ran in this session", + }; + } + if (sessionStorage.getItem(attemptKey) === "1") { + attemptedRef.current = organizationId; + return { + completed: false, + reason: "Migration already ran in this session", + }; + } + } + + attemptedRef.current = organizationId; + sessionStorage.setItem(attemptKey, "1"); + setMigrationRunning(true); + + try { + const hostService = getHostServiceClientByUrl(activeHostUrl); + const summary = await migrateV1DataToV2({ + organizationId, + electronTrpc: electronTrpcClient, + hostService, + collections, + }); + + // Persist summary unconditionally before any early-return paths — it's + // an idempotent side effect and must survive strict-mode effect + // teardowns that can happen between migration completion and here. + const didAnything = + summary.projectsCreated + + summary.projectsLinked + + summary.projectsErrored + + summary.workspacesCreated + + summary.workspacesSkipped + + summary.workspacesErrored > + 0; + const alreadyShown = + localStorage.getItem(getShownKey(organizationId)) === "1"; + if (manual || (didAnything && !alreadyShown)) { + persistSummary(organizationId, summary); + } + + if (summary.errors.length > 0) { + console.error("[v1-migration] errors", summary.errors); + } + return { completed: true, summary }; + } catch (err) { + // Clear marker so a relaunch can retry (e.g., transient cloud unreach + // before session fully hydrated). + sessionStorage.removeItem(attemptKey); + attemptedRef.current = null; + console.error("[v1-migration] fatal", err); + const reason = err instanceof Error ? err.message : String(err); + return { completed: false, reason }; + } finally { + setMigrationRunning(false); + } + }, + [activeHostUrl, collections, isV2CloudEnabled, organizationId], + ); + + useEffect(() => { + if (!autoRun) return; + void runMigration({ manual: false }); + }, [autoRun, runMigration]); + + const rerun = useCallback(async (): Promise<MigrationRunResult> => { + if (!organizationId) { + return { completed: false, reason: "No active organization" }; + } + sessionStorage.removeItem(getAttemptKey(organizationId)); + attemptedRef.current = null; + return runMigration({ manual: true }); + }, [organizationId, runMigration]); + + return { rerun, isRunning }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.test.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.test.ts new file mode 100644 index 00000000000..deaa996eb65 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, test } from "bun:test"; +import type { OrgCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; +import { + type SidebarInput, + type V1ProjectLike, + type V1SectionLike, + type V1WorkspaceLike, + writeV2SidebarState, +} from "./writeSidebarState"; + +interface InMemoryCollection<TValue, TKey extends string = string> { + get: (key: TKey) => TValue | undefined; + insert: (value: TValue) => void; + _values: () => TValue[]; +} + +function createCollection<TValue extends Record<string, unknown>>( + getKey: (v: TValue) => string, +): InMemoryCollection<TValue> { + const store = new Map<string, TValue>(); + return { + get: (key: string) => store.get(key), + insert: (value: TValue) => { + store.set(getKey(value), value); + }, + _values: () => Array.from(store.values()), + }; +} + +function makeCollections() { + const v2SidebarProjects = createCollection<{ + projectId: string; + tabOrder: number; + defaultOpenInApp: string | null; + isCollapsed: boolean; + createdAt: Date; + }>((v) => v.projectId); + const v2SidebarSections = createCollection<{ + sectionId: string; + projectId: string; + name: string; + tabOrder: number; + isCollapsed: boolean; + color: string | null; + createdAt: Date; + }>((v) => v.sectionId); + const v2WorkspaceLocalState = createCollection<{ + workspaceId: string; + sidebarState: { + projectId: string; + tabOrder: number; + sectionId: string | null; + changesFilter: { kind: string }; + }; + paneLayout: unknown; + viewedFiles: string[]; + recentlyViewedFiles: unknown[]; + createdAt: Date; + }>((v) => v.workspaceId); + + const collections = { + v2SidebarProjects, + v2SidebarSections, + v2WorkspaceLocalState, + } as unknown as OrgCollections; + + return { + collections, + v2SidebarProjects, + v2SidebarSections, + v2WorkspaceLocalState, + }; +} + +function project( + id: string, + tabOrder: number | null = 0, + defaultApp: string | null = null, +): V1ProjectLike { + return { id, tabOrder, defaultApp }; +} + +function section( + id: string, + projectId: string, + tabOrder: number, + overrides: Partial<V1SectionLike> = {}, +): V1SectionLike { + return { + id, + projectId, + tabOrder, + name: `section-${id}`, + isCollapsed: false, + color: null, + ...overrides, + }; +} + +function workspace( + id: string, + projectId: string, + tabOrder: number, + sectionId: string | null = null, +): V1WorkspaceLike { + return { id, projectId, tabOrder, sectionId }; +} + +function buildInput(partial: Partial<SidebarInput>): SidebarInput { + return { + projectV1ToV2: partial.projectV1ToV2 ?? new Map(), + workspaceV1ToV2: partial.workspaceV1ToV2 ?? new Map(), + v1Projects: partial.v1Projects ?? [], + v1Sections: partial.v1Sections ?? [], + v1Workspaces: partial.v1Workspaces ?? [], + }; +} + +describe("writeV2SidebarState", () => { + test("empty input writes nothing", () => { + const c = makeCollections(); + writeV2SidebarState(c.collections, buildInput({})); + expect(c.v2SidebarProjects._values()).toHaveLength(0); + expect(c.v2SidebarSections._values()).toHaveLength(0); + expect(c.v2WorkspaceLocalState._values()).toHaveLength(0); + }); + + test("migrated projects get sidebar entries with tabOrder + defaultApp", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([ + ["v1-p1", "v2-p1"], + ["v1-p2", "v2-p2"], + ]), + v1Projects: [project("v1-p1", 2, "cursor"), project("v1-p2", 5, null)], + }), + ); + const values = c.v2SidebarProjects._values(); + expect(values).toHaveLength(2); + const p1 = values.find((v) => v.projectId === "v2-p1"); + expect(p1?.tabOrder).toBe(2); + expect(p1?.defaultOpenInApp).toBe("cursor"); + const p2 = values.find((v) => v.projectId === "v2-p2"); + expect(p2?.tabOrder).toBe(5); + expect(p2?.defaultOpenInApp).toBe(null); + }); + + test("null tabOrder on v1 project falls back to 0", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["v1", "v2"]]), + v1Projects: [project("v1", null)], + }), + ); + expect(c.v2SidebarProjects._values()[0]?.tabOrder).toBe(0); + }); + + test("empty v1 section under a migrated project still migrates", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["v1-p", "v2-p"]]), + v1Projects: [project("v1-p")], + v1Sections: [ + section("sec-empty", "v1-p", 0, { + name: "Empty Group", + color: "#ff0000", + }), + ], + }), + ); + const sections = c.v2SidebarSections._values(); + expect(sections).toHaveLength(1); + expect(sections[0]?.name).toBe("Empty Group"); + expect(sections[0]?.projectId).toBe("v2-p"); + expect(sections[0]?.color).toBe("#ff0000"); + }); + + test("sections under un-migrated projects are skipped", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["v1-p1", "v2-p1"]]), + v1Projects: [project("v1-p1")], + v1Sections: [ + section("sec-a", "v1-p1", 0), + section("sec-b", "v1-untracked", 0), + ], + }), + ); + const sections = c.v2SidebarSections._values(); + expect(sections).toHaveLength(1); + expect(sections[0]?.projectId).toBe("v2-p1"); + }); + + test("workspace sectionId matches the v2 section id (deterministic from v1)", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["v1-p", "v2-p"]]), + workspaceV1ToV2: new Map([["v1-w", "v2-w"]]), + v1Projects: [project("v1-p")], + v1Sections: [section("v1-sec", "v1-p", 0)], + v1Workspaces: [workspace("v1-w", "v1-p", 0, "v1-sec")], + }), + ); + const ws = c.v2WorkspaceLocalState._values()[0]; + const sec = c.v2SidebarSections._values()[0]; + expect(ws?.sidebarState.sectionId).toBe(sec?.sectionId ?? "missing"); + // Reuses v1 id so reruns don't duplicate sections + expect(sec?.sectionId).toBe("v1-sec"); + }); + + test("rerun does not duplicate sections (idempotency)", () => { + const c = makeCollections(); + const input = buildInput({ + projectV1ToV2: new Map([["v1-p", "v2-p"]]), + v1Projects: [project("v1-p")], + v1Sections: [section("s1", "v1-p", 0), section("s2", "v1-p", 1)], + }); + writeV2SidebarState(c.collections, input); + writeV2SidebarState(c.collections, input); + writeV2SidebarState(c.collections, input); + expect(c.v2SidebarSections._values()).toHaveLength(2); + }); + + test("workspace pointing to non-existent v1 section ends up at top level", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["v1-p", "v2-p"]]), + workspaceV1ToV2: new Map([["v1-w", "v2-w"]]), + v1Projects: [project("v1-p")], + v1Sections: [], + v1Workspaces: [workspace("v1-w", "v1-p", 0, "v1-sec-missing")], + }), + ); + const ws = c.v2WorkspaceLocalState._values()[0]; + expect(ws?.sidebarState.sectionId).toBe(null); + }); + + test("only adopted workspaces (in workspaceV1ToV2) get sidebar entries", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["v1-p", "v2-p"]]), + workspaceV1ToV2: new Map([["v1-w1", "v2-w1"]]), + v1Projects: [project("v1-p")], + v1Workspaces: [ + workspace("v1-w1", "v1-p", 0), + workspace("v1-w2", "v1-p", 1), // not adopted + ], + }), + ); + const values = c.v2WorkspaceLocalState._values(); + expect(values).toHaveLength(1); + expect(values[0]?.workspaceId).toBe("v2-w1"); + }); + + test("workspace under un-migrated project is skipped", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["v1-p-ok", "v2-p-ok"]]), + workspaceV1ToV2: new Map([ + ["v1-w-ok", "v2-w-ok"], + ["v1-w-orphan", "v2-w-orphan"], + ]), + v1Projects: [project("v1-p-ok"), project("v1-p-missing")], + v1Workspaces: [ + workspace("v1-w-ok", "v1-p-ok", 0), + workspace("v1-w-orphan", "v1-p-missing", 0), + ], + }), + ); + const values = c.v2WorkspaceLocalState._values(); + expect(values).toHaveLength(1); + expect(values[0]?.workspaceId).toBe("v2-w-ok"); + }); + + test("tab orders apply the normalization rules", () => { + // v1: [Section A (0), Workspace X (1), Section B (2), Workspace Y (3)] + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["p", "v2-p"]]), + workspaceV1ToV2: new Map([ + ["X", "v2-X"], + ["Y", "v2-Y"], + ]), + v1Projects: [project("p")], + v1Sections: [section("A", "p", 0), section("B", "p", 2)], + v1Workspaces: [workspace("X", "p", 1), workspace("Y", "p", 3)], + }), + ); + const sections = c.v2SidebarSections._values(); + const workspaces = c.v2WorkspaceLocalState._values(); + // Top-level workspaces normalize to 0, 1; sections follow at 2, 3. + const xState = workspaces.find((w) => w.workspaceId === "v2-X"); + const yState = workspaces.find((w) => w.workspaceId === "v2-Y"); + expect(xState?.sidebarState.tabOrder).toBe(0); + expect(yState?.sidebarState.tabOrder).toBe(1); + const aSec = sections.find((s) => s.name === "section-A"); + const bSec = sections.find((s) => s.name === "section-B"); + expect(aSec?.tabOrder).toBe(2); + expect(bSec?.tabOrder).toBe(3); + }); + + test("idempotent: re-running with same input does not duplicate entries", () => { + const c = makeCollections(); + const input = buildInput({ + projectV1ToV2: new Map([["v1-p", "v2-p"]]), + workspaceV1ToV2: new Map([["v1-w", "v2-w"]]), + v1Projects: [project("v1-p")], + v1Sections: [section("sec", "v1-p", 0)], + v1Workspaces: [workspace("v1-w", "v1-p", 0)], + }); + writeV2SidebarState(c.collections, input); + writeV2SidebarState(c.collections, input); + expect(c.v2SidebarProjects._values()).toHaveLength(1); + // Note: section UUID is generated fresh per call; idempotency for sections + // relies on the outer migration calling writeV2SidebarState exactly once. + // Workspace entries re-use the same workspaceV1ToV2 mapping so they dedup. + expect(c.v2WorkspaceLocalState._values()).toHaveLength(1); + }); + + test("workspaces inside a section preserve within-section order", () => { + const c = makeCollections(); + writeV2SidebarState( + c.collections, + buildInput({ + projectV1ToV2: new Map([["p", "v2-p"]]), + workspaceV1ToV2: new Map([ + ["w1", "v2-w1"], + ["w2", "v2-w2"], + ["w3", "v2-w3"], + ]), + v1Projects: [project("p")], + v1Sections: [section("sec", "p", 0)], + v1Workspaces: [ + workspace("w1", "p", 7, "sec"), + workspace("w2", "p", 1, "sec"), + workspace("w3", "p", 4, "sec"), + ], + }), + ); + const workspaces = c.v2WorkspaceLocalState._values(); + const byId = new Map(workspaces.map((w) => [w.workspaceId, w])); + // w2 (v1 tabOrder=1) should come first, then w3 (4), then w1 (7) + expect(byId.get("v2-w2")?.sidebarState.tabOrder).toBe(0); + expect(byId.get("v2-w3")?.sidebarState.tabOrder).toBe(1); + expect(byId.get("v2-w1")?.sidebarState.tabOrder).toBe(2); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts new file mode 100644 index 00000000000..0084fa02784 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts @@ -0,0 +1,144 @@ +import type { WorkspaceState } from "@superset/panes"; +import type { OrgCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; +import { computeNormalizedOrders } from "./normalize"; + +const EMPTY_PANE_LAYOUT = { + version: 1, + tabs: [], + activeTabId: null, +} satisfies WorkspaceState<unknown>; + +/** + * v1 project row shape consumed by sidebar translation. Structural so callers + * can pass drizzle rows without coupling. + */ +export interface V1ProjectLike { + id: string; + tabOrder: number | null; + defaultApp: string | null; +} + +export interface V1SectionLike { + id: string; + projectId: string; + name: string; + tabOrder: number; + isCollapsed: boolean | null; + color: string | null; +} + +export interface V1WorkspaceLike { + id: string; + projectId: string; + sectionId: string | null; + tabOrder: number; +} + +export interface SidebarInput { + /** v1 project id → v2 project id, for projects that successfully migrated. */ + projectV1ToV2: Map<string, string>; + /** v1 workspace id → v2 workspace id, for workspaces that successfully adopted. */ + workspaceV1ToV2: Map<string, string>; + v1Projects: V1ProjectLike[]; + v1Sections: V1SectionLike[]; + v1Workspaces: V1WorkspaceLike[]; +} + +/** + * Translates v1 sidebar state (project order, sections, workspace order + + * section membership) into the three v2 collections that back the dashboard + * sidebar. Single entry point so the main migration loop only deals with + * cloud/host-service creates; all renderer-side collection writes live here. + * + * Tab orders are normalized via computeNormalizedOrders so top-level + * workspaces always sort before sections in v2 (v2 absorbs post-section + * top-level workspaces into the preceding section at render time — see + * useDashboardSidebarData.ts:343-357). + * + * Idempotent: each write checks collection.get(id) first, so rerunning over + * an already-populated sidebar is a no-op. + */ +export function writeV2SidebarState( + collections: OrgCollections, + input: SidebarInput, +): void { + const { workspaceTabOrder, sectionTabOrder } = computeNormalizedOrders({ + workspaces: input.v1Workspaces.map((w) => ({ + id: w.id, + projectId: w.projectId, + sectionId: w.sectionId, + tabOrder: w.tabOrder, + })), + sections: input.v1Sections.map((s) => ({ + id: s.id, + projectId: s.projectId, + tabOrder: s.tabOrder, + })), + }); + + // 1. Projects: write per-project sidebar meta (pin order + default app). + const v1ProjectsById = new Map(input.v1Projects.map((p) => [p.id, p])); + for (const [v1ProjectId, v2ProjectId] of input.projectV1ToV2) { + if (collections.v2SidebarProjects.get(v2ProjectId)) continue; + const v1Project = v1ProjectsById.get(v1ProjectId); + collections.v2SidebarProjects.insert({ + projectId: v2ProjectId, + createdAt: new Date(), + isCollapsed: false, + tabOrder: v1Project?.tabOrder ?? 0, + defaultOpenInApp: v1Project?.defaultApp ?? null, + }); + } + + // 2. Sections: create v2 sections for every v1 section under a migrated + // project. Reuse the v1 section id (already a UUID) as the v2 section + // id — deterministic mapping makes reruns idempotent and lets the + // `get(id)` guard actually dedup. Empty sections are preserved — v1 + // supports them as an organizational primitive and the user may have + // intentionally created one ahead of filling it. + const sectionV1ToV2 = new Map<string, string>(); + for (const v1Section of input.v1Sections) { + const v2ProjectId = input.projectV1ToV2.get(v1Section.projectId); + if (!v2ProjectId) continue; + const v2SectionId = v1Section.id; + sectionV1ToV2.set(v1Section.id, v2SectionId); + if (collections.v2SidebarSections.get(v2SectionId)) continue; + collections.v2SidebarSections.insert({ + sectionId: v2SectionId, + projectId: v2ProjectId, + name: v1Section.name, + createdAt: new Date(), + tabOrder: sectionTabOrder.get(v1Section.id) ?? v1Section.tabOrder, + isCollapsed: v1Section.isCollapsed ?? false, + color: v1Section.color ?? null, + }); + } + + // 3. Workspaces: per-workspace sidebar state (tab order + section + // membership + empty pane layout). Only adopted workspaces are + // included — skipped/errored workspaces have no v2 counterpart. + const v1WorkspacesById = new Map(input.v1Workspaces.map((w) => [w.id, w])); + for (const [v1WorkspaceId, v2WorkspaceId] of input.workspaceV1ToV2) { + if (collections.v2WorkspaceLocalState.get(v2WorkspaceId)) continue; + const v1Workspace = v1WorkspacesById.get(v1WorkspaceId); + if (!v1Workspace) continue; + const v2ProjectId = input.projectV1ToV2.get(v1Workspace.projectId); + if (!v2ProjectId) continue; + const v2SectionId = v1Workspace.sectionId + ? (sectionV1ToV2.get(v1Workspace.sectionId) ?? null) + : null; + collections.v2WorkspaceLocalState.insert({ + workspaceId: v2WorkspaceId, + createdAt: new Date(), + sidebarState: { + projectId: v2ProjectId, + tabOrder: workspaceTabOrder.get(v1WorkspaceId) ?? v1Workspace.tabOrder, + sectionId: v2SectionId, + changesFilter: { kind: "all" }, + }, + paneLayout: EMPTY_PANE_LAYOUT, + viewedFiles: [], + recentlyViewedFiles: [], + }); + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/index.ts new file mode 100644 index 00000000000..9a6522c4a0c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/index.ts @@ -0,0 +1 @@ +export { useMigrateV1PresetsToV2 } from "./useMigrateV1PresetsToV2"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts new file mode 100644 index 00000000000..81eb7e7905f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2/useMigrateV1PresetsToV2.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { MOCK_ORG_ID } from "shared/constants"; + +function getMigrationMarkerKey(organizationId: string): string { + return `v2-terminal-presets-migrated-${organizationId}`; +} + +/** + * Copies v1 main-process terminal presets into the v2TerminalPresets + * collection on first run per organization. v1's `getTerminalPresets` + * auto-initializes default agent presets on first call, so fresh users + * get a populated bar and users who customized v1 keep their presets. + * + * Uses the vanilla electronTrpcClient (ipcLink) instead of the React + * hook because V2PresetsBar is mounted inside WorkspaceTrpcProvider, + * which would route the request to the workspace HTTP server (404). + */ +export function useMigrateV1PresetsToV2() { + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const organizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : session?.session?.activeOrganizationId; + const migratedOrgRef = useRef<string | null>(null); + + useEffect(() => { + if (!organizationId) return; + if (migratedOrgRef.current === organizationId) return; + + const markerKey = getMigrationMarkerKey(organizationId); + if (localStorage.getItem(markerKey) === "1") { + migratedOrgRef.current = organizationId; + return; + } + + migratedOrgRef.current = organizationId; + + void (async () => { + try { + const v1Presets = + await electronTrpcClient.settings.getTerminalPresets.query(); + + const now = new Date(); + collections.v2TerminalPresets.insert( + v1Presets.map((v1Preset, index) => ({ + id: crypto.randomUUID(), + name: v1Preset.name, + description: v1Preset.description, + cwd: v1Preset.cwd, + commands: v1Preset.commands, + projectIds: v1Preset.projectIds ?? null, + pinnedToBar: v1Preset.pinnedToBar, + applyOnWorkspaceCreated: v1Preset.applyOnWorkspaceCreated, + applyOnNewTab: v1Preset.applyOnNewTab, + executionMode: v1Preset.executionMode ?? "new-tab", + tabOrder: index, + createdAt: now, + })), + ); + + localStorage.setItem(markerKey, "1"); + } catch { + migratedOrgRef.current = null; + } + })(); + }, [collections.v2TerminalPresets, organizationId]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/index.ts new file mode 100644 index 00000000000..7aa8751235c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/index.ts @@ -0,0 +1,4 @@ +export { + type PersistableTransaction, + useOptimisticCollectionActions, +} from "./useOptimisticCollectionActions"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts new file mode 100644 index 00000000000..1a762a7434e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useOptimisticCollectionActions/useOptimisticCollectionActions.ts @@ -0,0 +1,236 @@ +import type { TaskPriority, V2UsersHostRole } from "@superset/db/enums"; +import { toast } from "@superset/ui/sonner"; +import { useCallback, useMemo } from "react"; +import { isDesktopChatDevMode } from "renderer/lib/dev-chat"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +export type PersistableTransaction = { + isPersisted: { + promise: Promise<unknown>; + }; +}; + +interface V2ProjectPatch { + name?: string; + slug?: string; + repoCloneUrl?: string | null; + githubRepositoryId?: string | null; +} + +interface V2WorkspacePatch { + name?: string; + branch?: string; + hostId?: string; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + if (typeof error === "string" && error.trim()) { + return error; + } + + return "The local change was rolled back."; +} + +function useOptimisticMutationRunner() { + const reportFailure = useCallback( + (scope: string, title: string, error: unknown) => { + console.error(`[${scope}] ${title}:`, error); + toast.error(title, { + description: getErrorMessage(error), + }); + }, + [], + ); + + return useCallback( + ( + scope: string, + failureTitle: string, + mutation: () => PersistableTransaction, + ): PersistableTransaction | null => { + try { + const transaction = mutation(); + + void transaction.isPersisted.promise.catch((error) => { + reportFailure(scope, failureTitle, error); + }); + + return transaction; + } catch (error) { + reportFailure(scope, failureTitle, error); + return null; + } + }, + [reportFailure], + ); +} + +export function useOptimisticCollectionActions() { + const collections = useCollections(); + const runMutation = useOptimisticMutationRunner(); + + return useMemo(() => { + const runTaskMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.tasks", failureTitle, mutation); + + const runProjectMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.v2Projects", failureTitle, mutation); + + const runWorkspaceMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.v2Workspaces", failureTitle, mutation); + + const runChatSessionMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.chatSessions", failureTitle, mutation); + + const runUsersHostsMutation = ( + failureTitle: string, + mutation: () => PersistableTransaction, + ) => runMutation("optimistic.v2UsersHosts", failureTitle, mutation); + + return { + tasks: { + updateTitle: (taskId: string, title: string) => + runTaskMutation("Failed to update task title", () => + collections.tasks.update(taskId, (draft) => { + draft.title = title; + }), + ), + updateDescription: (taskId: string, description: string) => + runTaskMutation("Failed to update task description", () => + collections.tasks.update(taskId, (draft) => { + draft.description = description; + }), + ), + updateStatus: (taskId: string, statusId: string) => + runTaskMutation("Failed to update task status", () => + collections.tasks.update(taskId, (draft) => { + draft.statusId = statusId; + }), + ), + updatePriority: (taskId: string, priority: TaskPriority) => + runTaskMutation("Failed to update task priority", () => + collections.tasks.update(taskId, (draft) => { + draft.priority = priority; + }), + ), + updateAssignee: (taskId: string, assigneeId: string | null) => + runTaskMutation("Failed to update task assignee", () => + collections.tasks.update(taskId, (draft) => { + draft.assigneeId = assigneeId; + draft.assigneeExternalId = null; + draft.assigneeDisplayName = null; + draft.assigneeAvatarUrl = null; + }), + ), + deleteTask: (taskId: string) => + runTaskMutation("Failed to delete task", () => + collections.tasks.delete(taskId), + ), + }, + v2Projects: { + updateProject: (projectId: string, patch: V2ProjectPatch) => + runProjectMutation("Failed to update project", () => + collections.v2Projects.update(projectId, (draft) => { + if (patch.name !== undefined) { + draft.name = patch.name; + } + if (patch.slug !== undefined) { + draft.slug = patch.slug; + } + if (patch.repoCloneUrl !== undefined) { + draft.repoCloneUrl = patch.repoCloneUrl; + } + if (patch.githubRepositoryId !== undefined) { + draft.githubRepositoryId = patch.githubRepositoryId; + } + }), + ), + renameProject: (projectId: string, name: string) => + runProjectMutation("Failed to rename project", () => + collections.v2Projects.update(projectId, (draft) => { + draft.name = name; + }), + ), + updateRepository: (projectId: string, repoCloneUrl: string | null) => + runProjectMutation("Failed to update project repository", () => + collections.v2Projects.update(projectId, (draft) => { + draft.repoCloneUrl = repoCloneUrl; + draft.githubRepositoryId = null; + }), + ), + }, + v2Workspaces: { + updateWorkspace: (workspaceId: string, patch: V2WorkspacePatch) => + runWorkspaceMutation("Failed to update workspace", () => + collections.v2Workspaces.update(workspaceId, (draft) => { + if (patch.name !== undefined) { + draft.name = patch.name; + } + if (patch.branch !== undefined) { + draft.branch = patch.branch; + } + if (patch.hostId !== undefined) { + draft.hostId = patch.hostId; + } + }), + ), + renameWorkspace: (workspaceId: string, name: string) => + runWorkspaceMutation("Failed to rename workspace", () => + collections.v2Workspaces.update(workspaceId, (draft) => { + draft.name = name; + }), + ), + }, + chatSessions: { + deleteSession: (sessionId: string) => { + if (isDesktopChatDevMode()) return null; + + return runChatSessionMutation("Failed to delete chat session", () => + collections.chatSessions.delete(sessionId), + ); + }, + }, + v2UsersHosts: { + addMember: (input: { + hostId: string; + userId: string; + organizationId: string; + role?: V2UsersHostRole; + }) => + runUsersHostsMutation("Failed to add member", () => { + const now = new Date(); + return collections.v2UsersHosts.insert({ + hostId: input.hostId, + userId: input.userId, + organizationId: input.organizationId, + role: input.role ?? "member", + createdAt: now, + updatedAt: now, + }); + }), + removeMember: (rowKey: string) => + runUsersHostsMutation("Failed to remove member", () => + collections.v2UsersHosts.delete(rowKey), + ), + setMemberRole: (rowKey: string, role: V2UsersHostRole) => + runUsersHostsMutation("Failed to update role", () => + collections.v2UsersHosts.update(rowKey, (draft) => { + draft.role = role; + }), + ), + }, + }; + }, [collections, runMutation]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/index.ts new file mode 100644 index 00000000000..98df1dd16bd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/index.ts @@ -0,0 +1 @@ +export { useV2ProjectDefaultApp } from "./useV2ProjectDefaultApp"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/useV2ProjectDefaultApp.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/useV2ProjectDefaultApp.ts new file mode 100644 index 00000000000..3945f927f49 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useV2ProjectDefaultApp/useV2ProjectDefaultApp.ts @@ -0,0 +1,45 @@ +import type { ExternalApp } from "@superset/local-db"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; + +/** + * Single source of truth for the v2 per-project "open in" app choice — + * the value the user picked via the CMD+O menu in `V2OpenInMenuButton`. + * + * v2 stores this client-side in `v2SidebarProjects.defaultOpenInApp` + * (tanstack-db) because v2 projects are not in the v1 localDb tables + * that the server-side `resolveDefaultEditor` consults. Anywhere v2 code + * needs to read or write this preference should go through this hook so + * CMD+O and file-open flows stay in sync. + */ +export function useV2ProjectDefaultApp(projectId: string | undefined) { + const collections = useCollections(); + const { ensureProjectInSidebar } = useDashboardSidebarState(); + + const { data: rows = [] } = useLiveQuery( + (q) => + q + .from({ sp: collections.v2SidebarProjects }) + .where(({ sp }) => eq(sp.projectId, projectId ?? "")) + .select(({ sp }) => ({ defaultOpenInApp: sp.defaultOpenInApp })), + [collections, projectId], + ); + const app = + (rows[0]?.defaultOpenInApp as ExternalApp | null | undefined) ?? undefined; + + const setApp = useCallback( + (next: ExternalApp) => { + if (!projectId) return; + ensureProjectInSidebar(projectId); + collections.v2SidebarProjects.update(projectId, (draft) => { + draft.defaultOpenInApp = next; + }); + }, + [collections, ensureProjectInSidebar, projectId], + ); + + return { app, setApp }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 8083d6b0dec..61f654c1932 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -1,4 +1,4 @@ -import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import { Button } from "@superset/ui/button"; import { Spinner } from "@superset/ui/spinner"; import { @@ -8,7 +8,6 @@ import { useLocation, useNavigate, } from "@tanstack/react-router"; -import { useFeatureFlagEnabled } from "posthog-js/react"; import { useEffect, useRef } from "react"; import { DndProvider } from "react-dnd"; import { HiOutlineWifi } from "react-icons/hi2"; @@ -16,6 +15,7 @@ import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; import { Paywall } from "renderer/components/Paywall"; import { useUpdateListener } from "renderer/components/UpdateToast"; import { env } from "renderer/env.renderer"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { useOnlineStatus } from "renderer/hooks/useOnlineStatus"; import { authClient, getAuthToken } from "renderer/lib/auth-client"; import { dragDropManager } from "renderer/lib/dnd"; @@ -23,8 +23,8 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { showWorkspaceAutoNameWarningToast } from "renderer/lib/workspaces/showWorkspaceAutoNameWarningToast"; import { InitGitDialog } from "renderer/react-query/projects/InitGitDialog"; import { DashboardNewWorkspaceModal } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal"; +import { V1MigrationSummaryModal } from "renderer/routes/_authenticated/components/V1MigrationSummaryModal"; import { WorkspaceInitEffects } from "renderer/screens/main/components/WorkspaceInitEffects"; -import { useHotkeysSync } from "renderer/stores/hotkeys"; import { useSettingsStore } from "renderer/stores/settings-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; @@ -32,9 +32,13 @@ import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { MOCK_ORG_ID, NOTIFICATION_EVENTS } from "shared/constants"; import { AgentHooks } from "./components/AgentHooks"; +import { GlobalBrowserLifecycle } from "./components/GlobalBrowserLifecycle"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; +import { V2NotificationController } from "./components/V2NotificationController"; +import { createPierreWorker } from "./lib/pierreWorker"; import { CollectionsProvider } from "./providers/CollectionsProvider"; -import { HostServiceProvider } from "./providers/HostServiceProvider"; +import { DeletingWorkspacesProvider } from "./providers/DeletingWorkspacesProvider"; +import { LocalHostServiceProvider } from "./providers/LocalHostServiceProvider"; export const Route = createFileRoute("/_authenticated")({ component: AuthenticatedLayout, @@ -54,8 +58,7 @@ function AuthenticatedLayout() { const setOriginRoute = useSettingsStore((s) => s.setOriginRoute); const utils = electronTrpc.useUtils(); const shownWorkspaceInitWarningsRef = useRef(new Set<string>()); - const isV2CloudEnabled = - useFeatureFlagEnabled(FEATURE_FLAGS.V2_CLOUD) ?? false; + const { isV2CloudEnabled } = useIsV2CloudEnabled(); const isSignedIn = env.SKIP_ENV_VALIDATION || !!session?.user; const activeOrganizationId = env.SKIP_ENV_VALIDATION @@ -64,11 +67,33 @@ function AuthenticatedLayout() { useAgentHookListener(); useUpdateListener(); - useHotkeysSync(); // Update workspace-run pane state on terminal exit electronTrpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { + if ( + event.type === NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE && + event.data + ) { + localStorage.setItem("lastViewedWorkspaceId", event.data.workspaceId); + const source = event.data.source; + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: event.data.workspaceId }, + search: + source.type === "terminal" + ? { + terminalId: source.id, + focusRequestId: crypto.randomUUID(), + } + : { + chatSessionId: source.id, + focusRequestId: crypto.randomUUID(), + }, + }); + return; + } + if ( event.type !== NOTIFICATION_EVENTS.TERMINAL_EXIT || !event.data?.paneId @@ -174,19 +199,29 @@ function AuthenticatedLayout() { return ( <DndProvider manager={dragDropManager}> <CollectionsProvider> - <HostServiceProvider> - <AgentHooks /> - <Outlet /> - <WorkspaceInitEffects /> - {isV2CloudEnabled ? ( - <DashboardNewWorkspaceModal /> - ) : ( - <NewWorkspaceModal /> - )} - <InitGitDialog /> - <TeardownLogsDialog /> - <Paywall /> - </HostServiceProvider> + <GlobalBrowserLifecycle /> + <LocalHostServiceProvider> + <DeletingWorkspacesProvider> + <WorkerPoolContextProvider + poolOptions={{ workerFactory: createPierreWorker, poolSize: 8 }} + highlighterOptions={{ preferredHighlighter: "shiki-wasm" }} + > + <AgentHooks /> + <V2NotificationController /> + <Outlet /> + <V1MigrationSummaryModal /> + <WorkspaceInitEffects /> + {isV2CloudEnabled ? ( + <DashboardNewWorkspaceModal /> + ) : ( + <NewWorkspaceModal /> + )} + <InitGitDialog /> + <TeardownLogsDialog /> + <Paywall /> + </WorkerPoolContextProvider> + </DeletingWorkspacesProvider> + </LocalHostServiceProvider> </CollectionsProvider> </DndProvider> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/lib/pierreWorker/index.ts b/apps/desktop/src/renderer/routes/_authenticated/lib/pierreWorker/index.ts new file mode 100644 index 00000000000..282935242f7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/lib/pierreWorker/index.ts @@ -0,0 +1 @@ +export { createPierreWorker } from "./pierreWorker"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/lib/pierreWorker/pierreWorker.ts b/apps/desktop/src/renderer/routes/_authenticated/lib/pierreWorker/pierreWorker.ts new file mode 100644 index 00000000000..024d3bacf38 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/lib/pierreWorker/pierreWorker.ts @@ -0,0 +1,3 @@ +import PierreDiffsWorker from "@pierre/diffs/worker/worker.js?worker"; + +export const createPierreWorker = (): Worker => new PierreDiffsWorker(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx index ac8dcc86c13..f5562295086 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider.tsx @@ -4,6 +4,7 @@ import { useCallback, useContext, useEffect, + useMemo, useState, } from "react"; import { env } from "renderer/env.renderer"; @@ -55,16 +56,22 @@ export function CollectionsProvider({ children }: { children: ReactNode }) { preloadActiveOrganizationCollections(activeOrganizationId); }, [activeOrganizationId]); - const collections = activeOrganizationId - ? getCollections(activeOrganizationId) - : null; + const collections = useMemo( + () => (activeOrganizationId ? getCollections(activeOrganizationId) : null), + [activeOrganizationId], + ); + + const contextValue = useMemo<CollectionsContextType | null>( + () => (collections ? { ...collections, switchOrganization } : null), + [collections, switchOrganization], + ); - if (!collections || isSwitching) { + if (!contextValue || isSwitching) { return null; } return ( - <CollectionsContext.Provider value={{ ...collections, switchOrganization }}> + <CollectionsContext.Provider value={contextValue}> {children} </CollectionsContext.Provider> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index aae02543f58..d969fd12eff 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -1,8 +1,9 @@ import { snakeCamelMapper } from "@electric-sql/client"; import type { SelectAgentCommand, + SelectAutomation, + SelectAutomationRun, SelectChatSession, - SelectDevicePresence, SelectGithubPullRequest, SelectGithubRepository, SelectIntegrationConnection, @@ -10,25 +11,29 @@ import type { SelectMember, SelectOrganization, SelectProject, - SelectSessionHost, SelectSubscription, SelectTask, SelectTaskStatus, SelectUser, - SelectV2Device, - SelectV2DevicePresence, + SelectV2Client, + SelectV2Host, SelectV2Project, - SelectV2UsersDevices, + SelectV2UsersHosts, SelectV2Workspace, SelectWorkspace, } from "@superset/db/schema"; import type { AppRouter } from "@superset/trpc"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; +import { + createElectronSQLitePersistence, + persistedCollectionOptions, +} from "@tanstack/electron-db-sqlite-persistence"; import type { Collection, LocalStorageCollectionUtils, } from "@tanstack/react-db"; import { + BasicIndex, createCollection, localStorageCollectionOptions, } from "@tanstack/react-db"; @@ -42,6 +47,12 @@ import { type DashboardSidebarSectionRow, dashboardSidebarProjectSchema, dashboardSidebarSectionSchema, + type PendingWorkspaceRow, + pendingWorkspaceSchema, + type V2TerminalPresetRow, + type V2UserPreferencesRow, + v2TerminalPresetSchema, + v2UserPreferencesSchema, type WorkspaceLocalStateRow, workspaceLocalStateSchema, } from "./dashboardSidebarLocal"; @@ -50,6 +61,35 @@ const columnMapper = snakeCamelMapper(); const electricUrl = `${env.NEXT_PUBLIC_ELECTRIC_URL}/v1/shape`; +const persistence = createElectronSQLitePersistence({ + invoke: (channel, request) => window.ipcRenderer.invoke(channel, request), +}); + +const indexDefaults = { + autoIndex: "eager", + defaultIndexType: BasicIndex, +} as const; + +const createIndexedCollection = (( + config: Parameters<typeof createCollection>[0], +) => + createCollection({ ...config, ...indexDefaults })) as typeof createCollection; + +type ElectricSyncConfig = ReturnType<typeof electricCollectionOptions>; +const createPersistedElectricCollection = ((config: ElectricSyncConfig) => { + const persisted = persistedCollectionOptions({ + ...config, + persistence, + schemaVersion: 1, + // biome-ignore lint/suspicious/noExplicitAny: forces sync-wrapped overload + } as any); + return createCollection({ + ...persisted, + ...indexDefaults, + // biome-ignore lint/suspicious/noExplicitAny: persisted utils widen generics + } as any); +}) as unknown as typeof createCollection; + const apiKeyDisplaySchema = z.object({ id: z.string(), name: z.string().nullable(), @@ -69,24 +109,24 @@ export interface OrgCollections { tasks: Collection<SelectTask>; taskStatuses: Collection<SelectTaskStatus>; projects: Collection<SelectProject>; - v2Devices: Collection<SelectV2Device>; - v2DevicePresence: Collection<SelectV2DevicePresence>; + v2Hosts: Collection<SelectV2Host>; + v2Clients: Collection<SelectV2Client>; + v2UsersHosts: Collection<SelectV2UsersHosts>; v2Projects: Collection<SelectV2Project>; - v2UsersDevices: Collection<SelectV2UsersDevices>; v2Workspaces: Collection<SelectV2Workspace>; workspaces: Collection<SelectWorkspace>; members: Collection<SelectMember>; users: Collection<SelectUser>; invitations: Collection<SelectInvitation>; agentCommands: Collection<SelectAgentCommand>; - devicePresence: Collection<SelectDevicePresence>; integrationConnections: Collection<IntegrationConnectionDisplay>; subscriptions: Collection<SelectSubscription>; apiKeys: Collection<ApiKeyDisplay>; chatSessions: Collection<SelectChatSession>; - sessionHosts: Collection<SelectSessionHost>; githubRepositories: Collection<SelectGithubRepository>; githubPullRequests: Collection<SelectGithubPullRequest>; + automations: Collection<SelectAutomation>; + automationRuns: Collection<SelectAutomationRun>; v2SidebarProjects: Collection< DashboardSidebarProjectRow, string, @@ -108,6 +148,27 @@ export interface OrgCollections { typeof dashboardSidebarSectionSchema, z.input<typeof dashboardSidebarSectionSchema> >; + v2TerminalPresets: Collection< + V2TerminalPresetRow, + string, + LocalStorageCollectionUtils, + typeof v2TerminalPresetSchema, + z.input<typeof v2TerminalPresetSchema> + >; + pendingWorkspaces: Collection< + PendingWorkspaceRow, + string, + LocalStorageCollectionUtils, + typeof pendingWorkspaceSchema, + z.input<typeof pendingWorkspaceSchema> + >; + v2UserPreferences: Collection< + V2UserPreferencesRow, + string, + LocalStorageCollectionUtils, + typeof v2UserPreferencesSchema, + z.input<typeof v2UserPreferencesSchema> + >; } // Per-org collections cache @@ -138,7 +199,7 @@ const electricHeaders = { }, }; -const organizationsCollection = createCollection( +const organizationsCollection = createPersistedElectricCollection( electricCollectionOptions<SelectOrganization>({ id: "organizations", shapeOptions: { @@ -152,7 +213,7 @@ const organizationsCollection = createCollection( ); function createOrgCollections(organizationId: string): OrgCollections { - const tasks = createCollection( + const tasks = createPersistedElectricCollection( electricCollectionOptions<SelectTask>({ id: `tasks-${organizationId}`, shapeOptions: { @@ -165,11 +226,6 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - const item = transaction.mutations[0].modified; - const result = await apiClient.task.create.mutate(item); - return { txid: result.txid }; - }, onUpdate: async ({ transaction }) => { const { original, changes } = transaction.mutations[0]; const result = await apiClient.task.update.mutate({ @@ -186,7 +242,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const taskStatuses = createCollection( + const taskStatuses = createPersistedElectricCollection( electricCollectionOptions<SelectTaskStatus>({ id: `task_statuses-${organizationId}`, shapeOptions: { @@ -202,7 +258,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const projects = createCollection( + const projects = createPersistedElectricCollection( electricCollectionOptions<SelectProject>({ id: `projects-${organizationId}`, shapeOptions: { @@ -218,7 +274,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const v2Projects = createCollection( + const v2Projects = createPersistedElectricCollection( electricCollectionOptions<SelectV2Project>({ id: `v2_projects-${organizationId}`, shapeOptions: { @@ -231,58 +287,107 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + const githubRepositoryId = + changes.githubRepositoryId === null && + changes.repoCloneUrl !== undefined + ? undefined + : changes.githubRepositoryId; + const result = await apiClient.v2Project.update.mutate({ + id: original.id, + name: changes.name, + slug: changes.slug, + repoCloneUrl: changes.repoCloneUrl, + githubRepositoryId, + }); + return { txid: result.txid }; + }, }), ); - const v2Devices = createCollection( - electricCollectionOptions<SelectV2Device>({ - id: `v2_devices-${organizationId}`, + const v2Hosts = createPersistedElectricCollection( + electricCollectionOptions<SelectV2Host>({ + id: `v2_hosts-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "v2_devices", + table: "v2_hosts", organizationId, }, headers: electricHeaders, columnMapper, }, - getKey: (item) => item.id, + // Composite PK on (organization_id, machine_id); within an + // org-scoped collection, machineId alone is unique. + getKey: (item) => item.machineId, }), ); - const v2DevicePresence = createCollection( - electricCollectionOptions<SelectV2DevicePresence>({ - id: `v2_device_presence-${organizationId}`, + const v2Clients = createPersistedElectricCollection( + electricCollectionOptions<SelectV2Client>({ + id: `v2_clients-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "v2_device_presence", + table: "v2_clients", organizationId, }, headers: electricHeaders, columnMapper, }, - getKey: (item) => item.deviceId, + // Composite PK on (organization_id, user_id, machine_id); within + // an org-scoped collection, (user_id, machine_id) is unique. + getKey: (item) => `${item.userId}:${item.machineId}`, }), ); - const v2UsersDevices = createCollection( - electricCollectionOptions<SelectV2UsersDevices>({ - id: `v2_users_devices-${organizationId}`, + const v2UsersHosts = createPersistedElectricCollection( + electricCollectionOptions<SelectV2UsersHosts>({ + id: `v2_users_hosts-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "v2_users_devices", + table: "v2_users_hosts", organizationId, }, headers: electricHeaders, columnMapper, }, - getKey: (item) => item.id, + getKey: (item) => `${item.userId}:${item.hostId}`, + onInsert: async ({ transaction }) => { + const item = transaction.mutations[0].modified; + const result = await apiClient.v2Host.addMember.mutate({ + hostId: item.hostId, + userId: item.userId, + role: item.role, + }); + return { txid: result.txid }; + }, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + if (changes.role === undefined) { + throw new Error("Only role updates are supported on v2_users_hosts"); + } + const result = await apiClient.v2Host.setMemberRole.mutate({ + hostId: original.hostId, + userId: original.userId, + role: changes.role, + }); + return { txid: result.txid }; + }, + onDelete: async ({ transaction }) => { + const item = transaction.mutations[0].original; + const result = await apiClient.v2Host.removeMember.mutate({ + hostId: item.hostId, + userId: item.userId, + }); + return { txid: result.txid }; + }, }), ); - const v2Workspaces = createCollection( + const v2Workspaces = createPersistedElectricCollection( electricCollectionOptions<SelectV2Workspace>({ id: `v2_workspaces-${organizationId}`, shapeOptions: { @@ -295,10 +400,21 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + const { branch, hostId, name } = changes; + const result = await apiClient.v2Workspace.update.mutate({ + id: original.id, + branch, + hostId, + name, + }); + return { txid: result.txid }; + }, }), ); - const workspaces = createCollection( + const workspaces = createPersistedElectricCollection( electricCollectionOptions<SelectWorkspace>({ id: `workspaces-${organizationId}`, shapeOptions: { @@ -314,7 +430,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const members = createCollection( + const members = createPersistedElectricCollection( electricCollectionOptions<SelectMember>({ id: `members-${organizationId}`, shapeOptions: { @@ -330,7 +446,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const users = createCollection( + const users = createPersistedElectricCollection( electricCollectionOptions<SelectUser>({ id: `users-${organizationId}`, shapeOptions: { @@ -346,7 +462,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const invitations = createCollection( + const invitations = createPersistedElectricCollection( electricCollectionOptions<SelectInvitation>({ id: `invitations-${organizationId}`, shapeOptions: { @@ -362,7 +478,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const agentCommands = createCollection( + const agentCommands = createPersistedElectricCollection( electricCollectionOptions<SelectAgentCommand>({ id: `agent_commands-${organizationId}`, shapeOptions: { @@ -386,23 +502,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const devicePresence = createCollection( - electricCollectionOptions<SelectDevicePresence>({ - id: `device_presence-${organizationId}`, - shapeOptions: { - url: electricUrl, - params: { - table: "device_presence", - organizationId, - }, - headers: electricHeaders, - columnMapper, - }, - getKey: (item) => item.id, - }), - ); - - const integrationConnections = createCollection( + const integrationConnections = createPersistedElectricCollection( electricCollectionOptions<IntegrationConnectionDisplay>({ id: `integration_connections-${organizationId}`, shapeOptions: { @@ -418,7 +518,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const subscriptions = createCollection( + const subscriptions = createPersistedElectricCollection( electricCollectionOptions<SelectSubscription>({ id: `subscriptions-${organizationId}`, shapeOptions: { @@ -434,7 +534,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const apiKeys = createCollection( + const apiKeys = createPersistedElectricCollection( electricCollectionOptions<ApiKeyDisplay>({ id: `apikeys-${organizationId}`, shapeOptions: { @@ -450,7 +550,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const chatSessions = createCollection( + const chatSessions = createPersistedElectricCollection( electricCollectionOptions<SelectChatSession>({ id: `chat_sessions-${organizationId}`, shapeOptions: { @@ -463,16 +563,26 @@ function createOrgCollections(organizationId: string): OrgCollections { columnMapper, }, getKey: (item) => item.id, + onDelete: async ({ transaction }) => { + const item = transaction.mutations[0].original; + const result = await apiClient.chat.deleteSession.mutate({ + sessionId: item.id, + }); + if (!result.deleted) { + throw new Error("Chat session was not deleted"); + } + return { txid: result.txid }; + }, }), ); - const sessionHosts = createCollection( - electricCollectionOptions<SelectSessionHost>({ - id: `session_hosts-${organizationId}`, + const githubRepositories = createPersistedElectricCollection( + electricCollectionOptions<SelectGithubRepository>({ + id: `github_repositories-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "session_hosts", + table: "github_repositories", organizationId, }, headers: electricHeaders, @@ -482,13 +592,13 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const githubRepositories = createCollection( - electricCollectionOptions<SelectGithubRepository>({ - id: `github_repositories-${organizationId}`, + const githubPullRequests = createPersistedElectricCollection( + electricCollectionOptions<SelectGithubPullRequest>({ + id: `github_pull_requests-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "github_repositories", + table: "github_pull_requests", organizationId, }, headers: electricHeaders, @@ -498,13 +608,29 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const githubPullRequests = createCollection( - electricCollectionOptions<SelectGithubPullRequest>({ - id: `github_pull_requests-${organizationId}`, + const automations = createPersistedElectricCollection( + electricCollectionOptions<SelectAutomation>({ + id: `automations-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "github_pull_requests", + table: "automations", + organizationId, + }, + headers: electricHeaders, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + + const automationRuns = createPersistedElectricCollection( + electricCollectionOptions<SelectAutomationRun>({ + id: `automation_runs-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "automation_runs", organizationId, }, headers: electricHeaders, @@ -514,7 +640,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const v2SidebarProjects = createCollection( + const v2SidebarProjects = createIndexedCollection( localStorageCollectionOptions({ id: `v2_sidebar_projects-${organizationId}`, storageKey: `v2-sidebar-projects-${organizationId}`, @@ -523,7 +649,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const v2WorkspaceLocalState = createCollection( + const v2WorkspaceLocalState = createIndexedCollection( localStorageCollectionOptions({ id: `v2_workspace_local_state-${organizationId}`, storageKey: `v2-workspace-local-state-${organizationId}`, @@ -532,7 +658,7 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const v2SidebarSections = createCollection( + const v2SidebarSections = createIndexedCollection( localStorageCollectionOptions({ id: `v2_sidebar_sections-${organizationId}`, storageKey: `v2-sidebar-sections-${organizationId}`, @@ -541,31 +667,64 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); + const v2TerminalPresets = createIndexedCollection( + localStorageCollectionOptions({ + id: `v2_terminal_presets-${organizationId}`, + storageKey: `v2-terminal-presets-${organizationId}`, + schema: v2TerminalPresetSchema, + getKey: (item) => item.id, + }), + ); + + const pendingWorkspaces = createIndexedCollection( + localStorageCollectionOptions({ + id: `pending_workspaces-${organizationId}`, + storageKey: `pending-workspaces-${organizationId}`, + schema: pendingWorkspaceSchema, + getKey: (item) => item.id, + }), + ); + + const v2UserPreferences = createCollection( + localStorageCollectionOptions({ + id: `v2_user_preferences-${organizationId}`, + storageKey: `v2-user-preferences-${organizationId}`, + schema: v2UserPreferencesSchema, + // Cast widens the inferred literal "preferences" key to string so + // the collection slots into the shared OrgCollections.{...<TKey=string>} + // shape alongside the other v2 collections. + getKey: (item) => item.id as string, + }), + ); + return { tasks, taskStatuses, projects, - v2Devices, - v2DevicePresence, + v2Hosts, + v2Clients, + v2UsersHosts, v2Projects, - v2UsersDevices, v2Workspaces, workspaces, members, users, invitations, agentCommands, - devicePresence, integrationConnections, subscriptions, apiKeys, chatSessions, - sessionHosts, githubRepositories, githubPullRequests, + automations, + automationRuns, v2SidebarProjects, v2WorkspaceLocalState, v2SidebarSections, + v2TerminalPresets, + pendingWorkspaces, + v2UserPreferences, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/index.ts index 686fbd9e9f9..53b72976220 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/index.ts @@ -1 +1,2 @@ export * from "./schema"; +export * from "./sidebarVisibility"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 8ec7532de9b..8f7819fe9e6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -1,4 +1,4 @@ -import type { PaneWorkspaceState } from "@superset/pane-layout"; +import type { WorkspaceState } from "@superset/panes"; import { z } from "zod"; const persistedDateSchema = z @@ -10,9 +10,23 @@ export const dashboardSidebarProjectSchema = z.object({ createdAt: persistedDateSchema, isCollapsed: z.boolean().default(false), tabOrder: z.number().int().default(0), + defaultOpenInApp: z.string().nullable().default(null), }); -const paneWorkspaceStateSchema = z.custom<PaneWorkspaceState<unknown>>(); +const paneWorkspaceStateSchema = z.custom<WorkspaceState<unknown>>(); + +const changesFilterSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("all") }), + z.object({ kind: z.literal("uncommitted") }), + z.object({ kind: z.literal("commit"), hash: z.string() }), + z.object({ + kind: z.literal("range"), + fromHash: z.string(), + toHash: z.string(), + }), +]); + +export type ChangesFilter = z.infer<typeof changesFilterSchema>; export const workspaceLocalStateSchema = z.object({ workspaceId: z.string().uuid(), @@ -21,8 +35,21 @@ export const workspaceLocalStateSchema = z.object({ projectId: z.string().uuid(), tabOrder: z.number().int().default(0), sectionId: z.string().uuid().nullable().default(null), + changesFilter: changesFilterSchema.default({ kind: "all" }), + activeTab: z.enum(["changes", "files", "review"]).default("changes"), + isHidden: z.boolean().default(false), }), paneLayout: paneWorkspaceStateSchema, + viewedFiles: z.array(z.string()).default([]), + recentlyViewedFiles: z + .array( + z.object({ + relativePath: z.string(), + absolutePath: z.string(), + lastAccessedAt: z.number(), + }), + ) + .default([]), }); export const dashboardSidebarSectionSchema = z.object({ @@ -35,6 +62,146 @@ export const dashboardSidebarSectionSchema = z.object({ color: z.string().nullable().default(null), }); +const v2ExecutionModeSchema = z.enum([ + "split-pane", + "new-tab", + "new-tab-split-pane", +]); + +// projectIds uses plain z.string() (not uuid) because v1 accepts arbitrary +// string IDs and the migration copies them verbatim. +export const v2TerminalPresetSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().optional(), + cwd: z.string().default(""), + commands: z.array(z.string()).default([]), + projectIds: z.array(z.string()).nullable().default(null), + pinnedToBar: z.boolean().optional(), + applyOnWorkspaceCreated: z.boolean().optional(), + applyOnNewTab: z.boolean().optional(), + executionMode: v2ExecutionModeSchema.default("new-tab"), + tabOrder: z.number().int().default(0), + createdAt: persistedDateSchema, +}); + +// Structured shapes for pending-row payload fields. Previously these were +// `z.unknown()` which forced `as`-casts at every read site and hid malformed +// rows until they crashed a later consumer. Typing them here gives the +// collection real validation and lets consumers read fields directly. +const pendingHostTargetSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("local") }), + z.object({ kind: z.literal("host"), hostId: z.string() }), +]); + +const pendingLinkedIssueSchema = z.object({ + slug: z.string(), + title: z.string(), + source: z.enum(["github", "internal"]).optional(), + url: z.string().optional(), + taskId: z.string().optional(), + number: z.number().optional(), + state: z.enum(["open", "closed"]).optional(), +}); + +const pendingLinkedPRSchema = z.object({ + prNumber: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), +}); + +/** + * Transient dispatch intents written by the pending page after + * host-service.create resolves. Consumed by the V2 workspace page's + * useConsumePendingLaunch mount effect, then cleared. See + * apps/desktop/docs/V2_LAUNCH_CONTEXT.md "Dispatch architecture". + */ +const pendingTerminalLaunchSchema = z.object({ + command: z.string(), + name: z.string().optional(), + // Attachment filenames, already written to .superset/attachments/ + // by the pending page via workspaceTrpc.filesystem.writeFile. + attachmentNames: z.array(z.string()).default([]), +}); + +const pendingChatLaunchSchema = z.object({ + initialPrompt: z.string().optional(), + initialFiles: z + .array( + z.object({ + data: z.string(), + mediaType: z.string(), + filename: z.string().optional(), + }), + ) + .optional(), + model: z.string().optional(), + taskSlug: z.string().optional(), +}); + +export type PendingHostTarget = z.infer<typeof pendingHostTargetSchema>; +export type PendingLinkedIssue = z.infer<typeof pendingLinkedIssueSchema>; +export type PendingLinkedPR = z.infer<typeof pendingLinkedPRSchema>; +export type PendingTerminalLaunch = z.infer<typeof pendingTerminalLaunchSchema>; +export type PendingChatLaunch = z.infer<typeof pendingChatLaunchSchema>; + +export const pendingWorkspaceSchema = z.object({ + // Shared + id: z.string().uuid(), + projectId: z.string().uuid(), + hostTarget: pendingHostTargetSchema, + // Which mutation the pending page should run. See V2_WORKSPACE_CREATION.md §3. + // Defaults to "fork" for any rows that predate this field. + intent: z.enum(["fork", "checkout", "adopt", "pr-checkout"]).default("fork"), + name: z.string(), + // True iff `name` came from the friendly-random fallback (no user-typed + // title). Host-service uses this to decide whether to run the post-create + // AI rename — a user-typed title wins. Defaults to true for pre-field + // rows so behavior matches the unedited-name path. + workspaceNameWasAutoGenerated: z.boolean().default(true), + // fork: derived branch name from prompt; checkout/adopt: existing branch. + branchName: z.string(), + status: z.enum(["creating", "failed", "succeeded"]).default("creating"), + error: z.string().nullable().default(null), + workspaceId: z.string().nullable().default(null), + // Non-fatal messages from the procedure (e.g. "setup terminal failed"). + // Pending page renders these on success. + warnings: z.array(z.string()).default([]), + terminals: z + .array(z.object({ id: z.string(), role: z.string(), label: z.string() })) + .default([]), + createdAt: persistedDateSchema, + + // Fork-only (left at defaults for checkout/adopt). + prompt: z.string().default(""), + baseBranch: z.string().nullable().default(null), + // Picker hint: which form of `baseBranch` was selected. Lets the host- + // service skip re-resolution at create time so it can't be misled by a + // stale cached remote ref. Null when the caller didn't specify. + baseBranchSource: z + .enum(["local", "remote-tracking"]) + .nullable() + .default(null), + linkedIssues: z.array(pendingLinkedIssueSchema).default([]), + linkedPR: pendingLinkedPRSchema.nullable().default(null), + attachmentCount: z.number().int().default(0), + // User-selected agent from the modal. `"none"` = user explicitly chose not + // to launch; any other string = `AgentDefinitionId`; null = legacy rows + // (predating this field), treated as "use fallback". + agentId: z.string().nullable().default(null), + + // fork + checkout (irrelevant for adopt — worktree already exists). + runSetupScript: z.boolean().default(true), + + // Transient dispatch intents written after host-service.create resolves; + // consumed by the V2 workspace page on mount, then cleared to null. + terminalLaunch: pendingTerminalLaunchSchema.nullable().default(null), + chatLaunch: pendingChatLaunchSchema.nullable().default(null), +}); + +export type PendingWorkspaceRow = z.infer<typeof pendingWorkspaceSchema>; + export type DashboardSidebarProjectRow = z.infer< typeof dashboardSidebarProjectSchema >; @@ -42,3 +209,59 @@ export type WorkspaceLocalStateRow = z.infer<typeof workspaceLocalStateSchema>; export type DashboardSidebarSectionRow = z.infer< typeof dashboardSidebarSectionSchema >; +export type V2TerminalPresetRow = z.infer<typeof v2TerminalPresetSchema>; + +/** + * Singleton row of v2 user-scoped preferences. Today this carries link-click + * behavior only; add fields here as v2 grows additional preferences. + * + * fileLinks / urlLinks map click tiers (plain, ⌘, ⌘⇧) to an action: + * - null → tier is unbound (terminal shows a hint; chat/markdown no-op) + * - "pane" → open in an in-app pane (FilePane / BrowserPane) + * - "external" → open in the external app (editor / system browser) + * + * Terminal consumes all three tiers; 2-tier surfaces (chat, task markdown) + * read plain + meta and ignore metaShift. + */ +const linkActionSchema = z.enum(["pane", "external"]); + +export type LinkAction = z.infer<typeof linkActionSchema>; + +const linkTierMapSchema = z.object({ + plain: linkActionSchema.nullable(), + meta: linkActionSchema.nullable(), + metaShift: linkActionSchema.nullable(), +}); + +export type LinkTierMap = z.infer<typeof linkTierMapSchema>; +export type LinkTier = keyof LinkTierMap; + +const DEFAULT_LINK_TIER_MAP: LinkTierMap = { + plain: null, + meta: "pane", + metaShift: "external", +}; + +export const v2UserPreferencesSchema = z.object({ + id: z.literal("preferences"), + fileLinks: linkTierMapSchema.default(DEFAULT_LINK_TIER_MAP), + urlLinks: linkTierMapSchema.default(DEFAULT_LINK_TIER_MAP), + rightSidebarOpen: z.boolean().default(true), + rightSidebarTab: z.enum(["changes", "files"]).default("changes"), + rightSidebarWidth: z.number().default(340), + deleteLocalBranch: z.boolean().default(false), +}); + +export type V2UserPreferencesRow = z.infer<typeof v2UserPreferencesSchema>; + +export const V2_USER_PREFERENCES_ID = "preferences" as const; + +export const DEFAULT_V2_USER_PREFERENCES: V2UserPreferencesRow = { + id: V2_USER_PREFERENCES_ID, + fileLinks: DEFAULT_LINK_TIER_MAP, + urlLinks: DEFAULT_LINK_TIER_MAP, + rightSidebarOpen: true, + rightSidebarTab: "changes", + rightSidebarWidth: 340, + deleteLocalBranch: false, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/sidebarVisibility.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/sidebarVisibility.ts new file mode 100644 index 00000000000..9cf2b5c7d11 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/sidebarVisibility.ts @@ -0,0 +1,24 @@ +type SidebarWorkspaceVisibilitySource = + | { isHidden?: boolean | null } + | { sidebarState: { isHidden?: boolean | null } }; + +export function getSidebarWorkspaceIsHidden( + workspace: SidebarWorkspaceVisibilitySource, +): boolean { + if ("sidebarState" in workspace) { + return workspace.sidebarState.isHidden === true; + } + return workspace.isHidden === true; +} + +export function isSidebarWorkspaceVisible( + workspace: SidebarWorkspaceVisibilitySource, +): boolean { + return !getSidebarWorkspaceIsHidden(workspace); +} + +export function getVisibleSidebarWorkspaces< + Workspace extends SidebarWorkspaceVisibilitySource, +>(workspaces: readonly Workspace[]): Workspace[] { + return workspaces.filter(isSidebarWorkspaceVisible); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/DeletingWorkspacesProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/DeletingWorkspacesProvider.tsx new file mode 100644 index 00000000000..72f477304af --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/DeletingWorkspacesProvider.tsx @@ -0,0 +1,76 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +interface DeletingWorkspacesContextValue { + isDeleting: (workspaceId: string) => boolean; + markDeleting: (workspaceId: string) => void; + clearDeleting: (workspaceId: string) => void; +} + +const DeletingWorkspacesContext = + createContext<DeletingWorkspacesContextValue | null>(null); + +/** + * Tracks workspaces whose `workspaceCleanup.destroy` call is in flight. + * The sidebar hides these rows optimistically so users get instant feedback + * instead of watching the row sit there during the 10–20s destroy window. + * On error the caller calls `clearDeleting` and the row reappears; on + * success the row is naturally unmounted via `v2WorkspaceLocalState.delete`. + */ +export function DeletingWorkspacesProvider({ + children, +}: { + children: ReactNode; +}) { + const [ids, setIds] = useState<ReadonlySet<string>>(() => new Set()); + + const isDeleting = useCallback( + (workspaceId: string) => ids.has(workspaceId), + [ids], + ); + + const markDeleting = useCallback((workspaceId: string) => { + setIds((prev) => { + if (prev.has(workspaceId)) return prev; + const next = new Set(prev); + next.add(workspaceId); + return next; + }); + }, []); + + const clearDeleting = useCallback((workspaceId: string) => { + setIds((prev) => { + if (!prev.has(workspaceId)) return prev; + const next = new Set(prev); + next.delete(workspaceId); + return next; + }); + }, []); + + const value = useMemo( + () => ({ isDeleting, markDeleting, clearDeleting }), + [isDeleting, markDeleting, clearDeleting], + ); + + return ( + <DeletingWorkspacesContext.Provider value={value}> + {children} + </DeletingWorkspacesContext.Provider> + ); +} + +export function useDeletingWorkspaces() { + const ctx = useContext(DeletingWorkspacesContext); + if (!ctx) { + throw new Error( + "useDeletingWorkspaces must be used within DeletingWorkspacesProvider", + ); + } + return ctx; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/index.ts new file mode 100644 index 00000000000..be08e18fe85 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/DeletingWorkspacesProvider/index.ts @@ -0,0 +1,4 @@ +export { + DeletingWorkspacesProvider, + useDeletingWorkspaces, +} from "./DeletingWorkspacesProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx deleted file mode 100644 index cecb02bfe74..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/HostServiceProvider.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useLiveQuery } from "@tanstack/react-db"; -import { - createContext, - type ReactNode, - useContext, - useEffect, - useMemo, -} from "react"; -import { env } from "renderer/env.renderer"; -import { authClient } from "renderer/lib/auth-client"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { - getHostServiceClient, - type HostServiceClient, -} from "renderer/lib/host-service-client"; -import { MOCK_ORG_ID } from "shared/constants"; -import { useCollections } from "../CollectionsProvider"; - -export interface OrgService { - port: number; - url: string; - client: HostServiceClient; -} - -interface HostServiceContextValue { - /** Map of organizationId → { port, url, client } for all running services */ - services: Map<string, OrgService>; -} - -const HostServiceContext = createContext<HostServiceContextValue | null>(null); - -export function HostServiceProvider({ children }: { children: ReactNode }) { - const { data: session } = authClient.useSession(); - const collections = useCollections(); - const utils = electronTrpc.useUtils(); - - const activeOrganizationId = env.SKIP_ENV_VALIDATION - ? MOCK_ORG_ID - : (session?.session?.activeOrganizationId ?? null); - - const { data: organizations } = useLiveQuery( - (q) => q.from({ organizations: collections.organizations }), - [collections], - ); - - const orgIds = useMemo( - () => organizations?.map((o) => o.id) ?? [], - [organizations], - ); - - // Start a host service for every org - useEffect(() => { - for (const orgId of orgIds) { - utils.hostServiceManager.getLocalPort - .ensureData({ organizationId: orgId }) - .catch((err) => { - console.error( - `[host-service] Failed to start for org ${orgId}:`, - err, - ); - }); - } - }, [orgIds, utils]); - - // Query the active org's port reactively - const { data: activePortData } = - electronTrpc.hostServiceManager.getLocalPort.useQuery( - { organizationId: activeOrganizationId as string }, - { enabled: !!activeOrganizationId }, - ); - - // Build the services map from cached query data - const services = useMemo(() => { - const map = new Map<string, OrgService>(); - - const addOrg = (orgId: string, port: number) => { - map.set(orgId, { - port, - url: `http://127.0.0.1:${port}`, - client: getHostServiceClient(port), - }); - }; - - for (const orgId of orgIds) { - const cached = utils.hostServiceManager.getLocalPort.getData({ - organizationId: orgId, - }); - if (cached?.port) { - addOrg(orgId, cached.port); - } - } - - // Ensure active org is included even if orgIds hasn't updated yet - if ( - activeOrganizationId && - activePortData?.port && - !map.has(activeOrganizationId) - ) { - addOrg(activeOrganizationId, activePortData.port); - } - - return map; - }, [orgIds, utils, activeOrganizationId, activePortData]); - - const value = useMemo(() => ({ services }), [services]); - - return ( - <HostServiceContext.Provider value={value}> - {children} - </HostServiceContext.Provider> - ); -} - -export function useHostService(): HostServiceContextValue { - const context = useContext(HostServiceContext); - if (!context) { - throw new Error("useHostService must be used within HostServiceProvider"); - } - return context; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/index.ts deleted file mode 100644 index 0ae31c85955..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/HostServiceProvider/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { OrgService } from "./HostServiceProvider"; -export { - HostServiceProvider, - useHostService, -} from "./HostServiceProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx new file mode 100644 index 00000000000..5ef6fdfd5cc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/LocalHostServiceProvider.tsx @@ -0,0 +1,98 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, +} from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { setHostServiceSecret } from "renderer/lib/host-service-auth"; +import { MOCK_ORG_ID } from "shared/constants"; +import { useCollections } from "../CollectionsProvider"; + +interface LocalHostServiceContextValue { + machineId: string; + activeHostUrl: string | null; +} + +const LocalHostServiceContext = + createContext<LocalHostServiceContextValue | null>(null); + +export function LocalHostServiceProvider({ + children, +}: { + children: ReactNode; +}) { + const { data: session } = authClient.useSession(); + const collections = useCollections(); + const { mutate: startHostService } = + electronTrpc.hostServiceCoordinator.start.useMutation(); + + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + + const { data: organizations } = useLiveQuery( + (q) => q.from({ organizations: collections.organizations }), + [collections], + ); + + const organizationIds = useMemo( + () => organizations?.map((organization) => organization.id) ?? [], + [organizations], + ); + + useEffect(() => { + for (const organizationId of organizationIds) { + startHostService({ organizationId }); + } + }, [organizationIds, startHostService]); + + const { data: machineIdData } = electronTrpc.device.getMachineId.useQuery( + undefined, + { staleTime: Number.POSITIVE_INFINITY }, + ); + + const { data: activeConnection } = + electronTrpc.hostServiceCoordinator.getConnection.useQuery( + { organizationId: activeOrganizationId as string }, + { enabled: !!activeOrganizationId, refetchInterval: 5_000 }, + ); + + const value = useMemo<LocalHostServiceContextValue | null>(() => { + if (!machineIdData) return null; + const machineId = machineIdData.machineId; + + if (!activeConnection?.port) { + return { machineId, activeHostUrl: null }; + } + + const activeHostUrl = `http://127.0.0.1:${activeConnection.port}`; + if (activeConnection.secret) { + setHostServiceSecret(activeHostUrl, activeConnection.secret); + } + + return { machineId, activeHostUrl }; + }, [machineIdData, activeConnection]); + + if (!value) return null; + + return ( + <LocalHostServiceContext.Provider value={value}> + {children} + </LocalHostServiceContext.Provider> + ); +} + +export function useLocalHostService(): LocalHostServiceContextValue { + const context = useContext(LocalHostServiceContext); + if (!context) { + throw new Error( + "useLocalHostService must be used within LocalHostServiceProvider", + ); + } + return context; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/index.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/index.ts new file mode 100644 index 00000000000..cf441ab82ae --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/LocalHostServiceProvider/index.ts @@ -0,0 +1,4 @@ +export { + LocalHostServiceProvider, + useLocalHostService, +} from "./LocalHostServiceProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/AccountSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/AccountSettings.tsx index a2e4f5d40f3..ed8e00d59a8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/AccountSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/account/components/AccountSettings/AccountSettings.tsx @@ -10,6 +10,10 @@ import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + getImageExtensionFromMimeType, + parseBase64DataUrl, +} from "shared/file-types"; import { isItemVisible, SETTING_ITEM_ID, @@ -64,9 +68,8 @@ export function AccountSettings({ visibleItems }: AccountSettingsProps) { const result = await selectImageMutation.mutateAsync(); if (result.canceled || !result.dataUrl) return; - const mimeMatch = result.dataUrl.match(/^data:([^;]+);/); - const mimeType = mimeMatch?.[1] || "image/png"; - const ext = mimeType.split("/")[1] || "png"; + const { mimeType } = parseBase64DataUrl(result.dataUrl); + const ext = getImageExtensionFromMimeType(mimeType) ?? "png"; const uploadResult = await apiTrpcClient.user.uploadAvatar.mutate({ fileData: result.dataUrl, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/AgentCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/AgentCard.tsx index c659b912290..e25b99c7dd8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/AgentCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/AgentCard.tsx @@ -1,12 +1,12 @@ +import type { + AgentPresetPatch, + ResolvedAgentConfig, +} from "@superset/shared/agent-settings"; import { Card, CardContent } from "@superset/ui/card"; import { Collapsible, CollapsibleContent } from "@superset/ui/collapsible"; import { toast } from "@superset/ui/sonner"; import { useMemo, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import type { - AgentPresetPatch, - ResolvedAgentConfig, -} from "shared/utils/agent-settings"; import type { AgentCardProps, AgentEditableField } from "./agent-card.types"; import { buildAgentFieldPatch, @@ -27,11 +27,20 @@ export function AgentCard({ showTaskPrompts, }: AgentCardProps) { const utils = electronTrpc.useUtils(); + const isCustomTerminalAgent = + preset.source === "user" && preset.kind === "terminal"; const updatePreset = electronTrpc.settings.updateAgentPreset.useMutation({ onSuccess: async () => { await utils.settings.getAgentPresets.invalidate(); }, }); + const updateCustomAgent = electronTrpc.settings.updateCustomAgent.useMutation( + { + onSuccess: async () => { + await utils.settings.getAgentPresets.invalidate(); + }, + }, + ); const resetPreset = electronTrpc.settings.resetAgentPreset.useMutation({ onSuccess: async () => { await utils.settings.getAgentPresets.invalidate(); @@ -65,6 +74,11 @@ export function AgentCard({ setInputVersion((current) => current + 1); }; + const isMutating = + updatePreset.isPending || + updateCustomAgent.isPending || + resetPreset.isPending; + const mergePresetPatch = ( currentPreset: ResolvedAgentConfig, patch: AgentPresetPatch, @@ -116,10 +130,23 @@ export function AgentCard({ ); try { - const updatedPreset = await updatePreset.mutateAsync({ - id: preset.id, - patch, - }); + const updatedPreset = isCustomTerminalAgent + ? await updateCustomAgent.mutateAsync({ + id: preset.id, + patch: { + enabled: patch.enabled, + label: patch.label, + description: patch.description, + command: patch.command, + promptCommand: patch.promptCommand, + promptCommandSuffix: patch.promptCommandSuffix, + taskPromptTemplate: patch.taskPromptTemplate, + }, + }) + : await updatePreset.mutateAsync({ + id: preset.id, + patch, + }); if (updatedPreset) { utils.settings.getAgentPresets.setData(undefined, (currentPresets) => currentPresets?.map((candidate) => @@ -191,7 +218,7 @@ export function AgentCard({ isOpen={isOpen} showEnabled={showEnabled} enabled={preset.enabled} - isUpdatingEnabled={updatePreset.isPending || resetPreset.isPending} + isUpdatingEnabled={isMutating} onEnabledChange={handleEnabledChange} onToggle={() => handleOpenChange(!isOpen)} /> @@ -214,10 +241,9 @@ export function AgentCard({ onToggle={() => setShowPreview((current) => !current)} /> </CardContent> - <AgentCardActions - isResetting={resetPreset.isPending || updatePreset.isPending} - onReset={handleReset} - /> + {preset.source === "builtin" && ( + <AgentCardActions isResetting={isMutating} onReset={handleReset} /> + )} </CollapsibleContent> </Collapsible> </Card> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.types.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.types.ts index 48e450ec8b3..534f19d0952 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.types.ts @@ -1,4 +1,4 @@ -import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; +import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; export interface AgentCardProps { preset: ResolvedAgentConfig; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts new file mode 100644 index 00000000000..99ae32d4cfc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; +import { buildAgentFieldPatch } from "./agent-card.utils"; + +const BUILTIN_TERMINAL_PRESET: ResolvedAgentConfig = { + id: "claude", + source: "builtin", + kind: "terminal", + label: "Claude Code", + command: "claude", + promptCommand: "claude --print", + promptTransport: "argv", + taskPromptTemplate: "Task {{slug}}", + contextPromptTemplateSystem: "", + contextPromptTemplateUser: "", + enabled: true, + overriddenFields: [], +}; + +const CUSTOM_TERMINAL_PRESET: ResolvedAgentConfig = { + id: "custom:team-agent", + source: "user", + kind: "terminal", + label: "Team Agent", + command: "team-agent", + promptCommand: "team-agent --prompt", + promptTransport: "argv", + taskPromptTemplate: "Task {{slug}}", + contextPromptTemplateSystem: "", + contextPromptTemplateUser: "", + enabled: true, + overriddenFields: [], +}; + +describe("buildAgentFieldPatch", () => { + test("allows clearing the prompt command for custom terminal agents", () => { + expect( + buildAgentFieldPatch({ + preset: CUSTOM_TERMINAL_PRESET, + field: "promptCommand", + value: " ", + }), + ).toEqual({ + patch: { + promptCommand: "", + }, + }); + }); + + test("keeps prompt command required for builtin terminal agents", () => { + expect( + buildAgentFieldPatch({ + preset: BUILTIN_TERMINAL_PRESET, + field: "promptCommand", + value: " ", + }), + ).toEqual({ + error: "Prompt command is required for terminal agents.", + }); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.ts index ee5dbf5c328..f54f6462ab5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/agent-card.utils.ts @@ -4,7 +4,7 @@ import { type ResolvedAgentConfig, renderTaskPromptTemplate, validateTaskPromptTemplate, -} from "shared/utils/agent-settings"; +} from "@superset/shared/agent-settings"; import type { AgentEditableField } from "./agent-card.types"; const SAMPLE_TASK = { @@ -100,7 +100,9 @@ export function buildAgentFieldPatch({ }; } if (!value.trim()) { - return { error: "Prompt command is required for terminal agents." }; + return preset.source === "user" + ? { patch: { promptCommand: "" } } + : { error: "Prompt command is required for terminal agents." }; } return { patch: { promptCommand: value } }; case "promptCommandSuffix": diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardFields/AgentCardFields.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardFields/AgentCardFields.tsx index e486079b63d..9940b79c1e7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardFields/AgentCardFields.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardFields/AgentCardFields.tsx @@ -1,7 +1,7 @@ +import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; import { Input } from "@superset/ui/input"; import { Label } from "@superset/ui/label"; import { Textarea } from "@superset/ui/textarea"; -import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; import type { AgentEditableField } from "../../agent-card.types"; interface AgentCardFieldsProps { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardHeader/AgentCardHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardHeader/AgentCardHeader.tsx index ce3c421d1bb..3b3775cfb37 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardHeader/AgentCardHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardHeader/AgentCardHeader.tsx @@ -1,10 +1,12 @@ +import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; import { CardDescription, CardHeader, CardTitle } from "@superset/ui/card"; import { Switch } from "@superset/ui/switch"; +import { cn } from "@superset/ui/utils"; +import { ChevronDownIcon } from "lucide-react"; import { getPresetIcon, useIsDarkTheme, } from "renderer/assets/app-icons/preset-icons"; -import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; interface AgentCardHeaderProps { preset: ResolvedAgentConfig; @@ -58,7 +60,7 @@ export function AgentCardHeader({ </CardDescription> </div> </div> - <div className="flex shrink-0 items-center"> + <div className="flex shrink-0 items-center gap-3"> {showEnabled && ( <div className="flex items-center"> <Switch @@ -72,6 +74,13 @@ export function AgentCardHeader({ /> </div> )} + <ChevronDownIcon + aria-hidden="true" + className={cn( + "size-4 text-muted-foreground transition-transform duration-200", + isOpen && "rotate-180", + )} + /> </div> </div> </CardHeader> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardPreview/AgentCardPreview.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardPreview/AgentCardPreview.tsx index f5d232983f6..6c3994e9011 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardPreview/AgentCardPreview.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/AgentsSettings/components/AgentCard/components/AgentCardPreview/AgentCardPreview.tsx @@ -1,6 +1,6 @@ +import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; import { Button } from "@superset/ui/button"; import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; -import type { ResolvedAgentConfig } from "shared/utils/agent-settings"; interface AgentCardPreviewProps { preset: ResolvedAgentConfig; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/ApiKeysSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/ApiKeysSettings.tsx index 76c48a9edb2..927fc00cc00 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/ApiKeysSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/ApiKeysSettings.tsx @@ -88,14 +88,20 @@ export function ApiKeysSettings({ visibleItems }: ApiKeysSettingsProps) { }; const handleRevokeKey = (id: string, name: string | null) => { - alert.destructive({ + alert({ title: "Revoke API Key", description: `Are you sure you want to revoke "${name ?? "Unnamed Key"}"? This action cannot be undone.`, - confirmText: "Revoke", - onConfirm: async () => { - await authClient.apiKey.delete({ keyId: id }); - toast.success("API key revoked"); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Revoke", + variant: "destructive", + onClick: async () => { + await authClient.apiKey.delete({ keyId: id }); + toast.success("API key revoked"); + }, + }, + ], }); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx index 2f88f8d9a75..12cb52659a1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontFamilyCombobox/FontFamilyCombobox.tsx @@ -56,6 +56,11 @@ export function FontFamilyCombobox({ return { nerdFonts: nerd, monoFonts: mono, otherFonts: other }; }, [fonts]); + // Terminal fonts must be monospace — arbitrary free-form names would let + // users pick proportional fonts (see issue #3513), so the custom-entry + // escape hatches below are gated off for the terminal variant. + const allowCustomEntry = variant !== "terminal"; + const hasExactMatch = useMemo(() => { if (!search.trim()) return true; const lower = search.toLowerCase().trim(); @@ -120,7 +125,7 @@ export function FontFamilyCombobox({ /> <CommandList> <CommandEmpty> - {search.trim() ? ( + {allowCustomEntry && search.trim() ? ( <button type="button" className="w-full text-center cursor-pointer hover:underline" @@ -132,7 +137,7 @@ export function FontFamilyCombobox({ "No fonts found." )} </CommandEmpty> - {!hasExactMatch && search.trim() && ( + {allowCustomEntry && !hasExactMatch && search.trim() && ( <CommandGroup heading="Custom"> <CommandItem value={`__custom__${search.trim()}`} @@ -144,9 +149,9 @@ export function FontFamilyCombobox({ </CommandItem> </CommandGroup> )} - {variant === "terminal" && renderGroup("Nerd Fonts", nerdFonts)} + {renderGroup("Nerd Fonts", nerdFonts)} {renderGroup("Monospace", monoFonts)} - {renderGroup("Other", otherFonts)} + {variant !== "terminal" && renderGroup("Other", otherFonts)} </CommandList> </Command> </PopoverContent> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontPreview/FontPreview.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontPreview/FontPreview.tsx index 2d6560e15b9..07c25f72ea2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontPreview/FontPreview.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/FontSettingSection/components/FontPreview/FontPreview.tsx @@ -28,21 +28,16 @@ export const webSearchTool = createTool({ }, });`; -const TERMINAL_PREVIEW = `\u256D\u2500 mastra agent \u2500\u2500 feat/add-tool \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256E -\u2502 \u2713 Created inputSchema with zod \u2502 -\u2502 \u2713 Wired execute handler \u2502 -\u2502 \u2BFF Running tool integration tests... \u2502 -\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F -\u256D\u2500 mastra agent \u2500\u2500 fix/workspace-sandbox \u2500\u2500\u256E -\u2502 \u2713 Patched LocalSandbox timeout \u2502 -\u2502 \u2713 Updated workspace config \u2502 -\u2502 \u2713 All 5 tests passing \u2502 -\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F -\u256D\u2500 mastra agent \u2500\u2500 chore/mcp-server \u2500\u2500\u2500\u2500\u2500\u2500\u256E -\u2502 \u2BFF Registering tools with MCP server... \u2502 -\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256F +const TERMINAL_PREVIEW = `~/agent $ mastra dev +\u2192 Loaded 3 tools \u00B7 1 agent \u00B7 0 workflows +\u2192 Listening on http://localhost:4111 - 3 agents running \u00B7 2 workspaces \u00B7 8 files changed +~/agent $ mastra test + \u2713 web-search.test.ts (4) 47ms + \u2713 fetch-url.test.ts (7) 62ms + \u2713 researcher.test.ts (3) 91ms + + Files 3 passed \u00B7 Tests 14 passed \u00B7 0.24s Friends don't let friends compact.`; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/SystemThemeCard/SystemThemeCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/SystemThemeCard/SystemThemeCard.tsx index e25ac770474..04dbf81257b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/SystemThemeCard/SystemThemeCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/SystemThemeCard/SystemThemeCard.tsx @@ -1,131 +1,191 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { cn } from "@superset/ui/utils"; import { HiCheck } from "react-icons/hi2"; -import { darkTheme, lightTheme } from "shared/themes"; +import { getTerminalColors, type Theme } from "shared/themes"; interface SystemThemeCardProps { isSelected: boolean; onSelect: () => void; + darkTheme: Theme; + lightTheme: Theme; + allThemes: Theme[]; + onSystemThemePreferenceChange: ( + mode: "light" | "dark", + themeId: string, + ) => void; } export function SystemThemeCard({ isSelected, onSelect, + darkTheme, + lightTheme, + allThemes, + onSystemThemePreferenceChange, }: SystemThemeCardProps) { - const darkTerminal = darkTheme.terminal; - const lightTerminal = lightTheme.terminal; - - if (!darkTerminal || !lightTerminal) { - return null; - } + const darkTerminal = getTerminalColors(darkTheme); + const lightTerminal = getTerminalColors(lightTheme); return ( - <button - type="button" - onClick={onSelect} - className={cn( - "relative flex flex-col rounded-lg border-2 overflow-hidden transition-all text-left", - isSelected - ? "border-primary ring-2 ring-primary/20" - : "border-border hover:border-muted-foreground/50", - )} - > - {/* Theme Preview - Split view */} - <div className="h-28 flex overflow-hidden"> - {/* Dark half */} - <div - className="flex-1 p-3 flex flex-col justify-between" - style={{ backgroundColor: darkTerminal.background }} - > - <div className="space-y-1"> - <div className="flex items-center gap-1"> - <span - className="text-[11px] font-mono" - style={{ color: darkTerminal.green }} - > - $ - </span> - <span + <div className="flex flex-col gap-2"> + <button + type="button" + onClick={onSelect} + className={cn( + "relative flex flex-col rounded-lg border-2 overflow-hidden transition-all text-left", + isSelected + ? "border-primary ring-2 ring-primary/20" + : "border-border hover:border-muted-foreground/50", + )} + > + {/* Theme Preview - Split view */} + <div className="h-28 flex overflow-hidden"> + {/* Dark half */} + <div + className="flex-1 p-3 flex flex-col justify-between" + style={{ backgroundColor: darkTerminal.background }} + > + <div className="space-y-1"> + <div className="flex items-center gap-1"> + <span + className="text-[11px] font-mono" + style={{ color: darkTerminal.green }} + > + $ + </span> + <span + className="text-[11px] font-mono" + style={{ color: darkTerminal.foreground }} + > + dev + </span> + </div> + <div className="text-[11px] font-mono" - style={{ color: darkTerminal.foreground }} + style={{ color: darkTerminal.cyan }} > - dev - </span> + Starting... + </div> </div> - <div - className="text-[11px] font-mono" - style={{ color: darkTerminal.cyan }} - > - Starting... + <div className="flex gap-0.5 mt-2"> + {[darkTerminal.red, darkTerminal.green, darkTerminal.yellow].map( + (color) => ( + <div + key={color} + className="h-2 w-3 rounded-sm" + style={{ backgroundColor: color }} + /> + ), + )} </div> </div> - <div className="flex gap-0.5 mt-2"> - {[darkTerminal.red, darkTerminal.green, darkTerminal.yellow].map( - (color) => ( - <div - key={color} - className="h-2 w-3 rounded-sm" - style={{ backgroundColor: color }} - /> - ), - )} - </div> - </div> - {/* Light half */} - <div - className="flex-1 p-3 flex flex-col justify-between border-l border-border/20" - style={{ backgroundColor: lightTerminal.background }} - > - <div className="space-y-1"> - <div className="flex items-center gap-1"> - <span - className="text-[11px] font-mono" - style={{ color: lightTerminal.green }} - > - $ - </span> - <span + {/* Light half */} + <div + className="flex-1 p-3 flex flex-col justify-between border-l border-border/20" + style={{ backgroundColor: lightTerminal.background }} + > + <div className="space-y-1"> + <div className="flex items-center gap-1"> + <span + className="text-[11px] font-mono" + style={{ color: lightTerminal.green }} + > + $ + </span> + <span + className="text-[11px] font-mono" + style={{ color: lightTerminal.foreground }} + > + dev + </span> + </div> + <div className="text-[11px] font-mono" - style={{ color: lightTerminal.foreground }} + style={{ color: lightTerminal.cyan }} > - dev - </span> - </div> - <div - className="text-[11px] font-mono" - style={{ color: lightTerminal.cyan }} - > - Starting... + Starting... + </div> </div> - </div> - <div className="flex gap-0.5 mt-2"> - {[lightTerminal.red, lightTerminal.green, lightTerminal.yellow].map( - (color) => ( + <div className="flex gap-0.5 mt-2"> + {[ + lightTerminal.red, + lightTerminal.green, + lightTerminal.yellow, + ].map((color) => ( <div key={color} className="h-2 w-3 rounded-sm" style={{ backgroundColor: color }} /> - ), - )} + ))} + </div> </div> </div> - </div> - {/* Theme Info */} - <div className="p-3 bg-card border-t flex items-center justify-between"> - <div> - <div className="text-sm font-medium">System</div> - <div className="text-xs text-muted-foreground"> - Follows OS preference + {/* Theme Info */} + <div className="p-3 bg-card border-t flex items-center justify-between"> + <div> + <div className="text-sm font-medium">System</div> + <div className="text-xs text-muted-foreground"> + Follows OS preference + </div> </div> + {isSelected && ( + <div className="h-5 w-5 rounded-full bg-primary flex items-center justify-center"> + <HiCheck className="h-3 w-3 text-primary-foreground" /> + </div> + )} </div> - {isSelected && ( - <div className="h-5 w-5 rounded-full bg-primary flex items-center justify-center"> - <HiCheck className="h-3 w-3 text-primary-foreground" /> + </button> + + {/* Theme preference selectors (shown when system theme is selected) */} + {isSelected && ( + <div className="flex flex-col gap-2 px-1"> + <div className="flex items-center justify-between gap-2"> + <span className="text-xs text-muted-foreground">Light theme</span> + <Select + value={lightTheme.id} + onValueChange={(id) => onSystemThemePreferenceChange("light", id)} + > + <SelectTrigger size="sm" className="h-7 text-xs w-auto min-w-28"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {allThemes.map((t) => ( + <SelectItem key={t.id} value={t.id}> + {t.name} + </SelectItem> + ))} + </SelectContent> + </Select> </div> - )} - </div> - </button> + <div className="flex items-center justify-between gap-2"> + <span className="text-xs text-muted-foreground">Dark theme</span> + <Select + value={darkTheme.id} + onValueChange={(id) => onSystemThemePreferenceChange("dark", id)} + > + <SelectTrigger size="sm" className="h-7 text-xs w-auto min-w-28"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {allThemes.map((t) => ( + <SelectItem key={t.id} value={t.id}> + {t.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + )} + </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/ThemeCard/ThemeCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/ThemeCard/ThemeCard.tsx index 9625c0bd228..1fadbf9e44a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/ThemeCard/ThemeCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/ThemeCard/ThemeCard.tsx @@ -1,3 +1,4 @@ +import { ThemePreviewCard } from "@superset/ui/theme-preview-card"; import { cn } from "@superset/ui/utils"; import { HiCheck } from "react-icons/hi2"; import { getTerminalColors, type Theme } from "shared/themes"; @@ -10,8 +11,6 @@ interface ThemeCardProps { export function ThemeCard({ theme, isSelected, onSelect }: ThemeCardProps) { const terminal = getTerminalColors(theme); - const bgColor = terminal.background; - const fgColor = terminal.foreground; const accentColors = [ terminal.red, terminal.green, @@ -25,71 +24,32 @@ export function ThemeCard({ theme, isSelected, onSelect }: ThemeCardProps) { <button type="button" onClick={onSelect} - className={cn( - "relative flex flex-col rounded-lg border-2 overflow-hidden transition-all text-left", - isSelected - ? "border-primary ring-2 ring-primary/20" - : "border-border hover:border-muted-foreground/50", - )} + aria-pressed={isSelected} + className="w-full text-left" > - {/* Theme Preview */} - <div - className="h-28 p-3 flex flex-col justify-between" - style={{ backgroundColor: bgColor }} - > - {/* Fake terminal content */} - <div className="space-y-1"> - <div className="flex items-center gap-1"> - <span - className="text-[11px] font-mono" - style={{ color: terminal.green }} - > - $ - </span> - <span className="text-[11px] font-mono" style={{ color: fgColor }}> - npm run dev - </span> - </div> - <div - className="text-[11px] font-mono" - style={{ color: terminal.cyan }} - > - Starting development server... - </div> - <div - className="text-[11px] font-mono" - style={{ color: terminal.yellow }} - > - Ready on http://localhost:3000 - </div> - </div> - - {/* Color palette strip */} - <div className="flex gap-1 mt-2"> - {accentColors.map((color) => ( - <div - key={color} - className="h-2 w-5 rounded-sm" - style={{ backgroundColor: color }} - /> - ))} - </div> - </div> - - {/* Theme Info */} - <div className="p-3 bg-card border-t flex items-center justify-between"> - <div> - <div className="text-sm font-medium">{theme.name}</div> - {theme.author && ( - <div className="text-xs text-muted-foreground">{theme.author}</div> - )} - </div> - {isSelected && ( - <div className="h-5 w-5 rounded-full bg-primary flex items-center justify-center"> - <HiCheck className="h-3 w-3 text-primary-foreground" /> - </div> + <ThemePreviewCard + name={theme.name} + subtitle={theme.author} + backgroundColor={terminal.background} + foregroundColor={terminal.foreground} + promptColor={terminal.green} + infoColor={terminal.cyan} + readyColor={terminal.yellow} + palette={accentColors} + className={cn( + "border-2 transition-all", + isSelected + ? "border-primary ring-2 ring-primary/20" + : "border-border hover:border-muted-foreground/50", )} - </div> + footerRight={ + isSelected ? ( + <div className="flex h-5 w-5 items-center justify-center rounded-full bg-primary"> + <HiCheck className="h-3 w-3 text-primary-foreground" /> + </div> + ) : null + } + /> </button> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/ThemeSection/ThemeSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/ThemeSection/ThemeSection.tsx index d9fb2206d76..e03b2f88a96 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/ThemeSection/ThemeSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/appearance/components/AppearanceSettings/components/ThemeSection/ThemeSection.tsx @@ -9,12 +9,17 @@ import { } from "react-icons/hi2"; import { SYSTEM_THEME_ID, + useSetSystemThemePreference, useSetTheme, + useSystemDarkThemeId, + useSystemLightThemeId, useThemeId, useThemeStore, } from "renderer/stores"; import { builtInThemes, + darkTheme as defaultDarkTheme, + lightTheme as defaultLightTheme, getTerminalColors, parseThemeConfigFile, } from "shared/themes"; @@ -31,9 +36,23 @@ export function ThemeSection() { const activeTheme = useThemeStore((state) => state.activeTheme); const customThemes = useThemeStore((state) => state.customThemes); const upsertCustomThemes = useThemeStore((state) => state.upsertCustomThemes); + const systemLightThemeId = useSystemLightThemeId(); + const systemDarkThemeId = useSystemDarkThemeId(); + const setSystemThemePreference = useSetSystemThemePreference(); const allThemes = [...builtInThemes, ...customThemes]; + // Resolve system theme preference IDs to actual theme objects. + // Fallback chain ensures we always get a theme with terminal colors. + const systemLightTheme = + allThemes.find((t) => t.id === systemLightThemeId) ?? + builtInThemes.find((t) => t.id === "light") ?? + defaultLightTheme; + const systemDarkTheme = + allThemes.find((t) => t.id === systemDarkThemeId) ?? + builtInThemes.find((t) => t.id === "dark") ?? + defaultDarkTheme; + const handleImport = async (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; @@ -124,8 +143,30 @@ export function ThemeSection() { return ( <div> - <div className="mb-4 flex flex-wrap items-center justify-between gap-2"> - <h3 className="text-sm font-medium">Theme</h3> + <div className="mb-4 flex flex-wrap items-start justify-between gap-3"> + <div> + <h3 className="text-sm font-medium">Theme</h3> + <div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> + <a + href={`${COMPANY.MARKETING_URL}/marketplace/themes`} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1 hover:text-foreground hover:underline" + > + Marketplace + <HiOutlineArrowTopRightOnSquare className="h-3 w-3" /> + </a> + <a + href={`${COMPANY.DOCS_URL}/custom-themes`} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1 hover:text-foreground hover:underline" + > + Docs + <HiOutlineArrowTopRightOnSquare className="h-3 w-3" /> + </a> + </div> + </div> <div className="flex flex-wrap items-center gap-2 justify-end"> <input ref={fileInputRef} @@ -153,21 +194,16 @@ export function ThemeSection() { <HiOutlineArrowDownTray className="mr-1.5 h-4 w-4" /> Download Base File </Button> - <a - href={`${COMPANY.DOCS_URL}/custom-themes`} - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline" - > - Theme docs - <HiOutlineArrowTopRightOnSquare className="h-3 w-3" /> - </a> </div> </div> - <div className="grid grid-cols-2 lg:grid-cols-3 gap-4"> + <div className="grid grid-cols-2 lg:grid-cols-3 gap-4 items-start"> <SystemThemeCard isSelected={activeThemeId === SYSTEM_THEME_ID} onSelect={() => setTheme(SYSTEM_THEME_ID)} + darkTheme={systemDarkTheme} + lightTheme={systemLightTheme} + allThemes={allThemes} + onSystemThemePreferenceChange={setSystemThemePreference} /> {allThemes.map((theme) => ( <ThemeCard diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/billing/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/billing/constants.ts index 9af00ffa30c..1c06e29f80e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/billing/constants.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/billing/constants.ts @@ -1,5 +1,6 @@ -export const PLAN_TIERS = ["free", "pro", "enterprise"] as const; -export type PlanTier = (typeof PLAN_TIERS)[number]; +import { PLAN_TIERS, type PlanTier } from "@superset/shared/billing"; + +export { PLAN_TIERS, type PlanTier }; export interface PlanFeature { id: string; @@ -48,6 +49,7 @@ export const PLANS: Record<PlanTier, Plan> = { { id: "workspaces", name: "Up to 5 workspaces", included: true }, { id: "local-only", name: "Local workspaces only", included: true }, { id: "desktop-app", name: "Desktop app", included: true }, + { id: "github", name: "GitHub integration", included: true }, ], cta: { text: "Current plan", action: "current", disabled: true }, }, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/billing/plans/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/billing/plans/page.tsx index e38003e5022..7ed724627f0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/billing/plans/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/billing/plans/page.tsx @@ -1,3 +1,4 @@ +import { Badge } from "@superset/ui/badge"; import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { Switch } from "@superset/ui/switch"; @@ -49,7 +50,7 @@ type ComparisonValue = string | boolean | null; type ComparisonRow = { label: string; values: ComparisonValue[]; - comingSoon?: boolean; + badge?: { label: string; variant: "default" | "secondary" }; }; type ComparisonSection = { @@ -135,23 +136,31 @@ const COMPARISON_SECTIONS: ComparisonSection[] = [ values: [true, true, true], }, { - label: "GitHub integration", + label: "Remote workspaces", values: [null, true, true], + badge: { label: "Beta", variant: "default" }, }, { - label: "Cloud workspaces", + label: "Automations", values: [null, true, true], - comingSoon: true, }, { label: "Mobile app", values: [null, true, true], - comingSoon: true, + badge: { label: "Coming soon", variant: "secondary" }, + }, + { + label: "GitHub integration", + values: [true, true, true], }, { label: "Linear integration", values: [null, true, true], }, + { + label: "Slack integration", + values: [null, true, true], + }, { label: "Team collaboration", values: [null, true, true], @@ -579,10 +588,13 @@ function PlansPage() { <Fragment key={row.label}> <div className="flex items-center gap-1.5 px-2 py-2.5 text-xs text-muted-foreground"> {row.label} - {row.comingSoon && ( - <span className="text-[10px] text-muted-foreground/60"> - (Coming Soon) - </span> + {row.badge && ( + <Badge + variant={row.badge.variant} + className="px-1.5 py-0 text-[10px] font-medium" + > + {row.badge.label} + </Badge> )} </div> {row.values.map((value, valueIndex) => ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/ClickablePath/ClickablePath.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/ClickablePath/ClickablePath.tsx index d39134daba4..648292c35e4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/ClickablePath/ClickablePath.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/ClickablePath/ClickablePath.tsx @@ -56,14 +56,14 @@ export function ClickablePath({ path, className }: ClickablePathProps) { <button type="button" className={cn( - "group inline-flex items-center gap-2 text-sm font-mono break-all text-left", - "text-primary underline decoration-primary/40 underline-offset-2", - "hover:decoration-primary transition-colors cursor-pointer", + "group inline-flex items-center gap-1.5 text-sm font-mono break-all text-left", + "hover:underline decoration-current/40 underline-offset-2", + "transition-colors cursor-pointer", className, )} > <span>{path}</span> - <LuExternalLink className="size-3.5 shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" /> + <LuExternalLink className="size-3.5 shrink-0 opacity-0 group-hover:opacity-60 transition-opacity" /> </button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-48"> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SearchResultsBanner/SearchResultsBanner.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SearchResultsBanner/SearchResultsBanner.tsx new file mode 100644 index 00000000000..703479a088b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SearchResultsBanner/SearchResultsBanner.tsx @@ -0,0 +1,41 @@ +import { HiXMark } from "react-icons/hi2"; + +interface SearchResultsBannerProps { + query: string; + matchCount: number; + onClear: () => void; +} + +export function SearchResultsBanner({ + query, + matchCount, + onClear, +}: SearchResultsBannerProps) { + const hasMatches = matchCount > 0; + + return ( + <div className="sticky top-0 z-10 flex items-center gap-2 border-b border-border bg-background/95 px-6 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/85"> + <p className="flex-1 truncate text-xs text-muted-foreground"> + {hasMatches ? ( + <> + <span className="tabular-nums font-medium text-foreground"> + {matchCount} + </span> + {matchCount === 1 ? " result" : " results"} for “ + {query}” + </> + ) : ( + <>No results for “{query}”</> + )} + </p> + <button + type="button" + onClick={onClear} + aria-label="Clear search" + className="shrink-0 rounded-sm p-0.5 text-muted-foreground hover:text-foreground transition-colors" + > + <HiXMark className="h-3.5 w-3.5" /> + </button> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SearchResultsBanner/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SearchResultsBanner/index.ts new file mode 100644 index 00000000000..99d20125fdd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SearchResultsBanner/index.ts @@ -0,0 +1 @@ +export { SearchResultsBanner } from "./SearchResultsBanner"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index 1062c0e578a..e2e1b4bcef8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -1,13 +1,17 @@ import { cn } from "@superset/ui/utils"; import { Link, useMatchRoute } from "@tanstack/react-router"; import { + HiOutlineBeaker, HiOutlineBell, HiOutlineBuildingOffice2, HiOutlineCommandLine, + HiOutlineComputerDesktop, HiOutlineCpuChip, HiOutlineCreditCard, - HiOutlineDevicePhoneMobile, + HiOutlineFolder, HiOutlineKey, + HiOutlineLink, + HiOutlineLockClosed, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineShieldCheck, @@ -32,12 +36,16 @@ type SettingsRoute = | "/settings/git" | "/settings/agents" | "/settings/terminal" + | "/settings/links" | "/settings/models" + | "/settings/experimental" | "/settings/integrations" | "/settings/billing" - | "/settings/devices" | "/settings/api-keys" - | "/settings/permissions"; + | "/settings/security" + | "/settings/permissions" + | "/settings/projects" + | "/settings/hosts"; interface SectionItem { id: SettingsRoute; @@ -74,12 +82,6 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Notifications", icon: <HiOutlineBell className="h-4 w-4" />, }, - { - id: "/settings/keyboard", - section: "keyboard", - label: "Keyboard", - icon: <LuKeyboard className="h-4 w-4" />, - }, ], }, { @@ -91,6 +93,12 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "General", icon: <HiOutlineSparkles className="h-4 w-4" />, }, + { + id: "/settings/keyboard", + section: "keyboard", + label: "Keyboard", + icon: <LuKeyboard className="h-4 w-4" />, + }, { id: "/settings/git", section: "git", @@ -109,6 +117,12 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Terminal", icon: <HiOutlineCommandLine className="h-4 w-4" />, }, + { + id: "/settings/links", + section: "links", + label: "Links", + icon: <HiOutlineLink className="h-4 w-4" />, + }, { id: "/settings/models", section: "models", @@ -126,6 +140,18 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Organization", icon: <HiOutlineBuildingOffice2 className="h-4 w-4" />, }, + { + id: "/settings/projects", + section: "project", + label: "Projects", + icon: <HiOutlineFolder className="h-4 w-4" />, + }, + { + id: "/settings/hosts", + section: "hosts", + label: "Hosts", + icon: <HiOutlineComputerDesktop className="h-4 w-4" />, + }, { id: "/settings/integrations", section: "integrations", @@ -138,12 +164,6 @@ const SECTION_GROUPS: SectionGroup[] = [ label: "Billing", icon: <HiOutlineCreditCard className="h-4 w-4" />, }, - { - id: "/settings/devices", - section: "devices", - label: "Devices", - icon: <HiOutlineDevicePhoneMobile className="h-4 w-4" />, - }, { id: "/settings/api-keys", section: "apikeys", @@ -155,6 +175,12 @@ const SECTION_GROUPS: SectionGroup[] = [ { label: "System", items: [ + { + id: "/settings/security", + section: "security", + label: "Security", + icon: <HiOutlineLockClosed className="h-4 w-4" />, + }, { id: "/settings/permissions", section: "permissions", @@ -162,6 +188,12 @@ const SECTION_GROUPS: SectionGroup[] = [ icon: <HiOutlineShieldCheck className="h-4 w-4" />, macOnly: true, }, + { + id: "/settings/experimental", + section: "experimental", + label: "Experimental", + icon: <HiOutlineBeaker className="h-4 w-4" />, + }, ], }, ]; @@ -190,7 +222,10 @@ export function GeneralSettings({ matchCounts }: GeneralSettingsProps) { </h2> <nav className="flex flex-col"> {filteredItems.map((section) => { - const isActive = matchRoute({ to: section.id }); + const isActive = !!matchRoute({ + to: section.id, + fuzzy: true, + }); const count = matchCounts?.[section.section]; return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx deleted file mode 100644 index 5f43b538519..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { FEATURE_FLAGS } from "@superset/shared/constants"; -import { cn } from "@superset/ui/utils"; -import { Link, useMatchRoute } from "@tanstack/react-router"; -import { useFeatureFlagEnabled } from "posthog-js/react"; -import { HiOutlineFolder } from "react-icons/hi2"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import type { SettingsSection } from "renderer/stores/settings-state"; - -interface ProjectsSettingsProps { - searchQuery: string; - matchCounts: Partial<Record<SettingsSection, number>> | null; -} - -export function ProjectsSettings({ - searchQuery, - matchCounts, -}: ProjectsSettingsProps) { - const { data: groups = [] } = - electronTrpc.workspaces.getAllGrouped.useQuery(); - const matchRoute = useMatchRoute(); - const hasCloudAccess = useFeatureFlagEnabled(FEATURE_FLAGS.CLOUD_ACCESS); - - const hasProjectMatches = (matchCounts?.project ?? 0) > 0; - - if (searchQuery && !hasProjectMatches) { - return null; - } - - if (groups.length === 0) { - return null; - } - - // Check if we're on the projects list or any project settings page - const isProjectsListActive = matchRoute({ to: "/settings/projects" }); - const isAnyProjectActive = groups.some( - (group) => - matchRoute({ - to: "/settings/project/$projectId/general", - params: { projectId: group.project.id }, - }) || - (hasCloudAccess && - matchRoute({ - to: "/settings/project/$projectId/cloud/secrets", - params: { projectId: group.project.id }, - })), - ); - const isActive = !!isProjectsListActive || isAnyProjectActive; - - const count = matchCounts?.project; - - return ( - <div className="mt-4"> - <h2 className="text-[10px] font-medium text-muted-foreground/60 uppercase tracking-[0.1em] px-3 mb-1"> - Projects - </h2> - <nav className="flex flex-col"> - <Link - to="/settings/projects" - className={cn( - "flex items-center gap-3 px-3 py-1.5 text-sm rounded-md transition-colors text-left", - isActive - ? "bg-accent text-accent-foreground" - : "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground", - )} - > - <HiOutlineFolder className="h-4 w-4" /> - <span className="flex-1">Projects</span> - {count !== undefined && count > 0 && ( - <span className="text-xs text-muted-foreground bg-accent/50 px-1.5 py-0.5 rounded"> - {count} - </span> - )} - </Link> - </nav> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx index fa87f713de6..951afeec3e3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/SettingsSidebar.tsx @@ -13,13 +13,15 @@ import { } from "renderer/stores/settings-state"; import { getMatchCountBySection } from "../../utils/settings-search"; import { GeneralSettings } from "./GeneralSettings"; -import { ProjectsSettings } from "./ProjectsSettings"; export function SettingsSidebar() { const searchQuery = useSettingsSearchQuery(); const setSearchQuery = useSetSettingsSearchQuery(); const originRoute = useSettingsOriginRoute(); - const matchCounts = searchQuery ? getMatchCountBySection(searchQuery) : null; + const normalizedSearchQuery = searchQuery.trim(); + const matchCounts = normalizedSearchQuery + ? getMatchCountBySection(normalizedSearchQuery) + : null; return ( <div className="w-56 flex flex-col p-3 overflow-hidden"> @@ -58,7 +60,6 @@ export function SettingsSidebar() { <div className="flex-1 overflow-y-auto min-h-0"> <GeneralSettings matchCounts={matchCounts} /> - <ProjectsSettings searchQuery={searchQuery} matchCounts={matchCounts} /> </div> <div className="pt-3 mt-3 border-t border-border"> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsx deleted file mode 100644 index 9942e656c00..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { eq } from "@tanstack/db"; -import { useLiveQuery } from "@tanstack/react-db"; -import { useMemo } from "react"; -import { - HiOutlineComputerDesktop, - HiOutlineDevicePhoneMobile, - HiOutlineGlobeAlt, -} from "react-icons/hi2"; -import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; - -const DEVICE_ICONS = { - desktop: HiOutlineComputerDesktop, - mobile: HiOutlineDevicePhoneMobile, - web: HiOutlineGlobeAlt, -}; - -const ONLINE_THRESHOLD_MS = 30_000; - -export function DevicesSettings() { - const collections = useCollections(); - - const { data: allDevices } = useLiveQuery( - (q) => - q - .from({ devicePresence: collections.devicePresence }) - .innerJoin({ users: collections.users }, ({ devicePresence, users }) => - eq(devicePresence.userId, users.id), - ) - .select(({ devicePresence, users }) => ({ - ...devicePresence, - ownerName: users.name, - })), - [collections], - ); - - // Filter to only devices seen within the last 30s - const devices = useMemo( - () => - allDevices?.filter( - (d) => - Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_THRESHOLD_MS, - ), - [allDevices], - ); - - const formatLastSeen = (date: Date) => { - const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - return new Date(date).toLocaleTimeString(); - }; - - return ( - <div className="p-6 max-w-2xl"> - <div className="mb-6"> - <h1 className="text-2xl font-semibold mb-2">Online Devices</h1> - <p className="text-muted-foreground text-sm"> - Devices currently connected to your organization. - </p> - </div> - - {devices?.length === 0 && ( - <div className="text-muted-foreground">No devices online</div> - )} - - <div className="space-y-3"> - {devices?.map((device) => { - const Icon = - DEVICE_ICONS[device.deviceType] || HiOutlineComputerDesktop; - return ( - <div - key={device.id} - className="flex items-center gap-4 p-4 bg-card border rounded-lg" - > - <div className="p-2 bg-accent rounded-md"> - <Icon className="h-5 w-5" /> - </div> - <div className="flex-1 min-w-0"> - <div className="font-medium truncate">{device.deviceName}</div> - <div className="text-sm text-muted-foreground"> - {device.ownerName ?? "Unknown"} · {device.deviceType}{" "} - · {formatLastSeen(device.lastSeenAt)} - </div> - </div> - <div className="flex items-center gap-2"> - <div className="h-2 w-2 rounded-full bg-green-500" /> - <span className="text-sm text-muted-foreground">Online</span> - </div> - </div> - ); - })} - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.ts deleted file mode 100644 index c7656e000c9..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DevicesSettings } from "./DevicesSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsx deleted file mode 100644 index 76781244faa..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { DevicesSettings } from "./components/DevicesSettings"; - -export const Route = createFileRoute("/_authenticated/settings/devices/")({ - component: DevicesSettings, -}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx new file mode 100644 index 00000000000..4d1465d7f7b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/ExperimentalSettings.tsx @@ -0,0 +1,163 @@ +import { Button } from "@superset/ui/button"; +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { Switch } from "@superset/ui/switch"; +import { LuRefreshCw } from "react-icons/lu"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; +import { track } from "renderer/lib/analytics"; +import { useMigrateV1DataToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1DataToV2"; +import type { MigrationSummary } from "renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate"; +import { useV2LocalOverrideStore } from "renderer/stores/v2-local-override"; +import { + isItemVisible, + SETTING_ITEM_ID, + type SettingItemId, +} from "../../../utils/settings-search"; + +interface ExperimentalSettingsProps { + visibleItems?: SettingItemId[] | null; +} + +export function ExperimentalSettings({ + visibleItems, +}: ExperimentalSettingsProps) { + const showSupersetV2 = isItemVisible( + SETTING_ITEM_ID.EXPERIMENTAL_SUPERSET_V2, + visibleItems, + ); + const showV1Migration = isItemVisible( + SETTING_ITEM_ID.EXPERIMENTAL_V1_MIGRATION, + visibleItems, + ); + const { isV2CloudEnabled, isRemoteV2Enabled } = useIsV2CloudEnabled(); + const { rerun, isRunning } = useMigrateV1DataToV2({ autoRun: false }); + const setOptInV2 = useV2LocalOverrideStore((state) => state.setOptInV2); + + async function rerunMigration() { + const result = await rerun(); + if (!result.completed) throw new Error(result.reason); + return result.summary; + } + + function handleRerunMigration() { + toast.promise(rerunMigration(), { + loading: "Running migration...", + success: (summary) => formatMigrationSuccess(summary), + error: (err) => `Migration run failed: ${errorMessage(err)}`, + }); + } + + return ( + <div className="p-6 max-w-4xl w-full"> + <div className="mb-8"> + <h2 className="text-xl font-semibold">Experimental</h2> + <p className="text-sm text-muted-foreground mt-1"> + Try early access features and previews + </p> + </div> + + <div className="space-y-6"> + {showSupersetV2 && ( + <div className="flex items-center justify-between"> + <div className="space-y-0.5"> + <Label htmlFor="superset-v2" className="text-sm font-medium"> + Try Superset Version 2 (Early Access) + </Label> + <p className="text-xs text-muted-foreground"> + Use the new workspace experience when early access is available + </p> + {!isRemoteV2Enabled && ( + <p className="text-xs text-muted-foreground"> + Early access is not enabled for this account. + </p> + )} + </div> + <Switch + id="superset-v2" + checked={isV2CloudEnabled} + onCheckedChange={(enabled) => { + track("surface_toggled", { + from: isV2CloudEnabled ? "v2" : "v1", + to: enabled && isRemoteV2Enabled ? "v2" : "v1", + }); + setOptInV2(enabled); + }} + disabled={!isRemoteV2Enabled} + /> + </div> + )} + {showV1Migration && ( + <div className="flex items-center justify-between border-t pt-6"> + <div className="space-y-0.5"> + <Label className="text-sm font-medium">V1 to V2 migration</Label> + <p className="text-xs text-muted-foreground"> + Rerun project and workspace import for this organization + </p> + </div> + <Button + type="button" + variant="outline" + onClick={handleRerunMigration} + disabled={!isV2CloudEnabled || isRunning} + > + <LuRefreshCw + className={`h-4 w-4${isRunning ? " animate-spin" : ""}`} + strokeWidth={2} + /> + {isRunning ? "Running" : "Run again"} + </Button> + </div> + )} + </div> + </div> + ); +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +function formatMigrationSuccess(summary: MigrationSummary): string { + const changed = + summary.projectsCreated + + summary.projectsLinked + + summary.projectsErrored + + summary.workspacesCreated + + summary.workspacesSkipped + + summary.workspacesErrored; + if (summary.errors.length > 0) { + const first = summary.errors[0]; + const successful = + summary.projectsCreated + + summary.projectsLinked + + summary.workspacesCreated + + summary.workspacesSkipped; + const successSuffix = + successful > 0 + ? ` (${successful} item${successful === 1 ? "" : "s"} completed or skipped)` + : ""; + return `Migration completed with ${summary.errors.length} error${ + summary.errors.length === 1 ? "" : "s" + }${successSuffix}: ${first.name}: ${first.message}`; + } + if ( + summary.projectsCreated + summary.projectsLinked === 0 && + summary.workspacesCreated === 0 && + summary.workspacesSkipped > 0 + ) { + return `Migration run completed: ${summary.workspacesSkipped} workspace${ + summary.workspacesSkipped === 1 ? "" : "s" + } skipped`; + } + if (changed === 0) return "Migration run completed: nothing to update"; + const skippedSuffix = + summary.workspacesSkipped > 0 + ? ` (+${summary.workspacesSkipped} skipped)` + : ""; + return `Migration run completed: ${summary.projectsCreated + summary.projectsLinked} project${ + summary.projectsCreated + summary.projectsLinked === 1 ? "" : "s" + }, ${summary.workspacesCreated} workspace${ + summary.workspacesCreated === 1 ? "" : "s" + }${skippedSuffix}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/index.ts new file mode 100644 index 00000000000..7f3499d9e21 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/components/ExperimentalSettings/index.ts @@ -0,0 +1 @@ +export { ExperimentalSettings } from "./ExperimentalSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/page.tsx new file mode 100644 index 00000000000..f03700a391b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/experimental/page.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { getMatchingItemsForSection } from "../utils/settings-search"; +import { ExperimentalSettings } from "./components/ExperimentalSettings"; + +export const Route = createFileRoute("/_authenticated/settings/experimental/")({ + component: ExperimentalSettingsPage, +}); + +function ExperimentalSettingsPage() { + const searchQuery = useSettingsSearchQuery(); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "experimental").map( + (item) => item.id, + ); + }, [searchQuery]); + + return <ExperimentalSettings visibleItems={visibleItems} />; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx index 39a190cef10..a5dcc1a5b26 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/git/components/GitSettings/GitSettings.tsx @@ -1,4 +1,8 @@ import type { BranchPrefixMode } from "@superset/local-db"; +import { + resolveBranchPrefix, + sanitizeSegment, +} from "@superset/shared/workspace-launch"; import { Input } from "@superset/ui/input"; import { Label } from "@superset/ui/label"; import { @@ -11,7 +15,6 @@ import { import { Switch } from "@superset/ui/switch"; import { useEffect, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { resolveBranchPrefix, sanitizeSegment } from "shared/utils/branch"; import { useDefaultWorktreePath, WorktreeLocationPicker, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx new file mode 100644 index 00000000000..cfc6d6b81c4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/HostSettings.tsx @@ -0,0 +1,192 @@ +import { toast } from "@superset/ui/sonner"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useMemo } from "react"; +import { authClient } from "renderer/lib/auth-client"; +import { + type PersistableTransaction, + useOptimisticCollectionActions, +} from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { CandidateRow } from "./components/AddMemberDropdown"; +import { AddMemberDropdown } from "./components/AddMemberDropdown"; +import { HostHeader } from "./components/HostHeader"; +import type { MemberRowData } from "./components/MembersTable"; +import { MembersTable } from "./components/MembersTable"; + +function notifyOnPersist( + tx: PersistableTransaction | null, + successMessage: string, +) { + tx?.isPersisted.promise.then( + () => toast.success(successMessage), + () => {}, + ); +} + +interface HostSettingsProps { + hostId: string; +} + +export function HostSettings({ hostId }: HostSettingsProps) { + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const currentUserId = session?.user?.id ?? null; + const actions = useOptimisticCollectionActions(); + + const { data: hostRows = [] } = useLiveQuery( + (q) => + q + .from({ hosts: collections.v2Hosts }) + .where(({ hosts }) => eq(hosts.machineId, hostId)) + .select(({ hosts }) => ({ ...hosts })), + [collections, hostId], + ); + const host = hostRows[0]; + + const { data: hostUserRows = [] } = useLiveQuery( + (q) => + q + .from({ uh: collections.v2UsersHosts }) + .where(({ uh }) => eq(uh.hostId, hostId)) + .select(({ uh }) => ({ ...uh })), + [collections, hostId], + ); + + const { data: orgUsers = [] } = useLiveQuery( + (q) => + q.from({ users: collections.users }).select(({ users }) => ({ + id: users.id, + name: users.name, + email: users.email, + })), + [collections], + ); + + const { data: orgMembers = [] } = useLiveQuery( + (q) => + q + .from({ m: collections.members }) + .where(({ m }) => eq(m.organizationId, host?.organizationId ?? "")) + .select(({ m }) => ({ userId: m.userId })), + [collections, host?.organizationId], + ); + + const userMap = useMemo(() => { + const map = new Map<string, { name: string; email: string }>(); + for (const u of orgUsers) { + map.set(u.id, { name: u.name, email: u.email }); + } + return map; + }, [orgUsers]); + + const members: MemberRowData[] = useMemo(() => { + return hostUserRows + .map((row) => { + const u = userMap.get(row.userId); + return { + usersHostsId: `${row.userId}:${row.hostId}`, + userId: row.userId, + role: row.role as "owner" | "member", + name: u?.name ?? "Unknown user", + email: u?.email ?? "", + }; + }) + .sort((a, b) => { + if (a.role !== b.role) return a.role === "owner" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [hostUserRows, userMap]); + + const candidates: CandidateRow[] = useMemo(() => { + const onHost = new Set(hostUserRows.map((r) => r.userId)); + return orgMembers + .filter((m) => !onHost.has(m.userId)) + .map((m) => { + const u = userMap.get(m.userId); + return { + userId: m.userId, + name: u?.name ?? "Unknown user", + email: u?.email ?? "", + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [orgMembers, hostUserRows, userMap]); + + const isOwner = useMemo(() => { + if (!currentUserId) return false; + return ( + hostUserRows.find((r) => r.userId === currentUserId)?.role === "owner" + ); + }, [hostUserRows, currentUserId]); + + if (!host) { + return ( + <div className="p-6 text-sm text-muted-foreground"> + Host not found in this organization. + </div> + ); + } + + const handleAdd = (candidate: CandidateRow) => { + notifyOnPersist( + actions.v2UsersHosts.addMember({ + hostId, + userId: candidate.userId, + organizationId: host.organizationId, + }), + "Member added", + ); + }; + + const handleRemove = (member: MemberRowData) => { + notifyOnPersist( + actions.v2UsersHosts.removeMember(member.usersHostsId), + "Member removed", + ); + }; + + const handleSetRole = (member: MemberRowData, role: "owner" | "member") => { + notifyOnPersist( + actions.v2UsersHosts.setMemberRole(member.usersHostsId, role), + "Role updated", + ); + }; + + return ( + <div className="p-6 max-w-4xl w-full select-text"> + <HostHeader + name={host.name} + isOnline={host.isOnline} + machineId={host.machineId} + /> + + <section className="space-y-3"> + <div className="flex items-end justify-between"> + <div> + <h3 className="text-base font-medium">Members</h3> + <p className="text-sm text-muted-foreground"> + People in your organization who can use this host. + </p> + </div> + {isOwner && ( + <AddMemberDropdown candidates={candidates} onPick={handleAdd} /> + )} + </div> + + <MembersTable + members={members} + isOwner={isOwner} + onSetRole={handleSetRole} + onRemove={handleRemove} + /> + + {!isOwner && ( + <p className="text-xs text-muted-foreground"> + Only owners of this host can change membership. + </p> + )} + </section> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/AddMemberDropdown/AddMemberDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/AddMemberDropdown/AddMemberDropdown.tsx new file mode 100644 index 00000000000..bf558daffd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/AddMemberDropdown/AddMemberDropdown.tsx @@ -0,0 +1,59 @@ +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { HiOutlinePlus } from "react-icons/hi2"; + +export interface CandidateRow { + userId: string; + name: string; + email: string; +} + +interface AddMemberDropdownProps { + candidates: CandidateRow[]; + onPick: (candidate: CandidateRow) => void; +} + +export function AddMemberDropdown({ + candidates, + onPick, +}: AddMemberDropdownProps) { + if (candidates.length === 0) { + return ( + <Button size="sm" variant="outline" disabled> + <HiOutlinePlus className="h-4 w-4 mr-1" /> + Add member + </Button> + ); + } + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button size="sm" variant="outline"> + <HiOutlinePlus className="h-4 w-4 mr-1" /> + Add member + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-64"> + {candidates.map((candidate) => ( + <DropdownMenuItem + key={candidate.userId} + onSelect={() => onPick(candidate)} + > + <div className="flex flex-col"> + <span className="text-sm">{candidate.name}</span> + <span className="text-xs text-muted-foreground"> + {candidate.email} + </span> + </div> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/AddMemberDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/AddMemberDropdown/index.ts new file mode 100644 index 00000000000..38b08495d29 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/AddMemberDropdown/index.ts @@ -0,0 +1 @@ +export { AddMemberDropdown, type CandidateRow } from "./AddMemberDropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/HostHeader/HostHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/HostHeader/HostHeader.tsx new file mode 100644 index 00000000000..246083419fa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/HostHeader/HostHeader.tsx @@ -0,0 +1,27 @@ +import { cn } from "@superset/ui/utils"; + +interface HostHeaderProps { + name: string; + isOnline: boolean; + machineId: string; +} + +export function HostHeader({ name, isOnline, machineId }: HostHeaderProps) { + return ( + <div className="mb-8"> + <div className="flex items-center gap-2"> + <span + className={cn( + "h-2 w-2 rounded-full", + isOnline ? "bg-emerald-500" : "bg-muted-foreground/40", + )} + /> + <h2 className="text-xl font-semibold">{name}</h2> + </div> + <p className="text-sm text-muted-foreground mt-1"> + {isOnline ? "Online" : "Offline"} · machine ID{" "} + <code className="text-xs">{machineId}</code> + </p> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/HostHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/HostHeader/index.ts new file mode 100644 index 00000000000..b5cae8853b6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/HostHeader/index.ts @@ -0,0 +1 @@ +export { HostHeader } from "./HostHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/MembersTable.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/MembersTable.tsx new file mode 100644 index 00000000000..7f4f4bfffad --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/MembersTable.tsx @@ -0,0 +1,59 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@superset/ui/table"; +import { MemberRow, type MemberRowData } from "./components/MemberRow"; + +interface MembersTableProps { + members: MemberRowData[]; + isOwner: boolean; + onSetRole: (member: MemberRowData, role: "owner" | "member") => void; + onRemove: (member: MemberRowData) => void; +} + +export function MembersTable({ + members, + isOwner, + onSetRole, + onRemove, +}: MembersTableProps) { + return ( + <div className="rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead>Name</TableHead> + <TableHead>Email</TableHead> + <TableHead className="w-32">Role</TableHead> + {isOwner && <TableHead className="w-12" />} + </TableRow> + </TableHeader> + <TableBody> + {members.map((member) => ( + <MemberRow + key={member.usersHostsId} + member={member} + isOwner={isOwner} + onSetRole={onSetRole} + onRemove={onRemove} + /> + ))} + {members.length === 0 && ( + <TableRow> + <TableCell + colSpan={isOwner ? 4 : 3} + className="text-center text-sm text-muted-foreground py-6" + > + No members yet. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/components/MemberRow/MemberRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/components/MemberRow/MemberRow.tsx new file mode 100644 index 00000000000..274c207e362 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/components/MemberRow/MemberRow.tsx @@ -0,0 +1,71 @@ +import { Button } from "@superset/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { TableCell, TableRow } from "@superset/ui/table"; +import { HiOutlineTrash } from "react-icons/hi2"; + +export interface MemberRowData { + usersHostsId: string; + userId: string; + role: "owner" | "member"; + name: string; + email: string; +} + +interface MemberRowProps { + member: MemberRowData; + isOwner: boolean; + onSetRole: (member: MemberRowData, role: "owner" | "member") => void; + onRemove: (member: MemberRowData) => void; +} + +export function MemberRow({ + member, + isOwner, + onSetRole, + onRemove, +}: MemberRowProps) { + return ( + <TableRow> + <TableCell className="font-medium">{member.name}</TableCell> + <TableCell className="text-muted-foreground">{member.email}</TableCell> + <TableCell> + {isOwner ? ( + <Select + value={member.role} + onValueChange={(value) => + onSetRole(member, value as "owner" | "member") + } + > + <SelectTrigger className="h-8"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="owner">Owner</SelectItem> + <SelectItem value="member">Member</SelectItem> + </SelectContent> + </Select> + ) : ( + <span className="text-sm capitalize">{member.role}</span> + )} + </TableCell> + {isOwner && ( + <TableCell> + <Button + variant="ghost" + size="sm" + onClick={() => onRemove(member)} + aria-label={`Remove ${member.name}`} + > + <HiOutlineTrash className="h-4 w-4" /> + </Button> + </TableCell> + )} + </TableRow> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/components/MemberRow/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/components/MemberRow/index.ts new file mode 100644 index 00000000000..034f9d3c590 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/components/MemberRow/index.ts @@ -0,0 +1 @@ +export { MemberRow, type MemberRowData } from "./MemberRow"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/index.ts new file mode 100644 index 00000000000..165f7ae9532 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/components/MembersTable/index.ts @@ -0,0 +1,2 @@ +export type { MemberRowData } from "./components/MemberRow"; +export { MembersTable } from "./MembersTable"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/index.ts new file mode 100644 index 00000000000..660113f0795 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/components/HostSettings/index.ts @@ -0,0 +1 @@ +export { HostSettings } from "./HostSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/page.tsx new file mode 100644 index 00000000000..259d6dcf34c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/$hostId/page.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { NotFound } from "renderer/routes/not-found"; +import { HostSettings } from "./components/HostSettings"; + +export const Route = createFileRoute("/_authenticated/settings/hosts/$hostId/")( + { + component: HostDetailPage, + notFoundComponent: NotFound, + }, +); + +function HostDetailPage() { + const { hostId } = Route.useParams(); + return <HostSettings hostId={hostId} />; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx new file mode 100644 index 00000000000..c30081b1170 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/HostsSettingsSidebar.tsx @@ -0,0 +1,156 @@ +import { cn } from "@superset/ui/utils"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { HiMagnifyingGlass } from "react-icons/hi2"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { MOCK_ORG_ID } from "shared/constants"; + +interface HostRow { + id: string; + name: string; + machineId: string; + isOnline: boolean; +} + +interface HostsSettingsSidebarProps { + selectedHostId: string | null; +} + +export function HostsSettingsSidebar({ + selectedHostId, +}: HostsSettingsSidebarProps) { + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const [filter, setFilter] = useState(""); + + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + + const { data: hosts = [] } = useLiveQuery( + (q) => + q + .from({ hosts: collections.v2Hosts }) + .where(({ hosts }) => + eq(hosts.organizationId, activeOrganizationId ?? ""), + ) + .select(({ hosts }) => ({ + id: hosts.machineId, + name: hosts.name, + machineId: hosts.machineId, + isOnline: hosts.isOnline, + })), + [collections, activeOrganizationId], + ); + + const { online, offline } = useMemo(() => { + const trimmed = filter.trim().toLowerCase(); + const matches = trimmed + ? hosts.filter((h) => h.name.toLowerCase().includes(trimmed)) + : hosts; + const sorted = [...matches].sort((a, b) => a.name.localeCompare(b.name)); + const online: HostRow[] = sorted.filter((h) => h.isOnline); + const offline: HostRow[] = sorted.filter((h) => !h.isOnline); + return { online, offline }; + }, [hosts, filter]); + + const isEmpty = hosts.length === 0; + const noMatches = !isEmpty && online.length === 0 && offline.length === 0; + const showHeaders = online.length > 0 && offline.length > 0; + + return ( + <div className="w-64 shrink-0 border-r overflow-y-auto"> + <div className="p-3 space-y-3"> + <div className="relative"> + <HiMagnifyingGlass className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" /> + <input + type="text" + aria-label="Filter hosts" + placeholder="Filter hosts..." + value={filter} + onChange={(e) => setFilter(e.target.value)} + className="w-full h-8 pl-8 pr-2 text-sm bg-accent/50 rounded-md border-0 outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground" + /> + </div> + + {isEmpty && ( + <p className="px-2 text-sm text-muted-foreground">No hosts yet.</p> + )} + {noMatches && ( + <p className="px-2 text-sm text-muted-foreground"> + No hosts match "{filter}". + </p> + )} + + {online.length > 0 && ( + <Section title={showHeaders ? "Online" : null}> + {online.map((row) => ( + <HostLink + key={row.id} + row={row} + isActive={row.id === selectedHostId} + /> + ))} + </Section> + )} + {offline.length > 0 && ( + <Section title={showHeaders ? "Offline" : null}> + {offline.map((row) => ( + <HostLink + key={row.id} + row={row} + isActive={row.id === selectedHostId} + /> + ))} + </Section> + )} + </div> + </div> + ); +} + +function Section({ + title, + children, +}: { + title: string | null; + children: React.ReactNode; +}) { + return ( + <div> + {title && ( + <h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-2 mb-2"> + {title} + </h2> + )} + <nav className="flex flex-col gap-0.5">{children}</nav> + </div> + ); +} + +function HostLink({ row, isActive }: { row: HostRow; isActive: boolean }) { + return ( + <Link + to="/settings/hosts/$hostId" + params={{ hostId: row.id }} + className={cn( + "flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors", + isActive + ? "bg-accent text-accent-foreground" + : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + )} + > + <span + className={cn( + "h-1.5 w-1.5 rounded-full shrink-0", + row.isOnline ? "bg-emerald-500" : "bg-muted-foreground/40", + )} + /> + <span className="truncate flex-1">{row.name}</span> + </Link> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/index.ts new file mode 100644 index 00000000000..bf449663424 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/components/HostsSettingsSidebar/index.ts @@ -0,0 +1 @@ +export { HostsSettingsSidebar } from "./HostsSettingsSidebar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/layout.tsx new file mode 100644 index 00000000000..821fc21dd21 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/layout.tsx @@ -0,0 +1,18 @@ +import { createFileRoute, Outlet, useParams } from "@tanstack/react-router"; +import { HostsSettingsSidebar } from "./components/HostsSettingsSidebar"; + +export const Route = createFileRoute("/_authenticated/settings/hosts")({ + component: HostsSettingsLayout, +}); + +function HostsSettingsLayout() { + const params = useParams({ strict: false }) as { hostId?: string }; + return ( + <div className="flex h-full w-full"> + <HostsSettingsSidebar selectedHostId={params.hostId ?? null} /> + <div className="flex-1 overflow-y-auto"> + <Outlet /> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx new file mode 100644 index 00000000000..643c8e8adbd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/hosts/page.tsx @@ -0,0 +1,63 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { MOCK_ORG_ID } from "shared/constants"; + +export const Route = createFileRoute("/_authenticated/settings/hosts/")({ + component: HostsIndexPage, +}); + +function HostsIndexPage() { + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const navigate = useNavigate(); + + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + + const { data: hosts = [] } = useLiveQuery( + (q) => + q + .from({ hosts: collections.v2Hosts }) + .where(({ hosts }) => + eq(hosts.organizationId, activeOrganizationId ?? ""), + ) + .select(({ hosts }) => ({ + id: hosts.machineId, + name: hosts.name, + isOnline: hosts.isOnline, + })), + [collections, activeOrganizationId], + ); + + const firstHostId = useMemo(() => { + const sorted = [...hosts].sort((a, b) => a.name.localeCompare(b.name)); + const online = sorted.find((h) => h.isOnline); + return (online ?? sorted[0])?.id ?? null; + }, [hosts]); + + useEffect(() => { + if (firstHostId) { + navigate({ + to: "/settings/hosts/$hostId", + params: { hostId: firstHostId }, + replace: true, + }); + } + }, [firstHostId, navigate]); + + if (hosts.length === 0) { + return ( + <div className="flex items-center justify-center h-full p-6 text-sm text-muted-foreground"> + No hosts yet. + </div> + ); + } + + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx index 1ca327fdf92..33c33fc7af9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/integrations/components/IntegrationsSettings/IntegrationsSettings.tsx @@ -14,7 +14,6 @@ import { useCallback, useEffect, useState } from "react"; import { FaGithub, FaSlack } from "react-icons/fa"; import { HiCheckCircle, HiOutlineArrowTopRightOnSquare } from "react-icons/hi2"; import { SiLinear } from "react-icons/si"; -import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; import { env } from "renderer/env.renderer"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { authClient } from "renderer/lib/auth-client"; @@ -44,7 +43,6 @@ export function IntegrationsSettings({ const { data: session } = authClient.useSession(); const activeOrganizationId = session?.session?.activeOrganizationId; const collections = useCollections(); - const { gateFeature } = usePaywall(); const { data: integrations } = useLiveQuery( (q) => @@ -60,9 +58,6 @@ export function IntegrationsSettings({ useState<GithubInstallation | null>(null); const [isLoadingGithub, setIsLoadingGithub] = useState(true); - const hasGithubAccess = useFeatureFlagEnabled( - FEATURE_FLAGS.GITHUB_INTEGRATION_ACCESS, - ); const hasSlackAccess = useFeatureFlagEnabled( FEATURE_FLAGS.SLACK_INTEGRATION_ACCESS, ); @@ -71,9 +66,10 @@ export function IntegrationsSettings({ SETTING_ITEM_ID.INTEGRATIONS_LINEAR, visibleItems, ); - const showGithub = - hasGithubAccess && - isItemVisible(SETTING_ITEM_ID.INTEGRATIONS_GITHUB, visibleItems); + const showGithub = isItemVisible( + SETTING_ITEM_ID.INTEGRATIONS_GITHUB, + visibleItems, + ); const fetchGithubInstallation = useCallback(async () => { if (!activeOrganizationId) { @@ -145,11 +141,7 @@ export function IntegrationsSettings({ icon={<SiLinear className="size-6" />} isConnected={isLinearConnected} connectedOrgName={linearConnection?.externalOrgName} - onManage={() => - gateFeature(GATED_FEATURES.INTEGRATIONS, () => - handleOpenWeb("/integrations/linear"), - ) - } + onManage={() => handleOpenWeb("/integrations/linear")} /> )} @@ -161,11 +153,7 @@ export function IntegrationsSettings({ isConnected={isGithubConnected} connectedOrgName={githubInstallation?.accountLogin} isLoading={isLoadingGithub} - onManage={() => - gateFeature(GATED_FEATURES.INTEGRATIONS, () => - handleOpenWeb("/integrations/github"), - ) - } + onManage={() => handleOpenWeb("/integrations/github")} /> )} @@ -176,11 +164,7 @@ export function IntegrationsSettings({ icon={<FaSlack className="size-6" />} isConnected={isSlackConnected} connectedOrgName={slackConnection?.externalOrgName} - onManage={() => - gateFeature(GATED_FEATURES.INTEGRATIONS, () => - handleOpenWeb("/integrations/slack"), - ) - } + onManage={() => handleOpenWeb("/integrations/slack")} /> )} </div> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx index aa00a30efd9..1d1cc0bd511 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/keyboard/page.tsx @@ -11,27 +11,21 @@ import { Input } from "@superset/ui/input"; import { Kbd, KbdGroup } from "@superset/ui/kbd"; import { toast } from "@superset/ui/sonner"; import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { HiMagnifyingGlass } from "react-icons/hi2"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { - captureHotkeyFromEvent, - getHotkeyConflict, - useHotkeyDisplay, - useHotkeysByCategory, - useHotkeysStore, -} from "renderer/stores/hotkeys"; -import { - formatHotkeyText, HOTKEYS, type HotkeyCategory, type HotkeyId, - type HotkeysState, - isOsReservedHotkey, - isTerminalReservedHotkey, -} from "shared/hotkeys"; + type ShortcutBinding, + useFormatBinding, + useHotkeyDisplay, + useHotkeyOverridesStore, + useRecordHotkeys, +} from "renderer/hotkeys"; const CATEGORY_ORDER: HotkeyCategory[] = [ + "Navigation", "Workspace", "Terminal", "Layout", @@ -54,7 +48,7 @@ function HotkeyRow({ onStartRecording: () => void; onReset: () => void; }) { - const display = useHotkeyDisplay(id); + const { keys } = useHotkeyDisplay(id); return ( <div className="flex items-center justify-between gap-4 py-3 px-4"> @@ -74,7 +68,7 @@ function HotkeyRow({ <span className="text-xs text-muted-foreground">Recording…</span> ) : ( <KbdGroup> - {display.map((key) => ( + {keys.map((key) => ( <Kbd key={key}>{key}</Kbd> ))} </KbdGroup> @@ -92,34 +86,69 @@ export const Route = createFileRoute("/_authenticated/settings/keyboard/")({ component: KeyboardShortcutsPage, }); +function getHotkeysByCategory(): Record< + HotkeyCategory, + Array<{ id: HotkeyId; label: string; description?: string }> +> { + const grouped: Record< + HotkeyCategory, + Array<{ id: HotkeyId; label: string; description?: string }> + > = { + Navigation: [], + Workspace: [], + Layout: [], + Terminal: [], + Window: [], + Help: [], + }; + for (const [id, hotkey] of Object.entries(HOTKEYS)) { + grouped[hotkey.category as HotkeyCategory].push({ + id: id as HotkeyId, + label: hotkey.label, + description: hotkey.description, + }); + } + return grouped; +} + +const hotkeysByCategory = getHotkeysByCategory(); + function KeyboardShortcutsPage() { const [searchQuery, setSearchQuery] = useState(""); const [recordingId, setRecordingId] = useState<HotkeyId | null>(null); const [pendingConflict, setPendingConflict] = useState<{ - id: HotkeyId; - keys: string; + targetId: HotkeyId; + binding: ShortcutBinding; conflictId: HotkeyId; } | null>(null); - const [pendingImport, setPendingImport] = useState<{ - path: string; - state: HotkeysState; - summary: { assigned: number; disabled: number }; - } | null>(null); - const platform = useHotkeysStore((state) => state.platform); - const setHotkey = useHotkeysStore((state) => state.setHotkey); - const setHotkeysBatch = useHotkeysStore((state) => state.setHotkeysBatch); - const resetHotkey = useHotkeysStore((state) => state.resetHotkey); - const resetAllHotkeys = useHotkeysStore((state) => state.resetAllHotkeys); - const replaceHotkeysState = useHotkeysStore( - (state) => state.replaceHotkeysState, - ); - const hotkeysByCategory = useHotkeysByCategory(); + const resetOverride = useHotkeyOverridesStore((s) => s.resetOverride); + const resetAll = useHotkeyOverridesStore((s) => s.resetAll); + const setOverride = useHotkeyOverridesStore((s) => s.setOverride); - const exportMutation = electronTrpc.hotkeys.export.useMutation(); - const importMutation = electronTrpc.hotkeys.import.useMutation(); + useRecordHotkeys(recordingId, { + // New printable bindings follow the printed character (matches what the + // user sees on their keyboard). F-keys / named keys are forced to + // "named" by the recorder regardless of this preference. + preferredMode: "logical", + onSave: () => setRecordingId(null), + onCancel: () => setRecordingId(null), + onUnassign: () => setRecordingId(null), + onConflict: (targetId, binding, conflictId) => { + setPendingConflict({ targetId, binding, conflictId }); + setRecordingId(null); + }, + onReserved: (_binding, info) => { + if (info.severity === "error") { + toast.error(info.reason); + setRecordingId(null); + } else { + toast.warning(info.reason); + } + }, + }); - const showHotkeysDisplay = useHotkeyDisplay("SHOW_HOTKEYS"); + const { keys: showHotkeysKeys } = useHotkeyDisplay("SHOW_HOTKEYS"); const filteredHotkeysByCategory = useMemo(() => { if (!searchQuery) return hotkeysByCategory; @@ -132,121 +161,21 @@ function KeyboardShortcutsPage() { ), ]), ) as typeof hotkeysByCategory; - }, [hotkeysByCategory, searchQuery]); - - useEffect(() => { - if (!recordingId) return; - - const handleKeyDown = (event: KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); - - if (event.key === "Escape") { - setRecordingId(null); - return; - } - - if (event.key === "Backspace" || event.key === "Delete") { - setHotkey(recordingId, null); - setRecordingId(null); - return; - } - - const captured = captureHotkeyFromEvent(event, platform); - if (!captured) return; - - if (isTerminalReservedHotkey(captured)) { - toast.error("That shortcut is reserved by the terminal."); - setRecordingId(null); - return; - } - - const conflictId = getHotkeyConflict(captured, recordingId); - if (conflictId) { - setPendingConflict({ id: recordingId, keys: captured, conflictId }); - setRecordingId(null); - return; - } - - if (isOsReservedHotkey(captured, platform)) { - toast.warning("This shortcut may be reserved by your OS."); - } - - setHotkey(recordingId, captured); - setRecordingId(null); - }; - - window.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => { - window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }; - }, [recordingId, platform, setHotkey]); + }, [searchQuery]); const handleStartRecording = (id: HotkeyId) => { setRecordingId((current) => (current === id ? null : id)); }; - const handleExport = async () => { - try { - const result = await exportMutation.mutateAsync(); - if ("canceled" in result && result.canceled) return; - if ("error" in result) { - toast.error("Failed to export shortcuts", { - description: result.error, - }); - return; - } - toast.success("Keyboard shortcuts exported", { - description: result.path, - }); - } catch (error) { - toast.error("Failed to export shortcuts", { - description: error instanceof Error ? error.message : undefined, - }); - } - }; - - const handleImport = async () => { - try { - const result = await importMutation.mutateAsync(); - if ("canceled" in result && result.canceled) return; - if ("error" in result) { - toast.error("Failed to import shortcuts", { - description: result.error, - }); - return; - } - setPendingImport({ - path: result.path, - state: result.state, - summary: result.summary, - }); - } catch (error) { - toast.error("Failed to import shortcuts", { - description: error instanceof Error ? error.message : undefined, - }); - } - }; - - const handleConfirmImport = () => { - if (!pendingImport) return; - replaceHotkeysState(pendingImport.state); - toast.success("Keyboard shortcuts imported"); - setPendingImport(null); - }; - const handleConflictReassign = () => { if (!pendingConflict) return; - setHotkeysBatch({ - [pendingConflict.conflictId]: null, - [pendingConflict.id]: pendingConflict.keys, - }); - if (isOsReservedHotkey(pendingConflict.keys, platform)) { - toast.warning("This shortcut may be reserved by your OS."); - } + setOverride(pendingConflict.conflictId, null); + setOverride(pendingConflict.targetId, pendingConflict.binding); setPendingConflict(null); }; + const conflictDisplay = useFormatBinding(pendingConflict?.binding ?? null); + return ( <div className="p-6 w-full max-w-4xl"> {/* Header */} @@ -256,7 +185,7 @@ function KeyboardShortcutsPage() { <p className="text-sm text-muted-foreground mt-1"> Customize keyboard shortcuts for your workflow. Press{" "} <KbdGroup> - {showHotkeysDisplay.map((key) => ( + {showHotkeysKeys.map((key) => ( <Kbd key={key}>{key}</Kbd> ))} </KbdGroup>{" "} @@ -264,18 +193,12 @@ function KeyboardShortcutsPage() { </p> </div> <div className="flex items-center gap-2"> - <Button variant="outline" size="sm" onClick={handleImport}> - Import - </Button> - <Button variant="outline" size="sm" onClick={handleExport}> - Export - </Button> <Button variant="ghost" size="sm" onClick={() => { setRecordingId(null); - resetAllHotkeys(); + resetAll(); }} > Reset all @@ -324,7 +247,12 @@ function KeyboardShortcutsPage() { description={hotkey.description} isRecording={recordingId === hotkey.id} onStartRecording={() => handleStartRecording(hotkey.id)} - onReset={() => resetHotkey(hotkey.id)} + onReset={() => { + setRecordingId((current) => + current === hotkey.id ? null : current, + ); + resetOverride(hotkey.id); + }} /> ))} </div> @@ -356,12 +284,9 @@ function KeyboardShortcutsPage() { <div className="text-muted-foreground space-y-1.5"> <span className="block"> {pendingConflict - ? `${formatHotkeyText( - pendingConflict.keys, - platform, - )} is already assigned to “${ + ? `${conflictDisplay.text} is already assigned to "${ HOTKEYS[pendingConflict.conflictId].label - }”.` + }".` : ""} </span> <span className="block">Would you like to reassign it?</span> @@ -386,45 +311,6 @@ function KeyboardShortcutsPage() { </AlertDialogFooter> </AlertDialogContent> </AlertDialog> - - {/* Import dialog */} - <AlertDialog - open={!!pendingImport} - onOpenChange={() => setPendingImport(null)} - > - <AlertDialogContent className="max-w-[420px] gap-0 p-0"> - <AlertDialogHeader className="px-4 pt-4 pb-2"> - <AlertDialogTitle className="font-medium"> - Import keyboard shortcuts? - </AlertDialogTitle> - <AlertDialogDescription asChild> - <div className="text-muted-foreground space-y-1.5"> - <span className="block"> - This will replace your shortcuts on all platforms. - </span> - {pendingImport && ( - <span className="block"> - {pendingImport.summary.assigned} assigned,{" "} - {pendingImport.summary.disabled} disabled on {platform}. - </span> - )} - </div> - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2"> - <Button - variant="ghost" - size="sm" - onClick={() => setPendingImport(null)} - > - Cancel - </Button> - <Button variant="secondary" size="sm" onClick={handleConfirmImport}> - Import - </Button> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index 53693e63b88..3c79af7bec9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -5,13 +5,20 @@ import { useNavigate, } from "@tanstack/react-router"; import { useEffect } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { type SettingsSection, + useSetSettingsSearchQuery, + useSettingsOriginRoute, useSettingsSearchQuery, } from "renderer/stores/settings-state"; +import { SearchResultsBanner } from "./components/SearchResultsBanner"; import { SettingsSidebar } from "./components/SettingsSidebar"; -import { getMatchCountBySection } from "./utils/settings-search"; +import { + getMatchCountBySection, + searchSettings, +} from "./utils/settings-search"; export const Route = createFileRoute("/_authenticated/settings")({ component: SettingsLayout, @@ -25,13 +32,15 @@ const SECTION_ORDER: SettingsSection[] = [ "behavior", "git", "terminal", + "links", "models", "organization", "integrations", "billing", - "devices", "apikeys", "permissions", + "hosts", + "experimental", ]; function getSectionFromPath(pathname: string): SettingsSection | null { @@ -43,9 +52,12 @@ function getSectionFromPath(pathname: string): SettingsSection | null { if (pathname.includes("/settings/behavior")) return "behavior"; if (pathname.includes("/settings/git")) return "git"; if (pathname.includes("/settings/terminal")) return "terminal"; + if (pathname.includes("/settings/links")) return "links"; if (pathname.includes("/settings/models")) return "models"; + if (pathname.includes("/settings/experimental")) return "experimental"; if (pathname.includes("/settings/integrations")) return "integrations"; if (pathname.includes("/settings/permissions")) return "permissions"; + if (pathname.includes("/settings/hosts")) return "hosts"; if (pathname.includes("/settings/project")) return "project"; return null; } @@ -68,12 +80,18 @@ function getPathFromSection(section: SettingsSection): string { return "/settings/git"; case "terminal": return "/settings/terminal"; + case "links": + return "/settings/links"; case "models": return "/settings/models"; + case "experimental": + return "/settings/experimental"; case "integrations": return "/settings/integrations"; case "permissions": return "/settings/permissions"; + case "hosts": + return "/settings/hosts"; default: return "/settings/account"; } @@ -83,18 +101,26 @@ function SettingsLayout() { const { data: platform } = electronTrpc.window.getPlatform.useQuery(); const isMac = platform === undefined || platform === "darwin"; const searchQuery = useSettingsSearchQuery(); + const setSearchQuery = useSetSettingsSearchQuery(); + const originRoute = useSettingsOriginRoute(); const location = useLocation(); const navigate = useNavigate(); + const normalizedSearchQuery = searchQuery.trim(); + const isSearchActive = normalizedSearchQuery.length > 0; + const totalMatches = isSearchActive + ? searchSettings(normalizedSearchQuery).length + : 0; useEffect(() => { - if (!searchQuery) return; + if (!isSearchActive) return; const currentSection = getSectionFromPath(location.pathname); if (!currentSection) return; if (currentSection === "project") return; + if (currentSection === "hosts") return; - const matchCounts = getMatchCountBySection(searchQuery); + const matchCounts = getMatchCountBySection(normalizedSearchQuery); const currentHasMatches = (matchCounts[currentSection] ?? 0) > 0; if (!currentHasMatches) { @@ -105,7 +131,18 @@ function SettingsLayout() { navigate({ to: getPathFromSection(firstMatch), replace: true }); } } - }, [searchQuery, location.pathname, navigate]); + }, [isSearchActive, location.pathname, navigate, normalizedSearchQuery]); + + useHotkeys( + "escape", + (event) => { + if (document.querySelector('[data-state="open"]')) return; + event.preventDefault(); + navigate({ to: originRoute }); + }, + { enableOnFormTags: false, enableOnContentEditable: false }, + [navigate, originRoute], + ); return ( <div className="flex flex-col h-screen w-screen bg-tertiary"> @@ -119,6 +156,13 @@ function SettingsLayout() { <div className="flex flex-1 overflow-hidden"> <SettingsSidebar /> <div className="flex-1 m-3 bg-background rounded overflow-auto"> + {isSearchActive && ( + <SearchResultsBanner + query={normalizedSearchQuery} + matchCount={totalMatches} + onClear={() => setSearchQuery("")} + /> + )} <Outlet /> </div> </div> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinkTierMapper/LinkTierMapper.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinkTierMapper/LinkTierMapper.tsx new file mode 100644 index 00000000000..0d29e8014ca --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinkTierMapper/LinkTierMapper.tsx @@ -0,0 +1,102 @@ +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { useCallback } from "react"; +import type { + LinkAction, + LinkTier, + LinkTierMap, +} from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; + +type SlotValue = LinkAction | "none"; + +const TIER_LABELS: Array<{ key: LinkTier; label: string }> = [ + { key: "plain", label: "Click" }, + { key: "meta", label: "⌘-click" }, + { key: "metaShift", label: "⌘⇧-click" }, +]; + +function toSlot(action: LinkAction | null): SlotValue { + return action ?? "none"; +} + +function fromSlot(slot: SlotValue): LinkAction | null { + return slot === "none" ? null : slot; +} + +export interface ActionLabels { + pane: string; + external: string; +} + +export interface LinkTierMapperProps { + title: string; + description: string; + value: LinkTierMap; + onChange: (next: LinkTierMap) => void; + idPrefix: string; + actionLabels: ActionLabels; +} + +export function LinkTierMapper({ + title, + description, + value, + onChange, + idPrefix, + actionLabels, +}: LinkTierMapperProps) { + const pick = useCallback( + (tier: LinkTier, nextSlot: SlotValue) => { + const nextAction = fromSlot(nextSlot); + if (value[tier] === nextAction) return; + onChange({ ...value, [tier]: nextAction }); + }, + [value, onChange], + ); + + const options: Array<{ value: SlotValue; label: string }> = [ + { value: "none", label: "Do nothing" }, + { value: "pane", label: actionLabels.pane }, + { value: "external", label: actionLabels.external }, + ]; + + return ( + <section className="rounded-md border border-border p-5"> + <div className="mb-1 text-sm font-medium">{title}</div> + <p className="mb-4 text-xs text-muted-foreground">{description}</p> + <div className="space-y-3"> + {TIER_LABELS.map(({ key, label }) => { + const id = `${idPrefix}-${key}`; + return ( + <div key={key} className="flex items-center justify-between"> + <Label htmlFor={id} className="text-sm"> + {label} + </Label> + <Select + value={toSlot(value[key])} + onValueChange={(v) => pick(key, v as SlotValue)} + > + <SelectTrigger id={id} className="w-[180px]"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {options.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + ); + })} + </div> + </section> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinkTierMapper/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinkTierMapper/index.ts new file mode 100644 index 00000000000..706826df1cd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinkTierMapper/index.ts @@ -0,0 +1,2 @@ +export type { ActionLabels, LinkTierMapperProps } from "./LinkTierMapper"; +export { LinkTierMapper } from "./LinkTierMapper"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinksSettings/LinksSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinksSettings/LinksSettings.tsx new file mode 100644 index 00000000000..4e55c463eac --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinksSettings/LinksSettings.tsx @@ -0,0 +1,79 @@ +import { toast } from "@superset/ui/sonner"; +import { useCallback } from "react"; +import { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; +import type { LinkTierMap } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema"; +import { + isItemVisible, + SETTING_ITEM_ID, + type SettingItemId, +} from "../../../utils/settings-search"; +import { LinkTierMapper } from "../LinkTierMapper"; + +interface LinksSettingsProps { + visibleItems?: SettingItemId[] | null; +} + +export function LinksSettings({ visibleItems }: LinksSettingsProps) { + const { preferences, setFileLinks, setUrlLinks } = useV2UserPreferences(); + + const showFile = isItemVisible(SETTING_ITEM_ID.LINKS_FILE, visibleItems); + const showUrl = isItemVisible(SETTING_ITEM_ID.LINKS_URL, visibleItems); + + const handleFileChange = useCallback( + (next: LinkTierMap) => { + setFileLinks(next); + toast.success("Changes saved"); + }, + [setFileLinks], + ); + + const handleUrlChange = useCallback( + (next: LinkTierMap) => { + setUrlLinks(next); + toast.success("Changes saved"); + }, + [setUrlLinks], + ); + + return ( + <div className="p-6 max-w-4xl w-full"> + <div className="mb-8"> + <h2 className="text-xl font-semibold">Links</h2> + <p className="text-sm text-muted-foreground mt-1"> + Control how file paths and URLs open when clicked in terminals, chat, + and tasks. ⌘⇧-click only applies in the terminal. + </p> + </div> + + <div className="space-y-6"> + {showFile && ( + <LinkTierMapper + title="File links" + description="Applies to file paths in terminals, chat tool calls, and task markdown." + value={preferences.fileLinks} + onChange={handleFileChange} + idPrefix="links-file" + actionLabels={{ + pane: "File viewer", + external: "External editor", + }} + /> + )} + + {showUrl && ( + <LinkTierMapper + title="URL links" + description="Applies to URLs in terminals, chat messages, and task markdown." + value={preferences.urlLinks} + onChange={handleUrlChange} + idPrefix="links-url" + actionLabels={{ + pane: "In-app browser", + external: "Browser", + }} + /> + )} + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinksSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinksSettings/index.ts new file mode 100644 index 00000000000..684b25889cd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/links/components/LinksSettings/index.ts @@ -0,0 +1 @@ +export { LinksSettings } from "./LinksSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/links/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/links/page.tsx new file mode 100644 index 00000000000..1756d39e2ed --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/links/page.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { getMatchingItemsForSection } from "../utils/settings-search"; +import { LinksSettings } from "./components/LinksSettings"; + +export const Route = createFileRoute("/_authenticated/settings/links/")({ + component: LinksSettingsPage, +}); + +function LinksSettingsPage() { + const searchQuery = useSettingsSearchQuery(); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "links").map( + (item) => item.id, + ); + }, [searchQuery]); + + return <LinksSettings visibleItems={visibleItems} />; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/InviteMemberButton/InviteMemberButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/InviteMemberButton/InviteMemberButton.tsx index a17e45e44d3..bb3f8907410 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/InviteMemberButton/InviteMemberButton.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/InviteMemberButton/InviteMemberButton.tsx @@ -36,9 +36,10 @@ export function InviteMemberButton({ title: "This will affect your billing", description: "Adding members will increase your subscription cost, prorated to your billing cycle.", - confirmText: "Continue", - cancelText: "Cancel", - onConfirm: () => setOpen(true), + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { label: "Continue", onClick: () => setOpen(true) }, + ], }); }); }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/MemberActions/MemberActions.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/MemberActions/MemberActions.tsx index ea8c1bc8770..70408da1054 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/MemberActions/MemberActions.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/members/components/MembersSettings/components/MemberActions/MemberActions.tsx @@ -90,16 +90,19 @@ export function MemberActions({ ? " Your subscription will be adjusted accordingly." : ""; - alert.destructive({ + alert({ title: isCurrentUser ? "Leave organization?" : "Remove team member?", description: isCurrentUser ? `Are you sure you want to leave this organization? You will lose access immediately.${billingNote}` : `Are you sure you want to remove ${member.name} (${member.email}) from the organization? They will lose access immediately.${billingNote}`, - confirmText: isCurrentUser ? "Leave Organization" : "Remove Member", - cancelText: "Cancel", - onConfirm: () => { - handleRemove(); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: isCurrentUser ? "Leave Organization" : "Remove Member", + variant: "destructive", + onClick: () => handleRemove(), + }, + ], }); }; @@ -126,14 +129,17 @@ export function MemberActions({ isCurrentUser && getRoleLevel(newRole) < getRoleLevel(member.role); if (isSelfDemotion) { - alert.destructive({ + alert({ title: "Demote yourself?", description: `You're about to change your role from ${ORGANIZATION_ROLES[member.role].name} to ${ORGANIZATION_ROLES[newRole].name}. Another owner will need to restore your permissions. Are you sure?`, - confirmText: "Yes, demote me", - cancelText: "Cancel", - onConfirm: async () => { - await handleChangeRole(newRole); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Yes, demote me", + variant: "destructive", + onClick: () => handleChangeRole(newRole), + }, + ], }); } else { handleChangeRole(newRole); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx index 22e99031cda..e0abdfe43f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/ModelsSettings.tsx @@ -1,10 +1,12 @@ import { chatServiceTrpc } from "@superset/chat/client"; +import { Badge } from "@superset/ui/badge"; import { Button } from "@superset/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@superset/ui/collapsible"; +import { claudeIcon } from "@superset/ui/icons/preset-icons"; import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { Textarea } from "@superset/ui/textarea"; @@ -14,19 +16,17 @@ import { AnthropicOAuthDialog } from "renderer/components/Chat/ChatInterface/com import { OpenAIOAuthDialog } from "renderer/components/Chat/ChatInterface/components/ModelPicker/components/OpenAIOAuthDialog"; import { useAnthropicOAuth } from "renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useAnthropicOAuth"; import { useOpenAIOAuth } from "renderer/components/Chat/ChatInterface/components/ModelPicker/hooks/useOpenAIOAuth"; -import { electronTrpc } from "renderer/lib/electron-trpc"; import { isItemVisible, SETTING_ITEM_ID, type SettingItemId, } from "../../../utils/settings-search"; -import { AccountCard } from "./components/AccountCard"; import { ConfigRow } from "./components/ConfigRow"; import { SettingsSection } from "./components/SettingsSection"; import { buildAnthropicEnvText, EMPTY_ANTHROPIC_FORM, - getProviderSubtitle, + getProviderAction, getStatusBadge, parseAnthropicForm, resolveProviderStatus, @@ -47,20 +47,11 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { visibleItems, ); const showOpenAI = isItemVisible(SETTING_ITEM_ID.MODELS_OPENAI, visibleItems); - const [apiKeysOpen, setApiKeysOpen] = useState(true); const [overrideOpen, setOverrideOpen] = useState(true); const [openAIApiKeyInput, setOpenAIApiKeyInput] = useState(""); const [anthropicApiKeyInput, setAnthropicApiKeyInput] = useState(""); const [anthropicForm, setAnthropicForm] = useState(EMPTY_ANTHROPIC_FORM); - const { data: providerStatuses, refetch: refetchProviderStatuses } = - electronTrpc.modelProviders.getStatuses.useQuery(); - const anthropicDiagnosticStatus = providerStatuses?.find( - (status) => status.providerId === "anthropic", - ); - const openAIDiagnosticStatus = providerStatuses?.find( - (status) => status.providerId === "openai", - ); const { data: anthropicAuthStatus, refetch: refetchAnthropicAuthStatus } = chatServiceTrpc.auth.getAnthropicStatus.useQuery(); const { data: openAIAuthStatus, refetch: refetchOpenAIAuthStatus } = @@ -79,8 +70,6 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { chatServiceTrpc.auth.setOpenAIApiKey.useMutation(); const clearOpenAIApiKeyMutation = chatServiceTrpc.auth.clearOpenAIApiKey.useMutation(); - const clearProviderIssueMutation = - electronTrpc.modelProviders.clearIssue.useMutation(); const { isStartingOAuth: isStartingAnthropicOAuth, @@ -89,10 +78,7 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { } = useAnthropicOAuth({ ...DIALOG_CONTEXT, onAuthStateChange: async () => { - await Promise.all([ - refetchAnthropicAuthStatus(), - refetchProviderStatuses(), - ]); + await refetchAnthropicAuthStatus(); }, }); const { @@ -121,9 +107,8 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { resolveProviderStatus({ providerId: "anthropic", authStatus: anthropicAuthStatus, - diagnosticStatus: anthropicDiagnosticStatus, }), - [anthropicAuthStatus, anthropicDiagnosticStatus], + [anthropicAuthStatus], ); const openAIStatus = useMemo( @@ -131,19 +116,10 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { resolveProviderStatus({ providerId: "openai", authStatus: openAIAuthStatus, - diagnosticStatus: openAIDiagnosticStatus, }), - [openAIAuthStatus, openAIDiagnosticStatus], + [openAIAuthStatus], ); - const anthropicSubtitle = useMemo( - () => getProviderSubtitle("anthropic", anthropicStatus), - [anthropicStatus], - ); - const openAISubtitle = useMemo( - () => getProviderSubtitle("openai", openAIStatus), - [openAIStatus], - ); const anthropicBadge = useMemo( () => getStatusBadge(anthropicStatus), [anthropicStatus], @@ -153,9 +129,6 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { [openAIStatus], ); - const clearProviderIssue = (providerId: "anthropic" | "openai") => - clearProviderIssueMutation.mutateAsync({ providerId }); - const saveAnthropicForm = async (nextForm = anthropicForm) => { const envText = buildAnthropicEnvText(nextForm); try { @@ -167,8 +140,6 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { await Promise.all([ refetchAnthropicEnvConfig(), refetchAnthropicAuthStatus(), - clearProviderIssue("anthropic"), - refetchProviderStatuses(), ]); toast.success("Anthropic settings updated"); return true; @@ -184,11 +155,7 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { try { await setAnthropicApiKeyMutation.mutateAsync({ apiKey }); setAnthropicApiKeyInput(""); - await Promise.all([ - refetchAnthropicAuthStatus(), - clearProviderIssue("anthropic"), - refetchProviderStatuses(), - ]); + await refetchAnthropicAuthStatus(); toast.success("Anthropic API key updated"); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to save"); @@ -201,11 +168,7 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { try { await setOpenAIApiKeyMutation.mutateAsync({ apiKey }); setOpenAIApiKeyInput(""); - await Promise.all([ - refetchOpenAIAuthStatus(), - clearProviderIssue("openai"), - refetchProviderStatuses(), - ]); + await refetchOpenAIAuthStatus(); toast.success("OpenAI API key updated"); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to save"); @@ -216,63 +179,29 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { status, startOAuth, isStartingOAuth, - canDisconnect, onDisconnect, }: { status: typeof anthropicStatus | typeof openAIStatus; startOAuth: () => Promise<void>; isStartingOAuth: boolean; - canDisconnect: boolean; onDisconnect: () => void; }) => { - if (!status || status.connectionState === "disconnected") { - return ( - <Button - variant="outline" - size="sm" - onClick={() => { - void startOAuth(); - }} - disabled={isStartingOAuth} - > - Connect - </Button> - ); - } - - if (status.issue?.remediation === "reconnect") { - return ( - <Button - variant="outline" - size="sm" - onClick={() => { - void startOAuth(); - }} - disabled={isStartingOAuth} - > - Reconnect - </Button> - ); - } - - if (canDisconnect) { + const action = getProviderAction(status); + if (!action) return null; + if (action.kind === "logout") { return ( <Button variant="ghost" size="sm" onClick={onDisconnect}> Logout </Button> ); } - return ( <Button - variant="outline" size="sm" - onClick={() => { - void startOAuth(); - }} + onClick={() => void startOAuth()} disabled={isStartingOAuth} > - Connect + {action.kind === "reconnect" ? "Reconnect" : "Connect"} </Button> ); }; @@ -289,167 +218,165 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { <div className="space-y-8"> {showAnthropic ? ( - <SettingsSection title="Anthropic Account"> - <AccountCard - title="Claude" - subtitle={anthropicSubtitle} - badge={anthropicBadge?.label} - badgeVariant={anthropicBadge?.variant} - muted={anthropicStatus?.connectionState !== "connected"} - actions={renderProviderAction({ - status: anthropicStatus, - startOAuth: startAnthropicOAuth, - isStartingOAuth: isStartingAnthropicOAuth, - canDisconnect: anthropicOAuthDialog.canDisconnect, - onDisconnect: anthropicOAuthDialog.onDisconnect, - })} - /> + <SettingsSection + title="Anthropic" + icon={<img alt="Claude" className="size-5" src={claudeIcon} />} + > + <div className="divide-y divide-border rounded-xl border bg-card"> + <div className="flex items-center justify-between gap-4 px-4 py-3"> + <div className="flex items-center gap-2"> + <p className="text-sm font-semibold">OAuth</p> + {anthropicBadge ? ( + <Badge variant={anthropicBadge.variant}> + {anthropicBadge.label} + </Badge> + ) : null} + </div> + {renderProviderAction({ + status: anthropicStatus, + startOAuth: startAnthropicOAuth, + isStartingOAuth: isStartingAnthropicOAuth, + onDisconnect: async () => { + if (anthropicStatus?.authMethod === "oauth") { + anthropicOAuthDialog.onDisconnect(); + } else { + await clearAnthropicApiKeyMutation.mutateAsync(); + setAnthropicApiKeyInput(""); + } + await refetchAnthropicAuthStatus(); + }, + })} + </div> + <ConfigRow + title="API Key" + field={ + <Input + type="password" + value={anthropicApiKeyInput} + onChange={(event) => { + setAnthropicApiKeyInput(event.target.value); + }} + placeholder={ + anthropicStatus?.authMethod === "api_key" + ? "Saved Anthropic API key" + : "sk-ant-..." + } + className="font-mono" + disabled={isSavingAnthropicApiKey} + /> + } + onSave={() => { + void saveAnthropicApiKey(); + }} + onClear={() => { + const nextForm = { ...anthropicForm, apiKey: "" }; + void (async () => { + try { + await clearAnthropicApiKeyMutation.mutateAsync(); + setAnthropicApiKeyInput(""); + setAnthropicForm(nextForm); + await refetchAnthropicAuthStatus(); + toast.success("Anthropic API key cleared"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to clear", + ); + } + })(); + }} + showSave={anthropicApiKeyInput.trim().length > 0} + disableSave={isSavingAnthropicApiKey} + showClear={anthropicStatus?.authMethod === "api_key"} + disableClear={isSavingAnthropicApiKey} + /> + </div> </SettingsSection> ) : null} {showOpenAI ? ( - <SettingsSection title="Codex Account"> - <AccountCard - title="ChatGPT" - subtitle={openAISubtitle} - badge={openAIBadge?.label} - badgeVariant={openAIBadge?.variant} - muted={openAIStatus?.connectionState !== "connected"} - actions={renderProviderAction({ - status: openAIStatus, - startOAuth: startOpenAIOAuth, - isStartingOAuth: isStartingOpenAIOAuth, - canDisconnect: openAIOAuthDialog.canDisconnect, - onDisconnect: openAIOAuthDialog.onDisconnect, - })} - /> + <SettingsSection + title="OpenAI" + icon={ + <img + alt="OpenAI" + className="size-5 dark:invert" + src="https://models.dev/logos/openai.svg" + /> + } + > + <div className="divide-y divide-border rounded-xl border bg-card"> + <div className="flex items-center justify-between gap-4 px-4 py-3"> + <div className="flex items-center gap-2"> + <p className="text-sm font-semibold">OAuth</p> + {openAIBadge ? ( + <Badge variant={openAIBadge.variant}> + {openAIBadge.label} + </Badge> + ) : null} + </div> + {renderProviderAction({ + status: openAIStatus, + startOAuth: startOpenAIOAuth, + isStartingOAuth: isStartingOpenAIOAuth, + onDisconnect: async () => { + if (openAIStatus?.authMethod === "oauth") { + openAIOAuthDialog.onDisconnect(); + } else { + await clearOpenAIApiKeyMutation.mutateAsync(); + setOpenAIApiKeyInput(""); + } + await refetchOpenAIAuthStatus(); + }, + })} + </div> + <ConfigRow + title="API Key" + field={ + <Input + type="password" + value={openAIApiKeyInput} + onChange={(event) => { + setOpenAIApiKeyInput(event.target.value); + }} + placeholder={ + openAIStatus?.authMethod === "api_key" + ? "Saved OpenAI API key" + : "sk-..." + } + className="font-mono" + disabled={isSavingOpenAIConfig} + /> + } + onSave={() => { + void saveOpenAIApiKey(); + }} + onClear={() => { + void (async () => { + try { + await clearOpenAIApiKeyMutation.mutateAsync(); + setOpenAIApiKeyInput(""); + await refetchOpenAIAuthStatus(); + toast.success("OpenAI API key cleared"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to clear", + ); + } + })(); + }} + showSave={openAIApiKeyInput.trim().length > 0} + disableSave={isSavingOpenAIConfig} + showClear={openAIStatus?.authMethod === "api_key"} + disableClear={isSavingOpenAIConfig} + /> + </div> </SettingsSection> ) : null} - <Collapsible open={apiKeysOpen} onOpenChange={setApiKeysOpen}> - <div className="space-y-3"> - <CollapsibleTrigger asChild> - <button - type="button" - className="flex items-center gap-2 text-left text-sm font-semibold" - > - <HiChevronDown - className={`size-4 transition-transform ${apiKeysOpen ? "" : "-rotate-90"}`} - /> - API Keys - </button> - </CollapsibleTrigger> - <CollapsibleContent className="space-y-3"> - {showAnthropic ? ( - <ConfigRow - title="Anthropic API Key" - field={ - <Input - type="password" - value={anthropicApiKeyInput} - onChange={(event) => { - setAnthropicApiKeyInput(event.target.value); - }} - placeholder={ - anthropicStatus?.authMethod === "api_key" - ? "Saved Anthropic API key" - : "sk-ant-..." - } - className="font-mono" - disabled={isSavingAnthropicApiKey} - /> - } - onSave={() => { - void saveAnthropicApiKey(); - }} - onClear={() => { - const nextForm = { ...anthropicForm, apiKey: "" }; - void (async () => { - try { - await clearAnthropicApiKeyMutation.mutateAsync(); - setAnthropicApiKeyInput(""); - setAnthropicForm(nextForm); - await Promise.all([ - refetchAnthropicAuthStatus(), - clearProviderIssue("anthropic"), - refetchProviderStatuses(), - ]); - toast.success("Anthropic API key cleared"); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to clear", - ); - } - })(); - }} - disableSave={ - isSavingAnthropicApiKey || - anthropicApiKeyInput.trim().length === 0 - } - disableClear={ - isSavingAnthropicApiKey || - anthropicStatus?.authMethod !== "api_key" - } - /> - ) : null} - {showOpenAI ? ( - <ConfigRow - title="OpenAI API Key" - field={ - <Input - type="password" - value={openAIApiKeyInput} - onChange={(event) => { - setOpenAIApiKeyInput(event.target.value); - }} - placeholder={ - openAIStatus?.authMethod === "api_key" - ? "Saved OpenAI API key" - : "sk-..." - } - className="font-mono" - disabled={isSavingOpenAIConfig} - /> - } - onSave={() => { - void saveOpenAIApiKey(); - }} - onClear={() => { - void (async () => { - try { - await clearOpenAIApiKeyMutation.mutateAsync(); - setOpenAIApiKeyInput(""); - await Promise.all([ - refetchOpenAIAuthStatus(), - clearProviderIssue("openai"), - refetchProviderStatuses(), - ]); - toast.success("OpenAI API key cleared"); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to clear", - ); - } - })(); - }} - disableSave={ - isSavingOpenAIConfig || - openAIApiKeyInput.trim().length === 0 - } - disableClear={ - isSavingOpenAIConfig || - openAIStatus?.authMethod !== "api_key" - } - /> - ) : null} - </CollapsibleContent> - </div> - </Collapsible> - {showAnthropic ? ( <Collapsible open={overrideOpen} onOpenChange={setOverrideOpen}> <div className="space-y-3"> @@ -464,111 +391,113 @@ export function ModelsSettings({ visibleItems }: ModelsSettingsProps) { Override Provider </button> </CollapsibleTrigger> - <CollapsibleContent className="space-y-3"> - <ConfigRow - title="API token" - description="Anthropic auth token" - field={ - <Input - type="password" - value={anthropicForm.authToken} - onChange={(event) => { - setAnthropicForm((current) => ({ - ...current, - authToken: event.target.value, - })); - }} - placeholder="sk-ant-..." - className="font-mono" - disabled={isSavingAnthropicConfig} - /> - } - onSave={() => { - void saveAnthropicForm(); - }} - onClear={() => { - const nextForm = { ...anthropicForm, authToken: "" }; - setAnthropicForm(nextForm); - void saveAnthropicForm(nextForm); - }} - disableSave={isSavingAnthropicConfig} - disableClear={ - isSavingAnthropicConfig || - anthropicForm.authToken.length === 0 - } - /> - <ConfigRow - title="Base URL" - description="Custom API base URL" - field={ - <Input - value={anthropicForm.baseUrl} - onChange={(event) => { - setAnthropicForm((current) => ({ - ...current, - baseUrl: event.target.value, - })); - }} - placeholder="https://api.anthropic.com" - className="font-mono" - disabled={isSavingAnthropicConfig} - /> - } - onSave={() => { - void saveAnthropicForm(); - }} - onClear={() => { - const nextForm = { ...anthropicForm, baseUrl: "" }; - setAnthropicForm(nextForm); - void saveAnthropicForm(nextForm); - }} - disableSave={isSavingAnthropicConfig} - disableClear={ - isSavingAnthropicConfig || - anthropicForm.baseUrl.length === 0 - } - /> - <ConfigRow - title="Additional env" - description="Extra variables to keep with Anthropic config" - field={ - <Textarea - value={anthropicForm.extraEnv} - onChange={(event) => { - setAnthropicForm((current) => ({ - ...current, - extraEnv: event.target.value, - })); - }} - placeholder={ - "CLAUDE_CODE_USE_BEDROCK=1\nAWS_REGION=us-east-1" - } - className="min-h-24 font-mono text-xs" - disabled={isSavingAnthropicConfig} - /> - } - onSave={() => { - void saveAnthropicForm(); - }} - onClear={ - hasAnthropicConfig - ? () => { - const nextForm = { - ...anthropicForm, - extraEnv: "", - }; - setAnthropicForm(nextForm); - void saveAnthropicForm(nextForm); + <CollapsibleContent> + <div className="divide-y divide-border rounded-xl border bg-card"> + <ConfigRow + title="API token" + description="Anthropic auth token" + field={ + <Input + type="password" + value={anthropicForm.authToken} + onChange={(event) => { + setAnthropicForm((current) => ({ + ...current, + authToken: event.target.value, + })); + }} + placeholder="sk-ant-..." + className="font-mono" + disabled={isSavingAnthropicConfig} + /> + } + onSave={() => { + void saveAnthropicForm(); + }} + onClear={() => { + const nextForm = { ...anthropicForm, authToken: "" }; + setAnthropicForm(nextForm); + void saveAnthropicForm(nextForm); + }} + disableSave={isSavingAnthropicConfig} + disableClear={ + isSavingAnthropicConfig || + anthropicForm.authToken.length === 0 + } + /> + <ConfigRow + title="Base URL" + description="Custom API base URL" + field={ + <Input + value={anthropicForm.baseUrl} + onChange={(event) => { + setAnthropicForm((current) => ({ + ...current, + baseUrl: event.target.value, + })); + }} + placeholder="https://api.anthropic.com" + className="font-mono" + disabled={isSavingAnthropicConfig} + /> + } + onSave={() => { + void saveAnthropicForm(); + }} + onClear={() => { + const nextForm = { ...anthropicForm, baseUrl: "" }; + setAnthropicForm(nextForm); + void saveAnthropicForm(nextForm); + }} + disableSave={isSavingAnthropicConfig} + disableClear={ + isSavingAnthropicConfig || + anthropicForm.baseUrl.length === 0 + } + /> + <ConfigRow + title="Additional env" + description="Extra variables to keep with Anthropic config" + field={ + <Textarea + value={anthropicForm.extraEnv} + onChange={(event) => { + setAnthropicForm((current) => ({ + ...current, + extraEnv: event.target.value, + })); + }} + placeholder={ + "CLAUDE_CODE_USE_BEDROCK=1\nAWS_REGION=us-east-1" } - : undefined - } - clearLabel="Clear" - disableSave={isSavingAnthropicConfig} - disableClear={ - isSavingAnthropicConfig || - anthropicForm.extraEnv.length === 0 - } - /> + className="min-h-24 font-mono text-xs" + disabled={isSavingAnthropicConfig} + /> + } + onSave={() => { + void saveAnthropicForm(); + }} + onClear={ + hasAnthropicConfig + ? () => { + const nextForm = { + ...anthropicForm, + extraEnv: "", + }; + setAnthropicForm(nextForm); + void saveAnthropicForm(nextForm); + } + : undefined + } + clearLabel="Clear" + disableSave={isSavingAnthropicConfig} + disableClear={ + isSavingAnthropicConfig || + anthropicForm.extraEnv.length === 0 + } + /> + </div> </CollapsibleContent> </div> </Collapsible> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/AccountCard/AccountCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/AccountCard/AccountCard.tsx deleted file mode 100644 index 98e49024db8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/AccountCard/AccountCard.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Badge } from "@superset/ui/badge"; -import { cn } from "@superset/ui/utils"; -import type { ReactNode } from "react"; - -interface AccountCardProps { - title: string; - subtitle: string; - badge?: string; - badgeVariant?: "secondary" | "outline" | "destructive"; - actions?: ReactNode; - muted?: boolean; -} - -export function AccountCard({ - title, - subtitle, - badge, - badgeVariant = "secondary", - actions, - muted = false, -}: AccountCardProps) { - return ( - <div - className={cn( - "rounded-xl border bg-card px-4 py-4", - muted && "border-dashed bg-muted/20", - )} - > - <div className="flex items-center justify-between gap-4"> - <div className="min-w-0"> - <p className="truncate text-sm font-semibold">{title}</p> - <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p> - </div> - <div className="flex shrink-0 items-center gap-2"> - {badge ? <Badge variant={badgeVariant}>{badge}</Badge> : null} - {actions} - </div> - </div> - </div> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/AccountCard/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/AccountCard/index.ts deleted file mode 100644 index 88333bd623e..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/AccountCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AccountCard } from "./AccountCard"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/ConfigRow/ConfigRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/ConfigRow/ConfigRow.tsx index d0e0b24c61c..953ca4dd156 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/ConfigRow/ConfigRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/ConfigRow/ConfigRow.tsx @@ -1,4 +1,5 @@ import { Button } from "@superset/ui/button"; +import { cn } from "@superset/ui/utils"; import type { ReactNode } from "react"; interface ConfigRowProps { @@ -9,8 +10,11 @@ interface ConfigRowProps { onClear?: () => void; saveLabel?: string; clearLabel?: string; + showSave?: boolean; + showClear?: boolean; disableSave?: boolean; disableClear?: boolean; + className?: string; } export function ConfigRow({ @@ -21,11 +25,14 @@ export function ConfigRow({ onClear, saveLabel = "Save", clearLabel = "Clear", + showSave = true, + showClear = true, disableSave, disableClear, + className, }: ConfigRowProps) { return ( - <div className="rounded-xl border bg-card px-4 py-4"> + <div className={cn("px-4 py-4", className)}> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="min-w-0 lg:w-64"> <p className="text-sm font-semibold">{title}</p> @@ -36,7 +43,7 @@ export function ConfigRow({ <div className="flex min-w-0 flex-1 flex-col gap-3 lg:flex-row lg:items-center"> <div className="min-w-0 flex-1">{field}</div> <div className="flex shrink-0 items-center gap-2 self-end lg:self-auto"> - {onClear ? ( + {onClear && showClear ? ( <Button variant="outline" size="sm" @@ -46,7 +53,7 @@ export function ConfigRow({ {clearLabel} </Button> ) : null} - {onSave ? ( + {onSave && showSave ? ( <Button size="sm" onClick={onSave} disabled={disableSave}> {saveLabel} </Button> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/SettingsSection/SettingsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/SettingsSection/SettingsSection.tsx index 508ec0eabd9..19d4c485c0e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/SettingsSection/SettingsSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/components/SettingsSection/SettingsSection.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; interface SettingsSectionProps { title: string; + icon?: ReactNode; description?: string; action?: ReactNode; children: ReactNode; @@ -9,6 +10,7 @@ interface SettingsSectionProps { export function SettingsSection({ title, + icon, description, action, children, @@ -17,7 +19,10 @@ export function SettingsSection({ <section className="space-y-3"> <div className="flex items-start justify-between gap-4"> <div> - <h3 className="text-base font-semibold">{title}</h3> + <h3 className="flex items-center gap-2 text-base font-semibold"> + {icon} + {title} + </h3> {description ? ( <p className="text-sm text-muted-foreground">{description}</p> ) : null} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/utils.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/utils.ts index b14c60c63d2..b77b5b0bf75 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/utils.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/models/components/ModelsSettings/utils.ts @@ -84,7 +84,7 @@ export function getProviderSubtitle( return status.issue.message; } if (!status || status.connectionState === "disconnected") { - return "No account connected"; + return ""; } if (status.source === "external" && status.authMethod === "oauth") { return EXTERNAL_OAUTH_LABELS[providerId]; @@ -101,8 +101,8 @@ export function getProviderSubtitle( export function getStatusBadge( status: ModelProviderStatus | undefined, ): { label: string; variant: "secondary" | "outline" | "destructive" } | null { - if (!status) { - return null; + if (!status || status.connectionState === "disconnected") { + return { label: "Not connected", variant: "outline" }; } if (status.issue?.code === "expired") { return { label: "Expired", variant: "destructive" }; @@ -119,22 +119,32 @@ export function getStatusBadge( export function resolveProviderStatus(params: { providerId: ProviderId; authStatus?: AuthStatusLike; - diagnosticStatus?: ModelProviderStatus; }): ModelProviderStatus | undefined { - const { providerId, authStatus, diagnosticStatus } = params; - if (!authStatus) { - return diagnosticStatus; - } + const { providerId, authStatus } = params; + if (!authStatus) return undefined; + return deriveModelProviderStatus({ providerId, authStatus }); +} - return deriveModelProviderStatus({ - providerId, - authStatus, - diagnostic: { - providerId, - issue: authStatus.authenticated - ? (diagnosticStatus?.issue ?? null) - : null, - updatedAt: null, - }, - }); +export type ProviderAction = + | { kind: "connect" } + | { kind: "reconnect" } + | { kind: "logout" } + | null; + +/** + * Single source of truth for the provider action button. + */ +export function getProviderAction( + status: ModelProviderStatus | undefined, +): ProviderAction { + if (!status || status.connectionState === "disconnected") { + return { kind: "connect" }; + } + if (status.issue?.remediation === "reconnect") { + return { kind: "reconnect" }; + } + if (status.connectionState === "connected") { + return { kind: "logout" }; + } + return { kind: "connect" }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx index 2c6caa08dd1..6a62e328bcc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/organization/components/OrganizationSettings/OrganizationSettings.tsx @@ -26,6 +26,10 @@ import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + getImageExtensionFromMimeType, + parseBase64DataUrl, +} from "shared/file-types"; import { MemberActions } from "../../../members/components/MembersSettings/components/MemberActions"; import { PendingInvitations } from "../../../members/components/PendingInvitations"; import type { TeamMember } from "../../../members/types"; @@ -140,9 +144,8 @@ export function OrganizationSettings({ const result = await selectImageMutation.mutateAsync(); if (result.canceled || !result.dataUrl) return; - const mimeMatch = result.dataUrl.match(/^data:([^;]+);/); - const mimeType = mimeMatch?.[1] || "image/png"; - const ext = mimeType.split("/")[1] || "png"; + const { mimeType } = parseBase64DataUrl(result.dataUrl); + const ext = getImageExtensionFromMimeType(mimeType) ?? "png"; const uploadResult = await apiTrpcClient.organization.uploadLogo.mutate({ organizationId: organization.id, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx index bef14196c6e..9f317f8394c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx @@ -15,7 +15,7 @@ function CloudSettingsIndex() { if (!hasCloudAccess) { return ( <Navigate - to="/settings/project/$projectId/general" + to="/settings/projects/$projectId" params={{ projectId }} replace /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx index 760e4e30c2f..39a7cbc8119 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx @@ -73,12 +73,18 @@ export function AddSecretSheet({ const handleOpenChange = (nextOpen: boolean) => { if (!nextOpen && hasContent) { - alert.destructive({ + alert({ title: "Discard unsaved changes?", description: "You have unsaved environment variables. Are you sure you want to close?", - confirmText: "Discard", - onConfirm: () => onOpenChange(false), + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Discard", + variant: "destructive", + onClick: () => onOpenChange(false), + }, + ], }); return; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/page.tsx index 3a7f323290c..6083b7a49ad 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/page.tsx @@ -38,7 +38,7 @@ function SecretsSettingsPage() { if (!hasCloudAccess) { return ( <Navigate - to="/settings/project/$projectId/general" + to="/settings/projects/$projectId" params={{ projectId }} replace /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index bcf3ef95ea1..22e26ffe595 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -1,4 +1,8 @@ import type { BranchPrefixMode } from "@superset/local-db"; +import { + resolveBranchPrefix, + sanitizeSegment, +} from "@superset/shared/workspace-launch"; import { AlertDialog, AlertDialogAction, @@ -23,15 +27,8 @@ import { import { toast } from "@superset/ui/sonner"; import { Switch } from "@superset/ui/switch"; import { cn } from "@superset/ui/utils"; -import { useNavigate } from "@tanstack/react-router"; import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - HiOutlineCog6Tooth, - HiOutlineCommandLine, - HiOutlineFolderOpen, - HiOutlinePaintBrush, -} from "react-icons/hi2"; import { LuImagePlus, LuTrash2 } from "react-icons/lu"; import { ColorSelector } from "renderer/components/ColorSelector"; import { electronTrpc } from "renderer/lib/electron-trpc"; @@ -39,7 +36,6 @@ import { useImportAllWorktrees, useOpenExternalWorktree, } from "renderer/react-query/workspaces"; -import { resolveBranchPrefix, sanitizeSegment } from "shared/utils/branch"; import { ClickablePath } from "../../../../components/ClickablePath"; import { useDefaultWorktreePath, @@ -62,13 +58,13 @@ export function SettingsSection({ description, children, }: { - icon: ReactNode; + icon?: ReactNode; title: string; description?: string; children: ReactNode; }) { return ( - <div className="pt-3 border-t space-y-3"> + <div className="space-y-3"> <div> <h3 className="text-base font-semibold text-foreground flex items-center gap-2"> {icon} @@ -92,7 +88,6 @@ export function ProjectSettings({ projectId, visibleItems, }: ProjectSettingsProps) { - const navigate = useNavigate(); const utils = electronTrpc.useUtils(); const { data: project } = electronTrpc.projects.get.useQuery({ id: projectId, @@ -133,6 +128,7 @@ export function ProjectSettings({ const setProjectIcon = electronTrpc.projects.setProjectIcon.useMutation({ onError: (err) => { console.error("[project-settings/setProjectIcon] Failed:", err); + toast.error(err.message || "Failed to update project icon"); }, onSettled: () => { utils.projects.get.invalidate({ id: projectId }); @@ -295,27 +291,22 @@ export function ProjectSettings({ return ( <div className="p-6 max-w-4xl w-full select-text"> <ProjectSettingsHeader title={project.name}> - <ClickablePath path={project.mainRepoPath} /> + <ClickablePath + path={project.mainRepoPath} + className="text-xs text-muted-foreground" + /> </ProjectSettingsHeader> - <div className="space-y-4"> + <div className="space-y-8"> <SettingsSection - icon={<HiOutlineCog6Tooth className="h-4 w-4" />} title="Branch Prefix" - description="Override the default prefix for new workspaces." + description={ + previewPrefix + ? `Preview: ${previewPrefix}/branch-name` + : "Preview: branch-name" + } > - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label className="text-sm font-medium">Branch Prefix</Label> - <p className="text-xs text-muted-foreground"> - Preview:{" "} - <code className="bg-muted px-1.5 py-0.5 rounded text-foreground"> - {previewPrefix - ? `${previewPrefix}/branch-name` - : "branch-name"} - </code> - </p> - </div> + <div className="flex items-center justify-end"> <div className="flex items-center gap-2"> <Select value={currentMode} @@ -353,18 +344,10 @@ export function ProjectSettings({ </SettingsSection> <SettingsSection - icon={<HiOutlineCog6Tooth className="h-4 w-4" />} - title="Workspace Base Branch" - description="Set the default base branch for new workspaces in this repository." + title="Base Branch" + description="Default base for new workspaces. Override per-workspace at creation." > - <div className="flex items-center justify-between gap-4"> - <div className="space-y-0.5"> - <Label className="text-sm font-medium">Default Base Branch</Label> - <p className="text-xs text-muted-foreground"> - Used when creating a workspace unless you choose a one-off base - branch. - </p> - </div> + <div className="flex items-center justify-end gap-4"> <Select value={workspaceBaseBranchValue} onValueChange={handleWorkspaceBaseBranchChange} @@ -402,11 +385,7 @@ export function ProjectSettings({ )} </SettingsSection> - <SettingsSection - icon={<HiOutlineFolderOpen className="h-4 w-4" />} - title="Worktrees" - description="Manage worktree location and import existing worktrees." - > + <SettingsSection title="Worktrees"> <WorktreeLocationPicker currentPath={project.worktreeBaseDir} defaultPathLabel={`Using global default: ${globalPath}`} @@ -526,56 +505,10 @@ export function ProjectSettings({ )} </SettingsSection> - <SettingsSection - icon={<HiOutlineCommandLine className="h-4 w-4" />} - title="Terminal Presets" - description="Create repo-specific terminal presets without leaving settings." - > - <div className="flex items-center justify-between gap-4"> - <div className="space-y-0.5"> - <Label className="text-sm font-medium">Project Presets</Label> - <p className="text-xs text-muted-foreground"> - New presets can be limited to this project or expanded later to - multiple projects. - </p> - </div> - <div className="flex items-center gap-2"> - <Button - type="button" - variant="outline" - onClick={() => - navigate({ - to: "/settings/terminal", - }) - } - > - Manage Presets - </Button> - <Button - type="button" - onClick={() => - navigate({ - to: "/settings/terminal", - search: { createProjectId: projectId }, - }) - } - > - New Preset for This Project - </Button> - </div> - </div> - </SettingsSection> + <ScriptsEditor projectId={project.id} /> - <div className="pt-3 border-t"> - <ScriptsEditor projectId={project.id} /> - </div> - - <SettingsSection - icon={<HiOutlinePaintBrush className="h-4 w-4" />} - title="Appearance" - description="Customize this project's sidebar look." - > - <div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> + <SettingsSection title="Appearance"> + <div className="flex items-center justify-between gap-4"> <ColorSelector selectedColor={project.color} onSelectColor={(color) => @@ -584,73 +517,64 @@ export function ProjectSettings({ patch: { color }, }) } - className="max-w-xl" /> - <div className="flex items-center gap-2"> - <Label className="text-sm text-muted-foreground"> - Hide Image - </Label> - <Switch - checked={project.hideImage ?? false} - onCheckedChange={(checked) => - updateProject.mutate({ - id: projectId, - patch: { hideImage: checked }, - }) - } - /> - </div> - </div> - - {/* Project Icon */} - <div className="flex items-center justify-between"> - <div className="space-y-0.5"> - <Label className="text-sm font-medium">Project Icon</Label> - <p className="text-xs text-muted-foreground"> - Upload a custom icon for the sidebar. - </p> - </div> - <div className="flex items-center gap-2"> - {project.iconUrl && ( - <img - src={project.iconUrl} - alt="Project icon" - className="size-8 rounded object-cover border" - /> - )} - <input - ref={fileInputRef} - type="file" - accept="image/png,image/jpeg,image/svg+xml,image/x-icon" - className="hidden" - onChange={handleFileChange} - /> - <button - type="button" - onClick={handleIconUpload} - disabled={setProjectIcon.isPending} - className={cn( - "flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border", - "hover:bg-muted transition-colors", + <div className="flex items-center gap-4"> + <div className="flex items-center gap-2"> + {project.iconUrl && ( + <img + src={project.iconUrl} + alt="Project icon" + className="size-8 rounded object-cover border" + /> )} - > - <LuImagePlus className="size-4" /> - {project.iconUrl ? "Replace" : "Upload"} - </button> - {project.iconUrl && ( + <input + ref={fileInputRef} + type="file" + accept="image/png,image/jpeg,image/svg+xml,image/x-icon,image/vnd.microsoft.icon,.ico" + className="hidden" + onChange={handleFileChange} + /> <button type="button" - onClick={handleRemoveIcon} + onClick={handleIconUpload} disabled={setProjectIcon.isPending} className={cn( "flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border", - "hover:bg-destructive/10 text-destructive transition-colors", + "hover:bg-muted transition-colors", )} > - <LuTrash2 className="size-4" /> - Remove + <LuImagePlus className="size-4" /> + {project.iconUrl ? "Replace icon" : "Upload icon"} </button> - )} + {project.iconUrl && ( + <button + type="button" + onClick={handleRemoveIcon} + disabled={setProjectIcon.isPending} + className={cn( + "flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border", + "hover:bg-destructive/10 text-destructive transition-colors", + )} + > + <LuTrash2 className="size-4" /> + Remove + </button> + )} + </div> + <div className="flex items-center gap-2"> + <Label className="text-sm text-muted-foreground"> + Hide image + </Label> + <Switch + checked={project.hideImage ?? false} + onCheckedChange={(checked) => + updateProject.mutate({ + id: projectId, + patch: { hideImage: checked }, + }) + } + /> + </div> </div> </div> </SettingsSection> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx index 1ce752b65c3..d22a8090fdd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/components/ScriptsEditor/ScriptsEditor.tsx @@ -1,4 +1,5 @@ import { Button } from "@superset/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { cn } from "@superset/ui/utils"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -37,7 +38,6 @@ function parseContentFromConfig(content: string | null): { } interface ScriptTextareaProps { - title: string; description: string; placeholder: string; value: string; @@ -46,7 +46,6 @@ interface ScriptTextareaProps { } function ScriptTextarea({ - title, description, placeholder, value, @@ -110,15 +109,12 @@ function ScriptTextarea({ return ( <div className="space-y-2"> - <div> - <h4 className="text-sm font-medium">{title}</h4> - <p className="text-xs text-muted-foreground mt-0.5">{description}</p> - </div> + <p className="text-xs text-muted-foreground">{description}</p> {/* biome-ignore lint/a11y/useSemanticElements: Drop zone wrapper for drag-and-drop functionality */} <div role="region" - aria-label={`${title} script editor with file drop support`} + aria-label="Script editor with file drop support" className={cn( "relative rounded-lg border transition-colors", isDragOver @@ -349,67 +345,69 @@ export function ScriptsEditor({ projectId, className }: ScriptsEditorProps) { } return ( - <div className={cn("space-y-5", className)}> - <div className="flex items-start justify-between"> - <div className="space-y-1"> - <div className="flex items-center gap-2"> - <h3 className="text-base font-semibold text-foreground">Scripts</h3> - {saveStatus === "saving" && ( - <span className="text-xs text-muted-foreground flex items-center gap-1"> - <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse" /> - Saving... - </span> - )} - {saveStatus === "saved" && ( - <span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1"> - <HiCheckCircle className="h-3.5 w-3.5" /> - Saved - </span> - )} - </div> - <p className="text-sm text-muted-foreground"> - Automate your workspace lifecycle with setup and teardown scripts. - Changes are saved automatically. - </p> + <div className={cn("space-y-3", className)}> + <div className="flex items-center justify-between gap-2"> + <div className="flex items-center gap-2"> + <h3 className="text-base font-semibold text-foreground">Scripts</h3> + {saveStatus === "saving" && ( + <span className="text-xs text-muted-foreground flex items-center gap-1"> + <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse" /> + Saving… + </span> + )} + {saveStatus === "saved" && ( + <span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1"> + <HiCheckCircle className="h-3.5 w-3.5" /> + Saved + </span> + )} </div> - <Button variant="outline" size="sm" asChild> + <Button variant="ghost" size="sm" asChild> <a href={EXTERNAL_LINKS.SETUP_TEARDOWN_SCRIPTS} target="_blank" rel="noopener noreferrer" > - Get started with setup scripts + Docs <HiArrowTopRightOnSquare className="h-3.5 w-3.5" /> </a> </Button> </div> - <ScriptTextarea - title="Setup" - description="Runs when a new workspace is created." - placeholder="e.g. bun install && bun run dev" - value={setupContent} - onChange={handleSetupChange} - onBlur={handleBlurSave} - /> - - <ScriptTextarea - title="Teardown" - description="Runs when a workspace is deleted." - placeholder="e.g. docker compose down" - value={teardownContent} - onChange={handleTeardownChange} - onBlur={handleBlurSave} - /> - - <ScriptTextarea - title="Run" - description="A command to start your dev server, triggered via keyboard shortcut." - placeholder="e.g. bun run dev" - value={runContent} - onChange={handleRunChange} - onBlur={handleBlurSave} - /> + <Tabs defaultValue="setup"> + <TabsList> + <TabsTrigger value="setup">Setup</TabsTrigger> + <TabsTrigger value="teardown">Teardown</TabsTrigger> + <TabsTrigger value="run">Run</TabsTrigger> + </TabsList> + <TabsContent value="setup"> + <ScriptTextarea + description="Runs when a new workspace is created." + placeholder="e.g. bun install && bun run dev" + value={setupContent} + onChange={handleSetupChange} + onBlur={handleBlurSave} + /> + </TabsContent> + <TabsContent value="teardown"> + <ScriptTextarea + description="Runs when a workspace is deleted." + placeholder="e.g. docker compose down" + value={teardownContent} + onChange={handleTeardownChange} + onBlur={handleBlurSave} + /> + </TabsContent> + <TabsContent value="run"> + <ScriptTextarea + description="Command to start your dev server, triggered via keyboard shortcut." + placeholder="e.g. bun run dev" + value={runContent} + onChange={handleRunChange} + onBlur={handleBlurSave} + /> + </TabsContent> + </Tabs> </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettingsHeader/ProjectSettingsHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettingsHeader/ProjectSettingsHeader.tsx index f1ff9248290..7fcc307012c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettingsHeader/ProjectSettingsHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettingsHeader/ProjectSettingsHeader.tsx @@ -1,7 +1,4 @@ -import { Button } from "@superset/ui/button"; -import { Link } from "@tanstack/react-router"; import type { ReactNode } from "react"; -import { HiArrowLeft } from "react-icons/hi2"; interface ProjectSettingsHeaderProps { title: string; @@ -13,18 +10,9 @@ export function ProjectSettingsHeader({ children, }: ProjectSettingsHeaderProps) { return ( - <div className="mb-8 space-y-4"> - <Button variant="ghost" size="sm" asChild> - <Link to="/settings/projects"> - <HiArrowLeft className="h-4 w-4" /> - Projects - </Link> - </Button> - - <div> - <h2 className="text-xl font-semibold">{title}</h2> - {children && <div className="mt-1">{children}</div>} - </div> + <div className="mb-8"> + <h2 className="text-xl font-semibold">{title}</h2> + {children && <div className="mt-1">{children}</div>} </div> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/general/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/general/page.tsx deleted file mode 100644 index dfc3c190412..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/general/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { createFileRoute, notFound } from "@tanstack/react-router"; -import { useMemo } from "react"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { NotFound } from "renderer/routes/not-found"; -import { useSettingsSearchQuery } from "renderer/stores/settings-state"; -import { getMatchingItemsForSection } from "../../../utils/settings-search"; -import { ProjectSettings } from "../components/ProjectSettings"; - -export const Route = createFileRoute( - "/_authenticated/settings/project/$projectId/general/", -)({ - component: GeneralSettingsPage, - notFoundComponent: NotFound, - loader: async ({ params, context }) => { - const projectQueryKey = [ - ["projects", "get"], - { input: { id: params.projectId }, type: "query" }, - ]; - - const configQueryKey = [ - ["config", "getConfigFilePath"], - { input: { projectId: params.projectId }, type: "query" }, - ]; - - try { - await Promise.all([ - context.queryClient.ensureQueryData({ - queryKey: projectQueryKey, - queryFn: () => - electronTrpcClient.projects.get.query({ id: params.projectId }), - }), - context.queryClient.ensureQueryData({ - queryKey: configQueryKey, - queryFn: () => - electronTrpcClient.config.getConfigFilePath.query({ - projectId: params.projectId, - }), - }), - ]); - } catch (error) { - if (error instanceof Error && error.message.includes("not found")) { - throw notFound(); - } - throw error; - } - }, -}); - -function GeneralSettingsPage() { - const { projectId } = Route.useParams(); - const searchQuery = useSettingsSearchQuery(); - - const visibleItems = useMemo(() => { - if (!searchQuery) return null; - return getMatchingItemsForSection(searchQuery, "project").map( - (item) => item.id, - ); - }, [searchQuery]); - - return <ProjectSettings projectId={projectId} visibleItems={visibleItems} />; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx deleted file mode 100644 index ef30c71fbdb..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { createFileRoute, Navigate } from "@tanstack/react-router"; - -export const Route = createFileRoute( - "/_authenticated/settings/project/$projectId/", -)({ - component: ProjectSettingsIndex, -}); - -function ProjectSettingsIndex() { - const { projectId } = Route.useParams(); - return ( - <Navigate - to="/settings/project/$projectId/general" - params={{ projectId }} - replace - /> - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx new file mode 100644 index 00000000000..c93c0d2e7ad --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/$projectId/page.tsx @@ -0,0 +1,55 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { NotFound } from "renderer/routes/not-found"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { MOCK_ORG_ID } from "shared/constants"; +import { ProjectSettings } from "../../project/$projectId/components/ProjectSettings"; +import { getMatchingItemsForSection } from "../../utils/settings-search"; +import { V2ProjectSettings } from "../../v2-project/$projectId/components/V2ProjectSettings"; + +export const Route = createFileRoute( + "/_authenticated/settings/projects/$projectId/", +)({ + component: ProjectDetailPage, + notFoundComponent: NotFound, +}); + +function ProjectDetailPage() { + const { projectId } = Route.useParams(); + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const searchQuery = useSettingsSearchQuery(); + + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + + const { data: v2Match = [] } = useLiveQuery( + (q) => + q + .from({ projects: collections.v2Projects }) + .where(({ projects }) => eq(projects.id, projectId)) + .where(({ projects }) => + eq(projects.organizationId, activeOrganizationId ?? ""), + ) + .select(({ projects }) => ({ id: projects.id })), + [collections, projectId, activeOrganizationId], + ); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "project").map( + (item) => item.id, + ); + }, [searchQuery]); + + if (v2Match.length > 0) { + return <V2ProjectSettings projectId={projectId} />; + } + return <ProjectSettings projectId={projectId} visibleItems={visibleItems} />; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx new file mode 100644 index 00000000000..8ab0f025976 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx @@ -0,0 +1,176 @@ +import { cn } from "@superset/ui/utils"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { HiMagnifyingGlass } from "react-icons/hi2"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { MOCK_ORG_ID } from "shared/constants"; + +interface ProjectRow { + kind: "v1" | "v2"; + id: string; + name: string; +} + +interface ProjectsSettingsSidebarProps { + selectedProjectId: string | null; +} + +export function ProjectsSettingsSidebar({ + selectedProjectId, +}: ProjectsSettingsSidebarProps) { + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const [filter, setFilter] = useState(""); + + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + + const { data: groups = [] } = + electronTrpc.workspaces.getAllGrouped.useQuery(); + + const { data: v2Projects = [] } = useLiveQuery( + (q) => + q + .from({ projects: collections.v2Projects }) + .where(({ projects }) => + eq(projects.organizationId, activeOrganizationId ?? ""), + ) + .select(({ projects }) => ({ ...projects })), + [collections, activeOrganizationId], + ); + + const { v2Rows, v1Rows, totalUnfiltered } = useMemo(() => { + const loadedV2Ids = new Set(v2Projects.map((p) => p.id)); + + const allV2: ProjectRow[] = v2Projects.map((p) => ({ + kind: "v2", + id: p.id, + name: p.name, + })); + + const allV1: ProjectRow[] = groups + .filter( + (g) => + !g.project.neonProjectId || !loadedV2Ids.has(g.project.neonProjectId), + ) + .map((g) => ({ + kind: "v1", + id: g.project.id, + name: g.project.name, + })); + + const trimmed = filter.trim().toLowerCase(); + const matches = (rows: ProjectRow[]) => + trimmed + ? rows.filter((r) => r.name.toLowerCase().includes(trimmed)) + : rows; + + return { + v2Rows: matches(allV2), + v1Rows: matches(allV1), + totalUnfiltered: allV2.length + allV1.length, + }; + }, [groups, v2Projects, filter]); + + const isEmpty = totalUnfiltered === 0; + const noMatches = + !isEmpty && v2Rows.length === 0 && v1Rows.length === 0 && filter !== ""; + const showHeaders = v2Rows.length > 0 && v1Rows.length > 0; + + return ( + <div className="w-64 shrink-0 border-r overflow-y-auto"> + <div className="p-3 space-y-3"> + {!isEmpty && ( + <div className="relative"> + <HiMagnifyingGlass className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" /> + <input + type="text" + placeholder="Filter projects..." + value={filter} + onChange={(e) => setFilter(e.target.value)} + className="w-full h-8 pl-8 pr-2 text-sm bg-accent/50 rounded-md border-0 outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground" + /> + </div> + )} + {isEmpty && ( + <p className="px-2 text-sm text-muted-foreground">No projects yet.</p> + )} + {noMatches && ( + <p className="px-2 text-sm text-muted-foreground"> + No projects match "{filter}". + </p> + )} + {v2Rows.length > 0 && ( + <Section title={showHeaders ? "v2" : null}> + {v2Rows.map((row) => ( + <ProjectLink + key={`v2:${row.id}`} + row={row} + isActive={row.id === selectedProjectId} + /> + ))} + </Section> + )} + {v1Rows.length > 0 && ( + <Section title={showHeaders ? "v1" : null}> + {v1Rows.map((row) => ( + <ProjectLink + key={`v1:${row.id}`} + row={row} + isActive={row.id === selectedProjectId} + /> + ))} + </Section> + )} + </div> + </div> + ); +} + +function Section({ + title, + children, +}: { + title: string | null; + children: React.ReactNode; +}) { + return ( + <div> + {title && ( + <h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-2 mb-2"> + {title} + </h2> + )} + <nav className="flex flex-col gap-0.5">{children}</nav> + </div> + ); +} + +function ProjectLink({ + row, + isActive, +}: { + row: ProjectRow; + isActive: boolean; +}) { + return ( + <Link + to="/settings/projects/$projectId" + params={{ projectId: row.id }} + className={cn( + "flex items-center px-2 py-1.5 text-sm rounded-md transition-colors", + isActive + ? "bg-accent text-accent-foreground" + : "text-muted-foreground hover:bg-accent/50 hover:text-foreground", + )} + > + <span className="truncate">{row.name}</span> + </Link> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/index.ts new file mode 100644 index 00000000000..1d29666ebdd --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/index.ts @@ -0,0 +1 @@ +export { ProjectsSettingsSidebar } from "./ProjectsSettingsSidebar"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/layout.tsx new file mode 100644 index 00000000000..d5981668ec1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/layout.tsx @@ -0,0 +1,18 @@ +import { createFileRoute, Outlet, useParams } from "@tanstack/react-router"; +import { ProjectsSettingsSidebar } from "./components/ProjectsSettingsSidebar"; + +export const Route = createFileRoute("/_authenticated/settings/projects")({ + component: ProjectsSettingsLayout, +}); + +function ProjectsSettingsLayout() { + const params = useParams({ strict: false }) as { projectId?: string }; + return ( + <div className="flex h-full w-full"> + <ProjectsSettingsSidebar selectedProjectId={params.projectId ?? null} /> + <div className="flex-1 overflow-y-auto"> + <Outlet /> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx index 8f40231e3aa..5db0608732d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/page.tsx @@ -1,64 +1,78 @@ -import { cn } from "@superset/ui/utils"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { HiChevronRight } from "react-icons/hi2"; +import { useEffect, useMemo } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { MOCK_ORG_ID } from "shared/constants"; export const Route = createFileRoute("/_authenticated/settings/projects/")({ - component: ProjectsListPage, + component: ProjectsIndexPage, }); -function ProjectsListPage() { +function ProjectsIndexPage() { + const collections = useCollections(); + const { data: session } = authClient.useSession(); + const navigate = useNavigate(); + + const activeOrganizationId = env.SKIP_ENV_VALIDATION + ? MOCK_ORG_ID + : (session?.session?.activeOrganizationId ?? null); + const { data: groups = [] } = electronTrpc.workspaces.getAllGrouped.useQuery(); - const navigate = useNavigate(); - return ( - <div className="p-6 max-w-4xl w-full"> - <div className="mb-8"> - <h2 className="text-xl font-semibold">Projects</h2> - <p className="text-sm text-muted-foreground mt-1"> - Select a project to configure its settings - </p> + const { data: v2Projects = [] } = useLiveQuery( + (q) => + q + .from({ projects: collections.v2Projects }) + .where(({ projects }) => + eq(projects.organizationId, activeOrganizationId ?? ""), + ) + .select(({ projects }) => ({ + id: projects.id, + name: projects.name, + })), + [collections, activeOrganizationId], + ); + + const firstProjectId = useMemo(() => { + const v2Sorted = [...v2Projects].sort((a, b) => + a.name.localeCompare(b.name), + ); + if (v2Sorted[0]) return v2Sorted[0].id; + + const loadedV2Ids = new Set(v2Projects.map((p) => p.id)); + const v1Sorted = groups + .filter( + (g) => + !g.project.neonProjectId || !loadedV2Ids.has(g.project.neonProjectId), + ) + .map((g) => g.project) + .sort((a, b) => a.name.localeCompare(b.name)); + return v1Sorted[0]?.id ?? null; + }, [v2Projects, groups]); + + useEffect(() => { + if (firstProjectId) { + navigate({ + to: "/settings/projects/$projectId", + params: { projectId: firstProjectId }, + replace: true, + }); + } + }, [firstProjectId, navigate]); + + const isEmpty = v2Projects.length === 0 && groups.length === 0; + if (isEmpty) { + return ( + <div className="flex items-center justify-center h-full p-6 text-sm text-muted-foreground"> + No projects yet. </div> + ); + } - {groups.length === 0 ? ( - <p className="text-sm text-muted-foreground"> - No projects yet. Import a repository to get started. - </p> - ) : ( - <div className="space-y-1"> - {groups.map((group) => ( - <button - key={group.project.id} - type="button" - onClick={() => - navigate({ - to: "/settings/project/$projectId/general", - params: { projectId: group.project.id }, - }) - } - className={cn( - "flex items-center gap-3 w-full px-4 py-3 rounded-lg transition-colors text-left", - "hover:bg-accent/50 group", - )} - > - <div - className="w-3 h-3 rounded-full shrink-0" - style={{ backgroundColor: group.project.color }} - /> - <div className="flex-1 min-w-0"> - <p className="text-sm font-medium truncate"> - {group.project.name} - </p> - <p className="text-xs text-muted-foreground truncate"> - {group.project.mainRepoPath} - </p> - </div> - <HiChevronRight className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" /> - </button> - ))} - </div> - )} - </div> - ); + return null; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/RingtonesSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/RingtonesSettings.tsx index f8f62482c18..266270b2f42 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/RingtonesSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/RingtonesSettings.tsx @@ -18,6 +18,7 @@ import { SETTING_ITEM_ID, type SettingItemId, } from "../../../utils/settings-search"; +import { VolumeDropdown } from "./components/VolumeDropdown"; function formatDuration(seconds: number): string { return `${seconds}s`; @@ -135,7 +136,10 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { electronTrpc.ringtone.getCustom.useQuery(); const { data: isMutedData, isLoading: isMutedLoading } = electronTrpc.settings.getNotificationSoundsMuted.useQuery(); + const { data: volumeData } = + electronTrpc.settings.getNotificationVolume.useQuery(); const isMuted = isMutedData ?? false; + const volume = volumeData ?? 100; const customRingtone: Ringtone | null = customRingtoneData ? { ...customRingtoneData, @@ -231,6 +235,7 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { try { await electronTrpcClient.ringtone.preview.mutate({ ringtoneId: ringtone.id, + volume, }); } catch (error) { console.error("Failed to play ringtone:", error); @@ -244,7 +249,7 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { previewTimerRef.current = null; }, durationMs); }, - [playingId], + [playingId, volume], ); const handleSelect = useCallback( @@ -287,6 +292,9 @@ export function RingtonesSettings({ visibleItems }: RingtonesSettingsProps) { </div> )} + {/* Volume Dropdown */} + {showNotification && !isMuted && <VolumeDropdown />} + {/* Ringtone Section */} {showNotification && !isMuted && ( <div> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/components/VolumeDropdown/VolumeDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/components/VolumeDropdown/VolumeDropdown.tsx new file mode 100644 index 00000000000..be2915c8e52 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/components/VolumeDropdown/VolumeDropdown.tsx @@ -0,0 +1,98 @@ +import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; +import { useCallback } from "react"; +import { HiSpeakerWave } from "react-icons/hi2"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +const VOLUME_LEVELS = [ + { value: 20, label: "Quiet" }, + { value: 40, label: "Low" }, + { value: 60, label: "Medium" }, + { value: 80, label: "High" }, + { value: 100, label: "Maximum" }, +] as const; + +function getVolumeLabel(volume: number): string { + const level = VOLUME_LEVELS.find((l) => l.value === volume); + return level ? level.label : "Custom"; +} + +export function VolumeDropdown() { + const utils = electronTrpc.useUtils(); + const { data: volumeData, isLoading: volumeLoading } = + electronTrpc.settings.getNotificationVolume.useQuery(); + const volume = volumeData ?? 100; + + const setVolume = electronTrpc.settings.setNotificationVolume.useMutation({ + onMutate: async ({ volume }) => { + await utils.settings.getNotificationVolume.cancel(); + const previous = utils.settings.getNotificationVolume.getData(); + utils.settings.getNotificationVolume.setData(undefined, volume); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getNotificationVolume.setData( + undefined, + context.previous, + ); + } + }, + onSettled: async () => { + await utils.settings.getNotificationVolume.invalidate(); + }, + }); + + const handleVolumeChange = useCallback( + (value: string) => { + const newVolume = Number.parseInt(value, 10); + setVolume.mutate({ volume: newVolume }); + }, + [setVolume], + ); + + return ( + <div className="space-y-3"> + <div className="flex items-center justify-between gap-4"> + <div className="flex items-center gap-2"> + <HiSpeakerWave className="h-5 w-5 text-muted-foreground flex-shrink-0" /> + <Label htmlFor="notification-volume" className="text-sm font-medium"> + Volume + </Label> + </div> + <Select + value={volume.toString()} + onValueChange={handleVolumeChange} + disabled={volumeLoading} + > + <SelectTrigger id="notification-volume" className="w-[200px]"> + <SelectValue> + <span className="flex items-center gap-2"> + <span className="font-medium">{getVolumeLabel(volume)}</span> + <span className="text-muted-foreground">({volume}%)</span> + </span> + </SelectValue> + </SelectTrigger> + <SelectContent> + {VOLUME_LEVELS.map((level) => ( + <SelectItem key={level.value} value={level.value.toString()}> + <div className="flex items-center gap-2"> + <span className="font-medium">{level.label}</span> + <span className="text-muted-foreground text-xs"> + ({level.value}%) + </span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/components/VolumeDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/components/VolumeDropdown/index.ts new file mode 100644 index 00000000000..0e89d158857 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/ringtones/components/RingtonesSettings/components/VolumeDropdown/index.ts @@ -0,0 +1 @@ +export { VolumeDropdown } from "./VolumeDropdown"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/SecuritySettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/SecuritySettings.tsx new file mode 100644 index 00000000000..ef81d56dc0f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/SecuritySettings.tsx @@ -0,0 +1,122 @@ +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { Switch } from "@superset/ui/switch"; +import { useState } from "react"; +import { GATED_FEATURES, usePaywall } from "renderer/components/Paywall"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + isItemVisible, + SETTING_ITEM_ID, + type SettingItemId, +} from "../../../utils/settings-search"; +import { ExposeViaRelayConfirmDialog } from "./components/ExposeViaRelayConfirmDialog"; + +interface SecuritySettingsProps { + visibleItems?: SettingItemId[] | null; +} + +export function SecuritySettings({ visibleItems }: SecuritySettingsProps) { + const showRelayToggle = isItemVisible( + SETTING_ITEM_ID.SECURITY_EXPOSE_HOST_SERVICE_VIA_RELAY, + visibleItems, + ); + + const utils = electronTrpc.useUtils(); + const { data: exposeEnabled, isLoading } = + electronTrpc.settings.getExposeHostServiceViaRelay.useQuery(); + + const setExpose = + electronTrpc.settings.setExposeHostServiceViaRelay.useMutation({ + onMutate: async ({ enabled }) => { + await utils.settings.getExposeHostServiceViaRelay.cancel(); + const previous = utils.settings.getExposeHostServiceViaRelay.getData(); + utils.settings.getExposeHostServiceViaRelay.setData(undefined, enabled); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getExposeHostServiceViaRelay.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + utils.settings.getExposeHostServiceViaRelay.invalidate(); + }, + }); + + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmTargetEnabled, setConfirmTargetEnabled] = useState(false); + const { gateFeature } = usePaywall(); + + const runToggle = (enabled: boolean) => { + toast.promise(setExpose.mutateAsync({ enabled }), { + loading: "Restarting host services…", + success: ({ restartedOrgCount }) => + restartedOrgCount > 0 + ? `Restarted ${restartedOrgCount} host service${restartedOrgCount === 1 ? "" : "s"}` + : "Setting saved", + error: (err: Error) => err.message ?? "Failed to update setting", + }); + }; + + const openConfirm = (next: boolean) => { + setConfirmTargetEnabled(next); + setConfirmOpen(true); + }; + + const handleChange = (next: boolean) => { + if (next) { + gateFeature(GATED_FEATURES.REMOTE_WORKSPACES, () => openConfirm(true)); + } else { + openConfirm(false); + } + }; + + return ( + <div className="p-6 max-w-4xl w-full"> + <div className="mb-8"> + <h2 className="text-xl font-semibold">Security</h2> + <p className="text-sm text-muted-foreground mt-1"> + Control how your local machine is reachable from remote workspaces + </p> + </div> + + {showRelayToggle && ( + <div className="flex items-start justify-between gap-6"> + <div className="space-y-1 flex-1"> + <Label + htmlFor="expose-host-service-via-relay" + className="text-sm font-medium" + > + Allow remote workspaces to access this device via relay + </Label> + <p className="text-xs text-muted-foreground"> + When off, your local tools and files cannot be reached from any + remote workspace through the Superset relay. This does not affect + your ability to connect out to remote sandboxes from this device. + </p> + </div> + <Switch + id="expose-host-service-via-relay" + checked={exposeEnabled ?? false} + onCheckedChange={handleChange} + disabled={isLoading || setExpose.isPending} + /> + </div> + )} + + <ExposeViaRelayConfirmDialog + open={confirmOpen} + targetEnabled={confirmTargetEnabled} + onOpenChange={setConfirmOpen} + onConfirm={() => { + const enabled = confirmTargetEnabled; + setConfirmOpen(false); + runToggle(enabled); + }} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/components/ExposeViaRelayConfirmDialog/ExposeViaRelayConfirmDialog.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/components/ExposeViaRelayConfirmDialog/ExposeViaRelayConfirmDialog.tsx new file mode 100644 index 00000000000..910e5108d4f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/components/ExposeViaRelayConfirmDialog/ExposeViaRelayConfirmDialog.tsx @@ -0,0 +1,104 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { useEffect, useState } from "react"; + +const CONFIRM_PHRASE = "I understand"; + +interface ExposeViaRelayConfirmDialogProps { + open: boolean; + targetEnabled: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +} + +export function ExposeViaRelayConfirmDialog({ + open, + targetEnabled, + onOpenChange, + onConfirm, +}: ExposeViaRelayConfirmDialogProps) { + const [typed, setTyped] = useState(""); + + // Reset the typed confirmation whenever the dialog closes so reopening + // always starts from an empty input. + useEffect(() => { + if (!open) setTyped(""); + }, [open]); + + const canConfirm = !targetEnabled || typed === CONFIRM_PHRASE; + + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent className="max-w-[480px]"> + <AlertDialogHeader> + <AlertDialogTitle> + {targetEnabled ? "Enable Relay access?" : "Disable Relay access?"} + </AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-3 text-sm text-muted-foreground"> + <p> + This restarts the host service and stops running terminals. File + watches and other host-backed work will be interrupted. + </p> + {targetEnabled ? ( + <p> + Remote workspaces you grant access to will be able to reach + this device through Superset Relay. + </p> + ) : ( + <p> + Remote workspaces will no longer be able to reach this device + through Superset Relay. + </p> + )} + </div> + </AlertDialogDescription> + </AlertDialogHeader> + + {targetEnabled && ( + <div className="space-y-2 pt-2"> + <Label htmlFor="expose-relay-confirm" className="text-xs"> + Type{" "} + <span className="font-mono font-medium text-foreground"> + {CONFIRM_PHRASE} + </span>{" "} + to continue + </Label> + <Input + id="expose-relay-confirm" + autoFocus + value={typed} + onChange={(event) => setTyped(event.target.value)} + placeholder={CONFIRM_PHRASE} + autoComplete="off" + spellCheck={false} + /> + </div> + )} + + <AlertDialogFooter> + <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button + variant="destructive" + size="sm" + disabled={!canConfirm} + onClick={onConfirm} + > + {targetEnabled ? "Enable and restart" : "Disable and restart"} + </Button> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/components/ExposeViaRelayConfirmDialog/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/components/ExposeViaRelayConfirmDialog/index.ts new file mode 100644 index 00000000000..eec854fa14c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/components/ExposeViaRelayConfirmDialog/index.ts @@ -0,0 +1 @@ +export { ExposeViaRelayConfirmDialog } from "./ExposeViaRelayConfirmDialog"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/index.ts new file mode 100644 index 00000000000..c6d4e271cfe --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/security/components/SecuritySettings/index.ts @@ -0,0 +1 @@ +export { SecuritySettings } from "./SecuritySettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/security/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/security/page.tsx new file mode 100644 index 00000000000..f627f48c9de --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/security/page.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { getMatchingItemsForSection } from "../utils/settings-search"; +import { SecuritySettings } from "./components/SecuritySettings"; + +export const Route = createFileRoute("/_authenticated/settings/security/")({ + component: SecuritySettingsPage, +}); + +function SecuritySettingsPage() { + const searchQuery = useSettingsSearchQuery(); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "security").map( + (item) => item.id, + ); + }, [searchQuery]); + + return <SecuritySettings visibleItems={visibleItems} />; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx index 09581f20570..502ace48ab9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/TerminalSettings.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { isItemVisible, SETTING_ITEM_ID, @@ -7,6 +8,7 @@ import { import { LinkBehaviorSetting } from "./components/LinkBehaviorSetting"; import { PresetsSection } from "./components/PresetsSection"; import { SessionsSection } from "./components/SessionsSection"; +import { V2PresetsSection } from "./components/V2PresetsSection"; interface TerminalSettingsProps { visibleItems?: SettingItemId[] | null; @@ -44,6 +46,7 @@ export function TerminalSettings({ pendingCreateProjectId, onPendingCreateProjectIdChange, }: TerminalSettingsProps) { + const { isV2CloudEnabled } = useIsV2CloudEnabled(); const showPresets = isItemVisible( SETTING_ITEM_ID.TERMINAL_PRESETS, visibleItems, @@ -71,17 +74,28 @@ export function TerminalSettings({ </div> <SectionList> - {(showPresets || showQuickAdd) && ( - <PresetsSection - key="presets" - showPresets={showPresets} - showQuickAdd={showQuickAdd} - editingPresetId={editingPresetId} - onEditingPresetIdChange={onEditingPresetIdChange} - pendingCreateProjectId={pendingCreateProjectId} - onPendingCreateProjectIdChange={onPendingCreateProjectIdChange} - /> - )} + {(showPresets || showQuickAdd) && + (isV2CloudEnabled ? ( + <V2PresetsSection + key="presets" + showPresets={showPresets} + showQuickAdd={showQuickAdd} + editingPresetId={editingPresetId} + onEditingPresetIdChange={onEditingPresetIdChange} + pendingCreateProjectId={pendingCreateProjectId} + onPendingCreateProjectIdChange={onPendingCreateProjectIdChange} + /> + ) : ( + <PresetsSection + key="presets" + showPresets={showPresets} + showQuickAdd={showQuickAdd} + editingPresetId={editingPresetId} + onEditingPresetIdChange={onEditingPresetIdChange} + pendingCreateProjectId={pendingCreateProjectId} + onPendingCreateProjectIdChange={onPendingCreateProjectIdChange} + /> + ))} {showLinkBehavior && <LinkBehaviorSetting key="link-behavior" />} {showSessions && <SessionsSection key="sessions" />} </SectionList> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx index 7805513f17d..f15cd1f719d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetRow/PresetRow.tsx @@ -1,8 +1,9 @@ import { normalizeExecutionMode } from "@superset/local-db"; import { Badge } from "@superset/ui/badge"; +import { Eye, EyeOff } from "lucide-react"; import { useEffect, useRef } from "react"; import { useDrag, useDrop } from "react-dnd"; -import { LuGripVertical, LuPin } from "react-icons/lu"; +import { LuGripVertical } from "react-icons/lu"; import type { TerminalPreset } from "renderer/routes/_authenticated/settings/presets/types"; import { getPresetProjectTargetLabel, @@ -19,7 +20,7 @@ interface PresetRowProps { onEdit: (presetId: string) => void; onLocalReorder: (fromIndex: number, toIndex: number) => void; onPersistReorder: (presetId: string, targetIndex: number) => void; - onTogglePin: (presetId: string, pinned: boolean) => void; + onToggleVisibility: (presetId: string, visible: boolean) => void; } export function PresetRow({ @@ -30,7 +31,7 @@ export function PresetRow({ onEdit, onLocalReorder, onPersistReorder, - onTogglePin, + onToggleVisibility, }: PresetRowProps) { const rowRef = useRef<HTMLDivElement>(null); const dragHandleRef = useRef<HTMLDivElement>(null); @@ -68,6 +69,7 @@ export function PresetRow({ const isWorkspaceCreation = !!preset.applyOnWorkspaceCreated; const isNewTab = !!preset.applyOnNewTab; + const isVisibleInBar = preset.pinnedToBar !== false; const modeValue = normalizeExecutionMode(preset.executionMode); const modeLabel = modeValue === "new-tab" @@ -163,22 +165,17 @@ export function PresetRow({ className="p-1 rounded hover:bg-accent/50 transition-colors" onClick={(e) => { e.stopPropagation(); - const isPinned = preset.pinnedToBar !== false; - onTogglePin(preset.id, !isPinned); + onToggleVisibility(preset.id, !isVisibleInBar); }} - title={preset.pinnedToBar !== false ? "Unpin from bar" : "Pin to bar"} - aria-label={ - preset.pinnedToBar !== false ? "Unpin from bar" : "Pin to bar" - } - aria-pressed={preset.pinnedToBar !== false} + title={isVisibleInBar ? "Hide from bar" : "Show in bar"} + aria-label={isVisibleInBar ? "Hide from bar" : "Show in bar"} + aria-pressed={isVisibleInBar} > - <LuPin - className={`size-3.5 ${ - preset.pinnedToBar !== false - ? "text-foreground" - : "text-muted-foreground/40" - }`} - /> + {isVisibleInBar ? ( + <Eye className="size-3.5 text-foreground" /> + ) : ( + <EyeOff className="size-3.5 text-muted-foreground/40" /> + )} </button> </div> </div> diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/PresetsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/PresetsSection.tsx index da4154dc593..9ef733c6df7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/PresetsSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/PresetsSection.tsx @@ -312,11 +312,11 @@ export function PresetsSection({ [setPresetAutoApply], ); - const handleTogglePin = useCallback( - (presetId: string, pinned: boolean) => { + const handleToggleVisibility = useCallback( + (presetId: string, visible: boolean) => { updatePreset.mutate({ id: presetId, - patch: { pinnedToBar: pinned }, + patch: { pinnedToBar: visible }, }); }, [updatePreset], @@ -485,7 +485,7 @@ export function PresetsSection({ onEdit={setEditingPreset} onLocalReorder={handleLocalReorder} onPersistReorder={handlePersistReorder} - onTogglePin={handleTogglePin} + onToggleVisibility={handleToggleVisibility} /> <p className="text-xs text-muted-foreground"> Click a preset row to edit details. diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetsTable/PresetsTable.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetsTable/PresetsTable.tsx index 65d6a4eb803..ec10c06941b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetsTable/PresetsTable.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/PresetsSection/components/PresetsTable/PresetsTable.tsx @@ -11,7 +11,7 @@ interface PresetsTableProps { onEdit: (presetId: string) => void; onLocalReorder: (fromIndex: number, toIndex: number) => void; onPersistReorder: (presetId: string, targetIndex: number) => void; - onTogglePin: (presetId: string, pinned: boolean) => void; + onToggleVisibility: (presetId: string, visible: boolean) => void; } export function PresetsTable({ @@ -22,7 +22,7 @@ export function PresetsTable({ onEdit, onLocalReorder, onPersistReorder, - onTogglePin, + onToggleVisibility, }: PresetsTableProps) { return ( <div className="rounded-lg border border-border overflow-hidden"> @@ -33,7 +33,7 @@ export function PresetsTable({ <div className="w-40 shrink-0">Applies to</div> <div className="w-32 shrink-0">Mode</div> <div className="w-36 shrink-0">Auto-run</div> - <div className="w-16 shrink-0 text-center">Pinned</div> + <div className="w-16 shrink-0 text-center">Visibility</div> </div> <div @@ -55,7 +55,7 @@ export function PresetsTable({ onEdit={onEdit} onLocalReorder={onLocalReorder} onPersistReorder={onPersistReorder} - onTogglePin={onTogglePin} + onToggleVisibility={onToggleVisibility} /> )) ) : ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx new file mode 100644 index 00000000000..df5235af68b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/V2PresetsSection.tsx @@ -0,0 +1,589 @@ +import { + type ExecutionMode, + normalizeExecutionMode, + type TerminalPreset, +} from "@superset/local-db"; +import { Button } from "@superset/ui/button"; +import { Label } from "@superset/ui/label"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { HiOutlinePlus } from "react-icons/hi2"; +import { useIsDarkTheme } from "renderer/assets/app-icons/preset-icons"; +import { useMigrateV1PresetsToV2 } from "renderer/routes/_authenticated/hooks/useMigrateV1PresetsToV2"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; +import type { PresetColumnKey } from "renderer/routes/_authenticated/settings/presets/types"; +import { PresetEditorSheet } from "../PresetsSection/components/PresetEditorSheet"; +import { PresetsTable } from "../PresetsSection/components/PresetsTable"; +import { QuickAddPresets } from "../PresetsSection/components/QuickAddPresets"; +import { + type AutoApplyField, + PRESET_TEMPLATES, + type PresetTemplate, +} from "../PresetsSection/constants"; +import type { PresetProjectOption } from "../PresetsSection/preset-project-options"; + +interface V2PresetsSectionProps { + showPresets: boolean; + showQuickAdd: boolean; + editingPresetId?: string | null; + onEditingPresetIdChange?: (presetId: string | null) => void; + pendingCreateProjectId?: string | null; + onPendingCreateProjectIdChange?: (projectId: string | null) => void; +} + +/** + * V2 clone of PresetsSection wired to the renderer-side v2TerminalPresets + * collection. Reuses PresetsTable / PresetEditorSheet / QuickAddPresets from + * the v1 directory (they're prop-driven renderers). When v1 is deprecated, + * delete PresetsSection and move the shared sub-components here. + */ +export function V2PresetsSection({ + showPresets, + showQuickAdd, + editingPresetId: editingPresetIdFromRoute, + onEditingPresetIdChange, + pendingCreateProjectId, + onPendingCreateProjectIdChange, +}: V2PresetsSectionProps) { + const isDark = useIsDarkTheme(); + const collections = useCollections(); + useMigrateV1PresetsToV2(); + + const { data: v2Presets = [] } = useLiveQuery( + (query) => + query + .from({ v2TerminalPresets: collections.v2TerminalPresets }) + .orderBy(({ v2TerminalPresets }) => v2TerminalPresets.tabOrder), + [collections], + ); + + const { data: v2Projects = [] } = useLiveQuery( + (query) => + query + .from({ v2Projects: collections.v2Projects }) + .orderBy(({ v2Projects }) => v2Projects.name), + [collections], + ); + + // V2TerminalPresetRow is a superset of TerminalPreset — safe to cast + // for the prop-driven sub-components. + const serverPresets = useMemo<TerminalPreset[]>( + () => v2Presets as unknown as TerminalPreset[], + [v2Presets], + ); + + const [localPresets, setLocalPresets] = + useState<TerminalPreset[]>(serverPresets); + const [editingPresetId, setEditingPresetId] = useState<string | null>( + editingPresetIdFromRoute ?? null, + ); + const presetsContainerRef = useRef<HTMLDivElement>(null); + const prevPresetsCountRef = useRef(serverPresets.length); + const serverPresetsRef = useRef(serverPresets); + const previousServerPresetIdsRef = useRef<Set<string>>( + new Set(serverPresets.map((preset) => preset.id)), + ); + const shouldOpenNewPresetEditorRef = useRef(false); + const lastHandledCreateProjectIdRef = useRef<string | null>(null); + + const projectOptions = useMemo<PresetProjectOption[]>( + () => + v2Projects.map((project) => ({ + id: project.id, + name: project.name, + // v2 project schema has no color/mainRepoPath; degrade gracefully. + color: "", + mainRepoPath: "", + })), + [v2Projects], + ); + const projectOptionsById = useMemo( + () => new Map(projectOptions.map((project) => [project.id, project])), + [projectOptions], + ); + + useEffect(() => { + serverPresetsRef.current = serverPresets; + }, [serverPresets]); + + const setEditingPreset = useCallback( + (presetId: string | null) => { + setEditingPresetId(presetId); + onEditingPresetIdChange?.(presetId); + }, + [onEditingPresetIdChange], + ); + + useEffect(() => { + setEditingPresetId(editingPresetIdFromRoute ?? null); + }, [editingPresetIdFromRoute]); + + useEffect(() => { + setLocalPresets(serverPresets); + + const previousIds = previousServerPresetIdsRef.current; + if (shouldOpenNewPresetEditorRef.current) { + const addedPreset = serverPresets.find( + (preset) => !previousIds.has(preset.id), + ); + if (addedPreset) { + setEditingPreset(addedPreset.id); + shouldOpenNewPresetEditorRef.current = false; + } + } + + if (serverPresets.length > prevPresetsCountRef.current) { + requestAnimationFrame(() => { + presetsContainerRef.current?.scrollTo({ + top: presetsContainerRef.current.scrollHeight, + behavior: "smooth", + }); + }); + } + prevPresetsCountRef.current = serverPresets.length; + previousServerPresetIdsRef.current = new Set( + serverPresets.map((preset) => preset.id), + ); + }, [serverPresets, setEditingPreset]); + + const editingRowIndex = useMemo(() => { + if (!editingPresetId) return -1; + return localPresets.findIndex((preset) => preset.id === editingPresetId); + }, [editingPresetId, localPresets]); + + const editingPreset = useMemo( + () => (editingRowIndex >= 0 ? localPresets[editingRowIndex] : null), + [editingRowIndex, localPresets], + ); + + useEffect(() => { + if ( + editingPresetId && + !localPresets.some((preset) => preset.id === editingPresetId) + ) { + setEditingPreset(null); + } + }, [editingPresetId, localPresets, setEditingPreset]); + + const existingPresetNames = useMemo( + () => new Set(serverPresets.map((preset) => preset.name)), + [serverPresets], + ); + + const isTemplateAdded = useCallback( + (template: PresetTemplate) => existingPresetNames.has(template.preset.name), + [existingPresetNames], + ); + + const insertV2Preset = useCallback( + (input: { + name: string; + description?: string; + cwd: string; + commands: string[]; + projectIds?: string[] | null; + pinnedToBar?: boolean; + executionMode?: ExecutionMode; + }) => { + const maxTabOrder = v2Presets.reduce( + (max, preset) => Math.max(max, preset.tabOrder), + -1, + ); + collections.v2TerminalPresets.insert({ + id: crypto.randomUUID(), + name: input.name, + description: input.description, + cwd: input.cwd, + commands: input.commands, + projectIds: input.projectIds ?? null, + pinnedToBar: input.pinnedToBar, + executionMode: input.executionMode ?? "new-tab", + tabOrder: maxTabOrder + 1, + createdAt: new Date(), + }); + }, + [collections.v2TerminalPresets, v2Presets], + ); + + const updateV2Preset = useCallback( + (id: string, patch: Partial<V2TerminalPresetRow>) => { + collections.v2TerminalPresets.update(id, (draft) => { + for (const [key, value] of Object.entries(patch) as Array< + [keyof V2TerminalPresetRow, unknown] + >) { + // biome-ignore lint/suspicious/noExplicitAny: narrow assignment across union + (draft as any)[key] = value; + } + }); + }, + [collections.v2TerminalPresets], + ); + + const deleteV2Preset = useCallback( + (id: string) => { + collections.v2TerminalPresets.delete(id); + }, + [collections.v2TerminalPresets], + ); + + const reorderV2Presets = useCallback( + (presetId: string, targetIndex: number) => { + const orderedIds = v2Presets.map((preset) => preset.id); + const currentIndex = orderedIds.indexOf(presetId); + if (currentIndex === -1) return; + if (targetIndex < 0 || targetIndex >= orderedIds.length) return; + + const [moved] = orderedIds.splice(currentIndex, 1); + orderedIds.splice(targetIndex, 0, moved); + + for (const [index, id] of orderedIds.entries()) { + collections.v2TerminalPresets.update(id, (draft) => { + draft.tabOrder = index; + }); + } + }, + [collections.v2TerminalPresets, v2Presets], + ); + + const handleCellChange = useCallback( + (rowIndex: number, column: PresetColumnKey, value: string) => { + setLocalPresets((prev) => + prev.map((preset, index) => + index === rowIndex ? { ...preset, [column]: value } : preset, + ), + ); + }, + [], + ); + + const handleCellBlur = useCallback( + (rowIndex: number, column: PresetColumnKey) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; + const serverPreset = serverPresetsRef.current.find( + (serverPresetItem) => serverPresetItem.id === preset.id, + ); + if (!serverPreset) return currentLocal; + if (preset[column] === serverPreset[column]) return currentLocal; + + updateV2Preset(preset.id, { [column]: preset[column] }); + return currentLocal; + }); + }, + [updateV2Preset], + ); + + const handleCommandsChange = useCallback( + (rowIndex: number, commands: string[]) => { + setLocalPresets((prev) => { + const preset = prev[rowIndex]; + const isDelete = preset && commands.length < preset.commands.length; + const newPresets = prev.map((presetItem, index) => + index === rowIndex ? { ...presetItem, commands } : presetItem, + ); + + if (isDelete && preset) { + updateV2Preset(preset.id, { commands }); + } + return newPresets; + }); + }, + [updateV2Preset], + ); + + const handleCommandsBlur = useCallback( + (rowIndex: number) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; + const serverPreset = serverPresetsRef.current.find( + (serverPresetItem) => serverPresetItem.id === preset.id, + ); + if (!serverPreset) return currentLocal; + if ( + JSON.stringify(preset.commands) === + JSON.stringify(serverPreset.commands) + ) { + return currentLocal; + } + + updateV2Preset(preset.id, { commands: preset.commands }); + return currentLocal; + }); + }, + [updateV2Preset], + ); + + const handleExecutionModeChange = useCallback( + (rowIndex: number, mode: ExecutionMode) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (!preset) return currentLocal; + + const newPresets = currentLocal.map((presetItem, index) => + index === rowIndex + ? { ...presetItem, executionMode: mode } + : presetItem, + ); + + updateV2Preset(preset.id, { executionMode: mode }); + + return newPresets; + }); + }, + [updateV2Preset], + ); + + const handleAddRow = useCallback( + (projectIds?: string[] | null) => { + shouldOpenNewPresetEditorRef.current = true; + insertV2Preset({ + name: "", + cwd: "", + commands: [""], + projectIds, + executionMode: "new-tab", + }); + }, + [insertV2Preset], + ); + + const handleAddTemplate = useCallback( + (template: PresetTemplate) => { + if (existingPresetNames.has(template.preset.name)) return; + insertV2Preset(template.preset); + }, + [existingPresetNames, insertV2Preset], + ); + + useEffect(() => { + if (!pendingCreateProjectId) { + lastHandledCreateProjectIdRef.current = null; + return; + } + + if (lastHandledCreateProjectIdRef.current === pendingCreateProjectId) { + return; + } + + lastHandledCreateProjectIdRef.current = pendingCreateProjectId; + handleAddRow([pendingCreateProjectId]); + onPendingCreateProjectIdChange?.(null); + }, [handleAddRow, onPendingCreateProjectIdChange, pendingCreateProjectId]); + + const handleDeleteRow = useCallback( + (rowIndex: number) => { + setLocalPresets((currentLocal) => { + const preset = currentLocal[rowIndex]; + if (preset) { + deleteV2Preset(preset.id); + } + return currentLocal; + }); + }, + [deleteV2Preset], + ); + + const handleToggleAutoApply = useCallback( + (presetId: string, field: AutoApplyField, enabled: boolean) => { + updateV2Preset(presetId, { [field]: enabled ? true : undefined }); + }, + [updateV2Preset], + ); + + const handleToggleVisibility = useCallback( + (presetId: string, visible: boolean) => { + updateV2Preset(presetId, { pinnedToBar: visible }); + }, + [updateV2Preset], + ); + + const handleLocalReorder = useCallback( + (fromIndex: number, toIndex: number) => { + setLocalPresets((prev) => { + const newPresets = [...prev]; + const [removed] = newPresets.splice(fromIndex, 1); + newPresets.splice(toIndex, 0, removed); + return newPresets; + }); + }, + [], + ); + + const handlePersistReorder = useCallback( + (presetId: string, targetIndex: number) => { + reorderV2Presets(presetId, targetIndex); + }, + [reorderV2Presets], + ); + + const handleCloseEditor = useCallback(() => { + setEditingPreset(null); + }, [setEditingPreset]); + + const handleDeleteEditingPreset = useCallback(() => { + if (editingRowIndex < 0) return; + handleDeleteRow(editingRowIndex); + setEditingPreset(null); + }, [editingRowIndex, handleDeleteRow, setEditingPreset]); + + const isWorkspaceCreation = !!editingPreset?.applyOnWorkspaceCreated; + const isNewTab = !!editingPreset?.applyOnNewTab; + const hasMultipleCommands = (editingPreset?.commands.length ?? 0) > 1; + const normalizedMode = normalizeExecutionMode(editingPreset?.executionMode); + const modeValue: ExecutionMode = hasMultipleCommands + ? normalizedMode + : normalizedMode === "split-pane" + ? "split-pane" + : "new-tab"; + + const handleEditorFieldChange = useCallback( + (column: PresetColumnKey, value: string) => { + if (editingRowIndex < 0) return; + handleCellChange(editingRowIndex, column, value); + }, + [editingRowIndex, handleCellChange], + ); + + const handleEditorFieldBlur = useCallback( + (column: PresetColumnKey) => { + if (editingRowIndex < 0) return; + handleCellBlur(editingRowIndex, column); + }, + [editingRowIndex, handleCellBlur], + ); + + const handleEditorDirectorySelect = useCallback( + (value: string) => { + if (!editingPreset || editingRowIndex < 0) return; + + setLocalPresets((prev) => + prev.map((preset, index) => + index === editingRowIndex ? { ...preset, cwd: value } : preset, + ), + ); + + updateV2Preset(editingPreset.id, { cwd: value }); + }, + [editingPreset, editingRowIndex, updateV2Preset], + ); + + const handleEditorProjectIdsChange = useCallback( + (projectIds: string[] | null) => { + if (!editingPreset || editingRowIndex < 0) return; + + setLocalPresets((prev) => + prev.map((preset, index) => + index === editingRowIndex ? { ...preset, projectIds } : preset, + ), + ); + + updateV2Preset(editingPreset.id, { projectIds }); + }, + [editingPreset, editingRowIndex, updateV2Preset], + ); + + const handleEditorCommandsChange = useCallback( + (commands: string[]) => { + if (editingRowIndex < 0) return; + handleCommandsChange(editingRowIndex, commands); + }, + [editingRowIndex, handleCommandsChange], + ); + + const handleEditorCommandsBlur = useCallback(() => { + if (editingRowIndex < 0) return; + handleCommandsBlur(editingRowIndex); + }, [editingRowIndex, handleCommandsBlur]); + + const handleEditorModeChange = useCallback( + (mode: ExecutionMode) => { + if (editingRowIndex < 0) return; + handleExecutionModeChange(editingRowIndex, mode); + }, + [editingRowIndex, handleExecutionModeChange], + ); + + const handleEditorAutoApplyToggle = useCallback( + (field: AutoApplyField, enabled: boolean) => { + if (!editingPreset) return; + handleToggleAutoApply(editingPreset.id, field, enabled); + }, + [editingPreset, handleToggleAutoApply], + ); + + return ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="space-y-0.5"> + <Label className="text-sm font-medium">Terminal Presets</Label> + <p className="text-xs text-muted-foreground"> + Presets let you quickly launch terminals with pre-configured + commands. + </p> + </div> + {showPresets && ( + <Button + variant="default" + size="sm" + className="gap-2" + onClick={() => handleAddRow()} + > + <HiOutlinePlus className="h-4 w-4" /> + Add Preset + </Button> + )} + </div> + + {showQuickAdd && ( + <QuickAddPresets + templates={PRESET_TEMPLATES} + isDark={isDark} + isCreatePending={false} + isTemplateAdded={isTemplateAdded} + onAddTemplate={handleAddTemplate} + /> + )} + + {showPresets && ( + <> + <PresetsTable + presets={localPresets} + isLoading={false} + projectOptionsById={projectOptionsById} + presetsContainerRef={presetsContainerRef} + onEdit={setEditingPreset} + onLocalReorder={handleLocalReorder} + onPersistReorder={handlePersistReorder} + onToggleVisibility={handleToggleVisibility} + /> + <p className="text-xs text-muted-foreground"> + Click a preset row to edit details. + </p> + </> + )} + + <PresetEditorSheet + preset={editingPreset} + projects={projectOptions} + open={!!editingPreset} + onOpenChange={(open) => !open && handleCloseEditor()} + onDeletePreset={handleDeleteEditingPreset} + onFieldChange={handleEditorFieldChange} + onFieldBlur={handleEditorFieldBlur} + onProjectIdsChange={handleEditorProjectIdsChange} + onDirectorySelect={handleEditorDirectorySelect} + onCommandsChange={handleEditorCommandsChange} + onCommandsBlur={handleEditorCommandsBlur} + onModeChange={handleEditorModeChange} + onToggleAutoApply={handleEditorAutoApplyToggle} + modeValue={modeValue} + hasMultipleCommands={hasMultipleCommands} + isWorkspaceCreation={isWorkspaceCreation} + isNewTab={isNewTab} + /> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/index.ts new file mode 100644 index 00000000000..2089b5f5d8d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/terminal/components/TerminalSettings/components/V2PresetsSection/index.ts @@ -0,0 +1 @@ +export { V2PresetsSection } from "./V2PresetsSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index d7e56ed6276..28f92252440 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -40,9 +40,15 @@ export const SETTING_ITEM_ID = { TERMINAL_SESSIONS: "terminal-sessions", TERMINAL_LINK_BEHAVIOR: "terminal-link-behavior", + LINKS_FILE: "links-file", + LINKS_URL: "links-url", + MODELS_ANTHROPIC: "models-anthropic", MODELS_OPENAI: "models-openai", + EXPERIMENTAL_SUPERSET_V2: "experimental-superset-v2", + EXPERIMENTAL_V1_MIGRATION: "experimental-v1-migration", + INTEGRATIONS_LINEAR: "integrations-linear", INTEGRATIONS_GITHUB: "integrations-github", INTEGRATIONS_SLACK: "integrations-slack", @@ -67,6 +73,13 @@ export const SETTING_ITEM_ID = { PERMISSIONS_MICROPHONE: "permissions-microphone", PERMISSIONS_APPLE_EVENTS: "permissions-apple-events", PERMISSIONS_LOCAL_NETWORK: "permissions-local-network", + + SECURITY_EXPOSE_HOST_SERVICE_VIA_RELAY: + "security-expose-host-service-via-relay", + + HOST_MEMBERS: "host-members", + HOST_INVITE_MEMBER: "host-invite-member", + HOST_MEMBER_ROLE: "host-member-role", } as const; export type SettingItemId = @@ -617,6 +630,55 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "browser", ], }, + { + id: SETTING_ITEM_ID.LINKS_FILE, + section: "links", + title: "File links", + description: + "How file paths open when clicked in terminals, chat, and tasks", + keywords: [ + "links", + "file", + "click", + "cmd", + "ctrl", + "shift", + "meta", + "pane", + "editor", + "external", + "open", + "terminal", + "chat", + "markdown", + "behavior", + ], + }, + { + id: SETTING_ITEM_ID.LINKS_URL, + section: "links", + title: "URL links", + description: "How URLs open when clicked in terminals, chat, and tasks", + keywords: [ + "links", + "url", + "link", + "click", + "cmd", + "ctrl", + "shift", + "meta", + "browser", + "in-app", + "system", + "external", + "open", + "terminal", + "chat", + "markdown", + "behavior", + ], + }, { id: SETTING_ITEM_ID.MODELS_ANTHROPIC, section: "models", @@ -649,6 +711,44 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "auto name", ], }, + { + id: SETTING_ITEM_ID.EXPERIMENTAL_SUPERSET_V2, + section: "experimental", + title: "Try Superset Version 2 (Early Access)", + description: "Switch between Superset V1 and the new V2 experience", + keywords: [ + "experimental", + "experiments", + "v2", + "v1", + "version", + "early access", + "beta", + "preview", + "workspace", + "workspaces", + "toggle", + "switch", + ], + }, + { + id: SETTING_ITEM_ID.EXPERIMENTAL_V1_MIGRATION, + section: "experimental", + title: "V1 to V2 Migration", + description: "Rerun the V1 to V2 data migration", + keywords: [ + "experimental", + "migration", + "migrate", + "rerun", + "retry", + "recover", + "v1", + "v2", + "projects", + "workspaces", + ], + }, { id: SETTING_ITEM_ID.INTEGRATIONS_LINEAR, section: "integrations", @@ -984,6 +1084,74 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ "development servers", ], }, + { + id: SETTING_ITEM_ID.SECURITY_EXPOSE_HOST_SERVICE_VIA_RELAY, + section: "security", + title: "Allow remote workspaces to access this device via relay", + description: + "Controls whether remote workspaces can reach your local host service through the Superset relay", + keywords: [ + "security", + "relay", + "remote", + "workspace", + "expose", + "lockdown", + "network", + "inbound", + "host service", + "tunnel", + "attack surface", + ], + }, + { + id: SETTING_ITEM_ID.HOST_MEMBERS, + section: "hosts", + title: "Host members", + description: "View who has access to a host in your organization", + keywords: [ + "host", + "hosts", + "member", + "members", + "access", + "team", + "share", + "machine", + "device", + ], + }, + { + id: SETTING_ITEM_ID.HOST_INVITE_MEMBER, + section: "hosts", + title: "Grant access to a host", + description: "Add an organization member to a host", + keywords: [ + "host", + "hosts", + "invite", + "add", + "grant", + "member", + "access", + "share", + ], + }, + { + id: SETTING_ITEM_ID.HOST_MEMBER_ROLE, + section: "hosts", + title: "Host member role", + description: "Change a member's role on a host (owner or member)", + keywords: [ + "host", + "hosts", + "role", + "owner", + "member", + "permission", + "admin", + ], + }, ]; export function searchSettings(query: string): SettingsItem[] { diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx new file mode 100644 index 00000000000..5358a0a76f8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -0,0 +1,79 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useQuery } from "@tanstack/react-query"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { SettingsSection } from "../../../../project/$projectId/components/ProjectSettings"; +import { ProjectSettingsHeader } from "../../../../project/$projectId/components/ProjectSettingsHeader"; +import { DeleteProjectSection } from "./components/DeleteProjectSection"; +import { ProjectLocationSection } from "./components/ProjectLocationSection"; +import { RepositorySection } from "./components/RepositorySection"; + +interface V2ProjectSettingsProps { + projectId: string; +} + +export function V2ProjectSettings({ projectId }: V2ProjectSettingsProps) { + const collections = useCollections(); + const { activeHostUrl } = useLocalHostService(); + + const { data: v2Project } = useLiveQuery( + (q) => + q + .from({ projects: collections.v2Projects }) + .where(({ projects }) => eq(projects.id, projectId)) + .select(({ projects }) => ({ ...projects })), + [collections, projectId], + ); + + const { data: hostProject, refetch: refetchHostProject } = useQuery({ + queryKey: ["host-project", "get", activeHostUrl, projectId], + enabled: !!activeHostUrl, + queryFn: async () => { + if (!activeHostUrl) return null; + const client = getHostServiceClientByUrl(activeHostUrl); + return client.project.get.query({ projectId }); + }, + }); + + const project = v2Project?.[0]; + if (!project) return null; + + return ( + <div className="p-6 max-w-4xl w-full select-text"> + <ProjectSettingsHeader title={project.name} /> + + <div className="space-y-8"> + <SettingsSection + title="Repository" + description="The GitHub repository this project tracks. Change it to re-link this project to a different repo." + > + <RepositorySection + projectId={projectId} + currentRepoCloneUrl={project.repoCloneUrl} + /> + </SettingsSection> + + <SettingsSection + title="Host Service Location" + description="Where this project lives on disk, per host connected to this organization." + > + <ProjectLocationSection + projectId={projectId} + currentPath={hostProject?.repoPath ?? null} + repoCloneUrl={project.repoCloneUrl} + onChanged={() => refetchHostProject()} + /> + </SettingsSection> + + <SettingsSection title="Danger Zone"> + <DeleteProjectSection + projectId={projectId} + projectName={project.name} + /> + </SettingsSection> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/DeleteProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/DeleteProjectSection.tsx new file mode 100644 index 00000000000..45c9d4c0d1b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -0,0 +1,82 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; + +interface DeleteProjectSectionProps { + projectId: string; + projectName: string; +} + +export function DeleteProjectSection({ + projectId, + projectName, +}: DeleteProjectSectionProps) { + const navigate = useNavigate(); + const [isDeleting, setIsDeleting] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + try { + await apiTrpcClient.v2Project.delete.mutate({ id: projectId }); + toast.success(`Deleted "${projectName}"`); + setIsOpen(false); + navigate({ to: "/settings/projects" }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to delete"); + } finally { + setIsDeleting(false); + } + }; + + return ( + <div className="flex items-center justify-between gap-4"> + <p className="text-xs text-muted-foreground"> + Permanently delete this project from the organization. Workspaces and + local clones on any host are not affected. + </p> + <AlertDialog open={isOpen} onOpenChange={setIsOpen}> + <AlertDialogTrigger asChild> + <Button type="button" variant="destructive" size="sm"> + Delete project + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Delete "{projectName}"?</AlertDialogTitle> + <AlertDialogDescription> + This removes the project from the organization. Anyone with access + will lose it. This cannot be undone. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={(e) => { + e.preventDefault(); + handleDelete(); + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? "Deleting…" : "Delete"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/index.ts new file mode 100644 index 00000000000..04a624ec2ff --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/DeleteProjectSection/index.ts @@ -0,0 +1 @@ +export { DeleteProjectSection } from "./DeleteProjectSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx new file mode 100644 index 00000000000..8611d4ef73d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/ProjectLocationSection.tsx @@ -0,0 +1,352 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; +import { ClickablePath } from "../../../../../../components/ClickablePath"; + +interface BackfillConflict { + id: string; + name: string; +} + +interface ProjectLocationSectionProps { + projectId: string; + currentPath: string | null; + repoCloneUrl: string | null; + onChanged?: () => void; +} + +export function ProjectLocationSection({ + projectId, + currentPath, + repoCloneUrl, + onChanged, +}: ProjectLocationSectionProps) { + const { activeHostUrl, machineId } = useLocalHostService(); + const selectDirectory = electronTrpc.window.selectDirectory.useMutation(); + const navigate = useNavigate(); + const collections = useCollections(); + const { ensureProjectInSidebar, ensureWorkspaceInSidebar } = + useDashboardSidebarState(); + + const { data: hostRows } = useLiveQuery( + (q) => + q + .from({ hosts: collections.v2Hosts }) + .where(({ hosts }) => eq(hosts.machineId, machineId)) + .select(({ hosts }) => ({ + name: hosts.name, + isOnline: hosts.isOnline, + })), + [collections, machineId], + ); + const hostLabel = hostRows?.[0]?.name ?? "This device"; + + const [pendingPath, setPendingPath] = useState<string | null>(null); + const [conflict, setConflict] = useState<BackfillConflict | null>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const runSetup = async (repoPath: string, allowRelocate: boolean) => { + if (!activeHostUrl) { + toast.error("Host service not available"); + return false; + } + try { + const client = getHostServiceClientByUrl(activeHostUrl); + const result = await client.project.setup.mutate({ + projectId, + mode: { kind: "import", repoPath, allowRelocate }, + }); + toast.success( + allowRelocate + ? `Project relocated to ${result.repoPath}` + : `Project set up at ${result.repoPath}`, + ); + if (result.mainWorkspaceId) { + ensureWorkspaceInSidebar(result.mainWorkspaceId, projectId); + } else { + ensureProjectInSidebar(projectId); + } + onChanged?.(); + return true; + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + return false; + } + }; + + const runClone = async (parentDir: string) => { + if (!activeHostUrl) { + toast.error("Host service not available"); + return false; + } + try { + const client = getHostServiceClientByUrl(activeHostUrl); + const result = await client.project.setup.mutate({ + projectId, + mode: { kind: "clone", parentDir }, + }); + toast.success(`Cloned to ${result.repoPath}`); + if (result.mainWorkspaceId) { + ensureWorkspaceInSidebar(result.mainWorkspaceId, projectId); + } else { + ensureProjectInSidebar(projectId); + } + onChanged?.(); + return true; + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + return false; + } + }; + + const pickPath = async (title: string) => { + if (!activeHostUrl) { + toast.error("Host service not available"); + return null; + } + try { + const picked = await selectDirectory.mutateAsync({ + title, + defaultPath: currentPath ?? undefined, + }); + if (picked.canceled || !picked.path) return null; + return picked.path; + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + return null; + } + }; + + const handleImport = async () => { + const path = await pickPath("Select project location"); + if (!path) return; + if (!activeHostUrl) { + toast.error("Host service not available"); + return; + } + setIsSubmitting(true); + let keepSubmitting = false; + try { + const client = getHostServiceClientByUrl(activeHostUrl); + const precheck = await client.project.findBackfillConflict.query({ + projectId, + repoPath: path, + }); + if (precheck.conflict) { + setConflict(precheck.conflict); + keepSubmitting = true; + return; + } + await runSetup(path, false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + if (!keepSubmitting) setIsSubmitting(false); + } + }; + + const handleClone = async () => { + const parentDir = await pickPath("Select parent directory to clone into"); + if (!parentDir) return; + setIsSubmitting(true); + try { + await runClone(parentDir); + } finally { + setIsSubmitting(false); + } + }; + + const handleChange = async () => { + const path = await pickPath("Select new project location"); + if (!path) return; + if (path === currentPath) { + toast.info("Project is already at that location"); + return; + } + if (!activeHostUrl) { + toast.error("Host service not available"); + return; + } + try { + const client = getHostServiceClientByUrl(activeHostUrl); + const precheck = await client.project.findBackfillConflict.query({ + projectId, + repoPath: path, + }); + if (precheck.conflict) { + setConflict(precheck.conflict); + return; + } + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + return; + } + setPendingPath(path); + }; + + const handleConfirmRelocate = async () => { + if (!pendingPath) return; + setIsSubmitting(true); + const ok = await runSetup(pendingPath, true); + setIsSubmitting(false); + if (ok) setPendingPath(null); + }; + + return ( + <> + <div className="flex items-center gap-4"> + <div className="w-32 shrink-0 text-sm font-medium truncate"> + {hostLabel} + </div> + <div className="flex-1 min-w-0"> + {currentPath ? ( + <ClickablePath path={currentPath} /> + ) : ( + <span className="text-sm text-muted-foreground"> + Not set up on this host + </span> + )} + </div> + {currentPath ? ( + <Button + type="button" + variant="outline" + size="sm" + onClick={handleChange} + disabled={selectDirectory.isPending || isSubmitting} + > + Change location… + </Button> + ) : ( + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + size="sm" + onClick={handleClone} + disabled={ + !repoCloneUrl || selectDirectory.isPending || isSubmitting + } + title={ + repoCloneUrl + ? undefined + : "Link a GitHub repository first to enable cloning" + } + > + Clone here… + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={handleImport} + disabled={selectDirectory.isPending || isSubmitting} + > + Import existing… + </Button> + </div> + )} + </div> + + <AlertDialog + open={conflict !== null} + onOpenChange={(open) => { + if (!open) { + setConflict(null); + setIsSubmitting(false); + } + }} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Repository already linked</AlertDialogTitle> + <AlertDialogDescription> + This repository is already linked to project " + {conflict?.name ?? ""}" in this organization. Open that project to + set it up on this device. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={(e) => { + e.preventDefault(); + if (!conflict) return; + const target = conflict; + setConflict(null); + setIsSubmitting(false); + navigate({ + to: "/settings/projects/$projectId", + params: { projectId: target.id }, + }); + }} + > + Open project + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + <AlertDialog + open={pendingPath !== null} + onOpenChange={(open) => { + if (!open && !isSubmitting) setPendingPath(null); + }} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>Relocate project?</AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-3 text-sm"> + <div> + <div className="text-muted-foreground text-xs">From</div> + <div className="font-mono break-all">{currentPath}</div> + </div> + <div> + <div className="text-muted-foreground text-xs">To</div> + <div className="font-mono break-all">{pendingPath}</div> + </div> + <p className="text-muted-foreground"> + Existing worktrees under the old path will be orphaned. You + can re-import them from the worktrees flow. + </p> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isSubmitting}> + Cancel + </AlertDialogCancel> + <AlertDialogAction + onClick={(e) => { + e.preventDefault(); + handleConfirmRelocate(); + }} + disabled={isSubmitting} + > + {isSubmitting ? "Relocating…" : "Relocate"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/index.ts new file mode 100644 index 00000000000..b193f36035b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/ProjectLocationSection/index.ts @@ -0,0 +1 @@ +export { ProjectLocationSection } from "./ProjectLocationSection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx new file mode 100644 index 00000000000..eaaa5376eb4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/RepositorySection.tsx @@ -0,0 +1,95 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { useEffect, useRef, useState } from "react"; +import { useOptimisticCollectionActions } from "renderer/routes/_authenticated/hooks/useOptimisticCollectionActions"; + +interface RepositorySectionProps { + projectId: string; + currentRepoCloneUrl: string | null; +} + +export function RepositorySection({ + projectId, + currentRepoCloneUrl, +}: RepositorySectionProps) { + const { v2Projects: projectActions } = useOptimisticCollectionActions(); + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(currentRepoCloneUrl ?? ""); + const inputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + if (!isEditing) setValue(currentRepoCloneUrl ?? ""); + }, [currentRepoCloneUrl, isEditing]); + + const startEdit = () => { + setIsEditing(true); + setTimeout(() => inputRef.current?.focus(), 0); + }; + + const cancelEdit = () => { + setValue(currentRepoCloneUrl ?? ""); + setIsEditing(false); + }; + + const save = () => { + const trimmed = value.trim(); + if (trimmed === (currentRepoCloneUrl ?? "")) { + setIsEditing(false); + return; + } + const transaction = projectActions.updateRepository( + projectId, + trimmed === "" ? null : trimmed, + ); + if (transaction) { + setIsEditing(false); + } + }; + + return ( + <div className="flex items-center gap-2"> + {isEditing ? ( + <> + <Input + ref={inputRef} + value={value} + onChange={(e) => setValue(e.target.value)} + placeholder="https://github.com/owner/repo" + className="font-mono" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + save(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEdit(); + } + }} + /> + <Button + type="button" + variant="outline" + size="sm" + onClick={cancelEdit} + > + Cancel + </Button> + <Button type="button" size="sm" onClick={save}> + Save + </Button> + </> + ) : ( + <> + <span className="flex-1 text-sm font-mono break-all text-muted-foreground"> + {currentRepoCloneUrl ?? ( + <span className="italic">No repository linked</span> + )} + </span> + <Button type="button" variant="outline" size="sm" onClick={startEdit}> + Edit + </Button> + </> + )} + </div> + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/index.ts new file mode 100644 index 00000000000..87fd1d3f62f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/RepositorySection/index.ts @@ -0,0 +1 @@ +export { RepositorySection } from "./RepositorySection"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/index.ts new file mode 100644 index 00000000000..866a06bd40b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/index.ts @@ -0,0 +1 @@ +export { V2ProjectSettings } from "./V2ProjectSettings"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx b/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx index 55d627c264f..28c0f12ce7a 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx @@ -1,107 +1,228 @@ -import { - SearchDialog, - type SearchDialogItem, -} from "renderer/screens/main/components/SearchDialog"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; -import type { SearchScope } from "renderer/stores/search-dialog-state"; -import { ScopeToggle } from "./components/ScopeToggle"; - -interface CommandPaletteResult extends SearchDialogItem { - name: string; - relativePath: string; - path: string; - isDirectory: boolean; - score: number; - workspaceId?: string; - workspaceName?: string; -} +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { CommandPrimitive, CommandSeparator } from "@superset/ui/command"; +import { SearchIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import type { RecentFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles"; +import { RECENT_DISPLAY_LIMIT } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles"; +import { useFileSearch } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch"; +import { FileResultItem } from "./components/FileResultItem"; +import { useV2FileSearch } from "./hooks/useV2FileSearch"; + +// 48px input + 10 * 40px items +const MAX_DIALOG_HEIGHT = 448; +const SEARCH_LIMIT = 50; -interface CommandPaletteProps { +export interface CommandPaletteProps { + workspaceId: string; open: boolean; onOpenChange: (open: boolean) => void; - query: string; - onQueryChange: (query: string) => void; - filtersOpen: boolean; - onFiltersOpenChange: (open: boolean) => void; - includePattern: string; - onIncludePatternChange: (value: string) => void; - excludePattern: string; - onExcludePatternChange: (value: string) => void; - isLoading: boolean; - searchResults: CommandPaletteResult[]; - onSelectFile: (filePath: string, workspaceId?: string) => void; - scope: SearchScope; - onScopeChange: (scope: SearchScope) => void; - workspaceName?: string; + onSelectFile: (filePath: string) => void; + variant?: "v1" | "v2"; + recentlyViewedFiles?: RecentFile[]; + openFilePaths?: Set<string>; +} + +function getFileName(relativePath: string): string { + const segments = relativePath.split("/"); + return segments[segments.length - 1] ?? relativePath; } export function CommandPalette({ + workspaceId, open, onOpenChange, - query, - onQueryChange, - filtersOpen, - onFiltersOpenChange, - includePattern, - onIncludePatternChange, - excludePattern, - onExcludePatternChange, - isLoading, - searchResults, onSelectFile, - scope, - onScopeChange, - workspaceName, + variant = "v1", + recentlyViewedFiles, + openFilePaths, }: CommandPaletteProps) { - return ( - <SearchDialog - open={open} - onOpenChange={onOpenChange} - title="Quick Open" - description={ - scope === "global" - ? "Search for files across all workspaces" - : "Search for files in your workspace" - } - query={query} - onQueryChange={onQueryChange} - queryPlaceholder={ - scope === "global" ? "Search all workspaces..." : "Search files..." - } - filtersOpen={filtersOpen} - onFiltersOpenChange={onFiltersOpenChange} - includePattern={includePattern} - onIncludePatternChange={onIncludePatternChange} - excludePattern={excludePattern} - onExcludePatternChange={onExcludePatternChange} - emptyMessage="No files found." - isLoading={isLoading} - results={searchResults} - getItemValue={(file) => `${file.path} ${query}`} - onSelectItem={(file) => onSelectFile(file.path, file.workspaceId)} - headerExtra={ - <ScopeToggle - scope={scope} - onScopeChange={onScopeChange} - workspaceName={workspaceName} - /> + const [query, setQuery] = useState(""); + const [filtersOpen, setFiltersOpen] = useState(false); + const [includePattern, setIncludePattern] = useState(""); + const [excludePattern, setExcludePattern] = useState(""); + const inputRef = useRef<HTMLInputElement>(null); + + const v1Search = useFileSearch({ + workspaceId: variant === "v1" && open ? workspaceId : undefined, + searchTerm: variant === "v1" ? query : "", + includePattern: variant === "v1" ? includePattern : "", + excludePattern: variant === "v1" ? excludePattern : "", + limit: SEARCH_LIMIT, + }); + + const v2Search = useV2FileSearch( + variant === "v2" && open ? workspaceId : undefined, + variant === "v2" ? query : "", + ); + + const rawResults = + variant === "v2" ? v2Search.results : v1Search.searchResults; + const trimmedQuery = query.trim(); + const hasQuery = trimmedQuery.length > 0; + const showRecentSection = variant === "v2" && Boolean(recentlyViewedFiles); + + const orderedRecent = useMemo<RecentFile[]>(() => { + if (!showRecentSection || !recentlyViewedFiles) return []; + const openSet = openFilePaths ?? new Set<string>(); + const openFiles: RecentFile[] = []; + const rest: RecentFile[] = []; + for (const file of recentlyViewedFiles) { + if (openSet.has(file.absolutePath)) { + openFiles.push(file); + } else { + rest.push(file); } - renderItem={(file) => { - return ( - <> - <FileIcon fileName={file.name} className="size-3.5 shrink-0" /> - <span className="truncate font-medium">{file.name}</span> - {scope === "global" && file.workspaceName && ( - <span className="shrink-0 text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground"> - {file.workspaceName} - </span> + } + return [...openFiles, ...rest].slice(0, RECENT_DISPLAY_LIMIT); + }, [showRecentSection, recentlyViewedFiles, openFilePaths]); + + const filteredRecent = useMemo<RecentFile[]>(() => { + if (!showRecentSection) return []; + if (!hasQuery) return orderedRecent; + const needle = trimmedQuery.toLowerCase(); + return orderedRecent.filter((file) => + file.relativePath.toLowerCase().includes(needle), + ); + }, [showRecentSection, hasQuery, trimmedQuery, orderedRecent]); + + const recentAbsSet = useMemo( + () => new Set(filteredRecent.map((f) => f.absolutePath)), + [filteredRecent], + ); + + const dedupedResults = useMemo(() => { + if (!showRecentSection) return rawResults; + return rawResults.filter((r) => !recentAbsSet.has(r.path)); + }, [showRecentSection, rawResults, recentAbsSet]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + onOpenChange(nextOpen); + if (!nextOpen) setQuery(""); + }, + [onOpenChange], + ); + + const handleSelectFile = useCallback( + (filePath: string) => { + onSelectFile(filePath); + handleOpenChange(false); + }, + [onSelectFile, handleOpenChange], + ); + + useEffect(() => { + if (open) requestAnimationFrame(() => inputRef.current?.focus()); + }, [open]); + + const showHeading = showRecentSection && filteredRecent.length > 0; + const showSeparator = + showRecentSection && filteredRecent.length > 0 && dedupedResults.length > 0; + const showEmptyState = + filteredRecent.length === 0 && dedupedResults.length === 0; + + return ( + <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} modal> + <DialogPrimitive.Portal> + <DialogPrimitive.Overlay className="fixed inset-0 z-50" /> + <DialogPrimitive.Content + className="fixed left-[50%] z-50 w-full max-w-[672px] translate-x-[-50%] overflow-hidden rounded-lg border shadow-lg" + style={{ top: `calc(50% - ${MAX_DIALOG_HEIGHT / 2}px)` }} + > + <DialogPrimitive.Title className="sr-only"> + Quick Open + </DialogPrimitive.Title> + <DialogPrimitive.Description className="sr-only"> + Search for files in your workspace + </DialogPrimitive.Description> + + <CommandPrimitive + shouldFilter={false} + className="bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md" + > + <div className="flex h-12 items-center gap-2 border-b px-3"> + <SearchIcon className="size-5 shrink-0 opacity-50" /> + <CommandPrimitive.Input + ref={inputRef} + placeholder="Search files..." + value={query} + onValueChange={setQuery} + className="flex h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + {variant === "v1" && ( + <button + type="button" + className="shrink-0 rounded p-1 text-muted-foreground hover:text-foreground" + onClick={() => setFiltersOpen((v) => !v)} + aria-label={filtersOpen ? "Hide Filters" : "Show Filters"} + > + {filtersOpen ? ( + <LuChevronDown className="size-4" /> + ) : ( + <LuChevronRight className="size-4" /> + )} + </button> + )} + </div> + + {variant === "v1" && filtersOpen && ( + <div className="grid grid-cols-2 gap-2 border-b px-3 py-2"> + <input + value={includePattern} + onChange={(e) => setIncludePattern(e.target.value)} + placeholder="files to include (glob)" + className="h-8 rounded border bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground" + /> + <input + value={excludePattern} + onChange={(e) => setExcludePattern(e.target.value)} + placeholder="files to exclude (glob)" + className="h-8 rounded border bg-transparent px-2 text-xs outline-none placeholder:text-muted-foreground" + /> + </div> )} - <span className="truncate text-muted-foreground text-xs ml-auto"> - {file.relativePath} - </span> - </> - ); - }} - /> + + <CommandPrimitive.List className="max-h-[400px] overflow-x-hidden overflow-y-auto scroll-py-1 p-1"> + {showEmptyState && ( + <CommandPrimitive.Empty className="py-6 text-center text-sm text-muted-foreground"> + No files found. + </CommandPrimitive.Empty> + )} + + {showHeading && ( + <div className="px-2 pt-2 pb-1 text-muted-foreground text-xs"> + Recently Viewed + </div> + )} + + {filteredRecent.map((file) => ( + <FileResultItem + key={`recent:${file.absolutePath}`} + value={`recent:${file.absolutePath}`} + fileName={getFileName(file.relativePath)} + relativePath={file.relativePath} + onSelect={() => handleSelectFile(file.absolutePath)} + /> + ))} + + {showSeparator && ( + <CommandSeparator alwaysRender className="my-1" /> + )} + + {dedupedResults.map((file) => ( + <FileResultItem + key={file.id} + value={file.path} + fileName={file.name} + relativePath={file.relativePath} + onSelect={() => handleSelectFile(file.path)} + /> + ))} + </CommandPrimitive.List> + </CommandPrimitive> + </DialogPrimitive.Content> + </DialogPrimitive.Portal> + </DialogPrimitive.Root> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx new file mode 100644 index 00000000000..24958da6200 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx @@ -0,0 +1,33 @@ +import { CommandPrimitive } from "@superset/ui/command"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; + +interface FileResultItemProps { + value: string; + fileName: string; + relativePath: string; + onSelect: () => void; +} + +export function FileResultItem({ + value, + fileName, + relativePath, + onSelect, +}: FileResultItemProps) { + return ( + <CommandPrimitive.Item + value={value} + onSelect={onSelect} + className="group data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-2 text-sm outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + > + <FileIcon fileName={fileName} className="size-3.5 shrink-0" /> + <span className="max-w-[252px] truncate font-medium">{fileName}</span> + <span className="truncate text-muted-foreground text-xs"> + {relativePath} + </span> + <kbd className="ml-auto hidden shrink-0 text-xs text-muted-foreground group-data-[selected=true]:block"> + ↵ + </kbd> + </CommandPrimitive.Item> + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts new file mode 100644 index 00000000000..c8e5e1947f2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts @@ -0,0 +1 @@ +export { FileResultItem } from "./FileResultItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/ScopeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/ScopeToggle.tsx deleted file mode 100644 index 66ad9b9d26c..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/ScopeToggle.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { cn } from "@superset/ui/utils"; -import type { SearchScope } from "renderer/stores/search-dialog-state"; - -interface ScopeToggleProps { - scope: SearchScope; - onScopeChange: (scope: SearchScope) => void; - workspaceName?: string; -} - -export function ScopeToggle({ - scope, - onScopeChange, - workspaceName, -}: ScopeToggleProps) { - return ( - <div className="flex items-center gap-1 border-b px-3 py-1.5"> - <button - type="button" - aria-pressed={scope === "workspace"} - onClick={() => onScopeChange("workspace")} - className={cn( - "px-2 py-0.5 rounded text-xs transition-colors truncate max-w-[200px]", - scope === "workspace" - ? "bg-muted text-foreground" - : "text-muted-foreground hover:text-foreground", - )} - > - {workspaceName || "This workspace"} - </button> - <button - type="button" - aria-pressed={scope === "global"} - onClick={() => onScopeChange("global")} - className={cn( - "px-2 py-0.5 rounded text-xs transition-colors", - scope === "global" - ? "bg-muted text-foreground" - : "text-muted-foreground hover:text-foreground", - )} - > - All workspaces - </button> - </div> - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/index.ts deleted file mode 100644 index d0a07233684..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/ScopeToggle/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ScopeToggle } from "./ScopeToggle"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/index.ts new file mode 100644 index 00000000000..aa3c54552b6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/index.ts @@ -0,0 +1 @@ +export { useV2FileSearch } from "./useV2FileSearch"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/useV2FileSearch.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/useV2FileSearch.ts new file mode 100644 index 00000000000..b6fd31bdc74 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/hooks/useV2FileSearch/useV2FileSearch.ts @@ -0,0 +1,32 @@ +import { workspaceTrpc } from "@superset/workspace-client"; + +const SEARCH_LIMIT = 50; + +export function useV2FileSearch( + workspaceId: string | undefined, + query: string, +) { + const trimmedQuery = query.trim(); + + const { data, isFetching } = workspaceTrpc.filesystem.searchFiles.useQuery( + { + workspaceId: workspaceId ?? "", + query: trimmedQuery, + limit: SEARCH_LIMIT, + }, + { + enabled: Boolean(workspaceId) && trimmedQuery.length > 0, + placeholderData: (previous) => previous ?? { matches: [] }, + }, + ); + + const results = + data?.matches.map((match) => ({ + id: match.absolutePath, + name: match.name, + path: match.absolutePath, + relativePath: match.relativePath, + })) ?? []; + + return { results, isFetching }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts index bcc69d703b7..495c33448db 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/index.ts @@ -1,2 +1,2 @@ +export type { CommandPaletteProps } from "./CommandPalette"; export { CommandPalette } from "./CommandPalette"; -export { useCommandPalette } from "./useCommandPalette"; diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts deleted file mode 100644 index b71b7476dba..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/useCommandPalette.ts +++ /dev/null @@ -1,240 +0,0 @@ -import type { UseNavigateResult } from "@tanstack/react-router"; -import { useCallback, useMemo, useState } from "react"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getWorkspaceDisplayName } from "renderer/lib/getWorkspaceDisplayName"; -import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; -import { useFileSearch } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch"; -import { - type SearchScope, - useSearchDialogStore, -} from "renderer/stores/search-dialog-state"; -import { useTabsStore } from "renderer/stores/tabs/store"; - -const SEARCH_LIMIT = 50; - -interface UseCommandPaletteParams { - workspaceId: string; - navigate: UseNavigateResult<string>; - onSelectFile?: (input: { - filePath: string; - targetWorkspaceId: string; - close: () => void; - navigate: UseNavigateResult<string>; - }) => void; -} - -export function useCommandPalette({ - workspaceId, - navigate, - onSelectFile, -}: UseCommandPaletteParams) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const includePattern = useSearchDialogStore( - (state) => state.byMode.quickOpen.includePattern, - ); - const excludePattern = useSearchDialogStore( - (state) => state.byMode.quickOpen.excludePattern, - ); - const filtersOpen = useSearchDialogStore( - (state) => state.byMode.quickOpen.filtersOpen, - ); - const scope = - useSearchDialogStore((state) => state.byMode.quickOpen.scope) ?? - "workspace"; - const setIncludePatternByMode = useSearchDialogStore( - (state) => state.setIncludePattern, - ); - const setExcludePatternByMode = useSearchDialogStore( - (state) => state.setExcludePattern, - ); - const setFiltersOpenByMode = useSearchDialogStore( - (state) => state.setFiltersOpen, - ); - const setScopeByMode = useSearchDialogStore((state) => state.setScope); - - // Fetch all grouped workspaces (only when global scope is active and dialog is open) - const { data: allGrouped } = electronTrpc.workspaces.getAllGrouped.useQuery( - undefined, - { - enabled: open && scope === "global", - }, - ); - - // Build roots array for multi-workspace search - const roots = useMemo(() => { - if (scope !== "global" || !allGrouped) return []; - const result: { - rootPath: string; - workspaceId: string; - workspaceName: string; - }[] = []; - for (const group of allGrouped) { - const addWorkspace = (ws: { - id: string; - worktreePath: string; - name: string; - type: "worktree" | "branch"; - }) => { - if (ws.worktreePath) { - result.push({ - rootPath: ws.worktreePath, - workspaceId: ws.id, - workspaceName: getWorkspaceDisplayName( - ws.name, - ws.type, - group.project.name, - ), - }); - } - }; - for (const ws of group.workspaces) { - addWorkspace(ws); - } - for (const section of group.sections) { - for (const ws of section.workspaces) { - addWorkspace(ws); - } - } - } - return result; - }, [scope, allGrouped]); - - // Single-workspace search (existing behavior) - const singleSearch = useFileSearch({ - workspaceId: open && scope === "workspace" ? workspaceId : undefined, - searchTerm: query, - includePattern, - excludePattern, - limit: SEARCH_LIMIT, - }); - - // Multi-workspace search - const debouncedQuery = useDebouncedValue(query.trim(), 150); - const multiSearchQueries = electronTrpc.useQueries((t) => - open && scope === "global" && roots.length > 0 && debouncedQuery.length > 0 - ? roots.map((root) => - t.filesystem.searchFiles({ - workspaceId: root.workspaceId, - query: debouncedQuery, - includePattern, - excludePattern, - limit: SEARCH_LIMIT, - }), - ) - : [], - ); - - const multiSearchResults = useMemo( - () => - roots - .flatMap((root, index) => - (multiSearchQueries[index]?.data?.matches ?? []).map((match) => ({ - id: match.absolutePath, - name: match.name, - path: match.absolutePath, - relativePath: match.relativePath, - isDirectory: match.kind === "directory", - score: match.score, - workspaceId: root.workspaceId, - workspaceName: root.workspaceName, - })), - ) - .sort((left, right) => right.score - left.score) - .slice(0, SEARCH_LIMIT), - [roots, multiSearchQueries], - ); - - const searchResults = - scope === "workspace" ? singleSearch.searchResults : multiSearchResults; - const isFetching = - scope === "workspace" - ? singleSearch.isFetching - : multiSearchQueries.some((query) => query.isFetching) || - (query.trim().length > 0 && query.trim() !== debouncedQuery); - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) { - setQuery(""); - } - }, []); - - const toggle = useCallback(() => { - setOpen((prev) => { - if (prev) { - setQuery(""); - } - return !prev; - }); - }, []); - - const selectFile = useCallback( - (filePath: string, resultWorkspaceId?: string) => { - const targetWs = resultWorkspaceId ?? workspaceId; - if (onSelectFile) { - onSelectFile({ - filePath, - targetWorkspaceId: targetWs, - close: () => handleOpenChange(false), - navigate, - }); - return; - } - useTabsStore.getState().addFileViewerPane(targetWs, { filePath }); - handleOpenChange(false); - if (targetWs !== workspaceId) { - navigateToWorkspace(targetWs, navigate); - } - }, - [workspaceId, onSelectFile, handleOpenChange, navigate], - ); - - const setIncludePattern = useCallback( - (value: string) => { - setIncludePatternByMode("quickOpen", value); - }, - [setIncludePatternByMode], - ); - - const setExcludePattern = useCallback( - (value: string) => { - setExcludePatternByMode("quickOpen", value); - }, - [setExcludePatternByMode], - ); - - const setFiltersOpen = useCallback( - (nextOpen: boolean) => { - setFiltersOpenByMode("quickOpen", nextOpen); - }, - [setFiltersOpenByMode], - ); - - const setScope = useCallback( - (newScope: SearchScope) => { - setScopeByMode("quickOpen", newScope); - }, - [setScopeByMode], - ); - - return { - open, - query, - setQuery, - filtersOpen, - setFiltersOpen, - includePattern, - setIncludePattern, - excludePattern, - setExcludePattern, - handleOpenChange, - toggle, - selectFile, - searchResults, - isFetching, - scope, - setScope, - }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/KeywordSearch.tsx b/apps/desktop/src/renderer/screens/main/components/KeywordSearch/KeywordSearch.tsx deleted file mode 100644 index cc1edf315cf..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/KeywordSearch.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import type { ReactNode } from "react"; -import { - SearchDialog, - type SearchDialogItem, -} from "renderer/screens/main/components/SearchDialog"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; - -interface KeywordSearchResult extends SearchDialogItem { - name: string; - relativePath: string; - path: string; - line: number; - column: number; - preview: string; -} - -interface KeywordSearchProps { - open: boolean; - onOpenChange: (open: boolean) => void; - query: string; - onQueryChange: (query: string) => void; - filtersOpen: boolean; - onFiltersOpenChange: (open: boolean) => void; - includePattern: string; - onIncludePatternChange: (value: string) => void; - excludePattern: string; - onExcludePatternChange: (value: string) => void; - isLoading: boolean; - searchResults: KeywordSearchResult[]; - onSelectMatch: (match: KeywordSearchResult) => void; -} - -function renderHighlightedText(text: string, query: string): ReactNode { - const trimmedQuery = query.trim(); - if (!trimmedQuery) { - return text; - } - - const lowerText = text.toLowerCase(); - const lowerNeedle = trimmedQuery.toLowerCase(); - const nodes: ReactNode[] = []; - let searchIndex = 0; - - while (searchIndex < text.length) { - const matchIndex = lowerText.indexOf(lowerNeedle, searchIndex); - if (matchIndex === -1) { - break; - } - - if (matchIndex > searchIndex) { - nodes.push( - <span key={`text-${searchIndex}`}> - {text.slice(searchIndex, matchIndex)} - </span>, - ); - } - - nodes.push( - <mark - key={`mark-${matchIndex}`} - className="rounded bg-[var(--highlight-match)] px-0.5 text-foreground" - > - {text.slice(matchIndex, matchIndex + trimmedQuery.length)} - </mark>, - ); - - searchIndex = matchIndex + trimmedQuery.length; - } - - if (nodes.length === 0) { - return text; - } - - if (searchIndex < text.length) { - nodes.push( - <span key={`text-${searchIndex}`}>{text.slice(searchIndex)}</span>, - ); - } - - return nodes; -} - -export function KeywordSearch({ - open, - onOpenChange, - query, - onQueryChange, - filtersOpen, - onFiltersOpenChange, - includePattern, - onIncludePatternChange, - excludePattern, - onExcludePatternChange, - isLoading, - searchResults, - onSelectMatch, -}: KeywordSearchProps) { - return ( - <SearchDialog - open={open} - onOpenChange={onOpenChange} - title="Keyword Search" - description="Search for keyword matches across files in your workspace" - query={query} - onQueryChange={onQueryChange} - queryPlaceholder="Search keywords in files..." - filtersOpen={filtersOpen} - onFiltersOpenChange={onFiltersOpenChange} - includePattern={includePattern} - onIncludePatternChange={onIncludePatternChange} - excludePattern={excludePattern} - onExcludePatternChange={onExcludePatternChange} - emptyMessage="No keyword matches found." - isLoading={isLoading} - results={searchResults} - getItemValue={(match) => `${match.id} ${query}`} - onSelectItem={onSelectMatch} - renderItem={(match) => { - return ( - <> - <FileIcon fileName={match.name} className="size-3.5 shrink-0" /> - <div className="min-w-0 flex-1"> - <div className="flex items-center gap-2 min-w-0"> - <span className="truncate font-medium"> - {renderHighlightedText(match.name, query)} - </span> - <span className="truncate text-muted-foreground text-xs"> - {renderHighlightedText(match.relativePath, query)}: - {match.line} - </span> - </div> - {match.preview ? ( - <div className="truncate text-muted-foreground text-xs"> - {renderHighlightedText(match.preview, query)} - </div> - ) : null} - </div> - </> - ); - }} - /> - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/index.ts b/apps/desktop/src/renderer/screens/main/components/KeywordSearch/index.ts deleted file mode 100644 index 835c4d14c75..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { KeywordSearch } from "./KeywordSearch"; -export { useKeywordSearch } from "./useKeywordSearch"; diff --git a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts b/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts deleted file mode 100644 index 7e58cc93e2c..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/KeywordSearch/useKeywordSearch.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { useCallback, useState } from "react"; -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useSearchDialogStore } from "renderer/stores/search-dialog-state"; -import { useTabsStore } from "renderer/stores/tabs/store"; - -const SEARCH_LIMIT = 200; - -interface UseKeywordSearchParams { - workspaceId: string; -} - -interface KeywordSearchResult { - id: string; - name: string; - relativePath: string; - path: string; - line: number; - column: number; - preview: string; -} - -export function useKeywordSearch({ workspaceId }: UseKeywordSearchParams) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const includePattern = useSearchDialogStore( - (state) => state.byMode.keywordSearch.includePattern, - ); - const excludePattern = useSearchDialogStore( - (state) => state.byMode.keywordSearch.excludePattern, - ); - const filtersOpen = useSearchDialogStore( - (state) => state.byMode.keywordSearch.filtersOpen, - ); - const setIncludePatternByMode = useSearchDialogStore( - (state) => state.setIncludePattern, - ); - const setExcludePatternByMode = useSearchDialogStore( - (state) => state.setExcludePattern, - ); - const setFiltersOpenByMode = useSearchDialogStore( - (state) => state.setFiltersOpen, - ); - const trimmedQuery = query.trim(); - const debouncedQuery = useDebouncedValue(trimmedQuery, 150); - const isDebouncing = - trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery; - - const { data: searchResults, isFetching } = - electronTrpc.filesystem.searchContent.useQuery( - { - workspaceId, - query: debouncedQuery, - includePattern, - excludePattern, - limit: SEARCH_LIMIT, - }, - { - enabled: open && debouncedQuery.length > 0, - staleTime: 1000, - placeholderData: (previous) => previous ?? { matches: [] }, - }, - ); - - const results = - searchResults?.matches.map((match) => ({ - id: `${match.absolutePath}:${match.line}:${match.column}`, - name: match.absolutePath.split(/[/\\]/).pop() ?? match.absolutePath, - relativePath: match.relativePath, - path: match.absolutePath, - line: match.line, - column: match.column, - preview: match.preview, - })) ?? []; - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) { - setQuery(""); - } - }, []); - - const toggle = useCallback(() => { - setOpen((prev) => { - if (prev) { - setQuery(""); - } - return !prev; - }); - }, []); - - const selectMatch = useCallback( - (match: KeywordSearchResult) => { - useTabsStore.getState().addFileViewerPane(workspaceId, { - filePath: match.path, - line: match.line, - column: match.column, - }); - handleOpenChange(false); - }, - [workspaceId, handleOpenChange], - ); - - const setIncludePattern = useCallback( - (value: string) => { - setIncludePatternByMode("keywordSearch", value); - }, - [setIncludePatternByMode], - ); - - const setExcludePattern = useCallback( - (value: string) => { - setExcludePatternByMode("keywordSearch", value); - }, - [setExcludePatternByMode], - ); - - const setFiltersOpen = useCallback( - (nextOpen: boolean) => { - setFiltersOpenByMode("keywordSearch", nextOpen); - }, - [setFiltersOpenByMode], - ); - - return { - open, - query, - setQuery, - filtersOpen, - setFiltersOpen, - includePattern, - setIncludePattern, - excludePattern, - setExcludePattern, - handleOpenChange, - toggle, - selectMatch, - searchResults: results, - isFetching: isFetching || isDebouncing, - }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx b/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx deleted file mode 100644 index 98e5aa69d46..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/SearchDialog/SearchDialog.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - CommandDialog, - CommandEmpty, - CommandInput, - CommandItem, - CommandList, -} from "@superset/ui/command"; -import { Input } from "@superset/ui/input"; -import { Spinner } from "@superset/ui/spinner"; -import type { ReactNode } from "react"; -import { LuChevronDown, LuChevronRight } from "react-icons/lu"; - -export interface SearchDialogItem { - id: string; -} - -interface SearchDialogProps<TItem extends SearchDialogItem> { - open: boolean; - onOpenChange: (open: boolean) => void; - title: string; - description: string; - query: string; - onQueryChange: (query: string) => void; - queryPlaceholder: string; - filtersOpen: boolean; - onFiltersOpenChange: (open: boolean) => void; - includePattern: string; - onIncludePatternChange: (value: string) => void; - excludePattern: string; - onExcludePatternChange: (value: string) => void; - emptyMessage: string; - isLoading: boolean; - results: TItem[]; - getItemValue: (item: TItem) => string; - onSelectItem: (item: TItem) => void; - renderItem: (item: TItem) => ReactNode; - headerExtra?: ReactNode; -} - -export function SearchDialog<TItem extends SearchDialogItem>({ - open, - onOpenChange, - title, - description, - query, - onQueryChange, - queryPlaceholder, - filtersOpen, - onFiltersOpenChange, - includePattern, - onIncludePatternChange, - excludePattern, - onExcludePatternChange, - emptyMessage, - isLoading, - results, - getItemValue, - onSelectItem, - renderItem, - headerExtra, -}: SearchDialogProps<TItem>) { - return ( - <CommandDialog - open={open} - onOpenChange={onOpenChange} - title={title} - description={description} - showCloseButton={false} - > - <div className="relative"> - <CommandInput - placeholder={queryPlaceholder} - value={query} - onValueChange={onQueryChange} - className="pr-9" - /> - <div className="pointer-events-none absolute top-2 right-2 z-10"> - {isLoading ? ( - <div className="pointer-events-none absolute top-1 right-8"> - <Spinner className="size-4 text-muted-foreground" /> - </div> - ) : null} - <Button - type="button" - variant="ghost" - size="icon" - className="size-7 pointer-events-auto" - aria-label={filtersOpen ? "Hide Filters" : "Show Filters"} - aria-expanded={filtersOpen} - onClick={() => onFiltersOpenChange(!filtersOpen)} - > - {filtersOpen ? ( - <LuChevronDown className="size-4" /> - ) : ( - <LuChevronRight className="size-4" /> - )} - </Button> - </div> - </div> - {filtersOpen ? ( - <div className="grid grid-cols-2 gap-2 border-b px-3 py-2"> - <Input - value={includePattern} - onChange={(event) => onIncludePatternChange(event.target.value)} - placeholder="files to include (glob)" - className="h-8 text-xs" - /> - <Input - value={excludePattern} - onChange={(event) => onExcludePatternChange(event.target.value)} - placeholder="files to exclude (glob)" - className="h-8 text-xs" - /> - </div> - ) : null} - {headerExtra} - <CommandList> - {query.trim().length > 0 && !isLoading && results.length === 0 && ( - <CommandEmpty>{emptyMessage}</CommandEmpty> - )} - {results.map((item) => ( - <CommandItem - key={item.id} - value={getItemValue(item)} - onSelect={() => onSelectItem(item)} - > - {renderItem(item)} - </CommandItem> - ))} - </CommandList> - </CommandDialog> - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/SearchDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/SearchDialog/index.ts deleted file mode 100644 index ad081da57a5..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/SearchDialog/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchDialog, type SearchDialogItem } from "./SearchDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsButton/SettingsButton.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsButton/SettingsButton.tsx index 0b945388686..982d21b14da 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsButton/SettingsButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsButton/SettingsButton.tsx @@ -2,7 +2,7 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; import { CiSettings } from "react-icons/ci"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import { HotkeyLabel } from "renderer/hotkeys"; export function SettingsButton() { const navigate = useNavigate(); @@ -21,7 +21,7 @@ export function SettingsButton() { </Button> </TooltipTrigger> <TooltipContent side="bottom" sideOffset={8}> - <HotkeyTooltipContent label="Open settings" hotkeyId="OPEN_SETTINGS" /> + <HotkeyLabel label="Open settings" id="OPEN_SETTINGS" /> </TooltipContent> </Tooltip> ); diff --git a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx index 4514fc7814e..6b2bf8f2fe4 100644 --- a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx @@ -2,7 +2,7 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { LuDiff } from "react-icons/lu"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import { HotkeyLabel } from "renderer/hotkeys"; import { useSidebarStore } from "renderer/stores"; export function SidebarControl() { @@ -30,10 +30,7 @@ export function SidebarControl() { </Button> </TooltipTrigger> <TooltipContent side="bottom" showArrow={false}> - <HotkeyTooltipContent - label="Open Code Sidebar" - hotkeyId="TOGGLE_SIDEBAR" - /> + <HotkeyLabel label="Open Code Sidebar" id="TOGGLE_SIDEBAR" /> </TooltipContent> </Tooltip> ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx index 12813863547..7991e700376 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx @@ -51,7 +51,7 @@ export function PortsList() { </button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> - <p className="text-xs">Learn about static port configuration</p> + <p className="text-xs">Learn about port labels</p> </TooltipContent> </Tooltip> <span className="text-[10px] font-normal">{totalPortCount}</span> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index 600023673bd..ca55c77983f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -1,6 +1,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; -import { LuExternalLink, LuX } from "react-icons/lu"; +import { LuExternalLink, LuLoaderCircle, LuX } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -14,39 +15,18 @@ interface MergedPortBadgeProps { export function MergedPortBadge({ port }: MergedPortBadgeProps) { const navigate = useNavigate(); - const setActiveTab = useTabsStore((s) => s.setActiveTab); - const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const openInBrowserPane = useTabsStore((s) => s.openInBrowserPane); const { data: openLinksInApp } = electronTrpc.settings.getOpenLinksInApp.useQuery(); const openUrl = electronTrpc.external.openUrl.useMutation(); - const { killPort } = useKillPort(); - - const displayContent = port.label ? ( - <> - {port.label}{" "} - <span className="font-mono font-normal text-muted-foreground"> - {port.port} - </span> - </> - ) : ( - <span className="font-mono text-muted-foreground">{port.port}</span> - ); - - const canJumpToTerminal = !!port.paneId; + const { isPending, killPort } = useKillPort(); const handleClick = () => { - if (!port.paneId) return; - - const pane = useTabsStore.getState().panes[port.paneId]; - if (!pane) return; - navigateToWorkspace(port.workspaceId, navigate); - setActiveTab(port.workspaceId, pane.tabId); - setFocusedPane(pane.tabId, port.paneId); }; const handleOpenInBrowser = () => { + if (openUrl.isPending) return; const url = `http://localhost:${port.port}`; if (openLinksInApp) { @@ -59,36 +39,63 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { }; const handleClose = () => { - killPort(port); + if (isPending) return; + void killPort(port); }; return ( <Tooltip> <TooltipTrigger asChild> - <div className="group relative inline-flex items-center gap-1 rounded-md text-xs transition-colors mb-1 bg-primary/10 text-primary hover:bg-primary/20"> + <div + className={cn( + "group relative mb-1 inline-flex max-w-full items-center gap-1 rounded-md", + "bg-primary/10 text-xs text-primary transition-colors hover:bg-primary/20", + isPending && "opacity-70", + )} + > <button type="button" onClick={handleClick} - disabled={!canJumpToTerminal} - className={`font-medium px-2 py-1 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-md ${!canJumpToTerminal ? "cursor-default" : ""}`} + className="flex max-w-40 min-w-0 items-center gap-1 rounded-md px-2 py-1 font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {displayContent} + {port.label ? ( + <> + <span className="min-w-0 truncate">{port.label}</span> + <span className="shrink-0 font-mono font-normal text-muted-foreground"> + {port.port} + </span> + </> + ) : ( + <span className="font-mono text-muted-foreground"> + {port.port} + </span> + )} </button> <button type="button" onClick={handleOpenInBrowser} + disabled={openUrl.isPending} aria-label={`Open ${port.label || `port ${port.port}`} in browser`} - className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none" + className="text-muted-foreground opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 group-hover:opacity-100" > <LuExternalLink className="size-3.5" strokeWidth={STROKE_WIDTH} /> </button> <button type="button" onClick={handleClose} + disabled={isPending} + aria-busy={isPending} aria-label={`Close ${port.label || `port ${port.port}`}`} - className="opacity-0 group-hover:opacity-100 pr-1 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none" + className="pr-1 text-muted-foreground opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-70 group-hover:opacity-100" > - <LuX className="size-3.5" strokeWidth={STROKE_WIDTH} /> + {isPending ? ( + <LuLoaderCircle + className="size-3.5 animate-spin" + strokeWidth={STROKE_WIDTH} + /> + ) : ( + <LuX className="size-3.5" strokeWidth={STROKE_WIDTH} /> + )} </button> </div> </TooltipTrigger> @@ -106,11 +113,9 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { {port.pid != null && ` (pid ${port.pid})`} </div> )} - {canJumpToTerminal && ( - <div className="text-muted-foreground/70 text-[10px]"> - Click to open workspace - </div> - )} + <div className="text-[10px] text-muted-foreground/70"> + Click to open workspace + </div> </div> </TooltipContent> </Tooltip> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx index 4c9e5e3fdb9..7b6c7989a9d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx @@ -1,6 +1,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; -import { LuX } from "react-icons/lu"; +import { LuLoaderCircle, LuX } from "react-icons/lu"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { STROKE_WIDTH } from "../../../constants"; import { useKillPort } from "../../hooks/useKillPort"; @@ -13,14 +14,15 @@ interface WorkspacePortGroupProps { export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { const navigate = useNavigate(); - const { killPorts } = useKillPort(); + const { isPending, killPorts } = useKillPort(); const handleWorkspaceClick = () => { navigateToWorkspace(group.workspaceId, navigate); }; const handleCloseAll = () => { - killPorts(group.ports); + if (isPending) return; + void killPorts(group.ports); }; return ( @@ -38,9 +40,21 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { <button type="button" onClick={handleCloseAll} - className="ml-auto p-0.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-primary" + disabled={isPending} + aria-busy={isPending} + className={cn( + "ml-auto rounded p-0.5 text-muted-foreground hover:bg-muted/50 hover:text-primary", + "disabled:pointer-events-none disabled:opacity-60", + )} > - <LuX className="size-3" strokeWidth={STROKE_WIDTH} /> + {isPending ? ( + <LuLoaderCircle + className="size-3 animate-spin" + strokeWidth={STROKE_WIDTH} + /> + ) : ( + <LuX className="size-3" strokeWidth={STROKE_WIDTH} /> + )} </button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> @@ -50,7 +64,10 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { </div> <div className="flex flex-wrap gap-1 px-3"> {group.ports.map((port) => ( - <MergedPortBadge key={port.port} port={port} /> + <MergedPortBadge + key={`${port.terminalId}:${port.port}`} + port={port} + /> ))} </div> </div> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts index 8d3b75cd259..7a8d1ae9a6b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/useKillPort.ts @@ -1,39 +1,11 @@ -import { toast } from "@superset/ui/sonner"; +import { usePortKillActions } from "renderer/hooks/ports/usePortKillActions"; import { electronTrpc } from "renderer/lib/electron-trpc"; import type { EnrichedPort } from "shared/types"; export function useKillPort() { const killMutation = electronTrpc.ports.kill.useMutation(); - - const killPort = async (port: EnrichedPort) => { - const result = await killMutation.mutateAsync({ - paneId: port.paneId, - port: port.port, - }); - if (!result.success) { - toast.error(`Failed to close port ${port.port}`, { - description: result.error, - }); - } - }; - - const killPorts = async (ports: EnrichedPort[]) => { - if (ports.length === 0) return; - - const results = await Promise.all( - ports.map((port) => - killMutation.mutateAsync({ - paneId: port.paneId, - port: port.port, - }), - ), - ); - - const failed = results.filter((r) => !r.success); - if (failed.length > 0) { - toast.error(`Failed to close ${failed.length} port(s)`); - } - }; - - return { killPort, killPorts, isPending: killMutation.isPending }; + return usePortKillActions<EnrichedPort>({ + localKill: killMutation.mutateAsync, + externalPending: killMutation.isPending, + }); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts index 4e3e926b9fb..938007cf41d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts @@ -15,13 +15,10 @@ export function usePortsData() { const utils = electronTrpc.useUtils(); - const { data: detectedPorts } = electronTrpc.ports.getAll.useQuery( - undefined, - { - // Keep a low-frequency safety net in case subscription events are missed. - refetchInterval: PORTS_FALLBACK_REFETCH_INTERVAL_MS, - }, - ); + const { data: localPorts } = electronTrpc.ports.getAll.useQuery(undefined, { + // Keep a low-frequency safety net in case subscription events are missed. + refetchInterval: PORTS_FALLBACK_REFETCH_INTERVAL_MS, + }); electronTrpc.ports.subscribe.useSubscription(undefined, { onData: () => { @@ -29,7 +26,9 @@ export function usePortsData() { }, }); - const ports = detectedPorts ?? []; + const ports = useMemo<EnrichedPort[]>(() => { + return localPorts ? [...localPorts] : []; + }, [localPorts]); const workspaceNames = useMemo(() => { if (!allWorkspaces) return {}; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx index 4d5cd2303bf..34046ff8587 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx @@ -1,10 +1,11 @@ import { AlertDialog, - AlertDialogContent, + AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + EnterEnabledAlertDialogContent, } from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; @@ -23,14 +24,9 @@ export function CloseProjectDialog({ onOpenChange, onConfirm, }: CloseProjectDialogProps) { - const handleConfirm = () => { - onOpenChange(false); - onConfirm(); - }; - return ( <AlertDialog open={open} onOpenChange={onOpenChange}> - <AlertDialogContent className="max-w-[340px] gap-0 p-0"> + <EnterEnabledAlertDialogContent className="max-w-[340px] gap-0 p-0"> <AlertDialogHeader className="px-4 pt-4 pb-2"> <AlertDialogTitle className="font-medium"> Close project "{projectName}"? @@ -58,16 +54,16 @@ export function CloseProjectDialog({ > Cancel </Button> - <Button + <AlertDialogAction variant="destructive" size="sm" className="h-7 px-3 text-xs" - onClick={handleConfirm} + onClick={onConfirm} > Close Project - </Button> + </AlertDialogAction> </AlertDialogFooter> - </AlertDialogContent> + </EnterEnabledAlertDialogContent> </AlertDialog> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index 4d3510d4f8b..94aabb808a1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -134,7 +134,7 @@ export function ProjectHeader({ }; const handleOpenSettings = () => { - navigate({ to: "/settings/project/$projectId", params: { projectId } }); + navigate({ to: "/settings/projects/$projectId", params: { projectId } }); }; const updateProject = useUpdateProject({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SetupScriptCard/SetupScriptCard.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SetupScriptCard/SetupScriptCard.tsx index d8a236aa436..b755954f57a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SetupScriptCard/SetupScriptCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/SetupScriptCard/SetupScriptCard.tsx @@ -54,7 +54,7 @@ export function SetupScriptCard({ actionLabel="Configure" onAction={() => navigate({ - to: "/settings/project/$projectId", + to: "/settings/projects/$projectId", params: { projectId }, }) } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx index b0f72bb9d75..14a052c8356 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/CollapsedWorkspaceItem.tsx @@ -13,11 +13,15 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { type RefObject, useMemo, useState } from "react"; -import { LuCopy, LuX } from "react-icons/lu"; +import { LuCopy, LuGitBranch, LuX } from "react-icons/lu"; import { createContextMenuDeleteDialogCoordinator } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; import type { ActivePaneStatus } from "shared/tabs-types"; import { STROKE_WIDTH } from "../constants"; -import { DeleteWorkspaceDialog, WorkspaceHoverCardContent } from "./components"; +import { + DeleteWorkspaceDialog, + RenameBranchDialog, + WorkspaceHoverCardContent, +} from "./components"; import { HOVER_CARD_CLOSE_DELAY, HOVER_CARD_OPEN_DELAY } from "./constants"; import { WorkspaceIcon } from "./WorkspaceIcon"; @@ -36,6 +40,7 @@ interface CollapsedWorkspaceItemProps { onClick: () => void; onDeleteClick: () => void; onCopyPath: () => void; + onCopyBranchName: () => void; } export function CollapsedWorkspaceItem({ @@ -53,6 +58,7 @@ export function CollapsedWorkspaceItem({ onClick, onDeleteClick, onCopyPath, + onCopyBranchName, }: CollapsedWorkspaceItemProps) { const isBranchWorkspace = type === "branch"; const deleteDialogCoordinator = useMemo( @@ -60,6 +66,9 @@ export function CollapsedWorkspaceItem({ [onDeleteClick], ); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const [renameBranchTarget, setRenameBranchTarget] = useState<string | null>( + null, + ); const collapsedButton = ( <button @@ -75,8 +84,8 @@ export function CollapsedWorkspaceItem({ onMouseEnter={onMouseEnter} className={cn( "relative flex items-center justify-center size-8 rounded-md", - "hover:bg-muted/50 transition-colors", - isActive && "bg-muted", + "transition-colors", + isActive ? "bg-muted hover:bg-muted" : "hover:bg-muted/50", )} > <WorkspaceIcon @@ -92,15 +101,25 @@ export function CollapsedWorkspaceItem({ if (isBranchWorkspace) { return ( <> - <Tooltip delayDuration={300}> - <TooltipTrigger asChild>{collapsedButton}</TooltipTrigger> - <TooltipContent side="right" className="flex flex-col gap-0.5"> - <span className="font-medium">local</span> - <span className="text-xs text-muted-foreground font-mono"> - {branch} - </span> - </TooltipContent> - </Tooltip> + <ContextMenu> + <Tooltip delayDuration={300}> + <TooltipTrigger asChild> + <ContextMenuTrigger asChild>{collapsedButton}</ContextMenuTrigger> + </TooltipTrigger> + <TooltipContent side="right" className="flex flex-col gap-0.5"> + <span className="font-medium">local</span> + <span className="text-xs text-muted-foreground font-mono"> + {branch} + </span> + </TooltipContent> + </Tooltip> + <ContextMenuContent> + <ContextMenuItem onSelect={onCopyBranchName}> + <LuGitBranch className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> + Copy Branch Name + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> <DeleteWorkspaceDialog workspaceId={id} workspaceName={name} @@ -132,6 +151,10 @@ export function CollapsedWorkspaceItem({ <LuCopy className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> Copy Path </ContextMenuItem> + <ContextMenuItem onSelect={onCopyBranchName}> + <LuGitBranch className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> + Copy Branch Name + </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuItem onSelect={() => { @@ -139,12 +162,16 @@ export function CollapsedWorkspaceItem({ }} > <LuX className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> - Close Worktree + Close Workspace </ContextMenuItem> </ContextMenuContent> </ContextMenu> <HoverCardContent side="right" align="start" className="w-72"> - <WorkspaceHoverCardContent workspaceId={id} workspaceAlias={name} /> + <WorkspaceHoverCardContent + workspaceId={id} + workspaceAlias={name} + onEditBranchClick={setRenameBranchTarget} + /> </HoverCardContent> </HoverCard> <DeleteWorkspaceDialog @@ -154,6 +181,16 @@ export function CollapsedWorkspaceItem({ open={showDeleteDialog} onOpenChange={setShowDeleteDialog} /> + {renameBranchTarget && ( + <RenameBranchDialog + workspaceId={id} + currentBranchName={renameBranchTarget} + open={renameBranchTarget !== null} + onOpenChange={(open) => { + if (!open) setRenameBranchTarget(null); + }} + /> + )} </> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.test.ts new file mode 100644 index 00000000000..70091b81844 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { createContextMenuDeleteDialogCoordinator } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; + +describe("WorkspaceContextMenu - delete/close option (#2741)", () => { + test("coordinator calls onDelete when close auto-focus fires after request", () => { + let deleteCalled = false; + const coordinator = createContextMenuDeleteDialogCoordinator(() => { + deleteCalled = true; + }); + + coordinator.requestOpenDeleteDialog(); + + let preventDefaultCalled = false; + coordinator.handleCloseAutoFocus({ + preventDefault: () => { + preventDefaultCalled = true; + }, + }); + + expect(preventDefaultCalled).toBe(true); + expect(deleteCalled).toBe(true); + }); + + test("coordinator does not call onDelete if no request was made", () => { + let deleteCalled = false; + const coordinator = createContextMenuDeleteDialogCoordinator(() => { + deleteCalled = true; + }); + + coordinator.handleCloseAutoFocus({ + preventDefault: () => {}, + }); + + expect(deleteCalled).toBe(false); + }); + + test("coordinator resets after firing, so a second close does not re-trigger", () => { + let callCount = 0; + const coordinator = createContextMenuDeleteDialogCoordinator(() => { + callCount += 1; + }); + + coordinator.requestOpenDeleteDialog(); + coordinator.handleCloseAutoFocus({ preventDefault: () => {} }); + coordinator.handleCloseAutoFocus({ preventDefault: () => {} }); + + expect(callCount).toBe(1); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx index af12df664e1..26d45cee899 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceContextMenu.tsx @@ -13,15 +13,17 @@ import { HoverCardContent, HoverCardTrigger, } from "@superset/ui/hover-card"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { LuArrowRightLeft, LuBellOff, LuCopy, + LuExternalLink, LuEye, LuEyeOff, LuFolderOpen, LuFolderPlus, + LuGitBranch, LuMinus, LuPencil, LuX, @@ -31,9 +33,10 @@ import { useMoveWorkspacesToSection, useMoveWorkspaceToSection, } from "renderer/react-query/workspaces"; +import { createContextMenuDeleteDialogCoordinator } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; import { useWorkspaceSelectionStore } from "renderer/stores/workspace-selection"; import { STROKE_WIDTH } from "../constants"; -import { WorkspaceHoverCardContent } from "./components"; +import { RenameBranchDialog, WorkspaceHoverCardContent } from "./components"; import { HOVER_CARD_CLOSE_DELAY, HOVER_CARD_OPEN_DELAY } from "./constants"; interface WorkspaceContextMenuProps { @@ -46,10 +49,12 @@ interface WorkspaceContextMenuProps { sections: { id: string; name: string }[]; onRename: () => void; onOpenInFinder: () => void; + onOpenInEditor: () => void; onCopyPath: () => void; + onCopyBranchName: () => void; onSetUnread: (isUnread: boolean) => void; onResetStatus: () => void; - onClose: () => void; + onDelete: () => void; children: React.ReactNode; } @@ -63,18 +68,27 @@ export function WorkspaceContextMenu({ sections, onRename, onOpenInFinder, + onOpenInEditor, onCopyPath, + onCopyBranchName, onSetUnread, onResetStatus, - onClose, + onDelete, children, }: WorkspaceContextMenuProps) { const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const [renameBranchTarget, setRenameBranchTarget] = useState<string | null>( + null, + ); const contextMenuSelectionRef = useRef<string[]>([]); const selectionStore = useWorkspaceSelectionStore; const moveToSection = useMoveWorkspaceToSection(); const bulkMoveToSection = useMoveWorkspacesToSection(); const createSectionFromWorkspaces = useCreateSectionFromWorkspaces(); + const deleteDialogCoordinator = useMemo( + () => createContextMenuDeleteDialogCoordinator(onDelete), + [onDelete], + ); const handleContextMenuOpenChange = (open: boolean) => { setIsContextMenuOpen(open); @@ -134,10 +148,18 @@ export function WorkspaceContextMenu({ <LuFolderOpen className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> Open in Finder </ContextMenuItem> + <ContextMenuItem onSelect={onOpenInEditor}> + <LuExternalLink className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> + Open in Editor + </ContextMenuItem> <ContextMenuItem onSelect={onCopyPath}> <LuCopy className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> Copy Path </ContextMenuItem> + <ContextMenuItem onSelect={onCopyBranchName}> + <LuGitBranch className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> + Copy Branch Name + </ContextMenuItem> <ContextMenuSeparator /> <ContextMenuSub> <ContextMenuSubTrigger> @@ -176,15 +198,15 @@ export function WorkspaceContextMenu({ Clear Status </ContextMenuItem> )} - {!isBranchWorkspace && ( - <> - <ContextMenuSeparator /> - <ContextMenuItem onSelect={onClose}> - <LuX className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> - Close Worktree - </ContextMenuItem> - </> - )} + <ContextMenuSeparator /> + <ContextMenuItem + onSelect={() => { + deleteDialogCoordinator.requestOpenDeleteDialog(); + }} + > + <LuX className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> + {isBranchWorkspace ? "Close Workspace" : "Close Worktree"} + </ContextMenuItem> </> ); @@ -192,7 +214,13 @@ export function WorkspaceContextMenu({ return ( <ContextMenu onOpenChange={handleContextMenuOpenChange}> <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> - <ContextMenuContent>{commonContextMenuItems}</ContextMenuContent> + <ContextMenuContent + onCloseAutoFocus={(event) => { + deleteDialogCoordinator.handleCloseAutoFocus(event); + }} + > + {commonContextMenuItems} + </ContextMenuContent> </ContextMenu> ); } @@ -207,7 +235,11 @@ export function WorkspaceContextMenu({ <HoverCardTrigger asChild> <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> </HoverCardTrigger> - <ContextMenuContent> + <ContextMenuContent + onCloseAutoFocus={(event) => { + deleteDialogCoordinator.handleCloseAutoFocus(event); + }} + > <ContextMenuItem onSelect={onRename}> <LuPencil className="size-4 mr-2" strokeWidth={STROKE_WIDTH} /> Rename @@ -217,8 +249,22 @@ export function WorkspaceContextMenu({ </ContextMenuContent> </ContextMenu> <HoverCardContent side="right" align="start" className="w-72"> - <WorkspaceHoverCardContent workspaceId={id} workspaceAlias={name} /> + <WorkspaceHoverCardContent + workspaceId={id} + workspaceAlias={name} + onEditBranchClick={setRenameBranchTarget} + /> </HoverCardContent> + {renameBranchTarget && ( + <RenameBranchDialog + workspaceId={id} + currentBranchName={renameBranchTarget} + open={renameBranchTarget !== null} + onOpenChange={(open) => { + if (!open) setRenameBranchTarget(null); + }} + /> + )} </HoverCard> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 2b56af6ee39..9fd8b6b1176 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -3,11 +3,12 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { HiMiniXMark } from "react-icons/hi2"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; +import { HotkeyLabel } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getGitHubStatusQueryPolicy } from "renderer/lib/githubQueryPolicy"; +import { useHoverGitHubStatus } from "renderer/lib/githubQueryPolicy"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { WorkspaceRunIndicator } from "renderer/screens/main/components/WorkspaceRunIndicator"; @@ -66,7 +67,15 @@ export function WorkspaceListItem({ const isBranchWorkspace = type === "branch"; const navigate = useNavigate(); const matchRoute = useMatchRoute(); - const [hasHovered, setHasHovered] = useState(false); + const { + githubStatus, + hasHovered, + onMouseEnter: onGithubMouseEnter, + } = useHoverGitHubStatus({ + workspaceId: id, + surface: "workspace-list-item", + isWorktree: type === "worktree", + }); const rename = useWorkspaceRename(id, name, branch); const workspaceStatus = useTabsStore((state) => { function* paneStatuses() { @@ -134,6 +143,10 @@ export function WorkspaceListItem({ const openInFinder = electronTrpc.external.openInFinder.useMutation({ onError: (error) => toast.error(`Failed to open: ${error.message}`), }); + const openFileInEditor = electronTrpc.external.openFileInEditor.useMutation({ + onError: (error) => + toast.error(`Failed to open in editor: ${error.message}`), + }); const setUnread = electronTrpc.workspaces.setUnread.useMutation({ onSuccess: () => utils.workspaces.getAllGrouped.invalidate(), onError: (error) => @@ -142,20 +155,6 @@ export function WorkspaceListItem({ const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); - const githubStatusQueryPolicy = getGitHubStatusQueryPolicy( - "workspace-list-item", - { - hasWorkspaceId: !!id, - isActive: hasHovered && type === "worktree", - }, - ); - - const { data: githubStatus } = - electronTrpc.workspaces.getGitHubStatus.useQuery( - { workspaceId: id }, - githubStatusQueryPolicy, - ); - const { status: localChanges } = useGitChangesStatus({ worktreePath, enabled: hasHovered && !!worktreePath, @@ -168,7 +167,6 @@ export function WorkspaceListItem({ { enabled: isBranchWorkspace, staleTime: GITHUB_STATUS_STALE_TIME, - refetchInterval: hasHovered ? GITHUB_STATUS_STALE_TIME : false, }, ); @@ -226,7 +224,7 @@ export function WorkspaceListItem({ }; const handleMouseEnter = () => { - if (!hasHovered) setHasHovered(true); + onGithubMouseEnter(); if (isBranchWorkspace) void refetchAheadBehind(); }; @@ -234,12 +232,22 @@ export function WorkspaceListItem({ if (worktreePath) openInFinder.mutate(worktreePath); }; + const handleOpenInEditor = () => { + if (worktreePath) + openFileInEditor.mutate({ path: worktreePath, projectId }); + }; + const { copyToClipboard } = useCopyToClipboard(); const handleCopyPath = async () => { if (!worktreePath) return; await copyToClipboard(worktreePath); toast.success("Path copied to clipboard"); }; + const handleCopyBranchName = async () => { + if (!branch) return; + await copyToClipboard(branch); + toast.success("Branch name copied to clipboard"); + }; const pr = githubStatus?.pr; const diffStats = @@ -267,6 +275,7 @@ export function WorkspaceListItem({ onClick={handleClick} onDeleteClick={handleDeleteClick} onCopyPath={handleCopyPath} + onCopyBranchName={handleCopyBranchName} /> ); } @@ -294,7 +303,8 @@ export function WorkspaceListItem({ onDoubleClick={isBranchWorkspace ? undefined : rename.startRename} className={cn( "flex w-full pl-3 pr-2 text-sm", - "hover:bg-muted/50 transition-colors text-left cursor-pointer", + "transition-colors text-left cursor-pointer", + isActive ? "hover:bg-muted" : "hover:bg-muted/50", "group relative", showBranchSubtitle ? "py-1.5" : "py-2 items-center", isActive && "bg-muted", @@ -416,7 +426,10 @@ export function WorkspaceListItem({ </button> </TooltipTrigger> <TooltipContent side="top" sideOffset={4}> - Close workspace + <HotkeyLabel + label="Close workspace" + id={isActive ? "CLOSE_WORKSPACE" : undefined} + /> </TooltipContent> </Tooltip> )} @@ -459,10 +472,12 @@ export function WorkspaceListItem({ sections={sections} onRename={rename.startRename} onOpenInFinder={handleOpenInFinder} + onOpenInEditor={handleOpenInEditor} onCopyPath={handleCopyPath} + onCopyBranchName={handleCopyBranchName} onSetUnread={(unread) => setUnread.mutate({ id, isUnread: unread })} onResetStatus={() => resetWorkspaceStatus(id)} - onClose={handleDeleteClick} + onDelete={handleDeleteClick} > {content} </WorkspaceContextMenu> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/RenameBranchDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/RenameBranchDialog.tsx new file mode 100644 index 00000000000..4eb45c5e25e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/RenameBranchDialog.tsx @@ -0,0 +1,140 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { toast } from "@superset/ui/sonner"; +import { useEffect, useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; + +interface RenameBranchDialogProps { + workspaceId: string; + currentBranchName: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onAfterRename?: (newName: string) => void; +} + +export function RenameBranchDialog({ + workspaceId, + currentBranchName, + open, + onOpenChange, + onAfterRename, +}: RenameBranchDialogProps) { + const [value, setValue] = useState(currentBranchName); + const [isSubmitting, setIsSubmitting] = useState(false); + const electronUtils = electronTrpc.useUtils(); + const { activeHostUrl } = useLocalHostService(); + + useEffect(() => { + if (open) setValue(currentBranchName); + }, [open, currentBranchName]); + + const trimmed = value.trim(); + const isUnchanged = trimmed === currentBranchName; + const isInvalid = trimmed.length === 0 || isUnchanged; + + const handleSubmit = async () => { + if (isInvalid || isSubmitting) return; + if (!activeHostUrl) { + toast.error("Host service is not available"); + return; + } + + const client = getHostServiceClientByUrl(activeHostUrl); + const renamePromise = client.git.renameBranch.mutate({ + workspaceId, + oldName: currentBranchName, + newName: trimmed, + }); + + toast.promise(renamePromise, { + loading: `Renaming branch to ${trimmed}...`, + success: `Branch renamed to ${trimmed}`, + error: (err) => + err instanceof Error ? err.message : "Failed to rename branch", + }); + + setIsSubmitting(true); + try { + await renamePromise; + onAfterRename?.(trimmed); + void electronUtils.workspaces.getWorktreeInfo.invalidate({ + workspaceId, + }); + void electronUtils.workspaces.get.invalidate({ id: workspaceId }); + void electronUtils.workspaces.getAllGrouped.invalidate(); + onOpenChange(false); + } catch { + // toast.promise surfaced the error to the user + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange} modal> + <DialogContent className="max-w-[420px]"> + <DialogHeader> + <DialogTitle>Rename branch</DialogTitle> + <DialogDescription> + Rename the local branch. Branches that have been pushed to remote + cannot be renamed. + </DialogDescription> + </DialogHeader> + <form + onSubmit={(e) => { + e.preventDefault(); + void handleSubmit(); + }} + className="space-y-4" + > + <div className="space-y-1.5"> + <Label htmlFor="rename-branch-input" className="text-xs"> + Branch name + </Label> + <Input + id="rename-branch-input" + value={value} + onChange={(e) => setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + void handleSubmit(); + } + }} + autoFocus + disabled={isSubmitting} + spellCheck={false} + autoComplete="off" + className="font-mono" + /> + </div> + <DialogFooter> + <Button + type="button" + variant="ghost" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button type="submit" disabled={isInvalid || isSubmitting}> + Rename + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/index.ts new file mode 100644 index 00000000000..4c810563485 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/RenameBranchDialog/index.ts @@ -0,0 +1 @@ +export { RenameBranchDialog } from "./RenameBranchDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx index 707f212627d..42ef7472217 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx @@ -6,11 +6,12 @@ import { LuExternalLink, LuGlobe, LuLoaderCircle, + LuPencil, LuTriangleAlert, } from "react-icons/lu"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { usePRStatus } from "renderer/screens/main/hooks"; -import { useHotkeyDisplay } from "renderer/stores/hotkeys"; import { STROKE_WIDTH } from "../../../constants"; import { ChecksList } from "./components/ChecksList"; import { ChecksSummary } from "./components/ChecksSummary"; @@ -20,11 +21,13 @@ import { ReviewStatus } from "./components/ReviewStatus"; interface WorkspaceHoverCardContentProps { workspaceId: string; workspaceAlias?: string; + onEditBranchClick?: (branchName: string) => void; } export function WorkspaceHoverCardContent({ workspaceId, workspaceAlias, + onEditBranchClick, }: WorkspaceHoverCardContentProps) { const { data: worktreeInfo } = electronTrpc.workspaces.getWorktreeInfo.useQuery( @@ -40,7 +43,7 @@ export function WorkspaceHoverCardContent({ isLoading: isLoadingGithub, } = usePRStatus({ workspaceId, surface: "workspace-hover-card" }); - const openPRDisplay = useHotkeyDisplay("OPEN_PR"); + const { keys: openPRDisplay } = useHotkeyDisplay("OPEN_PR"); const hasOpenPRShortcut = !( openPRDisplay.length === 1 && openPRDisplay[0] === "Unassigned" ); @@ -71,33 +74,52 @@ export function WorkspaceHoverCardContent({ <div className="space-y-3"> <div className="space-y-1.5"> {hasCustomAlias && ( - <div className="text-sm font-medium">{workspaceAlias}</div> + <div className="text-sm font-medium break-words line-clamp-2"> + {workspaceAlias} + </div> )} {branchName && ( <div className="space-y-0.5"> <span className="text-[10px] uppercase tracking-wide text-muted-foreground"> Branch </span> - {repoUrl && branchExistsOnRemote ? ( - <a - href={`${repoUrl}/tree/${branchName}`} - target="_blank" - rel="noopener noreferrer" - className={`flex items-center gap-1 font-mono break-all hover:underline ${hasCustomAlias ? "text-xs" : "text-sm"}`} - > - {branchName} - <LuExternalLink - className="size-3 shrink-0" - strokeWidth={STROKE_WIDTH} - /> - </a> - ) : ( - <code - className={`font-mono break-all block ${hasCustomAlias ? "text-xs" : "text-sm"}`} - > - {branchName} - </code> - )} + <div className="flex items-center gap-1.5"> + {onEditBranchClick ? ( + <button + type="button" + onClick={() => onEditBranchClick(branchName)} + className={`group/branch flex min-w-0 flex-1 items-center gap-1 font-mono break-all text-left hover:text-foreground hover:underline ${hasCustomAlias ? "text-xs" : "text-sm"}`} + title="Rename branch" + > + <span className="break-all">{branchName}</span> + <LuPencil + className="size-3 shrink-0 opacity-0 group-hover/branch:opacity-100 transition-opacity" + strokeWidth={STROKE_WIDTH} + /> + </button> + ) : ( + <code + className={`font-mono break-all block min-w-0 flex-1 ${hasCustomAlias ? "text-xs" : "text-sm"}`} + > + {branchName} + </code> + )} + {repoUrl && branchExistsOnRemote && ( + <a + href={`${repoUrl}/tree/${branchName}`} + target="_blank" + rel="noopener noreferrer" + className="shrink-0 text-muted-foreground hover:text-foreground" + title="Open branch on GitHub" + onClick={(e) => e.stopPropagation()} + > + <LuExternalLink + className="size-3" + strokeWidth={STROKE_WIDTH} + /> + </a> + )} + </div> </div> )} {worktreeInfo?.createdAt && ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts index 489dd1ba41a..4d0ce41efb9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts @@ -1,2 +1,3 @@ export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; +export { RenameBranchDialog } from "./RenameBranchDialog"; export { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts index b00bf489828..8a854f3bde4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts @@ -1,5 +1,5 @@ export const WORKSPACE_DND_TYPE = "WORKSPACE"; export const MAX_KEYBOARD_SHORTCUT_INDEX = 9; -export const GITHUB_STATUS_STALE_TIME = 30_000; +export const GITHUB_STATUS_STALE_TIME = 10_000; export const HOVER_CARD_OPEN_DELAY = 400; export const HOVER_CARD_CLOSE_DELAY = 100; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx index 07cdd320674..c248f78fae8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx @@ -1,13 +1,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useMatchRoute } from "@tanstack/react-router"; import { LuPlus } from "react-icons/lu"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { - useEffectiveHotkeysMap, - useHotkeysStore, -} from "renderer/stores/hotkeys"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; -import { formatHotkeyText } from "shared/hotkeys"; import { STROKE_WIDTH_THICK } from "../constants"; interface NewWorkspaceButtonProps { @@ -18,9 +14,7 @@ export function NewWorkspaceButton({ isCollapsed = false, }: NewWorkspaceButtonProps) { const openModal = useOpenNewWorkspaceModal(); - const platform = useHotkeysStore((state) => state.platform); - const effective = useEffectiveHotkeysMap(); - const shortcutText = formatHotkeyText(effective.NEW_WORKSPACE, platform); + const shortcutText = useHotkeyDisplay("NEW_WORKSPACE").text; // Derive current workspace from route to pre-select project in modal const matchRoute = useMatchRoute(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index e6b3e785714..8d920e73a5b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -138,7 +138,7 @@ export function FileDiffSection({ e.stopPropagation(); if (worktreePath) { const absolutePath = toAbsoluteWorkspacePath(worktreePath, file.path); - openInEditorMutation.mutate({ path: absolutePath, cwd: worktreePath }); + openInEditorMutation.mutate({ path: absolutePath, worktreePath }); } }, [worktreePath, file.path, openInEditorMutation], diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index ffd4a619a7d..8490416d47e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -6,10 +6,10 @@ import { BsTerminalPlus } from "react-icons/bs"; import { LuExternalLink, LuSearch, LuTrash2 } from "react-icons/lu"; import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; import { getAppOption } from "renderer/components/OpenInExternalDropdown"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces"; import { DeleteWorkspaceDialog } from "renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog"; -import { useHotkeyDisplay } from "renderer/stores/hotkeys"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; import { useTheme } from "renderer/stores/theme"; @@ -49,11 +49,11 @@ export function EmptyTabView({ const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); - const newGroupDisplay = useHotkeyDisplay("NEW_GROUP"); - const newChatDisplay = useHotkeyDisplay("NEW_CHAT"); - const quickOpenDisplay = useHotkeyDisplay("QUICK_OPEN"); - const newBrowserDisplay = useHotkeyDisplay("NEW_BROWSER"); - const openInAppDisplay = useHotkeyDisplay("OPEN_IN_APP"); + const { keys: newGroupDisplay } = useHotkeyDisplay("NEW_GROUP"); + const { keys: newChatDisplay } = useHotkeyDisplay("NEW_CHAT"); + const { keys: quickOpenDisplay } = useHotkeyDisplay("QUICK_OPEN"); + const { keys: newBrowserDisplay } = useHotkeyDisplay("NEW_BROWSER"); + const { keys: openInAppDisplay } = useHotkeyDisplay("OPEN_IN_APP"); const resolvedExternalApp: ExternalApp = defaultExternalApp ?? "cursor"; const handleShowTerminal = useCallback(() => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx index 274653d66bd..5420fecbcb2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabContentContextMenu.tsx @@ -16,7 +16,7 @@ import { LuEyeOff, } from "react-icons/lu"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; -import { useHotkeyText } from "renderer/stores/hotkeys"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import { type PaneContextMenuActions, PaneContextMenuItems, @@ -66,9 +66,9 @@ export function TabContentContextMenu({ onMoveToNewTab, closeLabel = "Close Pane", }: TabContentContextMenuProps) { - const clearShortcut = useHotkeyText("CLEAR_TERMINAL"); + const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const showClearShortcut = clearShortcut !== "Unassigned"; - const scrollToBottomShortcut = useHotkeyText("SCROLL_TO_BOTTOM"); + const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; const showScrollToBottomShortcut = scrollToBottomShortcut !== "Unassigned"; const modKey = getModifierKeyLabel(); const hasTerminalActions = !!onClearTerminal || !!onScrollToBottom; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx index 87ab9247493..0225544e8bb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx @@ -3,6 +3,7 @@ import { GlobeIcon } from "lucide-react"; import { useCallback } from "react"; import { TbDeviceDesktop } from "react-icons/tb"; import type { MosaicBranch } from "react-mosaic-component"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import { BasePaneWindow, PaneToolbarActions } from "../components"; import { BrowserErrorOverlay } from "./components/BrowserErrorOverlay"; @@ -34,7 +35,6 @@ export function BrowserPane({ setFocusedPane, }: BrowserPaneProps) { const pane = useTabsStore((s) => s.panes[paneId]); - const openDevToolsPane = useTabsStore((s) => s.openDevToolsPane); const browserState = pane?.browser; const currentUrl = browserState?.currentUrl ?? DEFAULT_BROWSER_URL; const pageTitle = @@ -42,6 +42,8 @@ export function BrowserPane({ const isLoading = browserState?.isLoading ?? false; const loadError = browserState?.error ?? null; const isBlankPage = currentUrl === "about:blank"; + const { mutate: openDevTools } = + electronTrpc.browser.openDevTools.useMutation(); const { containerRef, @@ -57,8 +59,8 @@ export function BrowserPane({ }); const handleOpenDevTools = useCallback(() => { - openDevToolsPane(tabId, paneId, path); - }, [openDevToolsPane, tabId, paneId, path]); + openDevTools({ paneId }); + }, [openDevTools, paneId]); return ( <BasePaneWindow diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx index 80f0f07bb50..05e0e6a7b65 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx @@ -2,12 +2,9 @@ import { ChatRuntimeServiceProvider, ChatServiceProvider, } from "@superset/chat/client"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { CopyIcon } from "lucide-react"; import { useCallback } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import { createChatServiceIpcClient } from "renderer/components/Chat/utils/chat-service-client"; -import { env } from "renderer/env.renderer"; import { electronQueryClient } from "renderer/providers/ElectronTRPCProvider"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { SplitPaneOptions, Tab } from "renderer/stores/tabs/types"; @@ -16,7 +13,6 @@ import { BasePaneWindow, PaneToolbarActions } from "../components"; import { ChatPaneInterface } from "./ChatPaneInterface"; import { SessionSelector } from "./components/SessionSelector"; import { useChatPaneController } from "./hooks/useChatPaneController"; -import { useChatRawSnapshot } from "./hooks/useChatRawSnapshot"; import { createChatRuntimeServiceIpcClient } from "./utils/chat-runtime-service-client"; const chatRuntimeIpcClient = createChatRuntimeServiceIpcClient(); @@ -66,7 +62,6 @@ export function ChatPane({ onMoveToTab, onMoveToNewTab, }: ChatPaneProps) { - const showDevToolbarActions = env.NODE_ENV === "development"; const isFocused = useTabsStore((s) => s.focusedPaneIds[tabId] === paneId); const equalizePaneSplits = useTabsStore((s) => s.equalizePaneSplits); const paneName = useTabsStore((s) => s.panes[paneId]?.name ?? "New Chat"); @@ -90,11 +85,6 @@ export function ChatPane({ paneId, workspaceId, }); - const { - snapshotAvailableForSession, - handleRawSnapshotChange, - handleCopyRawSnapshot, - } = useChatRawSnapshot({ sessionId }); const applySubmittedMessageFallbackTitle = useCallback( (message: string) => { @@ -165,27 +155,6 @@ export function ChatPane({ splitOrientation={handlers.splitOrientation} onSplitPane={handlers.onSplitPane} onClosePane={handlers.onClosePane} - leadingActions={ - showDevToolbarActions ? ( - <Tooltip> - <TooltipTrigger asChild> - <button - type="button" - onClick={() => { - void handleCopyRawSnapshot(); - }} - disabled={!snapshotAvailableForSession} - className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-40" - > - <CopyIcon className="size-3.5" /> - </button> - </TooltipTrigger> - <TooltipContent side="bottom" showArrow={false}> - Copy raw chat JSON (dev) - </TooltipContent> - </Tooltip> - ) : null - } closeHotkeyId="CLOSE_TERMINAL" /> </div> @@ -224,9 +193,6 @@ export function ChatPane({ onStartFreshSession={handleStartFreshSession} onConsumeLaunchConfig={consumeLaunchConfig} onUserMessageSubmitted={applySubmittedMessageFallbackTitle} - onRawSnapshotChange={ - showDevToolbarActions ? handleRawSnapshotChange : undefined - } /> </div> </TabContentContextMenu> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx index bd3547c6fb7..cfbbbb9d2e5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/ChatPaneInterface.tsx @@ -16,12 +16,15 @@ import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChatInputFooter } from "renderer/components/Chat/ChatInterface/components/ChatInputFooter"; import { useSlashCommandExecutor } from "renderer/components/Chat/ChatInterface/hooks/useSlashCommandExecutor"; -import type { SlashCommand } from "renderer/components/Chat/ChatInterface/hooks/useSlashCommands"; import type { ModelOption, PermissionMode, } from "renderer/components/Chat/ChatInterface/types"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { + getDesktopChatModelOptions, + isDesktopChatDevMode, +} from "renderer/lib/dev-chat"; import { posthog } from "renderer/lib/posthog"; import { useChatPreferencesStore } from "renderer/stores/chat-preferences"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -128,12 +131,14 @@ function useAvailableModels(): { models: ModelOption[]; defaultModel: ModelOption | null; } { + const localModels = getDesktopChatModelOptions(); const { data } = useQuery({ queryKey: ["chat", "models"], queryFn: () => apiTrpcClient.chat.getModels.query(), + enabled: !isDesktopChatDevMode(), staleTime: Number.POSITIVE_INFINITY, }); - const models = data?.models ?? []; + const models = localModels.length > 0 ? localModels : (data?.models ?? []); return { models, defaultModel: models[0] ?? null }; } @@ -194,7 +199,6 @@ export function ChatPaneInterface({ onStartFreshSession, onConsumeLaunchConfig, onUserMessageSubmitted, - onRawSnapshotChange, }: ChatPaneInterfaceProps) { const { models: availableModels, defaultModel } = useAvailableModels(); const selectedModelId = useChatPreferencesStore( @@ -222,6 +226,9 @@ export function ChatPaneInterface({ const [approvalResponsePending, setApprovalResponsePending] = useState(false); const [planResponsePending, setPlanResponsePending] = useState(false); const [questionResponsePending, setQuestionResponsePending] = useState(false); + const [answeredQuestionId, setAnsweredQuestionId] = useState<string | null>( + null, + ); const [editingUserMessageId, setEditingUserMessageId] = useState< string | null >(null); @@ -468,6 +475,13 @@ export function ChatPaneInterface({ clearDraftInStore(); }, [clearDraftInStore, sessionId]); + // Reset optimistic hide when a new question arrives + useEffect(() => { + if (pendingQuestion && pendingQuestion.questionId !== answeredQuestionId) { + setAnsweredQuestionId(null); + } + }, [pendingQuestion, answeredQuestionId]); + useEffect(() => { if ( shouldClearPendingUserTurn({ @@ -506,23 +520,6 @@ export function ChatPaneInterface({ setSubmitStatus(undefined); }, [isRunning]); - useEffect(() => { - onRawSnapshotChange?.({ - sessionId, - isRunning: canAbort, - currentMessage: currentMessage ?? null, - messages: messages ?? [], - error, - }); - }, [ - canAbort, - currentMessage, - error, - messages, - onRawSnapshotChange, - sessionId, - ]); - useEffect(() => { messagesLengthRef.current = messages?.length ?? 0; }, [messages]); @@ -820,14 +817,6 @@ export function ChatPaneInterface({ [stopActiveResponse], ); - const handleSlashCommandSend = useCallback( - (command: SlashCommand) => { - void handleSend({ content: `/${command.name}` }).catch((error) => { - console.debug("[chat] handleSlashCommandSend error", error); - }); - }, - [handleSend], - ); const restartFromUserMessage = useCallback( async ( request: UserMessageRestartRequest, @@ -968,7 +957,10 @@ export function ChatPaneInterface({ const trimmedAnswer = answer.trim(); if (!trimmedQuestionId || !trimmedAnswer) return; clearRuntimeError(); + setAnsweredQuestionId(trimmedQuestionId); setQuestionResponsePending(true); + // Clear the orange dot immediately when the user submits their answer + useTabsStore.getState().setPaneStatus(paneId, "idle"); try { await commands.respondToQuestion({ payload: { @@ -976,11 +968,16 @@ export function ChatPaneInterface({ answer: trimmedAnswer, }, }); + } catch (error) { + // Roll back optimistic UI if the RPC fails + setAnsweredQuestionId(null); + useTabsStore.getState().setPaneStatus(paneId, "permission"); + throw error; } finally { setQuestionResponsePending(false); } }, - [clearRuntimeError, commands], + [clearRuntimeError, commands, paneId], ); const errorMessage = runtimeError ?? toErrorMessage(error); @@ -1014,15 +1011,14 @@ export function ChatPaneInterface({ pendingPlanApproval={pendingPlanApproval} isPlanSubmitting={planResponsePending} onPlanRespond={handlePlanResponse} - pendingQuestion={pendingQuestion} - isQuestionSubmitting={questionResponsePending} - onQuestionRespond={handleQuestionResponse} editingUserMessageId={editingUserMessageId} isEditSubmitting={isAwaitingAssistant} onStartEditUserMessage={setEditingUserMessageId} onCancelEditUserMessage={() => setEditingUserMessageId(null)} onSubmitEditedUserMessage={handleSubmitEditedUserMessage} onRestartUserMessage={handleResendUserMessage} + pendingQuestion={pendingQuestion} + answeredQuestionId={answeredQuestionId} /> <McpControls mcpUi={mcpUi} /> <ChatUploadFooter @@ -1046,7 +1042,14 @@ export function ChatPaneInterface({ onSend={handleSend} onSubmitStart={() => setSubmitStatus("submitted")} onStop={handleStop} - onSlashCommandSend={handleSlashCommandSend} + pendingQuestion={ + pendingQuestion?.questionId === answeredQuestionId + ? null + : pendingQuestion + } + isQuestionSubmitting={questionResponsePending} + onQuestionRespond={handleQuestionResponse} + onQuestionCancel={() => void stopActiveResponse()} /> </div> </PromptInputProvider> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.test.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.test.tsx deleted file mode 100644 index b1398f16adb..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.test.tsx +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import { forwardRef } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -mock.module("@superset/ui/ai-elements/conversation", () => ({ - Conversation: ({ children }: { children: React.ReactNode }) => ( - <div>{children}</div> - ), - ConversationContent: forwardRef< - HTMLDivElement, - { children: React.ReactNode } - >(({ children }, ref) => <div ref={ref}>{children}</div>), - ConversationLoadingState: ({ label }: { label?: string }) => ( - <div>{label ?? "Loading conversation..."}</div> - ), - ConversationEmptyState: ({ title }: { title?: string }) => ( - <div>{title ?? "Empty"}</div> - ), - ConversationScrollButton: () => null, -})); - -mock.module("@superset/ui/ai-elements/message", () => ({ - Message: ({ children }: { children: React.ReactNode }) => ( - <div>{children}</div> - ), - MessageContent: ({ children }: { children: React.ReactNode }) => ( - <div>{children}</div> - ), -})); - -mock.module("@superset/ui/ai-elements/shimmer-label", () => ({ - ShimmerLabel: ({ children }: { children: React.ReactNode }) => ( - <span>{children}</span> - ), -})); - -mock.module( - "renderer/components/Chat/ChatInterface/components/ToolCallBlock", - () => ({ - ToolCallBlock: () => null, - }), -); - -mock.module("./components/AssistantMessage", () => ({ - AssistantMessage: ({ - message, - footer, - }: { - message: { - id: string; - content: Array<{ type: string; text?: string }>; - }; - footer?: React.ReactNode; - }) => ( - <div data-assistant-id={message.id}> - {message.content - .filter((part) => part.type === "text") - .map((part, index) => ( - <span key={`${message.id}-${index}`}>{part.text}</span> - ))} - {footer} - </div> - ), -})); - -mock.module("./components/UserMessage", () => ({ - UserMessage: ({ - message, - }: { - message: { - id: string; - content: Array<{ type: string; text?: string }>; - }; - }) => ( - <div data-user-id={message.id}> - {message.content - .filter((part) => part.type === "text") - .map((part, index) => ( - <span key={`${message.id}-${index}`}>{part.text}</span> - ))} - </div> - ), -})); - -mock.module("./components/MessageScrollbackRail", () => ({ - MessageScrollbackRail: ({ - messages, - }: { - messages: Array<{ id: string }>; - }) => <div data-rail-count={messages.length} />, -})); - -mock.module("./components/SubagentExecutionMessage", () => ({ - SubagentExecutionMessage: () => <div>SUBAGENT_EXECUTION_MESSAGE</div>, -})); - -mock.module("./components/PendingApprovalMessage", () => ({ - PendingApprovalMessage: () => null, -})); - -mock.module("./components/PendingPlanApprovalMessage", () => ({ - PendingPlanApprovalMessage: () => <div>PENDING_PLAN_APPROVAL_MESSAGE</div>, -})); - -mock.module("./components/PendingQuestionMessage", () => ({ - PendingQuestionMessage: () => null, -})); - -mock.module("./components/ToolPreviewMessage", () => ({ - ToolPreviewMessage: ({ - pendingPlanToolCallId, - }: { - pendingPlanToolCallId?: string | null; - }) => ( - <div data-pending-plan-tool-call-id={pendingPlanToolCallId ?? ""}> - TOOL_PREVIEW_MESSAGE - </div> - ), -})); - -mock.module("./hooks/useChatMessageSearch", () => ({ - useChatMessageSearch: () => ({ - isSearchOpen: false, - query: "", - caseSensitive: false, - matchCount: 0, - activeMatchIndex: 0, - setQuery: () => {}, - setCaseSensitive: () => {}, - findNext: () => {}, - findPrevious: () => {}, - closeSearch: () => {}, - }), -})); - -const { ChatMessageList } = await import("./ChatMessageList"); -type ChatMessageListProps = Parameters<typeof ChatMessageList>[0]; - -type TestMessage = { - id: string; - role: "user" | "assistant"; - content: Array<{ type: "text"; text: string }>; - createdAt: Date; -}; - -function testMessage( - id: string, - role: TestMessage["role"], - text: string, - createdAt: string, -): TestMessage { - return { - id, - role, - content: [{ type: "text", text }], - createdAt: new Date(createdAt), - }; -} - -function createBaseProps( - overrides: Partial<ChatMessageListProps> = {}, -): ChatMessageListProps { - return { - messages: [] as never, - isFocused: true, - isRunning: false, - isConversationLoading: false, - isAwaitingAssistant: false, - currentMessage: null, - interruptedMessage: null, - workspaceId: "workspace-1", - sessionId: "session-1", - organizationId: "org-1", - workspaceCwd: "/repo", - activeTools: undefined, - toolInputBuffers: undefined, - activeSubagents: undefined, - pendingApproval: null, - isApprovalSubmitting: false, - onApprovalRespond: async () => {}, - pendingPlanApproval: null, - isPlanSubmitting: false, - onPlanRespond: async () => {}, - pendingQuestion: null, - isQuestionSubmitting: false, - onQuestionRespond: async () => {}, - editingUserMessageId: null, - isEditSubmitting: false, - onStartEditUserMessage: () => {}, - onCancelEditUserMessage: () => {}, - onSubmitEditedUserMessage: async () => {}, - onRestartUserMessage: async () => {}, - ...overrides, - }; -} - -function renderListHtml(overrides: Partial<ChatMessageListProps> = {}): string { - return renderToStaticMarkup( - <ChatMessageList {...createBaseProps(overrides)} />, - ); -} - -describe("ChatMessageList", () => { - it("shows loading state while conversation history is loading", () => { - const html = renderListHtml({ - isConversationLoading: true, - }); - - expect(html).toContain("Loading conversation..."); - expect(html).not.toContain("Start a conversation"); - }); - - it("shows interrupted preview content after stop and hides the source assistant message", () => { - const html = renderListHtml({ - messages: [ - testMessage( - "user-1", - "user", - "first user prompt", - "2026-03-03T00:00:00.000Z", - ), - testMessage( - "assistant-1", - "assistant", - "persisted assistant text", - "2026-03-03T00:00:01.000Z", - ), - ] as never, - interruptedMessage: { - id: "interrupted:assistant-1", - sourceMessageId: "assistant-1", - content: [{ type: "text", text: "interrupted snapshot text" }], - } as never, - }); - - expect(html).toContain("first user prompt"); - expect(html).toContain("interrupted snapshot text"); - expect(html).toContain("Interrupted"); - expect(html).toContain("Response stopped"); - expect(html).not.toContain("persisted assistant text"); - }); - - it("does not show interrupted preview while a response is still running", () => { - const html = renderListHtml({ - messages: [ - testMessage( - "user-1", - "user", - "first user prompt", - "2026-03-03T00:00:00.000Z", - ), - testMessage( - "assistant-1", - "assistant", - "persisted assistant text", - "2026-03-03T00:00:01.000Z", - ), - ] as never, - isRunning: true, - isAwaitingAssistant: true, - currentMessage: testMessage( - "assistant-current", - "assistant", - "streaming assistant text", - "2026-03-03T00:00:02.000Z", - ) as never, - interruptedMessage: { - id: "interrupted:assistant-1", - sourceMessageId: "assistant-1", - content: [{ type: "text", text: "interrupted snapshot text" }], - } as never, - }); - - expect(html).toContain("streaming assistant text"); - expect(html).not.toContain("interrupted snapshot text"); - expect(html).not.toContain("Interrupted"); - expect(html).not.toContain("Response stopped"); - }); - - it("renders subagent activity while keeping anchored pending plan inline", () => { - const html = renderListHtml({ - messages: [ - { - id: "assistant-plan-1", - role: "assistant", - content: [ - { - type: "tool_call", - id: "tool-call-1", - name: "submit_plan", - args: {}, - }, - ], - createdAt: new Date("2026-03-03T00:00:01.000Z"), - }, - ] as never, - activeSubagents: new Map([ - [ - "tool-call-1", - { - status: "running", - task: "Run tests", - }, - ], - ]) as never, - pendingPlanApproval: { - planId: "tool-call-1", - title: "Implementation plan", - plan: "Do the thing", - } as never, - }); - - expect(html).toContain("SUBAGENT_EXECUTION_MESSAGE"); - expect(html).not.toContain("PENDING_PLAN_APPROVAL_MESSAGE"); - }); - - it("shows tool preview while awaiting assistant when pending plan is anchored", () => { - const html = renderListHtml({ - isAwaitingAssistant: true, - activeTools: new Map([ - [ - "tool-call-1", - { - name: "submit_plan", - status: "streaming_input", - }, - ], - ]) as never, - pendingPlanApproval: { - toolCallId: "tool-call-1", - title: "Implementation plan", - plan: "Do the thing", - } as never, - }); - - expect(html).toContain("TOOL_PREVIEW_MESSAGE"); - expect(html).not.toContain("PENDING_PLAN_APPROVAL_MESSAGE"); - }); - - it("does not render standalone pending plan when anchored from interrupted preview", () => { - const html = renderListHtml({ - messages: [ - { - id: "assistant-1", - role: "assistant", - content: [ - { - type: "tool_call", - id: "tool-call-interrupted", - name: "submit_plan", - args: {}, - }, - ], - createdAt: new Date("2026-03-03T00:00:01.000Z"), - }, - ] as never, - interruptedMessage: { - id: "interrupted:assistant-1", - sourceMessageId: "assistant-1", - content: [ - { - type: "tool_call", - id: "tool-call-interrupted", - name: "submit_plan", - args: {}, - }, - ], - } as never, - pendingPlanApproval: { - title: "Implementation plan", - plan: "Do the thing", - } as never, - }); - - expect(html).not.toContain("PENDING_PLAN_APPROVAL_MESSAGE"); - }); -}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx index 5046915ee9a..23054335c24 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.tsx @@ -4,8 +4,9 @@ import { ConversationEmptyState, ConversationLoadingState, ConversationScrollButton, + useConversationContext, } from "@superset/ui/ai-elements/conversation"; -import { useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { HiMiniChatBubbleLeftRight } from "react-icons/hi2"; import type { ChatMessage, @@ -17,8 +18,6 @@ import { InterruptedFooter } from "./components/InterruptedFooter"; import { MessageScrollbackRail } from "./components/MessageScrollbackRail"; import { PendingApprovalMessage } from "./components/PendingApprovalMessage"; import { PendingPlanApprovalMessage } from "./components/PendingPlanApprovalMessage"; -import { PendingQuestionMessage } from "./components/PendingQuestionMessage"; -import { SubagentExecutionMessage } from "./components/SubagentExecutionMessage"; import { ThinkingMessage } from "./components/ThinkingMessage"; import { ToolPreviewMessage } from "./components/ToolPreviewMessage"; import { UserMessage } from "./components/UserMessage"; @@ -32,6 +31,44 @@ import { resolvePendingPlanToolCallId, } from "./utils/messageListHelpers"; +function ScrollAnchor({ + questionId, + answeredQuestionId, + isAwaitingAssistant, +}: { + questionId: string | null | undefined; + answeredQuestionId: string | null; + isAwaitingAssistant: boolean; +}) { + const { scrollToBottom } = useConversationContext(); + + // Scroll to bottom whenever the assistant starts responding (new message + // sent or question answered), so "Thinking…" and the response are visible. + useEffect(() => { + if (!isAwaitingAssistant) return; + void scrollToBottom("instant"); + }, [isAwaitingAssistant, scrollToBottom]); + + // Scroll to bottom when a new question arrives. + useEffect(() => { + if (!questionId) return; + void scrollToBottom("instant"); + }, [questionId, scrollToBottom]); + + // When an answer is submitted the overlay hides, shrinking the footer and + // growing the Conversation container. The browser clamps scrollTop, which + // the library interprets as "user scrolled up" (setIsAtBottom(false)) via + // its 1ms setTimeout. We run after that timer with a 10ms delay so our + // scrollToBottom fires AFTER the library has reset the pin, restoring it. + useEffect(() => { + if (!answeredQuestionId) return; + const id = setTimeout(() => void scrollToBottom("instant"), 10); + return () => clearTimeout(id); + }, [answeredQuestionId, scrollToBottom]); + + return null; +} + export function ChatMessageList({ messages, isFocused, @@ -46,7 +83,6 @@ export function ChatMessageList({ workspaceCwd, activeTools, toolInputBuffers, - activeSubagents, pendingApproval, isApprovalSubmitting, onApprovalRespond, @@ -54,8 +90,7 @@ export function ChatMessageList({ isPlanSubmitting, onPlanRespond, pendingQuestion, - isQuestionSubmitting, - onQuestionRespond, + answeredQuestionId, editingUserMessageId, isEditSubmitting, onStartEditUserMessage, @@ -105,12 +140,6 @@ export function ChatMessageList({ }), [activeTools, toolInputBuffers], ); - const activeSubagentEntries = useMemo( - () => (activeSubagents ? [...activeSubagents.entries()] : []), - [activeSubagents], - ); - const hasSubagentActivity = activeSubagentEntries.length > 0; - const pendingPlanToolCallId = useMemo(() => { const anchorMessages: ChatMessage[] = [...renderedMessages]; if (interruptedPreview) { @@ -142,14 +171,11 @@ export function ChatMessageList({ ); const canShowPendingAssistantUi = - isAwaitingAssistant && - !currentMessage && - !hasSubagentActivity && - !pendingApproval && - !pendingQuestion; + isAwaitingAssistant && !currentMessage && !pendingApproval; const shouldShowThinking = canShowPendingAssistantUi && !pendingPlanApproval && + !pendingQuestion && previewToolParts.length === 0; const shouldShowToolPreview = canShowPendingAssistantUi && @@ -227,6 +253,7 @@ export function ChatMessageList({ organizationId={organizationId} workspaceCwd={workspaceCwd} isStreaming={false} + isInterrupted previewToolParts={[]} {...inlineToolStateProps} footer={<InterruptedFooter />} @@ -259,9 +286,6 @@ export function ChatMessageList({ onPlanRespond={onPlanRespond} /> ) : null} - {hasSubagentActivity ? ( - <SubagentExecutionMessage subagents={activeSubagentEntries} /> - ) : null} {pendingApproval && ( <PendingApprovalMessage approval={pendingApproval} @@ -276,13 +300,6 @@ export function ChatMessageList({ onRespond={onPlanRespond} /> )} - {pendingQuestion && ( - <PendingQuestionMessage - question={pendingQuestion} - isSubmitting={isQuestionSubmitting} - onRespond={onQuestionRespond} - /> - )} </div> </ConversationContent> <ChatSearch @@ -299,6 +316,11 @@ export function ChatMessageList({ /> <MessageScrollbackRail messages={renderedMessages} /> <ConversationScrollButton /> + <ScrollAnchor + questionId={pendingQuestion?.questionId} + answeredQuestionId={answeredQuestionId} + isAwaitingAssistant={isAwaitingAssistant} + /> </Conversation> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.types.ts index 731578430bf..a2d7ad82670 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/ChatMessageList.types.ts @@ -81,8 +81,7 @@ export interface ChatMessageListProps { feedback?: string; }) => Promise<void>; pendingQuestion: ChatPendingQuestion; - isQuestionSubmitting: boolean; - onQuestionRespond: (questionId: string, answer: string) => Promise<void>; + answeredQuestionId: string | null; editingUserMessageId: string | null; isEditSubmitting: boolean; onStartEditUserMessage: (messageId: string) => void; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx index 186b9b16e59..cbae42d05ee 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/AssistantMessage/AssistantMessage.tsx @@ -21,6 +21,7 @@ type ChatPendingPlanApproval = UseChatDisplayReturn["pendingPlanApproval"]; interface AssistantMessageProps { message: ChatMessage; isStreaming: boolean; + isInterrupted?: boolean; workspaceId: string; sessionId?: string | null; organizationId?: string | null; @@ -101,6 +102,7 @@ function toToolPartFromResult(part: ChatToolResult): ToolPart { export function AssistantMessage({ message, isStreaming, + isInterrupted, workspaceId, sessionId, organizationId, @@ -261,6 +263,8 @@ export function AssistantMessage({ sessionId={sessionId} organizationId={organizationId} workspaceCwd={workspaceCwd} + isStreaming={isStreaming} + isInterrupted={isInterrupted} />, ); nodes.push(...getInlineToolStateNodes(part.id)); @@ -284,6 +288,8 @@ export function AssistantMessage({ sessionId={sessionId} organizationId={organizationId} workspaceCwd={workspaceCwd} + isStreaming={isStreaming} + isInterrupted={isInterrupted} />, ); nodes.push(...getInlineToolStateNodes(part.id)); @@ -313,11 +319,16 @@ export function AssistantMessage({ sessionId={sessionId} organizationId={organizationId} workspaceCwd={workspaceCwd} + isStreaming={isStreaming} />, ); nodes.push(...getInlineToolStateNodes(previewPart.toolCallId)); } + if (nodes.length === 0 && !isStreaming && !footer) { + return null; + } + return ( <Message from="assistant"> <MessageContent> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx index 11573df272a..e861d4d9bb2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/SubagentExecutionMessage.tsx @@ -1,7 +1,10 @@ -import { Message, MessageContent } from "@superset/ui/ai-elements/message"; +import { + Message, + MessageContent, + MessageResponse, +} from "@superset/ui/ai-elements/message"; import { cn } from "@superset/ui/lib/utils"; -import { useState } from "react"; -import { MarkdownToggleContent } from "renderer/components/Chat/components/MarkdownToggleContent"; +import { SubagentInnerToolCall } from "renderer/components/Chat/components/SubagentInnerToolCall"; import { type SubagentEntries, toSubagentViewModels, @@ -32,9 +35,6 @@ export function SubagentExecutionMessage({ subagents, inline = false, }: SubagentExecutionMessageProps) { - const [markdownBySubagent, setMarkdownBySubagent] = useState< - Record<string, boolean> - >({}); if (subagents.length === 0) return null; const viewModels = toSubagentViewModels(subagents); @@ -62,46 +62,32 @@ export function SubagentExecutionMessage({ {getStatusLabel(subagent.status)} </span> </div> - <div className="text-xs text-muted-foreground"> - {subagent.agentType} - {subagent.modelId ? ` • ${subagent.modelId}` : ""} - {subagent.durationMs !== undefined - ? ` • ${Math.round(subagent.durationMs)} ms` - : ""} - </div> - {subagent.text ? ( - <MarkdownToggleContent - toggleId={`subagent-markdown-${subagent.toolCallId}`} - checked={markdownBySubagent[subagent.toolCallId] ?? true} - onCheckedChange={(checked) => - setMarkdownBySubagent((previous) => ({ - ...previous, - [subagent.toolCallId]: checked, - })) - } - content={subagent.text} - labelClassName="flex cursor-pointer items-center gap-2 text-xs text-muted-foreground" - markdownContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2" - plainContainerClassName="max-h-[32rem] overflow-auto rounded border bg-background/80 p-2 text-xs whitespace-pre-wrap break-words" - /> - ) : null} {subagent.toolCalls.length > 0 ? ( - <div className="flex flex-wrap items-center gap-1.5"> + <div className="space-y-1"> {subagent.toolCalls.map((tool, index) => ( - <span + <SubagentInnerToolCall key={`${subagent.toolCallId}-${tool.name}-${index}`} - className={cn( - "rounded-full border px-2 py-0.5 text-xs", - tool.isError - ? "border-destructive/40 bg-destructive/10 text-destructive" - : "border-muted-foreground/30 bg-background/80 text-muted-foreground", - )} - > - {tool.name} - </span> + name={tool.name} + isError={tool.isError} + isPending={ + subagent.status === "running" && + index === subagent.toolCalls.length - 1 + } + args={tool.args} + result={tool.result} + /> ))} </div> ) : null} + {subagent.text ? ( + <MessageResponse + animated={false} + isAnimating={false} + mermaid={{ config: { theme: "default" } }} + > + {subagent.text} + </MessageResponse> + ) : null} </div> ))} </div> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts index 01782b488b4..901ec6cdc74 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/components/SubagentExecutionMessage/utils/toSubagentViewModels.ts @@ -12,6 +12,8 @@ export type SubagentStatus = "running" | "completed" | "error"; interface SubagentToolCall { name: string; isError: boolean; + args: Record<string, unknown> | null; + result: string | null; } export interface SubagentViewModel { @@ -88,6 +90,16 @@ function toToolCalls(value: unknown): SubagentToolCall[] { return { name, isError: record.isError === true, + args: + typeof record.args === "object" && record.args !== null + ? (record.args as Record<string, unknown>) + : null, + result: + typeof record.result === "string" + ? record.result + : record.result !== null && record.result !== undefined + ? String(record.result) + : null, }; }) .filter((item): item is SubagentToolCall => item !== null); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts index cd72fe4944a..3581877fd3a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/hooks/useChatMessageSearch/useChatMessageSearch.ts @@ -1,7 +1,7 @@ import type { RefObject } from "react"; import { useEffect } from "react"; +import { useHotkey } from "renderer/hotkeys"; import { useTextSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks"; -import { useAppHotkey } from "renderer/stores/hotkeys"; interface UseChatMessageSearchOptions { containerRef: RefObject<HTMLDivElement | null>; @@ -36,7 +36,7 @@ export function useChatMessageSearch({ } }, [isFocused, textSearch.closeSearch, textSearch.isSearchOpen]); - useAppHotkey( + useHotkey( "FIND_IN_CHAT", () => { if (textSearch.isSearchOpen) { @@ -46,7 +46,6 @@ export function useChatMessageSearch({ textSearch.setIsSearchOpen(true); }, { enabled: isFocused, preventDefault: true }, - [textSearch.closeSearch, textSearch.isSearchOpen], ); return { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/utils/messageListHelpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/utils/messageListHelpers.ts index 95c8ed8c8b9..9d4cffef8fb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/utils/messageListHelpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/components/ChatMessageList/utils/messageListHelpers.ts @@ -1,3 +1,7 @@ +import { + hasAnsweredQuestionToolCall, + hasPendingQuestionToolCall, +} from "renderer/components/Chat/ChatInterface/utils/messageHelpers"; import type { ToolPart } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; import { normalizeToolName } from "renderer/components/Chat/ChatInterface/utils/tool-helpers"; import type { @@ -100,7 +104,12 @@ export function getVisibleMessages({ const previousTurns = messages.slice(0, turnStartIndex); const activeTurnNonAssistant = messages .slice(turnStartIndex) - .filter((message) => message.role !== "assistant"); + .filter( + (message) => + message.role !== "assistant" || + hasAnsweredQuestionToolCall(message) || + hasPendingQuestionToolCall(message), + ); return [...previousTurns, ...activeTurnNonAssistant]; } @@ -136,9 +145,27 @@ export function removeInterruptedSourceMessage({ interruptedMessage: InterruptedMessagePreview | null; }): ChatMessage[] { if (!interruptedMessage) return messages; - return messages.filter( + + // Try id-based dedup first (works when streaming id matches storage id) + const filtered = messages.filter( (message) => message.id !== interruptedMessage.sourceMessageId, ); + if (filtered.length < messages.length) return filtered; + + // Fallback: mastracode uses separate in-memory ids (currentMessage.id from processStream) + // and storage ids (from listMessages). When they differ, remove active-turn assistant + // messages the same way getVisibleMessages does when isRunning=true. + const turnStartIndex = findLastUserMessageIndex(messages) + 1; + const previousTurns = messages.slice(0, turnStartIndex); + const activeTurnFiltered = messages + .slice(turnStartIndex) + .filter( + (message) => + message.role !== "assistant" || + hasAnsweredQuestionToolCall(message) || + hasPendingQuestionToolCall(message), + ); + return [...previousTurns, ...activeTurnFiltered]; } export function getStreamingPreviewToolParts({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/types.ts index b3b8061ad1c..e9bb74c24ba 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/types.ts @@ -1,15 +1,6 @@ -import type { UseChatDisplayReturn } from "@superset/chat/client"; import type { StartFreshSessionResult } from "renderer/components/Chat/ChatInterface/types"; import type { ChatLaunchConfig } from "shared/tabs-types"; -export interface ChatRawSnapshot { - sessionId: string | null; - isRunning: boolean; - currentMessage: UseChatDisplayReturn["currentMessage"] | null; - messages: UseChatDisplayReturn["messages"]; - error: unknown; -} - export interface ChatPaneInterfaceProps { paneId: string; sessionId: string | null; @@ -23,5 +14,4 @@ export interface ChatPaneInterfaceProps { onStartFreshSession: () => Promise<StartFreshSessionResult>; onConsumeLaunchConfig: () => void; onUserMessageSubmitted?: (message: string) => void; - onRawSnapshotChange?: (snapshot: ChatRawSnapshot) => void; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/utils/uploadFiles/uploadFiles.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/utils/uploadFiles/uploadFiles.ts index db22f211ec7..e0009a7e9ba 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/utils/uploadFiles/uploadFiles.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPaneInterface/utils/uploadFiles/uploadFiles.ts @@ -1,5 +1,6 @@ import type { FileUIPart } from "ai"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { isDesktopChatDevMode } from "renderer/lib/dev-chat"; async function getHttpErrorDetail(response: Response): Promise<string> { const errorBody = await response @@ -45,12 +46,22 @@ async function uploadFile( if (signal?.aborted) { throw new DOMException("The operation was aborted", "AbortError"); } + const fileData = await blobToDataUrl(blob); + + if (isDesktopChatDevMode()) { + return { + type: "file", + url: fileData, + mediaType: file.mediaType, + filename, + }; + } const result = await apiTrpcClient.chat.uploadAttachment.mutate({ sessionId, filename, mediaType: file.mediaType, - fileData: await blobToDataUrl(blob), + fileData, }); return { type: "file", ...result }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx index 87150c06a3f..a97f0b09753 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/components/SessionSelector/components/SessionSelectorItem/SessionSelectorItem.tsx @@ -36,17 +36,23 @@ export function SessionSelectorItem({ className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100" onClick={(event) => { event.stopPropagation(); - alert.destructive({ + alert({ title: "Delete Chat Session", description: "Are you sure you want to delete this session?", - confirmText: "Delete", - onConfirm: () => { - toast.promise(onDeleteSession(sessionId), { - loading: "Deleting session...", - success: "Session deleted", - error: "Failed to delete session", - }); - }, + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Delete", + variant: "destructive", + onClick: () => { + toast.promise(onDeleteSession(sessionId), { + loading: "Deleting session...", + success: "Session deleted", + error: "Failed to delete session", + }); + }, + }, + ], }); }} > diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts index c87b9107508..65cdd891823 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatPaneController/useChatPaneController.ts @@ -6,6 +6,11 @@ import type { StartFreshSessionResult } from "renderer/components/Chat/ChatInter import { env } from "renderer/env.renderer"; import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { authClient, getAuthToken } from "renderer/lib/auth-client"; +import { + isDesktopChatDevMode, + isDesktopChatSessionReady, + resolveDesktopChatOrganizationId, +} from "renderer/lib/dev-chat"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { posthog } from "renderer/lib/posthog"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; @@ -80,6 +85,7 @@ async function createSessionRecord(input: { organizationId: string; workspaceId: string; }): Promise<void> { + if (isDesktopChatDevMode()) return; const token = getAuthToken(); const response = await fetch(`${apiUrl}/api/chat/${input.sessionId}`, { method: "PUT", @@ -106,6 +112,7 @@ async function createSessionRecord(input: { } async function deleteSessionRecord(sessionId: string): Promise<void> { + if (isDesktopChatDevMode()) return; const token = getAuthToken(); const response = await fetch(`${apiUrl}/api/chat/${sessionId}/stream`, { method: "DELETE", @@ -131,7 +138,9 @@ export function useChatPaneController({ const launchConfig = pane?.chat?.launchConfig ?? null; const needsLegacySessionBootstrap = sessionId === null; const { data: session } = authClient.useSession(); - const organizationId = session?.session?.activeOrganizationId ?? null; + const organizationId = resolveDesktopChatOrganizationId( + session?.session?.activeOrganizationId, + ); const collections = useCollections(); const legacySessionBootstrapRef = useRef(false); const ensuredRef = useRef<string | null>(null); @@ -154,6 +163,7 @@ export function useChatPaneController({ ); useEffect(() => { + if (isDesktopChatDevMode()) return; if (existsRemotely) return; if (!workspace?.project || !organizationId) return; if (ensuredRef.current === workspaceId) return; @@ -213,9 +223,12 @@ export function useChatPaneController({ ); return scopedOrUnscoped.length > 0 ? scopedOrUnscoped : allSessions; }, [allSessions, workspaceId]); - const hasCurrentSessionRecord = Boolean( - sessionId && sessions.some((item) => item.id === sessionId), - ); + const hasCurrentSessionRecord = isDesktopChatSessionReady({ + sessionId, + hasPersistedSession: Boolean( + sessionId && sessions.some((item) => item.id === sessionId), + ), + }); const [isSessionInitializing, setIsSessionInitializing] = useState(false); const hasCurrentSessionRecordRef = useRef(hasCurrentSessionRecord); const sessionInitScopeRef = useRef<string | null>(null); @@ -429,10 +442,24 @@ export function useChatPaneController({ }); }, [handleNewChat, needsLegacySessionBootstrap, organizationId]); - const sessionItems = useMemo( - () => sessions.map((item) => toSessionSelectorItem(item)), - [sessions], - ); + const sessionItems = useMemo(() => { + const nextItems = sessions.map((item) => toSessionSelectorItem(item)); + if ( + !isDesktopChatDevMode() || + !sessionId || + nextItems.some((item) => item.sessionId === sessionId) + ) { + return nextItems; + } + return [ + { + sessionId, + title: "", + updatedAt: new Date(), + }, + ...nextItems, + ]; + }, [sessionId, sessions]); const consumeLaunchConfig = useCallback(() => { setChatLaunchConfig(paneId, null); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatRawSnapshot/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatRawSnapshot/index.ts deleted file mode 100644 index 0c9cdaecc48..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatRawSnapshot/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useChatRawSnapshot } from "./useChatRawSnapshot"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatRawSnapshot/useChatRawSnapshot.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatRawSnapshot/useChatRawSnapshot.ts deleted file mode 100644 index 37742633a11..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/hooks/useChatRawSnapshot/useChatRawSnapshot.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { toast } from "@superset/ui/sonner"; -import { useCallback, useRef, useState } from "react"; -import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; -import type { ChatRawSnapshot } from "../../ChatPaneInterface/types"; - -interface UseChatRawSnapshotOptions { - sessionId: string | null; -} - -interface UseChatRawSnapshotReturn { - snapshotAvailableForSession: boolean; - handleRawSnapshotChange: (snapshot: ChatRawSnapshot) => void; - handleCopyRawSnapshot: () => void; -} - -export function useChatRawSnapshot({ - sessionId, -}: UseChatRawSnapshotOptions): UseChatRawSnapshotReturn { - const rawSnapshotRef = useRef<ChatRawSnapshot | null>(null); - const [rawSnapshotSessionId, setRawSnapshotSessionId] = useState< - string | null - >(null); - - const handleRawSnapshotChange = useCallback((snapshot: ChatRawSnapshot) => { - rawSnapshotRef.current = snapshot; - setRawSnapshotSessionId((previousSessionId) => - previousSessionId === snapshot.sessionId - ? previousSessionId - : snapshot.sessionId, - ); - }, []); - - const { copyToClipboard } = useCopyToClipboard(); - - const handleCopyRawSnapshot = useCallback(() => { - const rawSnapshot = rawSnapshotRef.current; - if (!rawSnapshot || rawSnapshot.sessionId !== sessionId) { - toast.error("No raw chat data to copy yet"); - return; - } - - copyToClipboard(JSON.stringify(rawSnapshot, null, 2)); - toast.success("Copied raw chat JSON"); - }, [sessionId, copyToClipboard]); - - return { - snapshotAvailableForSession: - Boolean(rawSnapshotRef.current) && rawSnapshotSessionId === sessionId, - handleRawSnapshotChange, - handleCopyRawSnapshot, - }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/CommentPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/CommentPane.tsx new file mode 100644 index 00000000000..2a3dace7ce6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/CommentPane.tsx @@ -0,0 +1,358 @@ +import { mermaid } from "@streamdown/mermaid"; +import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { FaGithub } from "react-icons/fa"; +import { + LuArrowUpRight, + LuCheck, + LuCopy, + LuMessageSquare, +} from "react-icons/lu"; +import ReactMarkdown from "react-markdown"; +import type { MosaicBranch } from "react-mosaic-component"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { + oneDark, + oneLight, +} from "react-syntax-highlighter/dist/esm/styles/prism"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { useTheme } from "renderer/stores/theme"; +import { Streamdown } from "streamdown"; +import { BasePaneWindow, PaneTitle, PaneToolbarActions } from "../components"; +import "./comment-pane.css"; + +interface CommentPaneProps { + paneId: string; + path: MosaicBranch[]; + tabId: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; +} + +export function CommentPane({ + paneId, + path, + tabId, + splitPaneAuto, + removePane, + setFocusedPane, +}: CommentPaneProps) { + const comment = useTabsStore((s) => s.panes[paneId]?.comment); + const paneName = useTabsStore((s) => s.panes[paneId]?.name); + const setPaneName = useTabsStore((s) => s.setPaneName); + const [copied, setCopied] = useState(false); + const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }; + }, []); + + const handleCopyAll = useCallback(() => { + if (!comment) return; + void electronTrpcClient.external.copyText + .mutate(comment.body) + .then(() => { + if (!isMountedRef.current) return; + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + setCopied(true); + copyTimerRef.current = setTimeout(() => { + if (!isMountedRef.current) return; + setCopied(false); + copyTimerRef.current = null; + }, 1500); + }) + .catch((err) => { + console.warn("Failed to copy comment text", err); + }); + }, [comment]); + + return ( + <BasePaneWindow + paneId={paneId} + path={path} + tabId={tabId} + splitPaneAuto={splitPaneAuto} + removePane={removePane} + setFocusedPane={setFocusedPane} + renderToolbar={(handlers) => ( + <div className="flex h-full w-full items-center justify-between px-3"> + <div className="flex min-w-0 items-center gap-2"> + {comment?.avatarUrl ? ( + <img + src={comment.avatarUrl} + alt="" + className="size-4 shrink-0 rounded-full" + /> + ) : ( + <LuMessageSquare className="size-4 shrink-0 text-muted-foreground" /> + )} + <PaneTitle + name={paneName ?? ""} + fallback="Comment" + onRename={(newName) => setPaneName(paneId, newName)} + /> + {comment?.url && ( + <a + href={comment.url} + target="_blank" + rel="noopener noreferrer" + className="flex shrink-0 items-center gap-0.5 text-muted-foreground hover:text-foreground" + aria-label="View on GitHub" + > + <FaGithub className="size-3.5" /> + <LuArrowUpRight className="size-3" /> + </a> + )} + </div> + <PaneToolbarActions + splitOrientation={handlers.splitOrientation} + onSplitPane={handlers.onSplitPane} + onClosePane={handlers.onClosePane} + closeHotkeyId="CLOSE_TERMINAL" + /> + </div> + )} + > + {!comment ? ( + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + No comment selected + </div> + ) : ( + <div className="flex h-full w-full flex-col overflow-hidden"> + <div className="flex items-center gap-2 border-b border-border px-4 py-2.5"> + <Avatar className="size-5 shrink-0"> + {comment.avatarUrl ? ( + <AvatarImage + src={comment.avatarUrl} + alt={comment.authorLogin} + /> + ) : null} + <AvatarFallback className="text-[10px] font-medium"> + {comment.authorLogin.slice(0, 2).toUpperCase()} + </AvatarFallback> + </Avatar> + <span className="text-sm font-medium text-foreground"> + {comment.authorLogin} + </span> + {comment.path && ( + <span className="truncate text-xs text-muted-foreground"> + {comment.path} + {comment.line != null ? `:${comment.line}` : ""} + </span> + )} + <button + type="button" + onClick={handleCopyAll} + className="ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground hover:text-foreground" + > + {copied ? ( + <> + <LuCheck className="size-3" /> + Copied + </> + ) : ( + <> + <LuCopy className="size-3" /> + Copy All + </> + )} + </button> + </div> + <div className="comment-pane-markdown min-h-0 flex-1 overflow-y-auto select-text"> + <article className="w-full px-6 py-5"> + <ReactMarkdown + remarkPlugins={[remarkGfm]} + rehypePlugins={[rehypeRaw, rehypeSanitize]} + components={commentComponents} + > + {comment.body} + </ReactMarkdown> + </article> + </div> + </div> + )} + </BasePaneWindow> + ); +} + +const mermaidPlugins = { mermaid }; + +const MERMAID_DARK_VARS = { + background: "#1e1e2e", + primaryColor: "#313244", + primaryTextColor: "#cdd6f4", + primaryBorderColor: "#45475a", + secondaryColor: "#313244", + secondaryTextColor: "#cdd6f4", + secondaryBorderColor: "#45475a", + tertiaryColor: "#313244", + tertiaryTextColor: "#cdd6f4", + tertiaryBorderColor: "#45475a", + nodeBorder: "#45475a", + nodeTextColor: "#cdd6f4", + mainBkg: "#313244", + clusterBkg: "#1e1e2e", + titleColor: "#cdd6f4", + edgeLabelBackground: "transparent", + lineColor: "#6c7086", + textColor: "#cdd6f4", +}; + +const MERMAID_LIGHT_VARS = { + background: "#ffffff", + primaryColor: "#f0f0f4", + primaryTextColor: "#1e1e2e", + primaryBorderColor: "#d0d0d8", + lineColor: "#888", + textColor: "#1e1e2e", +}; + +function CommentCodeBlock({ + className, + children, +}: { + className?: string; + children?: ReactNode; +}) { + const theme = useTheme(); + const isDark = theme?.type !== "light"; + + const match = /language-(\w+)/.exec(className || ""); + const language = match ? match[1] : undefined; + const codeString = String(children).replace(/\n$/, ""); + + if (language === "mermaid") { + return ( + <Streamdown + mode="static" + plugins={mermaidPlugins} + mermaid={{ + config: { + theme: "base", + themeVariables: isDark ? MERMAID_DARK_VARS : MERMAID_LIGHT_VARS, + }, + }} + > + {`\`\`\`mermaid\n${codeString}\n\`\`\``} + </Streamdown> + ); + } + + if (!language) { + return ( + <code className="px-1.5 py-0.5 rounded bg-muted font-mono text-sm"> + {children} + </code> + ); + } + + return ( + <SyntaxHighlighter + style={ + (isDark ? oneDark : oneLight) as Record<string, React.CSSProperties> + } + language={language} + PreTag="div" + className="rounded-md text-sm" + > + {codeString} + </SyntaxHighlighter> + ); +} + +const commentComponents = { + code: CommentCodeBlock, + table: ({ children }: { children?: ReactNode }) => ( + <CopyableTable>{children}</CopyableTable> + ), +}; + +function CopyableTable({ children }: { children?: ReactNode }) { + const tableRef = useRef<HTMLTableElement>(null); + const [copied, setCopied] = useState(false); + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const handleCopy = useCallback(() => { + const el = tableRef.current; + if (!el) return; + + const rows = el.querySelectorAll("tr"); + const lines: string[] = []; + for (const row of rows) { + const cells = row.querySelectorAll("th, td"); + const values: string[] = []; + for (const cell of cells) { + values.push((cell.textContent ?? "").trim()); + } + lines.push(values.join("\t")); + } + const text = lines.join("\n"); + void electronTrpcClient.external.copyText + .mutate(text) + .then(() => { + if (!isMountedRef.current) return; + if (timerRef.current) clearTimeout(timerRef.current); + setCopied(true); + timerRef.current = setTimeout(() => { + if (!isMountedRef.current) return; + setCopied(false); + timerRef.current = null; + }, 1500); + }) + .catch((err) => { + console.warn("Failed to copy table text", err); + }); + }, []); + + return ( + <div className="relative"> + <button + type="button" + onClick={handleCopy} + className="absolute right-0 -top-6 z-10 rounded-sm px-1.5 py-0.5 text-2xs text-muted-foreground hover:text-foreground" + > + {copied ? ( + <span className="flex items-center gap-1"> + <LuCheck className="size-3" /> + Copied + </span> + ) : ( + "Copy" + )} + </button> + <div className="overflow-x-auto"> + <table ref={tableRef} className="table-auto w-full"> + {children} + </table> + </div> + </div> + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/comment-pane.css b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/comment-pane.css new file mode 100644 index 00000000000..c0333e827ce --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/comment-pane.css @@ -0,0 +1,263 @@ +.comment-pane-markdown { + color: var(--foreground); + background: var(--background); + font-size: 0.875rem; + line-height: 1.625; + -webkit-font-smoothing: antialiased; +} + +.comment-pane-markdown article { + width: 100%; +} + +/* Headings */ +.comment-pane-markdown h1 { + font-size: 1.75rem; + font-weight: 700; + line-height: 1.25; + margin-top: 0; + margin-bottom: 0.75rem; + letter-spacing: -0.02em; +} + +.comment-pane-markdown h2 { + font-size: 1.35rem; + font-weight: 600; + line-height: 1.3; + margin-top: 1.5rem; + margin-bottom: 0.625rem; +} + +.comment-pane-markdown h3 { + font-size: 1.1rem; + font-weight: 600; + line-height: 1.4; + margin-top: 1.25rem; + margin-bottom: 0.5rem; +} + +.comment-pane-markdown h4, +.comment-pane-markdown h5, +.comment-pane-markdown h6 { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.5; + margin-top: 1rem; + margin-bottom: 0.375rem; +} + +/* First child no top margin */ +.comment-pane-markdown article > *:first-child { + margin-top: 0; +} + +/* Paragraphs */ +.comment-pane-markdown p { + margin-top: 0; + margin-bottom: 0.75rem; +} + +/* Links */ +.comment-pane-markdown a { + color: var(--primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.comment-pane-markdown a:hover { + opacity: 0.8; +} + +/* Strong & emphasis */ +.comment-pane-markdown strong { + font-weight: 600; +} + +.comment-pane-markdown em { + font-style: italic; +} + +/* Lists */ +.comment-pane-markdown ul, +.comment-pane-markdown ol { + margin-top: 0; + margin-bottom: 0.75rem; + padding-left: 1.5rem; +} + +.comment-pane-markdown ul { + list-style-type: disc; +} + +.comment-pane-markdown ol { + list-style-type: decimal; +} + +.comment-pane-markdown li { + margin-bottom: 0.25rem; +} + +.comment-pane-markdown li > ul, +.comment-pane-markdown li > ol { + margin-top: 0.25rem; + margin-bottom: 0; +} + +/* Tables — full width with borders */ +.comment-pane-markdown table { + width: 100%; + border-collapse: collapse; + margin: 0.75rem 0; + font-size: 0.8125rem; +} + +.comment-pane-markdown th, +.comment-pane-markdown td { + border: 1px solid var(--border); + padding: 0.5rem 0.75rem; + vertical-align: middle; +} + +.comment-pane-markdown th { + font-weight: 500; + text-align: left; + background: color-mix(in srgb, var(--muted) 50%, transparent); +} + +.comment-pane-markdown td img { + display: inline-block; + vertical-align: middle; +} + +/* Blockquotes */ +.comment-pane-markdown blockquote { + border-left: 3px solid var(--border); + padding-left: 1rem; + margin: 0.75rem 0; + color: var(--muted-foreground); +} + +.comment-pane-markdown blockquote p:last-child { + margin-bottom: 0; +} + +/* Horizontal rules */ +.comment-pane-markdown hr { + border: none; + border-top: 1px solid var(--border); + margin: 1.5rem 0; +} + +/* Code — inline */ +.comment-pane-markdown code { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8125rem; + background: color-mix(in srgb, var(--muted) 60%, transparent); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; +} + +/* Code — blocks */ +.comment-pane-markdown pre { + margin: 0.75rem 0; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--muted) 40%, transparent); + border-radius: 0.375rem; + overflow-x: auto; +} + +.comment-pane-markdown pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: 0.8125rem; + line-height: 1.5; +} + +/* Images */ +.comment-pane-markdown img { + max-width: 100%; + height: auto; + border-radius: 0.375rem; +} + +/* Details/summary (common in GitHub bot comments) */ +.comment-pane-markdown details { + margin: 0.75rem 0; + border: 1px solid var(--border); + border-radius: 0.375rem; + overflow: hidden; +} + +.comment-pane-markdown details > summary { + cursor: pointer; + padding: 0.5rem 0.75rem; + font-weight: 500; + background: color-mix(in srgb, var(--muted) 30%, transparent); + user-select: none; +} + +.comment-pane-markdown details > summary:hover { + background: color-mix(in srgb, var(--muted) 50%, transparent); +} + +.comment-pane-markdown details[open] > summary { + border-bottom: 1px solid var(--border); +} + +.comment-pane-markdown details > *:not(summary) { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.comment-pane-markdown details > p:first-of-type { + margin-top: 0.5rem; +} + +/* Task lists (checkboxes) */ +.comment-pane-markdown .task-list-item { + list-style: none; + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.comment-pane-markdown .task-list-item input[type="checkbox"] { + margin-top: 0.25rem; +} + +/* Sub text */ +.comment-pane-markdown sub { + font-size: 0.75rem; + color: var(--muted-foreground); +} + +/* Strikethrough */ +.comment-pane-markdown del { + text-decoration: line-through; + opacity: 0.6; +} + +/* Mermaid diagrams */ +.comment-pane-markdown [data-streamdown="mermaid-block"] { + margin: 0.75rem 0; + border-radius: 0.375rem; + background: transparent; + border: none; + padding: 0; +} + +/* Hide "mermaid" label + action buttons */ +.comment-pane-markdown [data-streamdown="mermaid-block"] > .flex.h-8 { + display: none; +} + +.comment-pane-markdown [data-streamdown="mermaid-block-actions"] { + display: none; +} + +/* Remove the inner wrapper background */ +.comment-pane-markdown [data-streamdown="mermaid-block"] > div:last-child { + background: transparent; + border: none; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/index.ts new file mode 100644 index 00000000000..ed0e956694b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/CommentPane/index.ts @@ -0,0 +1 @@ +export { CommentPane } from "./CommentPane"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx index 0970fba5c28..36d7d4eb015 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/DevToolsPane/DevToolsPane.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { BasePaneWindow, PaneToolbarActions } from "../components"; @@ -26,16 +27,12 @@ export function DevToolsPane({ removePane, setFocusedPane, }: DevToolsPaneProps) { - // Query the CDP debug server for the DevTools frontend URL. - // Poll every 1s until a URL is obtained (the browser webview may still be loading). - const { data } = electronTrpc.browser.getDevToolsUrl.useQuery( - { browserPaneId: targetPaneId }, - { - refetchOnWindowFocus: false, - refetchInterval: (query) => (query.state.data?.url ? false : 1000), - }, - ); - const devToolsUrl = data?.url; + const { mutate: openDevTools } = + electronTrpc.browser.openDevTools.useMutation(); + + useEffect(() => { + openDevTools({ paneId: targetPaneId }); + }, [openDevTools, targetPaneId]); return ( <BasePaneWindow @@ -59,17 +56,16 @@ export function DevToolsPane({ </div> )} > - {devToolsUrl ? ( - <webview - src={devToolsUrl} - className="w-full h-full" - style={{ display: "flex", flex: 1 }} - /> - ) : ( - <div className="flex h-full w-full items-center justify-center text-muted-foreground text-xs"> - Connecting to DevTools... - </div> - )} + <div className="flex h-full w-full flex-col items-center justify-center gap-3 text-muted-foreground text-xs"> + <div>DevTools open in a separate window.</div> + <button + type="button" + onClick={() => openDevTools({ paneId: targetPaneId })} + className="rounded border border-border px-3 py-1.5 text-foreground transition-colors hover:bg-accent" + > + Reopen DevTools + </button> + </div> </BasePaneWindow> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx index 0203efdd70f..38e9d03ef72 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx @@ -2,11 +2,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, - AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + EnterEnabledAlertDialogContent, } from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; import { LuLoader } from "react-icons/lu"; @@ -34,36 +34,32 @@ export function UnsavedChangesDialog({ discardLabel = "Discard & Continue", saveLabel = "Save & Continue", }: UnsavedChangesDialogProps) { - const handleSaveAndSwitch = (e: React.MouseEvent) => { - e.preventDefault(); + const handleSaveAndSwitch = () => { onSave(); - // Don't close dialog - parent will close on success }; - const handleDiscardAndSwitch = (e: React.MouseEvent) => { - e.preventDefault(); + const handleDiscardAndSwitch = () => { onDiscard(); - onOpenChange(false); }; return ( <AlertDialog open={open} onOpenChange={isSaving ? undefined : onOpenChange}> - <AlertDialogContent> + <EnterEnabledAlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogDescription>{description}</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel disabled={isSaving}>Cancel</AlertDialogCancel> - <Button + <AlertDialogAction variant="outline" onClick={handleDiscardAndSwitch} disabled={isSaving} className="border-destructive/50 text-destructive hover:bg-destructive/10" > {discardLabel} - </Button> - <AlertDialogAction onClick={handleSaveAndSwitch} disabled={isSaving}> + </AlertDialogAction> + <Button onClick={handleSaveAndSwitch} disabled={isSaving}> {isSaving ? ( <> <LuLoader className="mr-2 h-4 w-4 animate-spin" /> @@ -72,9 +68,9 @@ export function UnsavedChangesDialog({ ) : ( saveLabel )} - </AlertDialogAction> + </Button> </AlertDialogFooter> - </AlertDialogContent> + </EnterEnabledAlertDialogContent> </AlertDialog> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/utils/diff-location.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/utils/diff-location.ts index d3d6c35ecc1..ffc11594504 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/utils/diff-location.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerContent/utils/diff-location.ts @@ -33,6 +33,10 @@ export interface RawEditorPosition { column: number; } +function getLineCount(lines: number | string[]): number { + return typeof lines === "number" ? lines : lines.length; +} + function isSupportedLineType(lineType: string): lineType is LineTypes { return ( lineType === "context" || @@ -44,9 +48,61 @@ function isSupportedLineType(lineType: string): lineType is LineTypes { function clampLineNumber(lineNumber: number, modifiedLines: string[]): number { if (modifiedLines.length === 0) return 1; + if (!Number.isFinite(lineNumber)) return 1; return Math.max(1, Math.min(lineNumber, modifiedLines.length)); } +function parseHunkStartLines(hunkSpecs: string | undefined): { + additionStart: number | null; + deletionStart: number | null; +} { + if (!hunkSpecs) { + return { + additionStart: null, + deletionStart: null, + }; + } + + const match = /@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(hunkSpecs); + if (!match) { + return { + additionStart: null, + deletionStart: null, + }; + } + + const deletionStart = Number.parseInt(match[1], 10); + const additionStart = Number.parseInt(match[2], 10); + + return { + additionStart: Number.isFinite(additionStart) ? additionStart : null, + deletionStart: Number.isFinite(deletionStart) ? deletionStart : null, + }; +} + +function resolveHunkStartLine( + hunk: { + additionStart?: number; + deletionStart?: number; + hunkSpecs?: string; + }, + side: "addition" | "deletion", +): number { + const directStart = + side === "addition" ? hunk.additionStart : hunk.deletionStart; + if (typeof directStart === "number" && Number.isFinite(directStart)) { + return directStart; + } + + const parsedStartLines = parseHunkStartLines(hunk.hunkSpecs); + const parsedStart = + side === "addition" + ? parsedStartLines.additionStart + : parsedStartLines.deletionStart; + + return parsedStart ?? 1; +} + function clampColumn( lineNumber: number, column: number | undefined, @@ -139,16 +195,21 @@ function mapOldSideLineToRawLine( let lineDelta = 0; for (const hunk of diff.hunks) { - if (lineNumber < hunk.deletionStart) { + const deletionStart = resolveHunkStartLine(hunk, "deletion"); + const additionStart = resolveHunkStartLine(hunk, "addition"); + + if (lineNumber < deletionStart) { return clampLineNumber(lineNumber + lineDelta, modifiedLines); } - let currentOldLine = hunk.deletionStart; - let currentNewLine = hunk.additionStart; + let currentOldLine = deletionStart; + let currentNewLine = additionStart; for (const chunk of hunk.hunkContent) { if (chunk.type === "context") { - for (let index = 0; index < chunk.lines.length; index += 1) { + const contextLineCount = getLineCount(chunk.lines); + + for (let index = 0; index < contextLineCount; index += 1) { if (currentOldLine === lineNumber) { return clampLineNumber(currentNewLine, modifiedLines); } @@ -160,15 +221,17 @@ function mapOldSideLineToRawLine( } const insertionLine = clampLineNumber(currentNewLine, modifiedLines); + const deletionLineCount = getLineCount(chunk.deletions); + const additionLineCount = getLineCount(chunk.additions); - for (let index = 0; index < chunk.deletions.length; index += 1) { + for (let index = 0; index < deletionLineCount; index += 1) { if (currentOldLine === lineNumber) { return insertionLine; } currentOldLine += 1; } - currentNewLine += chunk.additions.length; + currentNewLine += additionLineCount; } lineDelta = currentNewLine - currentOldLine; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useDiffSearch/useDiffSearch.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useDiffSearch/useDiffSearch.ts index d272035a3dc..b44ff6df513 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useDiffSearch/useDiffSearch.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useDiffSearch/useDiffSearch.ts @@ -1,7 +1,7 @@ import type { RefObject } from "react"; import { useCallback, useEffect } from "react"; +import { useHotkey } from "renderer/hotkeys"; import { useTextSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks"; -import { useAppHotkey } from "renderer/stores/hotkeys"; import { getDiffSearchRoots } from "../../utils/diffRendererRoots"; interface UseDiffSearchOptions { @@ -56,7 +56,7 @@ export function useDiffSearch({ } }, [filePath]); - useAppHotkey( + useHotkey( "FIND_IN_FILE_VIEWER", () => { if (textSearch.isSearchOpen) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useMarkdownSearch/useMarkdownSearch.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useMarkdownSearch/useMarkdownSearch.ts index 23fc3f3fb8b..c085c875183 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useMarkdownSearch/useMarkdownSearch.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useMarkdownSearch/useMarkdownSearch.ts @@ -1,7 +1,7 @@ import type { RefObject } from "react"; import { useEffect } from "react"; +import { useHotkey } from "renderer/hotkeys"; import { useTextSearch } from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/hooks"; -import { useAppHotkey } from "renderer/stores/hotkeys"; interface UseMarkdownSearchOptions { containerRef: RefObject<HTMLDivElement | null>; @@ -56,7 +56,7 @@ export function useMarkdownSearch({ } }, [filePath]); - useAppHotkey( + useHotkey( "FIND_IN_FILE_VIEWER", () => { if (textSearch.isSearchOpen) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx index 5d15fbd1ff5..5fb46c9d1f8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/components/PaneToolbarActions/PaneToolbarActions.tsx @@ -1,8 +1,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { HiMiniXMark } from "react-icons/hi2"; import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; -import type { HotkeyId } from "shared/hotkeys"; +import type { HotkeyId } from "renderer/hotkeys"; +import { HotkeyLabel } from "renderer/hotkeys"; import type { SplitOrientation } from "../../hooks"; interface PaneToolbarActionsProps { @@ -42,7 +42,7 @@ export function PaneToolbarActions({ </button> </TooltipTrigger> <TooltipContent side="bottom" showArrow={false}> - <HotkeyTooltipContent label="Split pane" hotkeyId="SPLIT_AUTO" /> + <HotkeyLabel label="Split pane" id="SPLIT_AUTO" /> </TooltipContent> </Tooltip> <Tooltip> @@ -56,7 +56,7 @@ export function PaneToolbarActions({ </button> </TooltipTrigger> <TooltipContent side="bottom" showArrow={false}> - <HotkeyTooltipContent label="Close pane" hotkeyId={closeHotkeyId} /> + <HotkeyLabel label="Close pane" id={closeHotkeyId} /> </TooltipContent> </Tooltip> </div> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index 7dd16399845..2fd09d72868 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -21,6 +21,7 @@ import { import { useTheme } from "renderer/stores/theme"; import { BrowserPane } from "./BrowserPane"; import { ChatPane } from "./ChatPane"; +import { CommentPane } from "./CommentPane"; import { MosaicSplitOverlay } from "./components"; import { DevToolsPane } from "./DevToolsPane"; import { FileViewerPane } from "./FileViewerPane"; @@ -246,6 +247,20 @@ export function TabView({ tab }: TabViewProps) { ); } + // Route comment panes + if (paneInfo.type === "comment") { + return ( + <CommentPane + paneId={paneId} + path={path} + tabId={tab.id} + splitPaneAuto={splitPaneAuto} + removePane={removePane} + setFocusedPane={setFocusedPane} + /> + ); + } + // Default: terminal panes return ( <TabPane diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton/ScrollToBottomButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton/ScrollToBottomButton.tsx index 2b38f6c5a16..3eb986cce6f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton/ScrollToBottomButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/ScrollToBottomButton/ScrollToBottomButton.tsx @@ -3,7 +3,7 @@ import { cn } from "@superset/ui/utils"; import type { Terminal } from "@xterm/xterm"; import { useCallback, useEffect, useState } from "react"; import { HiArrowDown } from "react-icons/hi2"; -import { useHotkeyText } from "renderer/stores/hotkeys"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import { scrollToBottom } from "../utils"; interface ScrollToBottomButtonProps { @@ -12,7 +12,7 @@ interface ScrollToBottomButtonProps { export function ScrollToBottomButton({ terminal }: ScrollToBottomButtonProps) { const [isVisible, setIsVisible] = useState(false); - const shortcutText = useHotkeyText("SCROLL_TO_BOTTOM"); + const shortcutText = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; const showShortcut = shortcutText !== "Unassigned"; const checkScrollPosition = useCallback(() => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 91fef49597e..04666ba7770 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -2,18 +2,15 @@ import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; import type { Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; -import { useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { sanitizeTerminalFontFamily } from "renderer/lib/terminal/appearance"; import { buildTerminalCommand } from "renderer/lib/terminal/launch-command"; import { useTabsStore } from "renderer/stores/tabs/store"; -import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; import { useTerminalTheme } from "renderer/stores/theme"; import { SessionKilledOverlay } from "./components"; -import { - DEFAULT_TERMINAL_FONT_FAMILY, - DEFAULT_TERMINAL_FONT_SIZE, -} from "./config"; -import { getDefaultTerminalBg, type TerminalRendererRef } from "./helpers"; +import { DEFAULT_TERMINAL_FONT_SIZE } from "./config"; +import { getDefaultTerminalBg } from "./helpers"; import { useFileLinkClick, useTerminalColdRestore, @@ -34,11 +31,16 @@ import type { TerminalStreamEvent, } from "./types"; import { shellEscapePaths } from "./utils"; +import * as v1TerminalCache from "./v1-terminal-cache"; const stripLeadingEmoji = (text: string) => text.trim().replace(/^[\p{Emoji}\p{Symbol}]\s*/u, ""); -export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { +export const Terminal = memo(function Terminal({ + paneId, + tabId, + workspaceId, +}: TerminalProps) { const pane = useTabsStore((s) => s.panes[paneId]); const isWorkspaceRunPane = Boolean(pane?.workspaceRun?.workspaceId); const paneInitialCwd = pane?.initialCwd; @@ -57,11 +59,12 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { { enabled: isWorkspaceRunPane }, ); + const workspaceRunRestartCommand = isWorkspaceRunPane + ? buildTerminalCommand(workspaceRunConfig?.commands) + : null; const defaultRestartCommandRef = useRef<string | undefined>(undefined); defaultRestartCommandRef.current = - (isWorkspaceRunPane - ? (buildTerminalCommand(workspaceRunConfig?.commands) ?? undefined) - : undefined) ?? pane?.workspaceRun?.command; + workspaceRunRestartCommand ?? pane?.workspaceRun?.command; const utils = electronTrpc.useUtils(); const updateWorkspace = electronTrpc.workspaces.update.useMutation({ @@ -85,7 +88,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { const xtermRef = useRef<XTerm | null>(null); const fitAddonRef = useRef<FitAddon | null>(null); const searchAddonRef = useRef<SearchAddon | null>(null); - const rendererRef = useRef<TerminalRendererRef | null>(null); const isExitedRef = useRef(false); const [exitStatus, setExitStatus] = useState<"killed" | "exited" | null>( null, @@ -109,7 +111,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { createOrAttach: createOrAttachRef, write: writeRef, resize: resizeRef, - detach: detachRef, cancelCreateOrAttach: cancelCreateOrAttachRef, clearScrollback: clearScrollbackRef, }, @@ -134,7 +135,7 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { // File link click handler const { handleFileLinkClick } = useFileLinkClick({ workspaceId, - workspaceCwd, + projectId: workspaceData?.projectId, }); // URL click handler - opens in app browser or system browser based on setting @@ -166,7 +167,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { initialThemeRef, paneInitialCwdRef, clearPaneInitialDataRef, - workspaceCwdRef, handleFileLinkClickRef, setPaneNameRef, handleTerminalFocusRef, @@ -185,7 +185,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { terminalTheme, paneInitialCwd, clearPaneInitialData, - workspaceCwd, handleFileLinkClick, setPaneName, setFocusedPane, @@ -271,27 +270,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { handleTerminalExitRef.current = handleTerminalExit; handleStreamErrorRef.current = handleStreamError; - // Stream subscription - electronTrpc.terminal.stream.useSubscription(paneId, { - onData: (event) => { - if (connectionErrorRef.current && event.type === "data") { - setConnectionError(null); - retryCountRef.current = 0; - } - handleStreamData(event); - }, - onError: (error) => { - console.error("[Terminal] Stream subscription error:", { - paneId, - error: error instanceof Error ? error.message : String(error), - }); - setConnectionError( - error instanceof Error ? error.message : "Connection to terminal lost", - ); - }, - enabled: true, - }); - // Auto-retry when connection error is set useEffect(() => { if (!connectionError) return; @@ -311,8 +289,16 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { return () => clearTimeout(timeout); }, [connectionError, handleRetryConnection]); + const handleClearHotkey = useCallback(() => { + const xterm = xtermRef.current; + if (!xterm) return; + xterm.clear(); + clearScrollbackRef.current({ paneId }); + }, [paneId, clearScrollbackRef]); + const { isSearchOpen, setIsSearchOpen } = useTerminalHotkeys({ isFocused, + onClear: handleClearHotkey, xtermRef, }); useEffect(() => { @@ -327,7 +313,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { xtermRef, fitAddonRef, searchAddonRef, - rendererRef, isExitedRef, wasKilledByUserRef, commandBufferRef, @@ -335,7 +320,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { isRestoredModeRef, connectionErrorRef, initialThemeRef, - workspaceCwdRef, handleFileLinkClickRef, handleUrlClickRef, paneInitialCwdRef, @@ -347,7 +331,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { createOrAttachRef, writeRef, resizeRef, - detachRef, cancelCreateOrAttachRef, clearScrollbackRef, isStreamReadyRef, @@ -357,7 +340,6 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { flushPendingEvents, resetModes, isAlternateScreenRef, - isBracketedPasteRef, setPaneNameRef, renameUnnamedWorkspaceRef, handleTerminalFocusRef, @@ -372,21 +354,45 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { defaultRestartCommandRef, }); - const registerRestartCallback = useTerminalCallbacksStore( - (s) => s.registerRestartCallback, - ); - const unregisterRestartCallback = useTerminalCallbacksStore( - (s) => s.unregisterRestartCallback, - ); + // Stream event handler registration — the subscription itself lives in + // v1TerminalCache and stays alive across mount/unmount cycles so data + // keeps flowing to xterm even while the tab is hidden. + // Placed after useTerminalLifecycle so the cache entry exists on cold mount. + // Gated on xtermInstance so it re-runs once the lifecycle hook creates it. useEffect(() => { - registerRestartCallback(paneId, restartTerminal); - return () => unregisterRestartCallback(paneId); - }, [ - paneId, - restartTerminal, - registerRestartCallback, - unregisterRestartCallback, - ]); + if (!xtermInstance) return; + + const queuedEvents = v1TerminalCache.registerHandlers(paneId, { + onEvent: (event) => { + if (connectionErrorRef.current && event.type === "data") { + setConnectionError(null); + retryCountRef.current = 0; + } + handleStreamData(event); + }, + onError: (error) => { + console.error("[Terminal] Stream subscription error:", { + paneId, + error: error instanceof Error ? error.message : String(error), + }); + setConnectionError( + error instanceof Error + ? error.message + : "Connection to terminal lost", + ); + }, + }); + + // Process lifecycle events (exit, error, disconnect) that arrived + // while this component was unmounted. + for (const event of queuedEvents) { + handleStreamData(event); + } + + return () => { + v1TerminalCache.unregisterHandlers(paneId); + }; + }, [paneId, xtermInstance, handleStreamData, setConnectionError]); useEffect(() => { const xterm = xtermRef.current; @@ -401,22 +407,16 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { }, ); + // biome-ignore lint/correctness/useExhaustiveDependencies: resizeRef is a stable MutableRefObject — .current is read inside the effect, not a dependency useEffect(() => { - const xterm = xtermRef.current; - if (!xterm || !fontSettings) return; - const family = - fontSettings.terminalFontFamily || DEFAULT_TERMINAL_FONT_FAMILY; + if (!fontSettings) return; + const family = sanitizeTerminalFontFamily(fontSettings.terminalFontFamily); const size = fontSettings.terminalFontSize ?? DEFAULT_TERMINAL_FONT_SIZE; - if ( - xterm.options.fontFamily === family && - xterm.options.fontSize === size - ) { - return; + const result = v1TerminalCache.updateAppearance(paneId, family, size); + if (result?.changed) { + resizeRef.current({ paneId, cols: result.cols, rows: result.rows }); } - xterm.options.fontFamily = family; - xterm.options.fontSize = size; - fitAddonRef.current?.fit(); - }, [fontSettings]); + }, [paneId, fontSettings]); const terminalBg = terminalTheme?.background ?? getDefaultTerminalBg(); @@ -469,4 +469,4 @@ export const Terminal = ({ paneId, tabId, workspaceId }: TerminalProps) => { </div> </div> ); -}; +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts new file mode 100644 index 00000000000..80c9e02a9a3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "bun:test"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "./clipboardShortcuts"; + +function makeEvent( + overrides: Partial<{ + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + }>, +) { + return { + code: "KeyC", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides, + }; +} + +describe("shouldBubbleClipboardShortcut", () => { + it("bubbles every Mac Cmd chord, Ghostty-style", () => { + const cases = [ + { + name: "Cmd+C (no selection)", + event: makeEvent({ code: "KeyC", metaKey: true }), + }, + { name: "Cmd+V", event: makeEvent({ code: "KeyV", metaKey: true }) }, + { name: "Cmd+Enter", event: makeEvent({ code: "Enter", metaKey: true }) }, + { name: "Cmd+W", event: makeEvent({ code: "KeyW", metaKey: true }) }, + { + name: "Cmd+Shift+K", + event: makeEvent({ code: "KeyK", metaKey: true, shiftKey: true }), + }, + { + name: "Cmd+Alt+Left", + event: makeEvent({ code: "ArrowLeft", metaKey: true, altKey: true }), + }, + ]; + + for (const { name, event } of cases) { + expect( + shouldBubbleClipboardShortcut(event, { + isMac: true, + isWindows: false, + hasSelection: false, + }), + name, + ).toBe(true); + } + }); + + it("does not bubble non-Cmd chords on Mac", () => { + const cases = [ + { name: "plain c", event: makeEvent({ code: "KeyC" }) }, + { + name: "Ctrl+C (not a Mac idiom)", + event: makeEvent({ code: "KeyC", ctrlKey: true }), + }, + { + name: "Shift+Insert", + event: makeEvent({ code: "Insert", shiftKey: true }), + }, + { + name: "Ctrl+Shift+V (linux chord on mac)", + event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + }, + ]; + + for (const { name, event } of cases) { + expect( + shouldBubbleClipboardShortcut(event, { + isMac: true, + isWindows: false, + hasSelection: false, + }), + name, + ).toBe(false); + } + }); + + it("matches standard Windows / Linux clipboard bindings", () => { + const cases = [ + { + name: "Windows Ctrl+V", + event: makeEvent({ code: "KeyV", ctrlKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: true, + }, + { + name: "Windows Ctrl+Shift+V", + event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: true, + }, + { + name: "Windows Ctrl+C with selection", + event: makeEvent({ code: "KeyC", ctrlKey: true }), + options: { isMac: false, isWindows: true, hasSelection: true }, + expected: true, + }, + { + name: "Windows Ctrl+C without selection stays with PTY (SIGINT)", + event: makeEvent({ code: "KeyC", ctrlKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: false, + }, + { + name: "Windows Ctrl+Shift+C without selection still bubbles", + event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: true, + }, + { + name: "Linux Ctrl+Shift+V", + event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: true, + }, + { + name: "Linux Shift+Insert", + event: makeEvent({ code: "Insert", shiftKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: true, + }, + { + name: "Linux Ctrl+Shift+C without selection still bubbles", + event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: true, + }, + { + name: "Linux Ctrl+Insert stays with the PTY", + event: makeEvent({ code: "Insert", ctrlKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: false, + }, + ]; + + for (const { name, event, options, expected } of cases) { + expect(shouldBubbleClipboardShortcut(event, options), name).toBe( + expected, + ); + } + }); +}); + +describe("shouldSelectAllShortcut", () => { + it("matches only the VS Code macOS terminal select-all binding", () => { + const cases = [ + { + name: "macOS Cmd+A", + event: makeEvent({ code: "KeyA", metaKey: true }), + isMac: true, + expected: true, + }, + { + name: "windows Ctrl+A is not intercepted", + event: makeEvent({ code: "KeyA", ctrlKey: true }), + isMac: false, + expected: false, + }, + { + name: "macOS Cmd+Shift+A is not intercepted", + event: makeEvent({ code: "KeyA", metaKey: true, shiftKey: true }), + isMac: true, + expected: false, + }, + ]; + + for (const { name, event, isMac, expected } of cases) { + expect(shouldSelectAllShortcut(event, isMac), name).toBe(expected); + } + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts new file mode 100644 index 00000000000..2e39a40a216 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts @@ -0,0 +1,72 @@ +export interface ClipboardShortcutEvent { + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + +export interface ClipboardShortcutOptions { + isMac: boolean; + isWindows: boolean; + hasSelection: boolean; +} + +/** Match VS Code's macOS terminal `Cmd+A` binding. */ +export function shouldSelectAllShortcut( + event: ClipboardShortcutEvent, + isMac: boolean, +): boolean { + return ( + isMac && + event.code === "KeyA" && + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ); +} + +/** + * Decide whether a chord should bubble to the host (Electron menu accelerators, + * OS clipboard handlers, etc.) instead of reaching xterm's kitty encoder and + * leaking into the PTY as a CSI-u sequence. + * + * On macOS we follow Ghostty's rule (ghostty/src/input/key_encode.zig:534-545: + * "on macOS, command+keys do not encode text"): every Cmd chord bubbles. Specific + * chords the terminal wants to intercept (Cmd+Left/Right/Backspace, Cmd+A, etc.) + * must run before this check in the caller. + * + * Windows/Linux have standard copy/paste keybinds that bubble selectively: + * Ctrl+C only bubbles with a selection because it doubles as SIGINT. + */ +export function shouldBubbleClipboardShortcut( + event: ClipboardShortcutEvent, + options: ClipboardShortcutOptions, +): boolean { + const { isMac, isWindows, hasSelection } = options; + + if (isMac) { + return event.metaKey; + } + + const onlyCtrl = + event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey; + const ctrlShiftOnly = + event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey; + const onlyShift = + event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey; + + if (isWindows) { + if (event.code === "KeyV" && (onlyCtrl || ctrlShiftOnly)) return true; + if (event.code === "KeyC" && ctrlShiftOnly) return true; + if (event.code === "KeyC" && onlyCtrl && hasSelection) return true; + return false; + } + + return ( + (event.code === "KeyV" && ctrlShiftOnly) || + (event.code === "Insert" && onlyShift) || + (event.code === "KeyC" && ctrlShiftOnly) + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts index cbb51c910f9..06ac54430d8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/config.ts @@ -1,4 +1,8 @@ import type { ITerminalOptions } from "@xterm/xterm"; +import { + DEFAULT_TERMINAL_FONT_FAMILY as SHARED_DEFAULT_TERMINAL_FONT_FAMILY, + DEFAULT_TERMINAL_FONT_SIZE as SHARED_DEFAULT_TERMINAL_FONT_SIZE, +} from "renderer/lib/terminal/appearance"; import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; // Use user's theme @@ -13,23 +17,10 @@ export const DEBUG_TERMINAL = typeof localStorage !== "undefined" && localStorage.getItem("SUPERSET_TERMINAL_DEBUG") === "1"; -// Nerd Fonts first for shell theme compatibility (Oh My Posh, Powerlevel10k, etc.) -export const DEFAULT_TERMINAL_FONT_FAMILY = [ - "MesloLGM Nerd Font", - "MesloLGM NF", - "MesloLGS NF", - "MesloLGS Nerd Font", - "Hack Nerd Font", - "FiraCode Nerd Font", - "JetBrainsMono Nerd Font", - "CaskaydiaCove Nerd Font", - "Menlo", - "Monaco", - '"Courier New"', - "monospace", -].join(", "); +// Shared terminal font defaults are serialized as a valid CSS font-family value. +export const DEFAULT_TERMINAL_FONT_FAMILY = SHARED_DEFAULT_TERMINAL_FONT_FAMILY; -export const DEFAULT_TERMINAL_FONT_SIZE = 14; +export const DEFAULT_TERMINAL_FONT_SIZE = SHARED_DEFAULT_TERMINAL_FONT_SIZE; export const TERMINAL_OPTIONS: ITerminalOptions = { cursorBlink: true, @@ -42,6 +33,7 @@ export const TERMINAL_OPTIONS: ITerminalOptions = { macOptionIsMeta: false, cursorStyle: "block", cursorInactiveStyle: "outline", + vtExtensions: { kittyKeyboard: true }, screenReaderMode: false, // xterm's fit addon permanently reserves scrollbar width from usable columns. // Hide the built-in scrollbar so terminal content can use the full pane width. @@ -49,5 +41,3 @@ export const TERMINAL_OPTIONS: ITerminalOptions = { showScrollbar: false, }, }; - -export const RESIZE_DEBOUNCE_MS = 150; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts deleted file mode 100644 index 88e50261f1a..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - it, - mock, -} from "bun:test"; -import type { Terminal as XTerm } from "@xterm/xterm"; - -// Mock localStorage for Node.js test environment -const mockStorage = new Map<string, string>(); -const mockLocalStorage = { - getItem: (key: string) => mockStorage.get(key) ?? null, - setItem: (key: string, value: string) => mockStorage.set(key, value), - removeItem: (key: string) => mockStorage.delete(key), - clear: () => mockStorage.clear(), -}; - -// @ts-expect-error - mocking global localStorage -globalThis.localStorage = mockLocalStorage; - -// Mock trpc-client to avoid electronTRPC dependency -mock.module("renderer/lib/trpc-client", () => ({ - electronTrpcClient: { - external: { - openUrl: { mutate: mock(() => Promise.resolve()) }, - openFileInEditor: { mutate: mock(() => Promise.resolve()) }, - }, - uiState: { - hotkeys: { - get: { query: mock(() => Promise.resolve(null)) }, - set: { mutate: mock(() => Promise.resolve()) }, - }, - theme: { - get: { query: mock(() => Promise.resolve(null)) }, - set: { mutate: mock(() => Promise.resolve()) }, - }, - }, - }, - electronReactClient: {}, -})); - -// Import after mocks are set up -const { - getDefaultTerminalBg, - getDefaultTerminalTheme, - setupCopyHandler, - setupKeyboardHandler, - setupPasteHandler, -} = await import("./helpers"); - -describe("getDefaultTerminalTheme", () => { - beforeEach(() => { - mockStorage.clear(); - }); - - afterEach(() => { - mockStorage.clear(); - }); - - it("should return cached terminal colors from localStorage", () => { - const cachedTerminal = { - background: "#272822", - foreground: "#f8f8f2", - cursor: "#f8f8f0", - red: "#f92672", - green: "#a6e22e", - }; - localStorage.setItem("theme-terminal", JSON.stringify(cachedTerminal)); - - const theme = getDefaultTerminalTheme(); - - expect(theme.background).toBe("#272822"); - expect(theme.foreground).toBe("#f8f8f2"); - expect(theme.cursor).toBe("#f8f8f0"); - }); - - it("should fall back to theme-id lookup when no cached terminal", () => { - localStorage.setItem("theme-id", "light"); - - const theme = getDefaultTerminalTheme(); - - // Light theme has white background - expect(theme.background).toBe("#ffffff"); - }); - - it("should fall back to default dark theme when localStorage is empty", () => { - const theme = getDefaultTerminalTheme(); - - // Default theme is dark (ember) - expect(theme.background).toBe("#151110"); - }); - - it("should handle invalid JSON in cached terminal gracefully", () => { - localStorage.setItem("theme-terminal", "invalid json{"); - - const theme = getDefaultTerminalTheme(); - - // Should fall back to default - expect(theme.background).toBe("#151110"); - }); -}); - -afterAll(() => { - mock.restore(); -}); - -describe("getDefaultTerminalBg", () => { - beforeEach(() => { - mockStorage.clear(); - }); - - afterEach(() => { - mockStorage.clear(); - }); - - it("should return background from cached theme", () => { - localStorage.setItem( - "theme-terminal", - JSON.stringify({ background: "#282c34" }), - ); - - expect(getDefaultTerminalBg()).toBe("#282c34"); - }); - - it("should return default background when no cache", () => { - expect(getDefaultTerminalBg()).toBe("#151110"); - }); -}); - -describe("setupKeyboardHandler", () => { - const originalNavigator = globalThis.navigator; - - afterEach(() => { - // Restore navigator between tests - globalThis.navigator = originalNavigator; - }); - - it("maps Option+Left/Right to Meta+B/F on macOS", () => { - // @ts-expect-error - mocking navigator for tests - globalThis.navigator = { platform: "MacIntel" }; - - const captured: { handler: ((event: KeyboardEvent) => boolean) | null } = { - handler: null, - }; - const xterm = { - attachCustomKeyEventHandler: ( - next: (event: KeyboardEvent) => boolean, - ) => { - captured.handler = next; - }, - }; - - const onWrite = mock(() => {}); - setupKeyboardHandler(xterm as unknown as XTerm, { onWrite }); - - captured.handler?.({ - type: "keydown", - key: "ArrowLeft", - altKey: true, - metaKey: false, - ctrlKey: false, - shiftKey: false, - } as KeyboardEvent); - captured.handler?.({ - type: "keydown", - key: "ArrowRight", - altKey: true, - metaKey: false, - ctrlKey: false, - shiftKey: false, - } as KeyboardEvent); - - expect(onWrite).toHaveBeenCalledWith("\x1bb"); - expect(onWrite).toHaveBeenCalledWith("\x1bf"); - }); - - it("maps Ctrl+Left/Right to Meta+B/F on Windows", () => { - // @ts-expect-error - mocking navigator for tests - globalThis.navigator = { platform: "Win32" }; - - const captured: { handler: ((event: KeyboardEvent) => boolean) | null } = { - handler: null, - }; - const xterm = { - attachCustomKeyEventHandler: ( - next: (event: KeyboardEvent) => boolean, - ) => { - captured.handler = next; - }, - }; - - const onWrite = mock(() => {}); - setupKeyboardHandler(xterm as unknown as XTerm, { onWrite }); - - captured.handler?.({ - type: "keydown", - key: "ArrowLeft", - altKey: false, - metaKey: false, - ctrlKey: true, - shiftKey: false, - } as KeyboardEvent); - captured.handler?.({ - type: "keydown", - key: "ArrowRight", - altKey: false, - metaKey: false, - ctrlKey: true, - shiftKey: false, - } as KeyboardEvent); - - expect(onWrite).toHaveBeenCalledWith("\x1bb"); - expect(onWrite).toHaveBeenCalledWith("\x1bf"); - }); -}); - -describe("setupCopyHandler", () => { - const originalNavigator = globalThis.navigator; - - afterEach(() => { - globalThis.navigator = originalNavigator; - }); - - function createXtermStub(selection: string) { - const listeners = new Map<string, EventListener>(); - const element = { - addEventListener: mock((eventName: string, listener: EventListener) => { - listeners.set(eventName, listener); - }), - removeEventListener: mock((eventName: string) => { - listeners.delete(eventName); - }), - } as unknown as HTMLElement; - const xterm = { - element, - getSelection: mock(() => selection), - } as unknown as XTerm; - return { xterm, listeners }; - } - - it("trims trailing whitespace and writes to clipboardData when available", () => { - const { xterm, listeners } = createXtermStub("foo \nbar "); - setupCopyHandler(xterm); - - const preventDefault = mock(() => {}); - const setData = mock(() => {}); - const copyEvent = { - preventDefault, - clipboardData: { setData }, - } as unknown as ClipboardEvent; - - const copyListener = listeners.get("copy"); - expect(copyListener).toBeDefined(); - copyListener?.(copyEvent); - - expect(preventDefault).toHaveBeenCalled(); - expect(setData).toHaveBeenCalledWith("text/plain", "foo\nbar"); - }); - - it("prefers clipboardData path over navigator.clipboard fallback", () => { - const { xterm, listeners } = createXtermStub("foo \nbar "); - const writeText = mock(() => Promise.resolve()); - - // @ts-expect-error - mocking navigator for tests - globalThis.navigator = { clipboard: { writeText } }; - - setupCopyHandler(xterm); - - const preventDefault = mock(() => {}); - const setData = mock(() => {}); - const copyEvent = { - preventDefault, - clipboardData: { setData }, - } as unknown as ClipboardEvent; - - const copyListener = listeners.get("copy"); - expect(copyListener).toBeDefined(); - copyListener?.(copyEvent); - - expect(preventDefault).toHaveBeenCalled(); - expect(setData).toHaveBeenCalledWith("text/plain", "foo\nbar"); - expect(writeText).not.toHaveBeenCalled(); - }); - - it("falls back to navigator.clipboard.writeText when clipboardData is missing", () => { - const { xterm, listeners } = createXtermStub("foo \nbar "); - const writeText = mock(() => Promise.resolve()); - - // @ts-expect-error - mocking navigator for tests - globalThis.navigator = { clipboard: { writeText } }; - - setupCopyHandler(xterm); - - const preventDefault = mock(() => {}); - const copyEvent = { - preventDefault, - clipboardData: null, - } as unknown as ClipboardEvent; - - const copyListener = listeners.get("copy"); - expect(copyListener).toBeDefined(); - copyListener?.(copyEvent); - - expect(preventDefault).not.toHaveBeenCalled(); - expect(writeText).toHaveBeenCalledWith("foo\nbar"); - }); - - it("does not throw when clipboardData is missing and navigator.clipboard is unavailable", () => { - const { xterm, listeners } = createXtermStub("foo \nbar "); - - // @ts-expect-error - mocking navigator for tests - globalThis.navigator = {}; - - setupCopyHandler(xterm); - - const copyEvent = { - preventDefault: mock(() => {}), - clipboardData: null, - } as unknown as ClipboardEvent; - - const copyListener = listeners.get("copy"); - expect(copyListener).toBeDefined(); - expect(() => copyListener?.(copyEvent)).not.toThrow(); - }); -}); - -describe("setupPasteHandler", () => { - function createXtermStub() { - const listeners = new Map<string, EventListener>(); - const textarea = { - addEventListener: mock((eventName: string, listener: EventListener) => { - listeners.set(eventName, listener); - }), - removeEventListener: mock((eventName: string) => { - listeners.delete(eventName); - }), - } as unknown as HTMLTextAreaElement; - const paste = mock(() => {}); - const xterm = { - textarea, - paste, - } as unknown as XTerm; - return { xterm, listeners, paste }; - } - - it("forwards Ctrl+V for image-only clipboard payloads", () => { - const { xterm, listeners } = createXtermStub(); - const onWrite = mock(() => {}); - setupPasteHandler(xterm, { onWrite }); - - const preventDefault = mock(() => {}); - const stopImmediatePropagation = mock(() => {}); - const pasteEvent = { - clipboardData: { - getData: mock(() => ""), - items: [{ kind: "file", type: "image/png" }], - types: ["Files", "image/png"], - }, - preventDefault, - stopImmediatePropagation, - } as unknown as ClipboardEvent; - - const pasteListener = listeners.get("paste"); - expect(pasteListener).toBeDefined(); - pasteListener?.(pasteEvent); - - expect(onWrite).toHaveBeenCalledWith("\x16"); - expect(preventDefault).toHaveBeenCalled(); - expect(stopImmediatePropagation).toHaveBeenCalled(); - }); - - it("forwards Ctrl+V for non-text clipboard payloads without plain text", () => { - const { xterm, listeners } = createXtermStub(); - const onWrite = mock(() => {}); - setupPasteHandler(xterm, { onWrite }); - - const preventDefault = mock(() => {}); - const stopImmediatePropagation = mock(() => {}); - const pasteEvent = { - clipboardData: { - getData: mock(() => ""), - items: [{ kind: "string", type: "text/html" }], - types: ["text/html"], - }, - preventDefault, - stopImmediatePropagation, - } as unknown as ClipboardEvent; - - const pasteListener = listeners.get("paste"); - expect(pasteListener).toBeDefined(); - pasteListener?.(pasteEvent); - - expect(onWrite).toHaveBeenCalledWith("\x16"); - expect(preventDefault).toHaveBeenCalled(); - expect(stopImmediatePropagation).toHaveBeenCalled(); - }); - - it("ignores empty clipboard payloads", () => { - const { xterm, listeners } = createXtermStub(); - const onWrite = mock(() => {}); - setupPasteHandler(xterm, { onWrite }); - - const preventDefault = mock(() => {}); - const stopImmediatePropagation = mock(() => {}); - const pasteEvent = { - clipboardData: { - getData: mock(() => ""), - items: [], - types: [], - }, - preventDefault, - stopImmediatePropagation, - } as unknown as ClipboardEvent; - - const pasteListener = listeners.get("paste"); - expect(pasteListener).toBeDefined(); - pasteListener?.(pasteEvent); - - expect(onWrite).not.toHaveBeenCalled(); - expect(preventDefault).not.toHaveBeenCalled(); - expect(stopImmediatePropagation).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 6e21a82bbe6..30f14c1f4bb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -3,29 +3,22 @@ import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; import { LigaturesAddon } from "@xterm/addon-ligatures"; +import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; import { Terminal as XTerm } from "@xterm/xterm"; -import { debounce } from "lodash"; +import type { DetectedLink } from "renderer/lib/terminal/links"; +import { TerminalLinkManager } from "renderer/lib/terminal/terminal-link-manager"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; -import { getHotkeyKeys, isAppHotkeyEvent } from "renderer/stores/hotkeys"; import { toXtermTheme } from "renderer/stores/theme/utils"; -import { - getCurrentPlatform, - hotkeyFromKeyboardEvent, - isTerminalReservedEvent, - matchesHotkeyEvent, -} from "shared/hotkeys"; import { builtInThemes, DEFAULT_THEME_ID, getTerminalColors, } from "shared/themes"; -import { RESIZE_DEBOUNCE_MS, TERMINAL_OPTIONS } from "./config"; -import { FilePathLinkProvider, UrlLinkProvider } from "./link-providers"; +import { TERMINAL_OPTIONS } from "./config"; import { suppressQueryResponses } from "./suppressQueryResponses"; -import { scrollToBottom } from "./utils"; /** * Get the default terminal theme from localStorage cache. @@ -68,260 +61,166 @@ export function getDefaultTerminalBg(): string { * Tries WebGL first, falls back to DOM if WebGL fails. * This follows VS Code's approach: WebGL → DOM (canvas addon removed in xterm.js 6.0). */ -export type TerminalRenderer = { - kind: "webgl" | "dom"; - dispose: () => void; - clearTextureAtlas?: () => void; -}; - -type PreferredRenderer = TerminalRenderer["kind"] | "auto"; - -// Track WebGL failures globally to avoid repeated initialization attempts (VS Code pattern) -let suggestedRendererType: TerminalRenderer["kind"] | undefined; - -function getPreferredRenderer(): PreferredRenderer { - // If WebGL previously failed, don't try again - if (suggestedRendererType === "dom") { - return "dom"; - } - - try { - const stored = localStorage.getItem("terminal-renderer"); - if (stored === "webgl" || stored === "dom") { - return stored; - } - if (stored === "canvas") { - // Canvas renderer was removed in xterm.js 6.0; fall back to DOM. - try { - localStorage.setItem("terminal-renderer", "dom"); - } catch { - // ignore storage errors - } - return "dom"; - } - } catch { - // ignore - } - - return "auto"; -} - -function loadRenderer(xterm: XTerm): TerminalRenderer { - let webglAddon: WebglAddon | null = null; - let kind: TerminalRenderer["kind"] = "dom"; - - const preferred = getPreferredRenderer(); - - if (preferred === "dom") { - return { kind: "dom", dispose: () => {}, clearTextureAtlas: undefined }; - } - - try { - webglAddon = new WebglAddon(); - - webglAddon.onContextLoss(() => { - console.warn( - "[Terminal] WebGL context lost, falling back to DOM renderer", - ); - webglAddon?.dispose(); - webglAddon = null; - kind = "dom"; - // Force refresh after context loss - xterm.refresh(0, xterm.rows - 1); - }); - - xterm.loadAddon(webglAddon); - kind = "webgl"; - } catch (e) { - console.warn( - "[Terminal] WebGL could not be loaded, falling back to DOM renderer", - e, - ); - suggestedRendererType = "dom"; - webglAddon = null; - kind = "dom"; - } - - return { - kind, - dispose: () => webglAddon?.dispose(), - clearTextureAtlas: webglAddon - ? () => { - try { - webglAddon?.clearTextureAtlas(); - } catch (error) { - console.warn("[Terminal] WebGL clearTextureAtlas() failed:", error); - } - } - : undefined, - }; -} +// Once WebGL fails, skip it for all subsequent terminals (VS Code pattern). +let suggestedRendererType: "webgl" | "dom" | undefined; export interface CreateTerminalOptions { - cwd?: string; + /** + * Workspace id used for worktree lookup during path stat/resolution. + * The main process looks up the worktree root, so relative paths always + * anchor to the correct worktree regardless of renderer load state. + */ + workspaceId?: string; initialTheme?: ITheme | null; - onFileLinkClick?: (path: string, line?: number, column?: number) => void; + onFileLinkClick?: (event: MouseEvent, link: DetectedLink) => void; onUrlClickRef?: { current: ((url: string) => void) | undefined }; } /** - * Mutable reference to the terminal renderer. - * Used because the GPU renderer is loaded asynchronously after the terminal is created. + * Create an xterm instance opened into a detached wrapper div (not a live container). + * The wrapper can be moved between DOM containers via appendChild without + * disposing the terminal — this is the "hide attach" pattern from v2. + * + * Used by v1-terminal-cache.ts to keep xterm alive across React mount/unmount. */ -export interface TerminalRendererRef { - current: TerminalRenderer; -} - -export function createTerminalInstance( - container: HTMLDivElement, - options: CreateTerminalOptions = {}, -): { +export function createTerminalInWrapper(options: CreateTerminalOptions = {}): { xterm: XTerm; fitAddon: FitAddon; - renderer: TerminalRendererRef; + searchAddon: SearchAddon; + wrapper: HTMLDivElement; + linkManager: TerminalLinkManager; cleanup: () => void; } { const { - cwd, + workspaceId, initialTheme, onFileLinkClick, onUrlClickRef: urlClickRef, } = options; - // Use provided theme, or fall back to localStorage-based default to prevent flash const theme = initialTheme ?? getDefaultTerminalTheme(); const terminalOptions = { ...TERMINAL_OPTIONS, theme }; const xterm = new XTerm(terminalOptions); const fitAddon = new FitAddon(); + const searchAddon = new SearchAddon(); const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); const imageAddon = new ImageAddon(); - // Track cleanup state to prevent operations on disposed terminal - let isDisposed = false; - let rafId: number | null = null; - - // Use a ref pattern so the renderer can be updated after rAF. - // Start with a no-op DOM renderer - the actual GPU renderer is loaded async. - const rendererRef: TerminalRendererRef = { - current: { - kind: "dom", - dispose: () => {}, - clearTextureAtlas: undefined, - }, - }; + let disposed = false; + let webglAddon: WebglAddon | null = null; - xterm.open(container); + // Open into a detached wrapper div — not the live container. + const wrapper = document.createElement("div"); + wrapper.style.width = "100%"; + wrapper.style.height = "100%"; + xterm.open(wrapper); - // Load non-renderer addons synchronously - these are safe and needed immediately xterm.loadAddon(fitAddon); + xterm.loadAddon(searchAddon); xterm.loadAddon(clipboardAddon); xterm.loadAddon(unicode11Addon); xterm.loadAddon(imageAddon); - // Defer GPU renderer loading to next animation frame. - // xterm.open() schedules a setTimeout for Viewport.syncScrollArea which expects - // the renderer to be ready. Loading WebGL immediately after open() can cause a - // race condition where the setTimeout fires during addon initialization, when - // _renderer is temporarily undefined (old renderer disposed, new not yet set). - // Deferring to rAF ensures xterm's internal setTimeout completes first with the - // default DOM renderer, then we safely swap to WebGL. - rafId = requestAnimationFrame(() => { - rafId = null; - if (isDisposed) return; - rendererRef.current = loadRenderer(xterm); - }); - try { - if (!isDisposed) { - xterm.loadAddon(new LigaturesAddon()); - } + xterm.loadAddon(new LigaturesAddon()); } catch { // Ligatures not supported by current font } - const cleanupQuerySuppression = suppressQueryResponses(xterm); + // Defer WebGL to rAF — same pattern as v2 terminal-addons.ts. + const rafId = requestAnimationFrame(() => { + if (disposed || suggestedRendererType === "dom") return; - const urlLinkProvider = new UrlLinkProvider(xterm, (_event, uri) => { - const handler = urlClickRef?.current; - if (handler) { - handler(uri); - return; - } - trpcClient.external.openUrl.mutate(uri).catch((error) => { - console.error("[Terminal] Failed to open URL:", uri, error); - toast.error("Failed to open URL", { - description: - error instanceof Error - ? error.message - : "Could not open URL in browser", + try { + webglAddon = new WebglAddon(); + webglAddon.onContextLoss(() => { + webglAddon?.dispose(); + webglAddon = null; + xterm.refresh(0, xterm.rows - 1); }); - }); + xterm.loadAddon(webglAddon); + } catch { + suggestedRendererType = "dom"; + webglAddon = null; + } }); - xterm.registerLinkProvider(urlLinkProvider); - const filePathLinkProvider = new FilePathLinkProvider( - xterm, - (_event, path, line, column) => { + const cleanupQuerySuppression = suppressQueryResponses(xterm); + + const linkManager = new TerminalLinkManager(xterm); + linkManager.setHandlers({ + stat: async (path) => { + try { + return await trpcClient.external.statPath.mutate({ path, workspaceId }); + } catch { + return null; + } + }, + onFileLinkClick: (event, link) => { + if (!event.metaKey && !event.ctrlKey) { + return; + } if (onFileLinkClick) { - onFileLinkClick(path, line, column); - } else { - // Fallback to default behavior (external editor) - trpcClient.external.openFileInEditor - .mutate({ - path, - line, - column, - cwd, - }) - .catch((error) => { - console.error( - "[Terminal] Failed to open file in editor:", - path, - error, - ); - }); + onFileLinkClick(event, link); + return; + } + trpcClient.external.openFileInEditor + .mutate({ + path: link.resolvedPath, + line: link.row, + column: link.col, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + link.resolvedPath, + error, + ); + }); + }, + onUrlClick: (event, uri) => { + if (!event.metaKey && !event.ctrlKey) return; + event.preventDefault(); + const handler = urlClickRef?.current; + if (handler) { + handler(uri); + return; } + trpcClient.external.openUrl.mutate(uri).catch((error) => { + console.error("[Terminal] Failed to open URL:", uri, error); + toast.error("Failed to open URL", { + description: + error instanceof Error + ? error.message + : "Could not open URL in browser", + }); + }); }, - ); - xterm.registerLinkProvider(filePathLinkProvider); + }); xterm.unicode.activeVersion = "11"; - fitAddon.fit(); return { xterm, fitAddon, - renderer: rendererRef, + searchAddon, + wrapper, + linkManager, cleanup: () => { - isDisposed = true; - if (rafId !== null) { - cancelAnimationFrame(rafId); - } + disposed = true; + cancelAnimationFrame(rafId); cleanupQuerySuppression(); - rendererRef.current.dispose(); + linkManager.dispose(); + try { + webglAddon?.dispose(); + } catch {} + webglAddon = null; }, }; } -export interface KeyboardHandlerOptions { - /** Callback for Shift+Enter (sends ESC+CR to avoid \ appearing in Claude Code while keeping line continuation behavior) */ - onShiftEnter?: () => void; - /** Callback for the configured clear terminal shortcut */ - onClear?: () => void; - onWrite?: (data: string) => void; -} - -export interface PasteHandlerOptions { - /** Callback when text is pasted, receives the pasted text */ - onPaste?: (text: string) => void; - /** Optional direct write callback to bypass xterm's paste burst */ - onWrite?: (data: string) => void; - /** Whether bracketed paste mode is enabled for the current terminal */ - isBracketedPasteEnabled?: () => boolean; -} - /** * Setup copy handler for xterm to trim trailing whitespace from copied text. * @@ -366,333 +265,6 @@ export function setupCopyHandler(xterm: XTerm): () => void { }; } -/** - * Setup paste handler for xterm to ensure bracketed paste mode works correctly. - * - * xterm.js's built-in paste handling via the textarea should work, but in some - * Electron environments the clipboard events may not propagate correctly. - * This handler explicitly intercepts paste events and uses xterm's paste() method, - * which properly handles bracketed paste mode (wrapping pasted content with - * \x1b[200~ and \x1b[201~ escape sequences when the shell has enabled it). - * - * This is required for TUI applications like opencode, vim, etc. that expect - * bracketed paste mode to distinguish between typed and pasted content. - * - * Returns a cleanup function to remove the handler. - */ -export function setupPasteHandler( - xterm: XTerm, - options: PasteHandlerOptions = {}, -): () => void { - const textarea = xterm.textarea; - if (!textarea) return () => {}; - - let cancelActivePaste: (() => void) | null = null; - - const shouldForwardCtrlVForNonTextPaste = ( - event: ClipboardEvent, - text: string, - ): boolean => { - if (text) return false; - const types = Array.from(event.clipboardData?.types ?? []); - if (types.length === 0) return false; - return types.some((type) => type !== "text/plain"); - }; - - const handlePaste = (event: ClipboardEvent) => { - const text = event.clipboardData?.getData("text/plain") ?? ""; - if (!text) { - // Match terminal behavior like iTerm's "Paste or send ^V": - // when clipboard has non-text payloads but no plain text, forward Ctrl+V. - if (options.onWrite && shouldForwardCtrlVForNonTextPaste(event, text)) { - event.preventDefault(); - event.stopImmediatePropagation(); - options.onWrite("\x16"); - } - return; - } - - event.preventDefault(); - event.stopImmediatePropagation(); - - options.onPaste?.(text); - - // Cancel any in-flight chunked paste to avoid overlapping writes. - cancelActivePaste?.(); - cancelActivePaste = null; - - // Chunk large pastes to avoid sending a single massive input burst that can - // overwhelm the PTY pipeline (especially when the app is repainting heavily). - const MAX_SYNC_PASTE_CHARS = 16_384; - - // If no direct write callback is provided, fall back to xterm's paste() - // (it handles newline normalization and bracketed paste mode internally). - if (!options.onWrite) { - const CHUNK_CHARS = 4096; - const CHUNK_DELAY_MS = 5; - - if (text.length <= MAX_SYNC_PASTE_CHARS) { - xterm.paste(text); - return; - } - - let cancelled = false; - let offset = 0; - - const pasteNext = () => { - if (cancelled) return; - - const chunk = text.slice(offset, offset + CHUNK_CHARS); - offset += CHUNK_CHARS; - xterm.paste(chunk); - - if (offset < text.length) { - setTimeout(pasteNext, CHUNK_DELAY_MS); - } - }; - - cancelActivePaste = () => { - cancelled = true; - }; - - pasteNext(); - return; - } - - // Direct write path: replicate xterm's paste normalization, but stream in - // controlled chunks while preserving bracketed-paste semantics. - const preparedText = text.replace(/\r?\n/g, "\r"); - const bracketedPasteEnabled = options.isBracketedPasteEnabled?.() ?? false; - const shouldBracket = bracketedPasteEnabled; - - // For small/medium pastes, preserve the fast path and avoid timers. - if (preparedText.length <= MAX_SYNC_PASTE_CHARS) { - options.onWrite( - shouldBracket ? `\x1b[200~${preparedText}\x1b[201~` : preparedText, - ); - return; - } - - let cancelled = false; - let offset = 0; - const CHUNK_CHARS = 16_384; - const CHUNK_DELAY_MS = 0; - - const pasteNext = () => { - if (cancelled) return; - - const chunk = preparedText.slice(offset, offset + CHUNK_CHARS); - offset += CHUNK_CHARS; - - if (shouldBracket) { - // Wrap each chunk to avoid long-running "open" bracketed paste blocks, - // which some TUIs may defer repainting until the closing sequence arrives. - options.onWrite?.(`\x1b[200~${chunk}\x1b[201~`); - } else { - options.onWrite?.(chunk); - } - - if (offset < preparedText.length) { - setTimeout(pasteNext, CHUNK_DELAY_MS); - return; - } - }; - - cancelActivePaste = () => { - cancelled = true; - }; - - pasteNext(); - }; - - textarea.addEventListener("paste", handlePaste, { capture: true }); - - return () => { - cancelActivePaste?.(); - cancelActivePaste = null; - textarea.removeEventListener("paste", handlePaste, { capture: true }); - }; -} - -/** - * Setup keyboard handling for xterm including: - * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens - * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) - * - Clear terminal: Uses the configured clear shortcut - * - * Returns a cleanup function to remove the handler. - */ -export function setupKeyboardHandler( - xterm: XTerm, - options: KeyboardHandlerOptions = {}, -): () => void { - const platform = - typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; - const isMac = platform.includes("mac"); - const isWindows = platform.includes("win"); - - const handler = (event: KeyboardEvent): boolean => { - const isShiftEnter = - event.key === "Enter" && - event.shiftKey && - !event.metaKey && - !event.ctrlKey && - !event.altKey; - - if (isShiftEnter) { - if (event.type === "keydown" && options.onShiftEnter) { - event.preventDefault(); - options.onShiftEnter(); - } - return false; - } - - const isCmdBackspace = - event.key === "Backspace" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdBackspace) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x15\x1b[D"); // Ctrl+U + left arrow - } - return false; - } - - // Cmd+Left: Move cursor to beginning of line (sends Ctrl+A) - const isCmdLeft = - event.key === "ArrowLeft" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdLeft) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x01"); // Ctrl+A - beginning of line - } - return false; - } - - // Cmd+Right: Move cursor to end of line (sends Ctrl+E) - const isCmdRight = - event.key === "ArrowRight" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey; - - if (isCmdRight) { - if (event.type === "keydown" && options.onWrite) { - event.preventDefault(); - options.onWrite("\x05"); // Ctrl+E - end of line - } - return false; - } - - // Option+Left/Right (macOS): word navigation (Meta+B / Meta+F) - const isOptionLeft = - event.key === "ArrowLeft" && - event.altKey && - isMac && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey; - - if (isOptionLeft) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bb"); // Meta+B - backward word - } - return false; - } - - // Option+Right: Move cursor forward by word (Meta+F) - const isOptionRight = - event.key === "ArrowRight" && - event.altKey && - isMac && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey; - - if (isOptionRight) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bf"); // Meta+F - forward word - } - return false; - } - - // Ctrl+Left/Right (Windows): word navigation (Meta+B / Meta+F) - const isCtrlLeft = - event.key === "ArrowLeft" && - event.ctrlKey && - isWindows && - !event.metaKey && - !event.altKey && - !event.shiftKey; - - if (isCtrlLeft) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bb"); // Meta+B - backward word - } - return false; - } - - const isCtrlRight = - event.key === "ArrowRight" && - event.ctrlKey && - isWindows && - !event.metaKey && - !event.altKey && - !event.shiftKey; - - if (isCtrlRight) { - if (event.type === "keydown" && options.onWrite) { - options.onWrite("\x1bf"); // Meta+F - forward word - } - return false; - } - - if (isTerminalReservedEvent(event)) return true; - - const clearKeys = getHotkeyKeys("CLEAR_TERMINAL"); - const isClearShortcut = - clearKeys !== null && matchesHotkeyEvent(event, clearKeys); - - if (isClearShortcut) { - if (event.type === "keydown" && options.onClear) { - options.onClear(); - } - return false; - } - - if (event.type !== "keydown") return true; - const potentialHotkey = hotkeyFromKeyboardEvent( - event, - getCurrentPlatform(), - ); - if (!potentialHotkey) return true; - - if (isAppHotkeyEvent(event)) { - // Return false to prevent xterm from processing the key. - // The original event bubbles to document where useAppHotkey handles it. - return false; - } - - return true; - }; - - xterm.attachCustomKeyEventHandler(handler); - - return () => { - xterm.attachCustomKeyEventHandler(() => true); - }; -} - export function setupFocusListener( xterm: XTerm, onFocus: () => void, @@ -707,33 +279,6 @@ export function setupFocusListener( }; } -export function setupResizeHandlers( - container: HTMLDivElement, - xterm: XTerm, - fitAddon: FitAddon, - onResize: (cols: number, rows: number) => void, -): () => void { - const debouncedHandleResize = debounce(() => { - const buffer = xterm.buffer.active; - const wasAtBottom = buffer.viewportY >= buffer.baseY; - fitAddon.fit(); - onResize(xterm.cols, xterm.rows); - if (wasAtBottom) { - requestAnimationFrame(() => scrollToBottom(xterm)); - } - }, RESIZE_DEBOUNCE_MS); - - const resizeObserver = new ResizeObserver(debouncedHandleResize); - resizeObserver.observe(container); - window.addEventListener("resize", debouncedHandleResize); - - return () => { - window.removeEventListener("resize", debouncedHandleResize); - resizeObserver.disconnect(); - debouncedHandleResize.cancel(); - }; -} - export interface ClickToMoveOptions { /** Callback to write data to the terminal PTY */ onWrite: (data: string) => void; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/attach-unmount.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/attach-unmount.ts deleted file mode 100644 index 65682c33c84..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/attach-unmount.ts +++ /dev/null @@ -1,17 +0,0 @@ -export function shouldKeepAttachAliveOnUnmount({ - paneDestroyed, - hasWorkspaceRun, - isStartingWorkspaceRun, - hasActiveAttachRequest, -}: { - paneDestroyed: boolean; - hasWorkspaceRun: boolean; - isStartingWorkspaceRun: boolean; - hasActiveAttachRequest: boolean; -}): boolean { - return ( - !paneDestroyed && - hasWorkspaceRun && - (isStartingWorkspaceRun || hasActiveAttachRequest) - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useFileLinkClick.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useFileLinkClick.ts index 414698160cc..44f8888b5c9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useFileLinkClick.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useFileLinkClick.ts @@ -1,21 +1,22 @@ import { toast } from "@superset/ui/sonner"; import { useCallback } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import type { DetectedLink } from "renderer/lib/terminal/links"; import { electronTrpcClient as trpcClient } from "renderer/lib/trpc-client"; import { useTabsStore } from "renderer/stores/tabs/store"; export interface UseFileLinkClickOptions { workspaceId: string; - workspaceCwd: string | null | undefined; + projectId?: string; } export interface UseFileLinkClickReturn { - handleFileLinkClick: (path: string, line?: number, column?: number) => void; + handleFileLinkClick: (event: MouseEvent, link: DetectedLink) => void; } export function useFileLinkClick({ workspaceId, - workspaceCwd, + projectId, }: UseFileLinkClickOptions): UseFileLinkClickReturn { const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); @@ -23,21 +24,17 @@ export function useFileLinkClick({ electronTrpc.settings.getTerminalLinkBehavior.useQuery(); const handleFileLinkClick = useCallback( - (path: string, line?: number, column?: number) => { + (_event: MouseEvent, link: DetectedLink) => { + const { resolvedPath, row: line, col: column, isDirectory } = link; const behavior = terminalLinkBehavior ?? "file-viewer"; const openInExternalEditor = () => { trpcClient.external.openFileInEditor - .mutate({ - path, - line, - column, - cwd: workspaceCwd ?? undefined, - }) + .mutate({ path: resolvedPath, line, column, projectId }) .catch((error) => { console.error( "[Terminal] Failed to open file in editor:", - path, + resolvedPath, error, ); const errorMessage = @@ -48,35 +45,18 @@ export function useFileLinkClick({ }); }; - if (behavior !== "file-viewer") { + if (behavior !== "file-viewer" || isDirectory) { openInExternalEditor(); return; } - if (!workspaceCwd) { - openInExternalEditor(); - return; - } - - trpcClient.external.resolvePath - .query({ path, cwd: workspaceCwd }) - .then((filePath) => { - if (filePath === workspaceCwd) { - return; - } - - addFileViewerPane(workspaceId, { - filePath, - line, - column, - }); - }) - .catch((error) => { - console.error("[Terminal] Failed to resolve path:", path, error); - openInExternalEditor(); - }); + addFileViewerPane(workspaceId, { + filePath: resolvedPath, + line, + column, + }); }, - [terminalLinkBehavior, workspaceId, workspaceCwd, addFileViewerPane], + [terminalLinkBehavior, workspaceId, projectId, addFileViewerPane], ); return { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts index 4b83b1534cd..bd1634e5aab 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalHotkeys.ts @@ -1,11 +1,12 @@ import type { Terminal as XTerm } from "@xterm/xterm"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import { useEffect, useState } from "react"; -import { useAppHotkey } from "renderer/stores/hotkeys"; +import { useHotkey } from "renderer/hotkeys"; import { scrollToBottom } from "../utils"; export interface UseTerminalHotkeysOptions { isFocused: boolean; + onClear: () => void; xtermRef: MutableRefObject<XTerm | null>; } @@ -16,6 +17,7 @@ export interface UseTerminalHotkeysReturn { export function useTerminalHotkeys({ isFocused, + onClear, xtermRef, }: UseTerminalHotkeysOptions): UseTerminalHotkeysReturn { const [isSearchOpen, setIsSearchOpen] = useState(false); @@ -34,14 +36,17 @@ export function useTerminalHotkeys({ } }, [isFocused, xtermRef]); - useAppHotkey( - "FIND_IN_TERMINAL", - () => setIsSearchOpen((prev) => !prev), - { enabled: isFocused, preventDefault: true }, - [isFocused], - ); + useHotkey("FIND_IN_TERMINAL", () => setIsSearchOpen((prev) => !prev), { + enabled: isFocused, + preventDefault: true, + }); + + useHotkey("CLEAR_TERMINAL", onClear, { + enabled: isFocused, + preventDefault: true, + }); - useAppHotkey( + useHotkey( "SCROLL_TO_BOTTOM", () => { if (xtermRef.current) { @@ -49,7 +54,6 @@ export function useTerminalHotkeys({ } }, { enabled: isFocused, preventDefault: true }, - [isFocused], ); return { isSearchOpen, setIsSearchOpen }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.test.ts index 4f6ae97a702..0505472946d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.test.ts @@ -22,7 +22,6 @@ * duration instead of silently returning. */ import { describe, expect, it } from "bun:test"; -import { shouldKeepAttachAliveOnUnmount } from "./attach-unmount"; // --------------------------------------------------------------------------- // Minimal model of the scheduleReattachRecovery throttle mechanism. @@ -169,49 +168,3 @@ describe("scheduleReattachRecovery throttle — issue #1873", () => { expect(calls).toBe(1); }); }); - -describe("shouldKeepAttachAliveOnUnmount", () => { - it("keeps a starting workspace-run attach alive while the pane is hidden", () => { - expect( - shouldKeepAttachAliveOnUnmount({ - paneDestroyed: false, - hasWorkspaceRun: true, - isStartingWorkspaceRun: true, - hasActiveAttachRequest: false, - }), - ).toBe(true); - }); - - it("keeps an in-flight workspace-run attach alive while the pane is hidden", () => { - expect( - shouldKeepAttachAliveOnUnmount({ - paneDestroyed: false, - hasWorkspaceRun: true, - isStartingWorkspaceRun: false, - hasActiveAttachRequest: true, - }), - ).toBe(true); - }); - - it("still cancels hidden non-workspace-run attaches", () => { - expect( - shouldKeepAttachAliveOnUnmount({ - paneDestroyed: false, - hasWorkspaceRun: false, - isStartingWorkspaceRun: true, - hasActiveAttachRequest: true, - }), - ).toBe(false); - }); - - it("still cancels when the pane was explicitly destroyed", () => { - expect( - shouldKeepAttachAliveOnUnmount({ - paneDestroyed: true, - hasWorkspaceRun: true, - isStartingWorkspaceRun: true, - hasActiveAttachRequest: true, - }), - ).toBe(false); - }); -}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts index ea6899f7470..983f4772e82 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalLifecycle.ts @@ -1,9 +1,15 @@ import type { FitAddon } from "@xterm/addon-fit"; -import { SearchAddon } from "@xterm/addon-search"; +import type { SearchAddon } from "@xterm/addon-search"; import type { IDisposable, ITheme, Terminal as XTerm } from "@xterm/xterm"; import type { MutableRefObject, RefObject } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { writeCommandInPane } from "renderer/lib/terminal/launch-command"; +import type { DetectedLink } from "renderer/lib/terminal/links"; +import { + clearTerminalSessionReady, + markTerminalSessionReady, + rejectTerminalSessionReady, +} from "renderer/lib/terminal/session-readiness"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { useTabsStore } from "renderer/stores/tabs/store"; import { killTerminalForPane } from "renderer/stores/tabs/utils/terminal-cleanup"; @@ -12,29 +18,24 @@ import { scheduleTerminalAttach } from "../attach-scheduler"; import { isCommandEchoed, sanitizeForTitle } from "../commandBuffer"; import { DEBUG_TERMINAL, FIRST_RENDER_RESTORE_FALLBACK_MS } from "../config"; import { - createTerminalInstance, setupClickToMoveCursor, setupCopyHandler, setupFocusListener, - setupKeyboardHandler, - setupPasteHandler, - setupResizeHandlers, - type TerminalRendererRef, } from "../helpers"; import { isPaneDestroyed } from "../pane-guards"; import { coldRestoreState, pendingDetaches } from "../state"; +import { setupKeyboardHandler } from "../terminalKeyboardHandler"; import type { CreateOrAttachMutate, CreateOrAttachResult, TerminalCancelCreateOrAttachMutate, TerminalClearScrollbackMutate, - TerminalDetachMutate, TerminalResizeMutate, TerminalWriteMutate, } from "../types"; import { scrollToBottom } from "../utils"; +import * as v1TerminalCache from "../v1-terminal-cache"; import { createAttachRequestId } from "./attach-request-id"; -import { shouldKeepAttachAliveOnUnmount } from "./attach-unmount"; import { getPaneWorkspaceRun, hasPaneWorkspaceRun, @@ -97,7 +98,6 @@ export interface UseTerminalLifecycleOptions { xtermRef: MutableRefObject<XTerm | null>; fitAddonRef: MutableRefObject<FitAddon | null>; searchAddonRef: MutableRefObject<SearchAddon | null>; - rendererRef: MutableRefObject<TerminalRendererRef | null>; isExitedRef: MutableRefObject<boolean>; wasKilledByUserRef: MutableRefObject<boolean>; commandBufferRef: MutableRefObject<string>; @@ -105,9 +105,8 @@ export interface UseTerminalLifecycleOptions { isRestoredModeRef: MutableRefObject<boolean>; connectionErrorRef: MutableRefObject<string | null>; initialThemeRef: MutableRefObject<ITheme | null>; - workspaceCwdRef: MutableRefObject<string | null>; handleFileLinkClickRef: MutableRefObject< - (path: string, line?: number, column?: number) => void + (event: MouseEvent, link: DetectedLink) => void >; handleUrlClickRef: MutableRefObject<((url: string) => void) | undefined>; paneInitialCwdRef: MutableRefObject<string | undefined>; @@ -119,7 +118,6 @@ export interface UseTerminalLifecycleOptions { createOrAttachRef: MutableRefObject<CreateOrAttachMutate>; writeRef: MutableRefObject<TerminalWriteMutate>; resizeRef: MutableRefObject<TerminalResizeMutate>; - detachRef: MutableRefObject<TerminalDetachMutate>; cancelCreateOrAttachRef: MutableRefObject<TerminalCancelCreateOrAttachMutate>; clearScrollbackRef: MutableRefObject<TerminalClearScrollbackMutate>; isStreamReadyRef: MutableRefObject<boolean>; @@ -129,7 +127,6 @@ export interface UseTerminalLifecycleOptions { flushPendingEvents: () => void; resetModes: () => void; isAlternateScreenRef: MutableRefObject<boolean>; - isBracketedPasteRef: MutableRefObject<boolean>; setPaneNameRef: MutableRefObject<(paneId: string, name: string) => void>; renameUnnamedWorkspaceRef: MutableRefObject<(title: string) => void>; handleTerminalFocusRef: MutableRefObject<() => void>; @@ -164,7 +161,6 @@ export function useTerminalLifecycle({ xtermRef, fitAddonRef, searchAddonRef, - rendererRef, isExitedRef, wasKilledByUserRef, commandBufferRef, @@ -172,7 +168,6 @@ export function useTerminalLifecycle({ isRestoredModeRef, connectionErrorRef, initialThemeRef, - workspaceCwdRef, handleFileLinkClickRef, handleUrlClickRef, paneInitialCwdRef, @@ -184,7 +179,6 @@ export function useTerminalLifecycle({ createOrAttachRef, writeRef, resizeRef, - detachRef, cancelCreateOrAttachRef, clearScrollbackRef, isStreamReadyRef, @@ -194,7 +188,6 @@ export function useTerminalLifecycle({ flushPendingEvents, resetModes, isAlternateScreenRef, - isBracketedPasteRef, setPaneNameRef, renameUnnamedWorkspaceRef, handleTerminalFocusRef, @@ -241,19 +234,55 @@ export function useTerminalLifecycle({ let activeAttachRequestId: string | null = null; let cancelAttachWait: (() => void) | null = null; - const { - xterm, - fitAddon, - renderer, - cleanup: cleanupQuerySuppression, - } = createTerminalInstance(container, { - cwd: workspaceCwdRef.current ?? undefined, + // Use the v1 terminal cache: reuse existing xterm instance across tab + // switches instead of creating/disposing each time (v2 "hide attach" pattern). + // Only treat as reattach when the prior mount actually completed attach — + // a cache entry can exist with streamReady=false if the previous mount + // unmounted before createOrAttach finished (e.g. bulk tab creation where + // React remounts a pane mid-attach). Taking the reattach fast path in + // that state leaves the pane permanently disconnected with no daemon + // session and no stream subscription. + const cachedBeforeCreate = v1TerminalCache.get(paneId); + const isReattach = cachedBeforeCreate?.streamReady === true; + if (DEBUG_TERMINAL) { + console.log(`[Terminal] isReattach=${isReattach} paneId=${paneId}`); + } + const cached = v1TerminalCache.getOrCreate(paneId, { + workspaceId, initialTheme: initialThemeRef.current, - onFileLinkClick: (path, line, column) => - handleFileLinkClickRef.current(path, line, column), + onFileLinkClick: (event, link) => + handleFileLinkClickRef.current(event, link), onUrlClickRef: handleUrlClickRef, }); + const { xterm, fitAddon, searchAddon } = cached; + + // Called after createOrAttach resolves: re-fit against the now-settled + // container and push dims to the backend. Guards against stale sizes + // from attachToContainer's fit running before flex layout resolved + // (e.g. preset tabs, new workspace bulk creation). Mirrors v2's + // terminal-ws-transport sendResize-on-open. + const syncBackendDimensions = () => { + if (container.clientWidth === 0 || container.clientHeight === 0) return; + fitAddon.fit(); + resizeRef.current({ paneId, cols: xterm.cols, rows: xterm.rows }); + }; + + // Attach the wrapper div to the live container. + // The cache creates a ResizeObserver that calls fitAddon.fit() and + // forwards resize events to the backend — no separate resize handler needed. + const prevCols = xterm.cols; + const prevRows = xterm.rows; + v1TerminalCache.attachToContainer(paneId, container, () => { + resizeRef.current({ paneId, cols: xterm.cols, rows: xterm.rows }); + }); + // If dimensions changed during attach (container resized while hidden), + // notify the backend PTY immediately — the ResizeObserver only fires on + // subsequent changes, not the initial fit. + if (xterm.cols !== prevCols || xterm.rows !== prevRows) { + resizeRef.current({ paneId, cols: xterm.cols, rows: xterm.rows }); + } + const scheduleScrollToBottom = () => { requestAnimationFrame(() => { if (isUnmounted || xtermRef.current !== xterm) return; @@ -263,43 +292,43 @@ export function useTerminalLifecycle({ xtermRef.current = xterm; fitAddonRef.current = fitAddon; - rendererRef.current = renderer; + searchAddonRef.current = searchAddon; isExitedRef.current = false; setXtermInstance(xterm); isStreamReadyRef.current = false; - didFirstRenderRef.current = false; pendingInitialStateRef.current = null; if (isFocusedRef.current) { xterm.focus(); } - if (!isUnmounted) { - const searchAddon = new SearchAddon(); - xterm.loadAddon(searchAddon); - searchAddonRef.current = searchAddon; - } - - // Wait for first render before applying restoration + // Wait for first render before applying restoration. + // On reattach, xterm is already rendered so skip the render gate. let renderDisposable: IDisposable | null = null; let firstRenderFallback: ReturnType<typeof setTimeout> | null = null; - renderDisposable = xterm.onRender(() => { - if (firstRenderFallback) { - clearTimeout(firstRenderFallback); - firstRenderFallback = null; - } - renderDisposable?.dispose(); - renderDisposable = null; + if (isReattach) { didFirstRenderRef.current = true; - maybeApplyInitialState(); - }); + } else { + didFirstRenderRef.current = false; - firstRenderFallback = setTimeout(() => { - if (isUnmounted || didFirstRenderRef.current) return; - didFirstRenderRef.current = true; - maybeApplyInitialState(); - }, FIRST_RENDER_RESTORE_FALLBACK_MS); + renderDisposable = xterm.onRender(() => { + if (firstRenderFallback) { + clearTimeout(firstRenderFallback); + firstRenderFallback = null; + } + renderDisposable?.dispose(); + renderDisposable = null; + didFirstRenderRef.current = true; + maybeApplyInitialState(); + }); + + firstRenderFallback = setTimeout(() => { + if (isUnmounted || didFirstRenderRef.current) return; + didFirstRenderRef.current = true; + maybeApplyInitialState(); + }, FIRST_RENDER_RESTORE_FALLBACK_MS); + } const nextAttachRequestId = () => createAttachRequestId(paneId); const cancelAttachRequest = (requestId: string | null) => { @@ -343,6 +372,7 @@ export function useTerminalLifecycle({ const requestId = nextAttachRequestId(); cancelAttachRequest(activeAttachRequestId); activeAttachRequestId = requestId; + clearTerminalSessionReady(paneId); createOrAttachRef.current( { paneId, @@ -361,6 +391,7 @@ export function useTerminalLifecycle({ return; } setConnectionError(null); + syncBackendDimensions(); pendingInitialStateRef.current = result; maybeApplyInitialState(); if (!command) { @@ -505,176 +536,203 @@ export function useTerminalLifecycle({ restartCommand: workspaceRunRestartCommand, } = resolveWorkspaceRunAttachMode(paneId, defaultRestartCommandRef.current); - const cancelInitialAttach = scheduleTerminalAttach({ - paneId, - priority: isFocusedRef.current ? 0 : 1, - run: (done) => { - const startAttach = (commandToRunAfterAttach?: string) => { - if (attachCanceled) return; - if (attachInFlightByPane.has(paneId)) { - cancelAttachWait = waitForAttachClear(paneId, () => { - if (attachCanceled || isUnmounted) return; - startAttach(commandToRunAfterAttach); - }); - return; - } + // On reattach: stream is already running and xterm buffer is current. + // Skip the entire createOrAttach + stream setup. + let cancelInitialAttach: (() => void) | null = null; - const requestId = nextAttachRequestId(); - cancelAttachRequest(activeAttachRequestId); - activeAttachRequestId = requestId; - activeAttachId = ++attachSequence; - const attachId = activeAttachId; - const isAttachActive = () => - !isUnmounted && !attachCanceled && attachId === activeAttachId; + if (isReattach) { + // Stream is ready — the cache has been writing data to xterm. + // Resize is handled by attachToContainer's ResizeObserver above. + isStreamReadyRef.current = true; + } else { + cancelInitialAttach = scheduleTerminalAttach({ + paneId, + priority: isFocusedRef.current ? 0 : 1, + run: (done) => { + const startAttach = (commandToRunAfterAttach?: string) => { + if (attachCanceled) return; + if (attachInFlightByPane.has(paneId)) { + cancelAttachWait = waitForAttachClear(paneId, () => { + if (attachCanceled || isUnmounted) return; + startAttach(commandToRunAfterAttach); + }); + return; + } - markAttachInFlight(paneId, attachId); + const requestId = nextAttachRequestId(); + cancelAttachRequest(activeAttachRequestId); + activeAttachRequestId = requestId; + activeAttachId = ++attachSequence; + const attachId = activeAttachId; + const isAttachActive = () => + !isUnmounted && !attachCanceled && attachId === activeAttachId; - const finishAttach = () => { - clearAttachInFlight(paneId, attachId); - done(); - }; + markAttachInFlight(paneId, attachId); + clearTerminalSessionReady(paneId); - if (DEBUG_TERMINAL) { - console.log(`[Terminal] createOrAttach start: ${paneId}`); - } - createOrAttachRef.current( - { - paneId, - requestId, - tabId: tabIdRef.current, - workspaceId, - cols: xterm.cols, - rows: xterm.rows, - cwd: initialCwd, - ...((isNewWorkspaceRun || Boolean(commandToRunAfterAttach)) && { - skipColdRestore: true, - }), - }, - { - onSuccess: (result) => { - if (!isAttachActive()) return; - if (activeAttachRequestId !== requestId) return; - setConnectionError(null); - clearPaneInitialDataRef.current(paneId); - - const storedColdRestore = coldRestoreState.get(paneId); - if (storedColdRestore?.isRestored) { - setIsRestoredMode(true); - setRestoredCwd(storedColdRestore.cwd); - if (storedColdRestore.scrollback && xterm) { - xterm.write( - storedColdRestore.scrollback, - scheduleScrollToBottom, - ); + const finishAttach = () => { + clearAttachInFlight(paneId, attachId); + done(); + }; + + if (DEBUG_TERMINAL) { + console.log(`[Terminal] createOrAttach start: ${paneId}`); + } + createOrAttachRef.current( + { + paneId, + requestId, + tabId: tabIdRef.current, + workspaceId, + cols: xterm.cols, + rows: xterm.rows, + cwd: initialCwd, + ...((isNewWorkspaceRun || Boolean(commandToRunAfterAttach)) && { + skipColdRestore: true, + }), + }, + { + onSuccess: (result) => { + if (!isAttachActive()) return; + if (activeAttachRequestId !== requestId) return; + setConnectionError(null); + clearPaneInitialDataRef.current(paneId); + + // Start the cache-owned stream subscription now that the + // backend session exists, and mark it ready so events + // flow through the component's registered handler. + v1TerminalCache.startStream(paneId); + v1TerminalCache.setStreamReady(paneId); + markTerminalSessionReady(paneId); + syncBackendDimensions(); + + const storedColdRestore = coldRestoreState.get(paneId); + if (storedColdRestore?.isRestored) { + setIsRestoredMode(true); + setRestoredCwd(storedColdRestore.cwd); + if (storedColdRestore.scrollback && xterm) { + xterm.write( + storedColdRestore.scrollback, + scheduleScrollToBottom, + ); + } + didFirstRenderRef.current = true; + return; } - didFirstRenderRef.current = true; - return; - } - if (result.isColdRestore) { - const scrollback = - result.snapshot?.snapshotAnsi ?? result.scrollback; - coldRestoreState.set(paneId, { - isRestored: true, - cwd: result.previousCwd || null, - scrollback, - }); - setIsRestoredMode(true); - setRestoredCwd(result.previousCwd || null); - if (scrollback && xterm) { - xterm.write(scrollback, scheduleScrollToBottom); + if (result.isColdRestore) { + const scrollback = + result.snapshot?.snapshotAnsi ?? result.scrollback; + coldRestoreState.set(paneId, { + isRestored: true, + cwd: result.previousCwd || null, + scrollback, + }); + setIsRestoredMode(true); + setRestoredCwd(result.previousCwd || null); + if (scrollback && xterm) { + xterm.write(scrollback, scheduleScrollToBottom); + } + didFirstRenderRef.current = true; + return; } - didFirstRenderRef.current = true; - return; - } - pendingInitialStateRef.current = result; - maybeApplyInitialState(); + pendingInitialStateRef.current = result; + maybeApplyInitialState(); - if (!commandToRunAfterAttach) { - return; - } + if (!commandToRunAfterAttach) { + return; + } - void writeWorkspaceRunCommand(commandToRunAfterAttach).catch( - (error) => { - console.error( - "[Terminal] Failed to write workspace run command after attach:", - error, + void writeWorkspaceRunCommand(commandToRunAfterAttach).catch( + (error) => { + console.error( + "[Terminal] Failed to write workspace run command after attach:", + error, + ); + if (paneWorkspaceRun) { + setPaneWorkspaceRunState(paneId, "stopped-by-exit"); + } + setConnectionError( + error instanceof Error + ? error.message + : "Failed to write workspace run command", + ); + isStreamReadyRef.current = true; + flushPendingEvents(); + }, + ); + }, + onError: (error) => { + if (!isAttachActive()) return; + if (activeAttachRequestId !== requestId) return; + if (isTerminalAttachCanceledMessage(error.message)) { + return; + } + const workspaceRun = getPaneWorkspaceRun(paneId); + if (error.message?.includes("TERMINAL_SESSION_KILLED")) { + rejectTerminalSessionReady( + paneId, + new Error(error.message || "Terminal session killed"), ); - if (paneWorkspaceRun) { - setPaneWorkspaceRunState(paneId, "stopped-by-exit"); + if (workspaceRun) { + setPaneWorkspaceRunState(paneId, "stopped-by-user"); } - setConnectionError( - error instanceof Error - ? error.message - : "Failed to write workspace run command", - ); - isStreamReadyRef.current = true; - flushPendingEvents(); - }, - ); - }, - onError: (error) => { - if (!isAttachActive()) return; - if (activeAttachRequestId !== requestId) return; - if (isTerminalAttachCanceledMessage(error.message)) { - return; - } - const workspaceRun = getPaneWorkspaceRun(paneId); - if (error.message?.includes("TERMINAL_SESSION_KILLED")) { + wasKilledByUserRef.current = true; + isExitedRef.current = true; + isStreamReadyRef.current = false; + setExitStatus("killed"); + setConnectionError(null); + return; + } + console.error("[Terminal] Failed to create/attach:", error); + rejectTerminalSessionReady( + paneId, + new Error(error.message || "Failed to connect to terminal"), + ); if (workspaceRun) { - setPaneWorkspaceRunState(paneId, "stopped-by-user"); + setPaneWorkspaceRunState(paneId, "stopped-by-exit"); } - wasKilledByUserRef.current = true; - isExitedRef.current = true; - isStreamReadyRef.current = false; - setExitStatus("killed"); - setConnectionError(null); - return; - } - console.error("[Terminal] Failed to create/attach:", error); - if (workspaceRun) { - setPaneWorkspaceRunState(paneId, "stopped-by-exit"); - } - setConnectionError( - error.message || "Failed to connect to terminal", - ); - isStreamReadyRef.current = true; - flushPendingEvents(); - }, - onSettled: () => { - if (activeAttachRequestId === requestId) { - activeAttachRequestId = null; - } - finishAttach(); + setConnectionError( + error.message || "Failed to connect to terminal", + ); + isStreamReadyRef.current = true; + flushPendingEvents(); + }, + onSettled: () => { + if (activeAttachRequestId === requestId) { + activeAttachRequestId = null; + } + finishAttach(); + }, }, - }, - ); - }; + ); + }; - // Handle workspace-run panes that need recovery (stopped or stale "running" after restart) - if (paneWorkspaceRun && !isNewWorkspaceRun) { - void recoverWorkspaceRunPane({ - paneId, - workspaceRun: paneWorkspaceRun, - isNewWorkspaceRun, - xterm, - shouldAbort: () => isUnmounted || attachCanceled, - startAttach, - done, - isExitedRef, - wasKilledByUserRef, - isStreamReadyRef, - setExitStatus, - restartCommand: workspaceRunRestartCommand, - }); - return; - } + // Handle workspace-run panes that need recovery (stopped or stale "running" after restart) + if (paneWorkspaceRun && !isNewWorkspaceRun) { + void recoverWorkspaceRunPane({ + paneId, + workspaceRun: paneWorkspaceRun, + isNewWorkspaceRun, + xterm, + shouldAbort: () => isUnmounted || attachCanceled, + startAttach, + done, + isExitedRef, + wasKilledByUserRef, + isStreamReadyRef, + setExitStatus, + restartCommand: workspaceRunRestartCommand, + }); + return; + } - startAttach(); - return; - }, - }); + startAttach(); + return; + }, + }); + } // end if (!isReattach) const inputDisposable = xterm.onData(handleTerminalInput); const keyDisposable = xterm.onKey(handleKeyPress); @@ -699,7 +757,6 @@ export function useTerminalLifecycle({ const cleanupKeyboard = setupKeyboardHandler(xterm, { onShiftEnter: () => handleWrite("\x1b\r"), - onClear: handleClear, onWrite: handleWrite, }); const cleanupClickToMove = setupClickToMoveCursor(xterm, { @@ -728,196 +785,66 @@ export function useTerminalLifecycle({ const cleanupFocus = setupFocusListener(xterm, () => handleTerminalFocusRef.current(), ); - const cleanupResize = setupResizeHandlers( - container, - xterm, - fitAddon, - (cols, rows) => resizeRef.current({ paneId, cols, rows }), - ); - const cleanupPaste = setupPasteHandler(xterm, { - onPaste: (text) => { - commandBufferRef.current += text; - }, - onWrite: handleWrite, - isBracketedPasteEnabled: () => isBracketedPasteRef.current, - }); const cleanupCopy = setupCopyHandler(xterm); - const reattachRecovery = { - throttleMs: 120, - pendingFrame: null as number | null, - lastRunAt: 0, - pendingForceResize: false, - }; - - const isCurrentTerminalRenderable = () => { - if (isUnmounted || xtermRef.current !== xterm) return false; - if (!container.isConnected) return false; - - const style = window.getComputedStyle(container); - if (style.display === "none" || style.visibility === "hidden") { - return false; - } - - const rect = container.getBoundingClientRect(); - return rect.width > 1 && rect.height > 1; - }; - - const runReattachRecovery = (forceResize: boolean) => { - if (!isCurrentTerminalRenderable()) return; - - const prevCols = xterm.cols; - const prevRows = xterm.rows; - const wasAtBottom = - xterm.buffer.active.viewportY >= xterm.buffer.active.baseY; - - // Rebuild stale WebGL glyph cache after occlusion and force a paint pass. - rendererRef.current?.current.clearTextureAtlas?.(); - - fitAddon.fit(); - xterm.refresh(0, Math.max(0, xterm.rows - 1)); - - if (forceResize || xterm.cols !== prevCols || xterm.rows !== prevRows) { - resizeRef.current({ paneId, cols: xterm.cols, rows: xterm.rows }); - } - - if (isFocusedRef.current && document.hasFocus()) { - xterm.focus(); - } - - if (!wasAtBottom) return; - requestAnimationFrame(() => { - if (isUnmounted || xtermRef.current !== xterm) return; - scrollToBottom(xterm); - }); - }; - - const scheduleReattachRecovery = (forceResize: boolean) => { - reattachRecovery.pendingForceResize ||= forceResize; - if (reattachRecovery.pendingFrame !== null) return; - - reattachRecovery.pendingFrame = requestAnimationFrame(() => { - reattachRecovery.pendingFrame = null; - - const now = Date.now(); - if (now - reattachRecovery.lastRunAt < reattachRecovery.throttleMs) { - // Schedule a retry after the remaining throttle window so the recovery - // is not permanently lost when focus events fire in rapid succession. - const remaining = - reattachRecovery.throttleMs - (now - reattachRecovery.lastRunAt); - setTimeout(() => { - if (!isUnmounted) - scheduleReattachRecovery(reattachRecovery.pendingForceResize); - }, remaining + 1); - return; - } - reattachRecovery.lastRunAt = now; - - const shouldForceResize = reattachRecovery.pendingForceResize; - reattachRecovery.pendingForceResize = false; - runReattachRecovery(shouldForceResize); - }); - }; - - const cancelReattachRecovery = () => { - if (reattachRecovery.pendingFrame === null) return; - cancelAnimationFrame(reattachRecovery.pendingFrame); - reattachRecovery.pendingFrame = null; - }; - - const handleVisibilityChange = () => { - if (document.hidden) return; - scheduleReattachRecovery(isFocusedRef.current); - }; - const handleWindowFocus = () => { - scheduleReattachRecovery(isFocusedRef.current); - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("focus", handleWindowFocus); const isPaneDestroyedInStore = () => isPaneDestroyed(useTabsStore.getState().panes, paneId); return () => { - if (DEBUG_TERMINAL) { - console.log(`[Terminal] Unmount: ${paneId}`); - } const paneDestroyed = isPaneDestroyedInStore(); - const hasWorkspaceRun = hasPaneWorkspaceRun(paneId); - const keepAttachAlive = shouldKeepAttachAliveOnUnmount({ - paneDestroyed, - hasWorkspaceRun, - isStartingWorkspaceRun: isNewWorkspaceRun, - hasActiveAttachRequest: activeAttachRequestId !== null, - }); - - if (!keepAttachAlive) { - cancelInitialAttach(); + if (DEBUG_TERMINAL) { + console.log( + `[Terminal] Unmount: ${paneId}, paneDestroyed=${paneDestroyed}`, + ); } + cancelInitialAttach?.(); isUnmounted = true; - attachCanceled = !keepAttachAlive; - if (!keepAttachAlive) { - cancelAttachRequest(activeAttachRequestId); - } + attachCanceled = true; + cancelAttachRequest(activeAttachRequestId); activeAttachRequestId = null; - const cleanupAttachId = !keepAttachAlive - ? activeAttachId || undefined - : undefined; + const cleanupAttachId = activeAttachId || undefined; activeAttachId = 0; if (cancelAttachWait) { cancelAttachWait(); cancelAttachWait = null; } - if (!keepAttachAlive) { - clearAttachInFlight(paneId, cleanupAttachId); - } + clearAttachInFlight(paneId, cleanupAttachId); if (firstRenderFallback) clearTimeout(firstRenderFallback); - cancelReattachRecovery(); - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("focus", handleWindowFocus); inputDisposable.dispose(); keyDisposable.dispose(); titleDisposable.dispose(); cleanupKeyboard(); cleanupClickToMove(); cleanupFocus?.(); - cleanupResize(); - cleanupPaste(); cleanupCopy(); - cleanupQuerySuppression(); unregisterClearCallbackRef.current(paneId); unregisterScrollToBottomCallbackRef.current(paneId); unregisterGetSelectionCallbackRef.current(paneId); unregisterPasteCallbackRef.current(paneId); if (paneDestroyed) { - // Pane was explicitly destroyed, so kill the session. + // Pane was explicitly destroyed — full cleanup. killTerminalForPane(paneId); coldRestoreState.delete(paneId); pendingDetaches.delete(paneId); - } else if (hasWorkspaceRun) { - // Keep workspace-run panes attached while hidden - pendingDetaches.delete(paneId); + v1TerminalCache.dispose(paneId); } else { - const detachTimeout = setTimeout(() => { - detachRef.current({ paneId }); - pendingDetaches.delete(paneId); - coldRestoreState.delete(paneId); - }, 50); - pendingDetaches.set(paneId, detachTimeout); + // Pane hidden (tab switch) — detach wrapper from DOM but keep + // xterm AND stream subscription alive in the cache. + // No backend detach — the session stays connected so data + // continues flowing to xterm while hidden. + v1TerminalCache.detachFromContainer(paneId); } - isStreamReadyRef.current = false; - didFirstRenderRef.current = false; pendingInitialStateRef.current = null; resetModes(); renderDisposable?.dispose(); - setTimeout(() => xterm.dispose(), 0); + // Do NOT dispose xterm or reset stream state — the cache owns + // both the xterm lifecycle and the stream subscription. xtermRef.current = null; searchAddonRef.current = null; - rendererRef.current = null; setXtermInstance(null); }; }, [ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts index 429e7735b1f..457c2641ff7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRefs.ts @@ -1,6 +1,7 @@ import type { ITheme } from "@xterm/xterm"; import type { MutableRefObject } from "react"; import { useRef } from "react"; +import type { DetectedLink } from "renderer/lib/terminal/links"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; type RegisterCallback = (paneId: string, callback: () => void) => void; @@ -21,8 +22,7 @@ export interface UseTerminalRefsOptions { terminalTheme: ITheme | null; paneInitialCwd?: string; clearPaneInitialData: (paneId: string) => void; - workspaceCwd: string | null | undefined; - handleFileLinkClick: (path: string, line?: number, column?: number) => void; + handleFileLinkClick: (event: MouseEvent, link: DetectedLink) => void; setPaneName: (paneId: string, name: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; } @@ -33,9 +33,8 @@ export interface UseTerminalRefsReturn { initialThemeRef: MutableRefObject<ITheme | null>; paneInitialCwdRef: MutableRefObject<string | undefined>; clearPaneInitialDataRef: MutableRefObject<(paneId: string) => void>; - workspaceCwdRef: MutableRefObject<string | null>; handleFileLinkClickRef: MutableRefObject< - (path: string, line?: number, column?: number) => void + (event: MouseEvent, link: DetectedLink) => void >; setPaneNameRef: MutableRefObject<(paneId: string, name: string) => void>; handleTerminalFocusRef: MutableRefObject<() => void>; @@ -56,7 +55,6 @@ export function useTerminalRefs({ terminalTheme, paneInitialCwd, clearPaneInitialData, - workspaceCwd, handleFileLinkClick, setPaneName, setFocusedPane, @@ -71,9 +69,6 @@ export function useTerminalRefs({ paneInitialCwdRef.current = paneInitialCwd; clearPaneInitialDataRef.current = clearPaneInitialData; - const workspaceCwdRef = useRef<string | null>(workspaceCwd ?? null); - workspaceCwdRef.current = workspaceCwd ?? null; - const handleFileLinkClickRef = useRef(handleFileLinkClick); handleFileLinkClickRef.current = handleFileLinkClick; @@ -116,7 +111,6 @@ export function useTerminalRefs({ initialThemeRef, paneInitialCwdRef, clearPaneInitialDataRef, - workspaceCwdRef, handleFileLinkClickRef, setPaneNameRef, handleTerminalFocusRef, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts index 41e1296d53f..cb52334f97d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts @@ -120,11 +120,10 @@ export function useTerminalRestore({ ++restoreSequenceRef.current; const restoreSequence = restoreSequenceRef.current; try { - const scheduleFitAndScroll = () => { + const scheduleScrollToBottom = () => { requestAnimationFrame(() => { if (xtermRef.current !== xterm) return; if (restoreSequenceRef.current !== restoreSequence) return; - fitAddon.fit(); scrollToBottom(xterm); }); }; @@ -185,7 +184,7 @@ export function useTerminalRestore({ } flushPendingEvents(); - scheduleFitAndScroll(); + scheduleScrollToBottom(); }); if (result.snapshot?.cwd) { @@ -200,7 +199,7 @@ export function useTerminalRestore({ const finalizeRestore = () => { isStreamReadyRef.current = true; - scheduleFitAndScroll(); + scheduleScrollToBottom(); if (DEBUG_TERMINAL) { console.log( `[Terminal] isStreamReady=true (finalizeRestore): ${paneId}, pendingEvents=${pendingEventsRef.current.length}`, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/workspaceRun.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/workspaceRun.test.ts deleted file mode 100644 index c992510f449..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/workspaceRun.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; - -const mockGetSessionQuery = mock(); - -const storeState = { - panes: {} as Record< - string, - { - workspaceRun?: { - workspaceId: string; - state: "running" | "stopped-by-user" | "stopped-by-exit"; - command?: string; - }; - } - >, - setPaneWorkspaceRun: mock( - ( - paneId: string, - workspaceRun: { - workspaceId: string; - state: "running" | "stopped-by-user" | "stopped-by-exit"; - command?: string; - } | null, - ) => { - if (!storeState.panes[paneId]) { - storeState.panes[paneId] = {}; - } - storeState.panes[paneId].workspaceRun = workspaceRun ?? undefined; - }, - ), -}; - -mock.module("renderer/lib/trpc-client", () => ({ - electronTrpcClient: { - terminal: { - getSession: { - query: mockGetSessionQuery, - }, - }, - }, -})); - -mock.module("renderer/stores/tabs/store", () => ({ - useTabsStore: { - getState: () => storeState, - }, -})); - -const { recoverWorkspaceRunPane, setPaneWorkspaceRunState } = await import( - "./workspaceRun" -); - -describe("recoverWorkspaceRunPane", () => { - beforeEach(() => { - mockGetSessionQuery.mockReset(); - storeState.panes = {}; - storeState.setPaneWorkspaceRun.mockClear(); - }); - - afterAll(() => { - mock.restore(); - }); - - it("reattaches panes stopped by user when the shell is still alive", async () => { - storeState.panes["pane-1"] = { - workspaceRun: { - workspaceId: "ws-1", - state: "stopped-by-user", - }, - }; - mockGetSessionQuery.mockResolvedValueOnce({ - isAlive: true, - cwd: "/tmp/ws-1", - lastActive: Date.now(), - }); - - const xterm = { writeln: mock(() => {}) }; - const done = mock(() => {}); - const startAttach = mock(() => {}); - const setExitStatus = mock(() => {}); - const isExitedRef = { current: false }; - const wasKilledByUserRef = { current: false }; - const isStreamReadyRef = { current: false }; - const workspaceRun = storeState.panes["pane-1"]?.workspaceRun; - if (!workspaceRun) { - throw new Error("Expected pane-1 workspaceRun to exist"); - } - - const handled = await recoverWorkspaceRunPane({ - paneId: "pane-1", - workspaceRun, - isNewWorkspaceRun: false, - xterm, - shouldAbort: () => false, - startAttach, - done, - isExitedRef, - wasKilledByUserRef, - isStreamReadyRef, - setExitStatus, - }); - - expect(handled).toBe(true); - expect(mockGetSessionQuery).toHaveBeenCalledWith("pane-1"); - expect(startAttach).toHaveBeenCalled(); - expect(isExitedRef.current).toBe(false); - expect(wasKilledByUserRef.current).toBe(false); - expect(isStreamReadyRef.current).toBe(false); - expect(setExitStatus).not.toHaveBeenCalled(); - expect(xterm.writeln).not.toHaveBeenCalled(); - expect(done).not.toHaveBeenCalled(); - }); - - it("shows exited state for panes stopped by user after the shell has exited", async () => { - storeState.panes["pane-1b"] = { - workspaceRun: { - workspaceId: "ws-1b", - state: "stopped-by-user", - }, - }; - mockGetSessionQuery.mockResolvedValueOnce(null); - - const xterm = { writeln: mock(() => {}) }; - const done = mock(() => {}); - const startAttach = mock(() => {}); - const setExitStatus = mock(() => {}); - const isExitedRef = { current: false }; - const wasKilledByUserRef = { current: false }; - const isStreamReadyRef = { current: false }; - const workspaceRun = storeState.panes["pane-1b"]?.workspaceRun; - if (!workspaceRun) { - throw new Error("Expected pane-1b workspaceRun to exist"); - } - - const handled = await recoverWorkspaceRunPane({ - paneId: "pane-1b", - workspaceRun, - isNewWorkspaceRun: false, - xterm, - shouldAbort: () => false, - startAttach, - done, - isExitedRef, - wasKilledByUserRef, - isStreamReadyRef, - setExitStatus, - }); - - expect(handled).toBe(true); - expect(mockGetSessionQuery).toHaveBeenCalledWith("pane-1b"); - expect(startAttach).not.toHaveBeenCalled(); - expect(isExitedRef.current).toBe(true); - expect(wasKilledByUserRef.current).toBe(true); - expect(isStreamReadyRef.current).toBe(true); - expect(setExitStatus).toHaveBeenCalledWith("killed"); - expect(xterm.writeln).toHaveBeenCalledWith("\r\n[Session killed]"); - expect(xterm.writeln).toHaveBeenCalledWith("[Press any key to restart]"); - expect(done).toHaveBeenCalled(); - }); - - it("falls back to attach when session inspection fails for running panes", async () => { - storeState.panes["pane-2"] = { - workspaceRun: { - workspaceId: "ws-2", - state: "running", - }, - }; - mockGetSessionQuery.mockRejectedValueOnce(new Error("transport down")); - - const xterm = { writeln: mock(() => {}) }; - const done = mock(() => {}); - const startAttach = mock(() => {}); - const setExitStatus = mock(() => {}); - const isExitedRef = { current: false }; - const wasKilledByUserRef = { current: false }; - const isStreamReadyRef = { current: false }; - const workspaceRun = storeState.panes["pane-2"]?.workspaceRun; - if (!workspaceRun) { - throw new Error("Expected pane-2 workspaceRun to exist"); - } - - const handled = await recoverWorkspaceRunPane({ - paneId: "pane-2", - workspaceRun, - isNewWorkspaceRun: false, - xterm, - shouldAbort: () => false, - startAttach, - done, - isExitedRef, - wasKilledByUserRef, - isStreamReadyRef, - setExitStatus, - }); - - expect(handled).toBe(true); - expect(startAttach).toHaveBeenCalled(); - expect(xterm.writeln).not.toHaveBeenCalled(); - expect(done).not.toHaveBeenCalled(); - expect(setExitStatus).not.toHaveBeenCalled(); - }); - - it("falls back to attach when session inspection fails for stopped panes", async () => { - storeState.panes["pane-2b"] = { - workspaceRun: { - workspaceId: "ws-2b", - state: "stopped-by-user", - }, - }; - mockGetSessionQuery.mockRejectedValueOnce(new Error("transport down")); - - const xterm = { writeln: mock(() => {}) }; - const done = mock(() => {}); - const startAttach = mock(() => {}); - const setExitStatus = mock(() => {}); - const isExitedRef = { current: false }; - const wasKilledByUserRef = { current: false }; - const isStreamReadyRef = { current: false }; - const workspaceRun = storeState.panes["pane-2b"]?.workspaceRun; - if (!workspaceRun) { - throw new Error("Expected pane-2b workspaceRun to exist"); - } - - const handled = await recoverWorkspaceRunPane({ - paneId: "pane-2b", - workspaceRun, - isNewWorkspaceRun: false, - xterm, - shouldAbort: () => false, - startAttach, - done, - isExitedRef, - wasKilledByUserRef, - isStreamReadyRef, - setExitStatus, - }); - - expect(handled).toBe(true); - expect(startAttach).toHaveBeenCalled(); - expect(xterm.writeln).not.toHaveBeenCalled(); - expect(done).not.toHaveBeenCalled(); - expect(setExitStatus).not.toHaveBeenCalled(); - }); - - it("restarts running panes when their session is gone and a restart command exists", async () => { - storeState.panes["pane-2c"] = { - workspaceRun: { - workspaceId: "ws-2c", - state: "running", - command: "bun run dev", - }, - }; - mockGetSessionQuery.mockResolvedValueOnce(null); - - const xterm = { writeln: mock(() => {}) }; - const done = mock(() => {}); - const startAttach = mock(() => {}); - const setExitStatus = mock(() => {}); - const isExitedRef = { current: false }; - const wasKilledByUserRef = { current: false }; - const isStreamReadyRef = { current: false }; - const workspaceRun = storeState.panes["pane-2c"]?.workspaceRun; - if (!workspaceRun) { - throw new Error("Expected pane-2c workspaceRun to exist"); - } - - const handled = await recoverWorkspaceRunPane({ - paneId: "pane-2c", - workspaceRun, - isNewWorkspaceRun: false, - xterm, - shouldAbort: () => false, - startAttach, - done, - isExitedRef, - wasKilledByUserRef, - isStreamReadyRef, - setExitStatus, - restartCommand: "bun run dev", - }); - - expect(handled).toBe(true); - expect(startAttach).toHaveBeenCalledWith("bun run dev"); - expect(xterm.writeln).not.toHaveBeenCalled(); - expect(done).not.toHaveBeenCalled(); - expect(setExitStatus).not.toHaveBeenCalled(); - expect(storeState.panes["pane-2c"]?.workspaceRun).toEqual({ - workspaceId: "ws-2c", - state: "running", - command: "bun run dev", - }); - }); - - it("preserves the stored run command when updating workspace-run state", () => { - storeState.panes["pane-3"] = { - workspaceRun: { - workspaceId: "ws-3", - state: "running", - command: "bun run dev", - }, - }; - - const updatedWorkspaceRun = setPaneWorkspaceRunState( - "pane-3", - "stopped-by-exit", - ); - - expect(updatedWorkspaceRun).toEqual({ - workspaceId: "ws-3", - state: "stopped-by-exit", - command: "bun run dev", - }); - expect(storeState.setPaneWorkspaceRun).toHaveBeenCalledWith("pane-3", { - workspaceId: "ws-3", - state: "stopped-by-exit", - command: "bun run dev", - }); - }); -}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.test.ts deleted file mode 100644 index a66e0065213..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.test.ts +++ /dev/null @@ -1,700 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import type { IBufferLine, ILink, Terminal } from "@xterm/xterm"; -import { FilePathLinkProvider } from "./file-path-link-provider"; - -function createMockLine(text: string, isWrapped = false): IBufferLine { - return { - translateToString: () => text, - isWrapped, - length: text.length, - getCell: mock(() => null), - getCells: mock(() => []), - } as unknown as IBufferLine; -} - -function createMockTerminal( - lines: Array<{ text: string; isWrapped?: boolean }>, -): Terminal { - const mockLines = lines.map((l) => - createMockLine(l.text, l.isWrapped ?? false), - ); - - return { - buffer: { - active: { - getLine: (index: number) => mockLines[index] ?? null, - }, - }, - element: { - style: { cursor: "" }, - }, - } as unknown as Terminal; -} - -function getLinks( - provider: FilePathLinkProvider, - lineNumber: number, -): Promise<ILink[]> { - return new Promise((resolve) => { - provider.provideLinks(lineNumber, (links) => { - resolve(links ?? []); - }); - }); -} - -describe("FilePathLinkProvider", () => { - describe("basic file path detection", () => { - it("should detect absolute paths", async () => { - const terminal = createMockTerminal([ - { text: "Error in /path/to/file.ts:10:5" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/to/file.ts:10:5"); - }); - - it("should detect relative paths starting with ./", async () => { - const terminal = createMockTerminal([{ text: "See ./src/utils.ts" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("./src/utils.ts"); - }); - - it("should detect relative paths starting with ../", async () => { - const terminal = createMockTerminal([ - { text: "Import from ../lib/helper.ts" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("../lib/helper.ts"); - }); - - it("should detect home directory paths", async () => { - const terminal = createMockTerminal([ - { text: "Config at ~/config/settings.json" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("~/config/settings.json"); - }); - - it("should detect paths with line and column numbers", async () => { - const terminal = createMockTerminal([{ text: "/path/file.ts:42:10" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/file.ts:42:10"); - }); - - it("should detect multiple paths on one line", async () => { - const terminal = createMockTerminal([ - { text: "Import ./src/a.ts and ./src/b.ts" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(2); - expect(links[0].text).toBe("./src/a.ts"); - expect(links[1].text).toBe("./src/b.ts"); - }); - }); - - describe("filtering false positives", () => { - it("should skip URLs with http://", async () => { - const terminal = createMockTerminal([ - { text: "Visit http://example.com/path" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(0); - }); - - it("should skip URLs with https://", async () => { - const terminal = createMockTerminal([ - { text: "Visit https://example.com/path" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(0); - }); - - it("should skip version strings", async () => { - const terminal = createMockTerminal([{ text: "Package v1.2.3" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(0); - }); - - it("should skip npm package references", async () => { - const terminal = createMockTerminal([ - { text: "lodash@4.17.21/index.js" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(0); - }); - - it("should skip pure numbers like 123:456", async () => { - // Note: "Line 123:456" is detected as a link to "Line" with row 123, col 456 - // because VSCode supports verbose formats like "foo line 339" - // We only skip patterns that are purely numeric with colons - const terminal = createMockTerminal([ - { text: "at position 123:456:789" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - // "position" will be detected with line 123, col 456 - // but pure "123:456:789" alone would not be detected as a path - expect(links.length).toBe(1); - expect(links[0].text).toBe("position 123:456"); - }); - }); - - describe("wrapped lines - forward looking (next line)", () => { - it("should detect path that spans current line and wrapped next line", async () => { - const terminal = createMockTerminal([ - { text: "/path/to/very/long/fi" }, - { text: "le/name.ts", isWrapped: true }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/to/very/long/file/name.ts"); - expect(links[0].range.start.y).toBe(1); - expect(links[0].range.end.y).toBe(2); - }); - - it("should calculate correct range for multi-line path starting on current line", async () => { - const terminal = createMockTerminal([ - { text: "/path/to/very/long/fi" }, - { text: "le/name.ts", isWrapped: true }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links[0].range.start.x).toBe(1); - expect(links[0].range.start.y).toBe(1); - expect(links[0].range.end.x).toBe(11); - expect(links[0].range.end.y).toBe(2); - }); - }); - - describe("wrapped lines - backward looking (previous line)", () => { - it("should detect path from previous line when current line is wrapped", async () => { - const terminal = createMockTerminal([ - { text: "/path/to/very/long/fi" }, - { text: "le/name.ts", isWrapped: true }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 2); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/to/very/long/file/name.ts"); - expect(links[0].range.start.y).toBe(1); - expect(links[0].range.end.y).toBe(2); - }); - - it("should handle clicking on wrapped portion of path", async () => { - const terminal = createMockTerminal([ - { text: "Error: /usr/local/lib/nod" }, - { text: "e_modules/pkg/index.js:10", isWrapped: true }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 2); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/usr/local/lib/node_modules/pkg/index.js:10"); - }); - }); - - describe("three-line wrapping", () => { - it("should handle path spanning three lines when scanned from middle", async () => { - const terminal = createMockTerminal([ - { text: "/path/to/ve" }, - { text: "ry/long/dir", isWrapped: true }, - { text: "/file.ts", isWrapped: true }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 2); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/to/very/long/dir/file.ts"); - }); - }); - - describe("non-wrapped lines", () => { - it("should not combine lines that are not wrapped", async () => { - const terminal = createMockTerminal([ - { text: "/path/one.ts" }, - { text: "/path/two.ts", isWrapped: false }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/one.ts"); - }); - - it("should handle paths on separate lines independently", async () => { - const terminal = createMockTerminal([ - { text: "/path/one.ts" }, - { text: "/path/two.ts", isWrapped: false }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links1 = await getLinks(provider, 1); - const links2 = await getLinks(provider, 2); - - expect(links1.length).toBe(1); - expect(links1[0].text).toBe("/path/one.ts"); - expect(links2.length).toBe(1); - expect(links2[0].text).toBe("/path/two.ts"); - }); - }); - - describe("handleActivation", () => { - it("should require metaKey (Cmd) for activation", async () => { - const terminal = createMockTerminal([{ text: "/path/file.ts" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - const mockEvent = { - metaKey: false, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - - links[0].activate(mockEvent, "/path/file.ts"); - - expect(onOpen).not.toHaveBeenCalled(); - }); - - it("should activate with metaKey (Cmd)", async () => { - const terminal = createMockTerminal([{ text: "/path/file.ts" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - - links[0].activate(mockEvent, "/path/file.ts"); - - expect(onOpen).toHaveBeenCalled(); - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - }); - - it("should activate with ctrlKey", async () => { - const terminal = createMockTerminal([{ text: "/path/file.ts" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - const mockEvent = { - metaKey: false, - ctrlKey: true, - preventDefault: mock(), - } as unknown as MouseEvent; - - links[0].activate(mockEvent, "/path/file.ts"); - - expect(onOpen).toHaveBeenCalled(); - }); - - it("should parse line and column from path", async () => { - const terminal = createMockTerminal([{ text: "/path/file.ts:42:10" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - - links[0].activate(mockEvent, "/path/file.ts:42:10"); - - expect(onOpen).toHaveBeenCalled(); - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - expect(onOpen.mock.calls[0][3]).toBe(10); - }); - }); - - describe("VSCode-style link formats", () => { - it("should detect parenthesis format: file.ts(42)", async () => { - const terminal = createMockTerminal([ - { text: "Error in /path/file.ts(42)" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/file.ts(42)"); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - }); - - it("should detect parenthesis format with column: file.ts(42, 10)", async () => { - const terminal = createMockTerminal([ - { text: "Error in /path/file.ts(42, 10)" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/file.ts(42, 10)"); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - expect(onOpen.mock.calls[0][3]).toBe(10); - }); - - it("should detect square bracket format: file.ts[42]", async () => { - const terminal = createMockTerminal([ - { text: "Error in /path/file.ts[42]" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/file.ts[42]"); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - }); - - it('should detect verbose format: "file.ts", line 42', async () => { - const terminal = createMockTerminal([ - { text: 'Error in "/path/file.ts", line 42' }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe('"/path/file.ts", line 42'); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - }); - - it('should detect verbose format with column: "file.ts", line 42, col 10', async () => { - const terminal = createMockTerminal([ - { text: 'Error in "/path/file.ts", line 42, col 10' }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe('"/path/file.ts", line 42, col 10'); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - expect(onOpen.mock.calls[0][3]).toBe(10); - }); - - it("should detect line ranges: file.ts:42-50", async () => { - const terminal = createMockTerminal([ - { text: "See /path/file.ts:42:10-50" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/file.ts:42:10-50"); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - expect(onOpen.mock.calls[0][3]).toBe(10); - expect(onOpen.mock.calls[0][5]).toBe(50); // columnEnd - }); - - it("should detect hash format: file.ts#42", async () => { - const terminal = createMockTerminal([{ text: "See /path/file.ts#42" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("/path/file.ts#42"); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen.mock.calls[0][1]).toBe("/path/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - }); - - it("should detect git diff paths: --- a/path/file.ts", async () => { - const terminal = createMockTerminal([{ text: "--- a/path/to/file.ts" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("path/to/file.ts"); - }); - - it("should detect git diff paths: +++ b/path/file.ts", async () => { - const terminal = createMockTerminal([{ text: "+++ b/path/to/file.ts" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("path/to/file.ts"); - }); - }); - - describe("URL-encoded paths", () => { - it("should decode URL-encoded path with line number on activation", async () => { - const terminal = createMockTerminal([ - { text: "apps/desktop/src/main/lib/workspace-manager.ts%3A50" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen).toHaveBeenCalled(); - expect(onOpen.mock.calls[0][1]).toBe( - "apps/desktop/src/main/lib/workspace-manager.ts", - ); - expect(onOpen.mock.calls[0][2]).toBe(50); - }); - - it("should decode URL-encoded path with line and column on activation", async () => { - const terminal = createMockTerminal([{ text: "src/file.ts%3A42%3A10" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen).toHaveBeenCalled(); - expect(onOpen.mock.calls[0][1]).toBe("src/file.ts"); - expect(onOpen.mock.calls[0][2]).toBe(42); - expect(onOpen.mock.calls[0][3]).toBe(10); - }); - - it("should decode URL-encoded spaces in path", async () => { - const terminal = createMockTerminal([ - { text: "./path/to%20file/name.ts" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - - const mockEvent = { - metaKey: true, - ctrlKey: false, - preventDefault: mock(), - } as unknown as MouseEvent; - links[0].activate(mockEvent, links[0].text); - - expect(onOpen).toHaveBeenCalled(); - expect(onOpen.mock.calls[0][1]).toBe("./path/to file/name.ts"); - }); - }); - - describe("punctuation handling", () => { - it("should handle path followed by period at end of sentence", async () => { - const terminal = createMockTerminal([ - { text: "See the file at ./path/something." }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - // The path should be detected without the trailing period - expect(links.length).toBe(1); - expect(links[0].text).toBe("./path/something"); - }); - - it("should handle path in quotes", async () => { - const terminal = createMockTerminal([ - { text: 'Error in "./path/file.ts"' }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("./path/file.ts"); - }); - }); - - describe("edge cases", () => { - it("should handle empty lines", async () => { - const terminal = createMockTerminal([{ text: "" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(0); - }); - - it("should handle line that doesn't exist", async () => { - const terminal = createMockTerminal([{ text: "Hello" }]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 999); - - expect(links.length).toBe(0); - }); - - it("should handle paths without directories (just relative path)", async () => { - const terminal = createMockTerminal([ - { text: "src/components/Button.tsx" }, - ]); - const onOpen = mock(); - const provider = new FilePathLinkProvider(terminal, onOpen); - - const links = await getLinks(provider, 1); - - expect(links.length).toBe(1); - expect(links[0].text).toBe("src/components/Button.tsx"); - }); - }); -}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.ts deleted file mode 100644 index 81ebd39327c..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/file-path-link-provider.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { - decodeUrlEncodedPath, - detectFallbackLinks, - detectLinks, - getCurrentOS, - type IFallbackLink, - type IParsedLink, - removeLinkSuffix, -} from "@superset/shared/terminal-link-parsing"; -import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm"; - -/** - * A link provider that detects file paths in terminal output using VSCode's - * terminal link parsing logic. Supports a wide variety of path formats including: - * - * - Basic paths: /path/to/file.ts, ./src/file.ts, ~/config.json - * - With line numbers: file.ts:42, file.ts:42:10 - * - With line ranges: file.ts:42-50, file.ts:42:10-50 - * - Parenthesis format: file.ts(42), file.ts(42, 10) - * - Square bracket format: file.ts[42], file.ts[42, 10] - * - Verbose formats: "file.ts", line 42, col 10 - * - Git diff paths: --- a/path/file.ts, +++ b/path/file.ts - * - * Also handles multi-line wrapped paths spanning up to 3 terminal lines. - */ -export class FilePathLinkProvider implements ILinkProvider { - constructor( - private readonly terminal: Terminal, - private readonly onOpen: ( - event: MouseEvent, - path: string, - line?: number, - column?: number, - lineEnd?: number, - columnEnd?: number, - ) => void, - ) {} - - provideLinks( - bufferLineNumber: number, - callback: (links: ILink[] | undefined) => void, - ): void { - const lineIndex = bufferLineNumber - 1; - const line = this.terminal.buffer.active.getLine(lineIndex); - if (!line) { - callback(undefined); - return; - } - - const lineText = line.translateToString(true); - const lineLength = lineText.length; - const isCurrentLineWrapped = line.isWrapped; - - // Get previous line if current is wrapped (for handling wrapped paths) - const prevLine = isCurrentLineWrapped - ? this.terminal.buffer.active.getLine(lineIndex - 1) - : null; - const prevLineText = prevLine ? prevLine.translateToString(true) : ""; - const prevLineLength = prevLineText.length; - - // Get next line if it's wrapped (for handling wrapped paths) - const nextLine = this.terminal.buffer.active.getLine(lineIndex + 1); - const nextLineIsWrapped = nextLine?.isWrapped ?? false; - const nextLineText = - nextLineIsWrapped && nextLine ? nextLine.translateToString(true) : ""; - - // Combine lines for multi-line path detection - const combinedText = prevLineText + lineText + nextLineText; - const currentLineOffset = prevLineLength; - - // Use VSCode's link detection - const os = getCurrentOS(); - const detectedLinks = detectLinks(combinedText, os); - - const links: ILink[] = []; - - for (let parsedLink of detectedLinks) { - // Strip trailing punctuation from paths without suffixes - // (paths with suffixes like :42 already have proper boundaries) - if (!parsedLink.suffix) { - parsedLink = this.stripTrailingPunctuation(parsedLink, combinedText); - } - - // Calculate the full link range including prefix and suffix - const linkStart = parsedLink.prefix?.index ?? parsedLink.path.index; - const linkEnd = parsedLink.suffix - ? parsedLink.suffix.suffix.index + parsedLink.suffix.suffix.text.length - : parsedLink.path.index + parsedLink.path.text.length; - - // Check if this link overlaps with the current line - const currentLineStart = currentLineOffset; - const currentLineEnd = currentLineOffset + lineLength; - - if (linkEnd <= currentLineStart || linkStart >= currentLineEnd) { - continue; - } - - // Get the path text (without suffix for opening) - const pathText = parsedLink.path.text; - - // Skip URLs - if (this.isUrl(pathText, linkStart, combinedText)) { - continue; - } - - // Skip version strings like v1.2.3 - if (this.isVersionString(pathText)) { - continue; - } - - // Skip npm package references like @scope/package@1.2.3 - if (this.isNpmPackageReference(pathText, linkStart, combinedText)) { - continue; - } - - // Skip pure numeric patterns - if (/^\d+(:\d+)*$/.test(pathText)) { - continue; - } - - // Calculate the range for highlighting - const range = this.calculateLinkRange( - linkStart, - linkEnd, - prevLineLength, - lineLength, - bufferLineNumber, - isCurrentLineWrapped, - nextLineIsWrapped, - ); - - // Build the full link text for display - const fullLinkText = combinedText.substring(linkStart, linkEnd); - - links.push({ - range, - text: fullLinkText, - activate: (event: MouseEvent) => { - this.handleActivation(event, parsedLink); - }, - }); - } - - // If no links found via primary detection, try fallback matchers - // These catch special formats like Python errors, Rust errors, etc. - if (links.length === 0) { - const fallbackLinks = detectFallbackLinks(combinedText); - for (const fallback of fallbackLinks) { - const linkStart = fallback.index; - const linkEnd = fallback.index + fallback.link.length; - - // Check if this link overlaps with the current line - const fbCurrentLineStart = currentLineOffset; - const fbCurrentLineEnd = currentLineOffset + lineLength; - if (linkEnd <= fbCurrentLineStart || linkStart >= fbCurrentLineEnd) { - continue; - } - - // Calculate the range for highlighting - const range = this.calculateLinkRange( - linkStart, - linkEnd, - prevLineLength, - lineLength, - bufferLineNumber, - isCurrentLineWrapped, - nextLineIsWrapped, - ); - - links.push({ - range, - text: fallback.link, - activate: (event: MouseEvent) => { - this.handleFallbackActivation(event, fallback); - }, - }); - } - } - - callback(links.length > 0 ? links : undefined); - } - - /** - * Strip trailing punctuation from a link that has no suffix. - * This handles cases like "See ./path/file." where the period is sentence punctuation, - * not part of the path. - */ - private stripTrailingPunctuation( - parsedLink: IParsedLink, - combinedText: string, - ): IParsedLink { - const pathText = parsedLink.path.text; - const linkEnd = parsedLink.path.index + pathText.length; - - // Check if the path ends with common sentence punctuation - // Only strip if followed by whitespace or end of line (to avoid stripping valid extensions) - const trailingPunctMatch = pathText.match(/([.,;:!?)]+)$/); - if (trailingPunctMatch) { - const punct = trailingPunctMatch[1]; - const afterPunct = combinedText[linkEnd]; - - // Only strip if followed by whitespace, end of string, or another punctuation - if ( - afterPunct === undefined || - /\s/.test(afterPunct) || - afterPunct === '"' || - afterPunct === "'" - ) { - // Don't strip if it looks like a file extension (e.g., "file.ts") - // A period followed by 1-4 alphanumeric characters at the end is likely an extension - if (punct === "." && /\.[a-zA-Z0-9]{1,4}$/.test(pathText)) { - return parsedLink; - } - - return { - ...parsedLink, - path: { - index: parsedLink.path.index, - text: pathText.slice(0, -punct.length), - }, - }; - } - } - - return parsedLink; - } - - private isUrl( - pathText: string, - linkStart: number, - combinedText: string, - ): boolean { - if ( - pathText.startsWith("http://") || - pathText.startsWith("https://") || - pathText.startsWith("ftp://") - ) { - return true; - } - - // Check if this is part of a URL (e.g., the path portion after ://) - if ( - linkStart > 0 && - combinedText[linkStart - 1] === ":" && - (pathText.startsWith("//") || pathText.startsWith("http")) - ) { - return true; - } - - return false; - } - - private isVersionString(pathText: string): boolean { - // Match version strings like 1.2.3, v1.2.3, 1.2.3.4 - return /^v?\d+\.\d+(\.\d+)*$/.test(pathText); - } - - private isNpmPackageReference( - pathText: string, - linkStart: number, - combinedText: string, - ): boolean { - // Check context for npm package patterns like @scope/package@1.2.3 - const contextStart = Math.max(0, linkStart - 30); - const contextEnd = linkStart + pathText.length; - const context = combinedText.substring(contextStart, contextEnd); - return /@\d+\.\d+/.test(context); - } - - private handleActivation(event: MouseEvent, parsedLink: IParsedLink): void { - if (!event.metaKey && !event.ctrlKey) { - return; - } - - event.preventDefault(); - - const pathText = parsedLink.path.text; - - // Clean up the path - remove any remaining suffix patterns that might have been - // included (defensive, since detectLinks should handle this) - let cleanPath = removeLinkSuffix(pathText); - - if (!cleanPath) { - return; - } - - // Decode URL-encoded characters (e.g., %3A -> :, %20 -> space) - cleanPath = decodeUrlEncodedPath(cleanPath); - - // Extract line/column info from suffix, or try to parse from URL-encoded path - let line = parsedLink.suffix?.row; - let column = parsedLink.suffix?.col; - const lineEnd = parsedLink.suffix?.rowEnd; - const columnEnd = parsedLink.suffix?.colEnd; - - // If no suffix was detected, check if the decoded path contains line:col info - if (line === undefined) { - const lineColMatch = cleanPath.match(/:(\d+)(?::(\d+))?$/); - if (lineColMatch) { - cleanPath = cleanPath.replace(/:(\d+)(?::(\d+))?$/, ""); - line = Number.parseInt(lineColMatch[1], 10); - if (lineColMatch[2]) { - column = Number.parseInt(lineColMatch[2], 10); - } - } - } - - this.onOpen(event, cleanPath, line, column, lineEnd, columnEnd); - } - - private handleFallbackActivation( - event: MouseEvent, - fallback: IFallbackLink, - ): void { - if (!event.metaKey && !event.ctrlKey) { - return; - } - - event.preventDefault(); - - const cleanPath = decodeUrlEncodedPath(fallback.path); - - if (!cleanPath) { - return; - } - - this.onOpen(event, cleanPath, fallback.line, fallback.col); - } - - private calculateLinkRange( - matchIndex: number, - matchEnd: number, - prevLineLength: number, - lineLength: number, - bufferLineNumber: number, - isCurrentLineWrapped: boolean, - nextLineIsWrapped: boolean, - ): ILink["range"] { - const currentLineStart = prevLineLength; - const currentLineEnd = prevLineLength + lineLength; - - const startsInPrevLine = - isCurrentLineWrapped && matchIndex < currentLineStart; - const endsInNextLine = nextLineIsWrapped && matchEnd > currentLineEnd; - - let startY: number; - let startX: number; - let endY: number; - let endX: number; - - if (startsInPrevLine) { - startY = bufferLineNumber - 1; - startX = matchIndex + 1; - } else { - startY = bufferLineNumber; - startX = matchIndex - currentLineStart + 1; - } - - if (endsInNextLine) { - endY = bufferLineNumber + 1; - endX = matchEnd - currentLineEnd + 1; - } else if (matchEnd <= currentLineStart) { - endY = bufferLineNumber - 1; - endX = matchEnd + 1; - } else { - endY = bufferLineNumber; - endX = matchEnd - currentLineStart + 1; - } - - return { - start: { x: startX, y: startY }, - end: { x: endX, y: endY }, - }; - } -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/index.ts index fca6aa68c10..03c9731b82e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/index.ts @@ -1,4 +1,3 @@ -export { FilePathLinkProvider } from "./file-path-link-provider"; export { type LinkMatch, MultiLineLinkProvider, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts index 2521bd79aec..099ad5b0f7e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/multi-line-link-provider.ts @@ -42,6 +42,10 @@ export abstract class MultiLineLinkProvider implements ILinkProvider { regexMatch: RegExpMatchArray, ): void; + /** Optional hooks fired when the mouse enters/leaves a detected link. */ + protected handleHover?(event: MouseEvent, text: string): void; + protected handleLeave?(): void; + /** * Optional hook to transform a match before creating the link. * Useful for stripping trailing characters. Return null to skip the match. @@ -177,6 +181,12 @@ export abstract class MultiLineLinkProvider implements ILinkProvider { activate: (event: MouseEvent, text: string) => { this.handleActivation(event, text, match); }, + hover: (event: MouseEvent, text: string) => { + this.handleHover?.(event, text); + }, + leave: () => { + this.handleLeave?.(); + }, }); } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.test.ts index bcc0eeba267..f27b3881a04 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.test.ts @@ -760,7 +760,7 @@ describe("UrlLinkProvider", () => { }); describe("handleActivation", () => { - it("should require metaKey (Cmd) or ctrlKey for activation", async () => { + it("should forward activation regardless of modifier (gate lives in consumer)", async () => { const terminal = createMockTerminal([{ text: "https://example.com" }]); const onOpen = mock(); const provider = new UrlLinkProvider(terminal, onOpen); @@ -774,7 +774,7 @@ describe("UrlLinkProvider", () => { links[0].activate(mockEvent, "https://example.com"); - expect(onOpen).not.toHaveBeenCalled(); + expect(onOpen).toHaveBeenCalled(); }); it("should activate with metaKey (Cmd)", async () => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts index c42aab75b12..aaa6d111130 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/link-providers/url-link-provider.ts @@ -313,10 +313,20 @@ export class UrlLinkProvider extends MultiLineLinkProvider { constructor( terminal: Terminal, private readonly onOpen: (event: MouseEvent, uri: string) => void, + private readonly onHover?: (event: MouseEvent, uri: string) => void, + private readonly onLeave?: () => void, ) { super(terminal); } + protected handleHover(event: MouseEvent, text: string): void { + this.onHover?.(event, text); + } + + protected handleLeave(): void { + this.onLeave?.(); + } + protected getPattern(): RegExp { return new RegExp(this.URL_PATTERN.source, "g"); } @@ -343,11 +353,6 @@ export class UrlLinkProvider extends MultiLineLinkProvider { } protected handleActivation(event: MouseEvent, text: string): void { - if (!event.metaKey && !event.ctrlKey) { - return; - } - - event.preventDefault(); this.onOpen(event, text); } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/terminalKeyboardHandler.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/terminalKeyboardHandler.ts new file mode 100644 index 00000000000..3ccb0efece6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/terminalKeyboardHandler.ts @@ -0,0 +1,90 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; +import { resolveHotkeyFromEvent } from "renderer/hotkeys"; +import { translateLineEditChord } from "renderer/lib/terminal/line-edit-translations"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "./clipboardShortcuts"; + +export interface KeyboardHandlerOptions { + /** Callback for Shift+Enter (sends ESC+CR to avoid \ appearing in Claude Code while keeping line continuation behavior) */ + onShiftEnter?: () => void; + onWrite?: (data: string) => void; +} + +/** + * Setup keyboard handling for xterm including: + * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens + * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) + * + * Returns a cleanup function to remove the handler. + */ +export function setupKeyboardHandler( + xterm: XTerm, + options: KeyboardHandlerOptions = {}, +): () => void { + const platform = + typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; + const isMac = platform.includes("mac"); + const isWindows = platform.includes("win"); + + const handler = (event: KeyboardEvent): boolean => { + // Match v2: registered app hotkeys must escape xterm before terminal + // translations or macOS Cmd bubbling can consume them. + if (resolveHotkeyFromEvent(event) !== null) return false; + + const isShiftEnter = + event.key === "Enter" && + event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey; + + if (isShiftEnter) { + if (event.type === "keydown" && options.onShiftEnter) { + event.preventDefault(); + options.onShiftEnter(); + } + return false; + } + + const translation = translateLineEditChord(event, { isMac, isWindows }); + if (translation !== null) { + if (event.type === "keydown" && options.onWrite) { + event.preventDefault(); + options.onWrite(translation); + } + return false; + } + + if (shouldSelectAllShortcut(event, isMac)) { + if (event.type === "keydown") { + event.preventDefault(); + xterm.selectAll(); + } + return false; + } + + // Mirror VS Code terminal clipboard bindings so host copy/paste happens + // before kitty CSI-u handling in xterm consumes the command chord. + if ( + shouldBubbleClipboardShortcut(event, { + isMac, + isWindows, + hasSelection: xterm.hasSelection(), + }) + ) { + return false; + } + + // Default: let xterm process unhandled keys, including terminal-reserved + // chords like ctrl+c/d/z/s/q. + return true; + }; + + xterm.attachCustomKeyEventHandler(handler); + + return () => { + xterm.attachCustomKeyEventHandler(() => true); + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts new file mode 100644 index 00000000000..98934789ab1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -0,0 +1,352 @@ +import type { Unsubscribable } from "@trpc/server/observable"; +import type { FitAddon } from "@xterm/addon-fit"; +import type { SearchAddon } from "@xterm/addon-search"; +import type { Terminal as XTerm } from "@xterm/xterm"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { DEBUG_TERMINAL } from "./config"; +import { type CreateTerminalOptions, createTerminalInWrapper } from "./helpers"; +import type { TerminalStreamEvent } from "./types"; + +/** + * Cached xterm instance that survives React mount/unmount cycles. + * Borrows the wrapper-div pattern from v2's terminal-runtime.ts: + * xterm is opened into a persistent wrapper <div> that can be + * moved between DOM containers without disposing the terminal. + * + * Also owns the tRPC stream subscription so data continues flowing + * to xterm even while the React component is unmounted (tab hidden). + */ +export interface CachedTerminal { + xterm: XTerm; + fitAddon: FitAddon; + searchAddon: SearchAddon; + wrapper: HTMLDivElement; + /** Disposes renderer RAF, query suppression, GPU renderer, etc. */ + cleanupCreation: () => void; + /** Last known dimensions — used to skip no-op resize events. */ + lastCols: number; + lastRows: number; + + // --- Stream management --- + + /** The live tRPC subscription. Null until startStream() is called. */ + subscription: Unsubscribable | null; + /** True once the first createOrAttach succeeds and the stream gate opens. */ + streamReady: boolean; + /** Events queued before streamReady (first mount only). */ + pendingStreamEvents: TerminalStreamEvent[]; + /** Non-data events queued while no component is mounted. */ + pendingLifecycleEvents: TerminalStreamEvent[]; + /** + * Handler provided by the mounted Terminal component. + * When set, ALL events are forwarded here so the component can + * update React state (exit status, connection error, modes, cwd, etc.). + * When null (component unmounted), data events write directly to xterm + * and non-data events are queued. + */ + eventHandler: ((event: TerminalStreamEvent) => void) | null; + /** + * Error handler for tRPC subscription-level errors (distinct from + * terminal stream error events). + */ + subscriptionErrorHandler: ((error: unknown) => void) | null; + /** ResizeObserver for the attached container. Managed by attach/detach. */ + resizeObserver: ResizeObserver | null; + /** Live container, when attached. */ + container: HTMLDivElement | null; +} + +const cache = new Map<string, CachedTerminal>(); + +function hostIsVisible(container: HTMLDivElement | null): boolean { + if (!container) return false; + return container.clientWidth > 0 && container.clientHeight > 0; +} + +function fitAndRefresh(entry: CachedTerminal): boolean { + if (!hostIsVisible(entry.container)) return false; + + const { xterm } = entry; + const buffer = xterm.buffer.active; + const wasPinnedToBottom = buffer.viewportY >= buffer.baseY; + const savedViewportY = buffer.viewportY; + const prevCols = xterm.cols; + const prevRows = xterm.rows; + + entry.fitAddon.fit(); + entry.lastCols = xterm.cols; + entry.lastRows = xterm.rows; + + if (wasPinnedToBottom) { + xterm.scrollToBottom(); + } else { + const targetY = Math.min(savedViewportY, xterm.buffer.active.baseY); + if (xterm.buffer.active.viewportY !== targetY) { + xterm.scrollToLine(targetY); + } + } + + xterm.refresh(0, Math.max(0, xterm.rows - 1)); + + return xterm.cols !== prevCols || xterm.rows !== prevRows; +} + +export function has(paneId: string): boolean { + return cache.has(paneId); +} + +export function get(paneId: string): CachedTerminal | undefined { + return cache.get(paneId); +} + +export function getOrCreate( + paneId: string, + options: CreateTerminalOptions, +): CachedTerminal { + const existing = cache.get(paneId); + if (existing) return existing; + + if (DEBUG_TERMINAL) { + console.log(`[v1-terminal-cache] Creating new terminal: ${paneId}`); + } + + const { xterm, fitAddon, searchAddon, wrapper, cleanup } = + createTerminalInWrapper(options); + + const entry: CachedTerminal = { + xterm, + fitAddon, + searchAddon, + wrapper, + cleanupCreation: cleanup, + subscription: null, + streamReady: false, + pendingStreamEvents: [], + pendingLifecycleEvents: [], + eventHandler: null, + subscriptionErrorHandler: null, + resizeObserver: null, + container: null, + lastCols: xterm.cols, + lastRows: xterm.rows, + }; + + cache.set(paneId, entry); + return entry; +} + +// --- DOM attach / detach --- + +export function attachToContainer( + paneId: string, + container: HTMLDivElement, + onResize?: () => void, +): void { + const entry = cache.get(paneId); + if (!entry) return; + + entry.container = container; + container.appendChild(entry.wrapper); + + fitAndRefresh(entry); + + // Manage ResizeObserver lifecycle in the cache, not in React. + entry.resizeObserver?.disconnect(); + const observer = new ResizeObserver(() => { + if (fitAndRefresh(entry)) { + onResize?.(); + } + }); + observer.observe(container); + entry.resizeObserver = observer; +} + +export function detachFromContainer(paneId: string): void { + const entry = cache.get(paneId); + if (!entry) return; + + if (DEBUG_TERMINAL) { + console.log(`[v1-terminal-cache] detachFromContainer: ${paneId}`); + } + entry.resizeObserver?.disconnect(); + entry.resizeObserver = null; + entry.container = null; + entry.wrapper.remove(); +} + +// --- Appearance --- + +/** + * Update font settings on a cached terminal. If font changed and the + * terminal is visible, re-fit and return true so the caller can send + * a backend resize if needed. + */ +export function updateAppearance( + paneId: string, + fontFamily: string, + fontSize: number, +): { cols: number; rows: number; changed: boolean } | null { + const entry = cache.get(paneId); + if (!entry) return null; + + const { xterm } = entry; + const fontChanged = + xterm.options.fontFamily !== fontFamily || + xterm.options.fontSize !== fontSize; + if (!fontChanged) return null; + + xterm.options.fontFamily = fontFamily; + xterm.options.fontSize = fontSize; + + const changed = fitAndRefresh(entry); + + return { + cols: xterm.cols, + rows: xterm.rows, + changed, + }; +} + +// --- Stream subscription --- + +function routeEvent(entry: CachedTerminal, event: TerminalStreamEvent): void { + // Before stream is ready: queue everything (first-mount gating). + if (!entry.streamReady) { + entry.pendingStreamEvents.push(event); + return; + } + + // Component mounted — forward all events there. + if (entry.eventHandler) { + entry.eventHandler(event); + return; + } + + // Component unmounted — write data directly to xterm, queue the rest. + if (event.type === "data") { + entry.xterm.write(event.data); + } else { + entry.pendingLifecycleEvents.push(event); + } +} + +/** + * Start the tRPC stream subscription for this terminal. + * Called once on first mount after createOrAttach succeeds. + * The subscription stays alive across component mount/unmount cycles + * and is only stopped on dispose(). + */ +export function startStream(paneId: string): void { + const entry = cache.get(paneId); + if (!entry || entry.subscription) return; + + if (DEBUG_TERMINAL) { + console.log(`[v1-terminal-cache] Starting stream: ${paneId}`); + } + + entry.subscription = electronTrpcClient.terminal.stream.subscribe(paneId, { + onData: (event: TerminalStreamEvent) => { + routeEvent(entry, event); + }, + onError: (error: unknown) => { + // Subscription is dead after onError — null it so startStream() + // can create a replacement on remount. + entry.subscription = null; + + if (entry.subscriptionErrorHandler) { + entry.subscriptionErrorHandler(error); + } else if (DEBUG_TERMINAL) { + console.error( + `[v1-terminal-cache] Stream error (no handler): ${paneId}`, + error, + ); + } + }, + }); +} + +/** + * Mark the stream as ready and flush any events queued during the + * first-mount gating period (before createOrAttach completed). + */ +export function setStreamReady(paneId: string): void { + const entry = cache.get(paneId); + if (!entry || entry.streamReady) return; + + if (DEBUG_TERMINAL) { + console.log( + `[v1-terminal-cache] Stream ready: ${paneId}, flushing ${entry.pendingStreamEvents.length} queued events`, + ); + } + + entry.streamReady = true; + const pending = entry.pendingStreamEvents.splice(0); + for (const event of pending) { + routeEvent(entry, event); + } +} + +/** + * Register event handlers from the mounted Terminal component. + * Returns any lifecycle events (exit, error, disconnect) that were + * queued while the component was unmounted. + */ +export function registerHandlers( + paneId: string, + handlers: { + onEvent: (event: TerminalStreamEvent) => void; + onError: (error: unknown) => void; + }, +): TerminalStreamEvent[] { + const entry = cache.get(paneId); + if (!entry) return []; + + entry.eventHandler = handlers.onEvent; + entry.subscriptionErrorHandler = handlers.onError; + + // Drain and return queued lifecycle events + return entry.pendingLifecycleEvents.splice(0); +} + +/** + * Unregister the component's event handlers (component unmounting). + * The subscription stays alive; data events write directly to xterm. + */ +export function unregisterHandlers(paneId: string): void { + const entry = cache.get(paneId); + if (!entry) return; + + entry.eventHandler = null; + entry.subscriptionErrorHandler = null; +} + +// --- Disposal --- + +export function dispose(paneId: string): void { + const entry = cache.get(paneId); + if (!entry) return; + + if (DEBUG_TERMINAL) { + console.log(`[v1-terminal-cache] Disposing: ${paneId}`); + } + + entry.resizeObserver?.disconnect(); + entry.subscription?.unsubscribe(); + entry.cleanupCreation(); + entry.xterm.dispose(); + cache.delete(paneId); +} + +// Preserve cache across Vite HMR in dev so active terminals aren't orphaned. +const hot = import.meta.hot; +if (hot) { + const existing = hot.data.v1TerminalCache as + | Map<string, CachedTerminal> + | undefined; + if (existing) { + for (const [k, v] of existing) { + cache.set(k, v); + } + } + hot.data.v1TerminalCache = cache; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/assets/superset-empty-state-wordmark.svg b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/assets/superset-empty-state-wordmark.svg index 421ac8d56e7..df3f5c70930 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/assets/superset-empty-state-wordmark.svg +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/assets/superset-empty-state-wordmark.svg @@ -1,3 +1,13 @@ -<svg width="137" height="55" viewBox="0 0 137 55" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M18.1818 4.30346e-05H27.2727V9.09095H18.1818V4.30346e-05ZM9.09091 4.30346e-05H18.1818V9.09095H9.09091V4.30346e-05ZM9.09091 9.09095H18.1818V18.1819H9.09091V9.09095ZM0 18.1819H9.09091V27.2728H0V18.1819ZM0 27.2728H9.09091V36.3637H0V27.2728ZM9.09091 36.3637H18.1818V45.4546H9.09091V36.3637ZM9.09091 45.4546H18.1818V54.5455H9.09091V45.4546ZM18.1818 45.4546H27.2727V54.5455H18.1818V45.4546ZM54.5099 4.30346e-05H63.6009V9.09095H54.5099V4.30346e-05ZM45.419 4.30346e-05H54.5099V9.09095H45.419V4.30346e-05ZM45.419 9.09095H54.5099V18.1819H45.419V9.09095ZM36.3281 18.1819H45.419V27.2728H36.3281V18.1819ZM36.3281 27.2728H45.419V36.3637H36.3281V27.2728ZM45.419 36.3637H54.5099V45.4546H45.419V36.3637ZM45.419 45.4546H54.5099V54.5455H45.419V45.4546ZM54.5099 45.4546H63.6009V54.5455H54.5099V45.4546ZM72.6562 4.30346e-05H81.7472V9.09095H72.6562V4.30346e-05ZM81.7472 9.09095H90.8381V18.1819H81.7472V9.09095ZM90.8381 18.1819H99.929V27.2728H90.8381V18.1819ZM81.7472 36.3637H90.8381V45.4546H81.7472V36.3637ZM72.6562 45.4546H81.7472V54.5455H72.6562V45.4546ZM90.8381 27.2728H99.929V36.3637H90.8381V27.2728ZM81.7472 45.4546H90.8381V54.5455H81.7472V45.4546ZM81.7472 4.30346e-05H90.8381V9.09095H81.7472V4.30346e-05ZM108.984 4.30346e-05H118.075V9.09095H108.984V4.30346e-05ZM118.075 9.09095H127.166V18.1819H118.075V9.09095ZM127.166 18.1819H136.257V27.2728H127.166V18.1819ZM118.075 36.3637H127.166V45.4546H118.075V36.3637ZM108.984 45.4546H118.075V54.5455H108.984V45.4546ZM127.166 27.2728H136.257V36.3637H127.166V27.2728ZM118.075 45.4546H127.166V54.5455H118.075V45.4546ZM118.075 4.30346e-05H127.166V9.09095H118.075V4.30346e-05Z" fill="#EAE8E6"/> +<svg width="145" height="60" viewBox="0 0 145 60" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_48_868)"> +<path d="M125 0H115V10H125V20H135V30V40H125V50H115V60H125H135V50V40H145V30V20H135V10V0H125Z" fill="#EAE8E6"/> +<path d="M20 0H30V10H20V20H10V30V40H20V50H30V60H20H10V50V40H0V30V20H10V10V0H20Z" fill="#EAE8E6"/> +<path d="M89.9999 0H79.9999V10H89.9999V20H99.9999V30V40H89.9999V50H79.9999V60H89.9999H99.9999V50V40H110V30V20H99.9999V10V0H89.9999Z" fill="#EAE8E6"/> +<path d="M54.9999 0H64.9999V10H54.9999V20H44.9999V30V40H54.9999V50H64.9999V60H54.9999H44.9999V50V40H34.9999V30V20H44.9999V10V0H54.9999Z" fill="#EAE8E6"/> +</g> +<defs> +<clipPath id="clip0_48_868"> +<rect width="145" height="60" fill="white"/> +</clipPath> +</defs> </svg> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PaneContextMenuItems/PaneContextMenuItems.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PaneContextMenuItems/PaneContextMenuItems.tsx index 72ec42c56ac..5ce5e3fd777 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PaneContextMenuItems/PaneContextMenuItems.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PaneContextMenuItems/PaneContextMenuItems.tsx @@ -16,7 +16,7 @@ import { LuRows2, LuX, } from "react-icons/lu"; -import { useHotkeyText } from "renderer/stores/hotkeys"; +import { useHotkeyDisplay } from "renderer/hotkeys"; import type { Tab } from "renderer/stores/tabs/types"; export interface PaneContextMenuActions { @@ -41,11 +41,13 @@ export function PaneContextMenuItems({ actions, closeLabel, }: PaneContextMenuItemsProps) { - const splitDownShortcut = useHotkeyText("SPLIT_DOWN"); - const splitRightShortcut = useHotkeyText("SPLIT_RIGHT"); - const splitWithChatShortcut = useHotkeyText("SPLIT_WITH_CHAT"); - const splitWithBrowserShortcut = useHotkeyText("SPLIT_WITH_BROWSER"); - const equalizePaneSplitsShortcut = useHotkeyText("EQUALIZE_PANE_SPLITS"); + const splitDownShortcut = useHotkeyDisplay("SPLIT_DOWN").text; + const splitRightShortcut = useHotkeyDisplay("SPLIT_RIGHT").text; + const splitWithChatShortcut = useHotkeyDisplay("SPLIT_WITH_CHAT").text; + const splitWithBrowserShortcut = useHotkeyDisplay("SPLIT_WITH_BROWSER").text; + const equalizePaneSplitsShortcut = useHotkeyDisplay( + "EQUALIZE_PANE_SPLITS", + ).text; const targetTabs = actions.availableTabs.filter( (tab) => tab.id !== actions.currentTabId, ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/components/PresetBarItem/PresetBarItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/components/PresetBarItem/PresetBarItem.tsx index 58a5dcc444b..ed35c78f6b3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/components/PresetBarItem/PresetBarItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar/components/PresetBarItem/PresetBarItem.tsx @@ -12,8 +12,8 @@ import { useEffect, useRef } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniCommandLine } from "react-icons/hi2"; import { getPresetIcon } from "renderer/assets/app-icons/preset-icons"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent/HotkeyTooltipContent"; -import type { HotkeyId } from "shared/hotkeys"; +import type { HotkeyId } from "renderer/hotkeys"; +import { HotkeyLabel } from "renderer/hotkeys"; const PRESET_BAR_ITEM_TYPE = "PRESET_BAR_ITEM"; @@ -113,7 +113,7 @@ export function PresetBarItem({ </Button> </TooltipTrigger> <TooltipContent side="bottom" sideOffset={4}> - <HotkeyTooltipContent label={label} hotkeyId={hotkeyId} /> + <HotkeyLabel label={label} id={hotkeyId} /> </TooltipContent> </Tooltip> </div> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index 31e46968ad7..77661eed921 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -1,12 +1,3 @@ -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@superset/ui/alert-dialog"; -import { Button } from "@superset/ui/button"; import { toast } from "@superset/ui/sonner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { cn } from "@superset/ui/utils"; @@ -36,6 +27,7 @@ import { sidebarHeaderTabTriggerClassName } from "../headerTabStyles"; import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; +import { DiscardConfirmDialog } from "./components/DiscardConfirmDialog"; import { ReviewPanel } from "./components/ReviewPanel"; import { useOrderedSections } from "./hooks"; import { getPRActionState, shouldAutoCreatePRAfterPublish } from "./utils"; @@ -99,13 +91,11 @@ export function ChangesView({ const worktreePath = workspace?.worktreePath; const projectId = workspace?.projectId; const activeTab = useChangesStore((s) => s.activeTab); - const isReviewTabActive = isActive && activeTab === "review"; const githubStatusQueryPolicy = getGitHubStatusQueryPolicy( "changes-sidebar", { hasWorkspaceId: !!workspaceId, isActive, - isReviewTabActive, }, ); @@ -270,7 +260,6 @@ export function ChangesView({ hasWorkspaceId: !!workspaceId, hasActivePullRequest: !!activePullRequest, isActive, - isReviewTabActive, }); const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pendingRefreshRef = useRef<PendingChangesRefresh>({ @@ -571,7 +560,6 @@ export function ChangesView({ const hasStagedChanges = stagedFiles.length > 0; const hasExistingPR = !!activePullRequest; - const prUrl = activePullRequest?.url; const hasGitHubRepo = !!githubStatus?.repoUrl; const defaultBranch = branchData?.defaultBranch ?? status?.defaultBranch ?? ""; @@ -786,10 +774,9 @@ export function ChangesView({ pushCount={status.pushCount} pullCount={status.pullCount} hasUpstream={status.hasUpstream} - hasExistingPR={hasExistingPR} + pullRequest={activePullRequest ?? null} canCreatePR={prActionState.canCreatePR} shouldAutoCreatePRAfterPublish={shouldAutoCreatePR} - prUrl={prUrl} onRefresh={handleRefresh} /> </div> @@ -832,89 +819,37 @@ export function ChangesView({ comments={githubComments} isLoading={isGitHubStatusLoading} isCommentsLoading={isGitHubCommentsLoading} + workspaceId={workspaceId} + onCommentsChange={refetchGitHubComments} /> </TabsContent> </Tabs> - <AlertDialog + <DiscardConfirmDialog open={showDiscardUnstagedDialog} onOpenChange={setShowDiscardUnstagedDialog} - > - <AlertDialogContent className="max-w-[340px] gap-0 p-0"> - <AlertDialogHeader className="px-4 pt-4 pb-2"> - <AlertDialogTitle className="font-medium"> - Discard all unstaged changes? - </AlertDialogTitle> - <AlertDialogDescription> - This will revert all unstaged modifications and delete untracked - files. This action cannot be undone. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2"> - <Button - variant="ghost" - size="sm" - className="h-7 px-3 text-xs" - onClick={() => setShowDiscardUnstagedDialog(false)} - > - Cancel - </Button> - <Button - variant="destructive" - size="sm" - className="h-7 px-3 text-xs" - onClick={() => { - setShowDiscardUnstagedDialog(false); - discardAllUnstagedMutation.mutate({ - worktreePath: worktreePath || "", - }); - }} - > - Discard All - </Button> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> + title="Discard all unstaged changes?" + description="This will revert all unstaged modifications and delete untracked files. This action cannot be undone." + onConfirm={() => + discardAllUnstagedMutation.mutate({ + worktreePath: worktreePath || "", + }) + } + confirmLabel="Discard All" + /> - <AlertDialog + <DiscardConfirmDialog open={showDiscardStagedDialog} onOpenChange={setShowDiscardStagedDialog} - > - <AlertDialogContent className="max-w-[340px] gap-0 p-0"> - <AlertDialogHeader className="px-4 pt-4 pb-2"> - <AlertDialogTitle className="font-medium"> - Discard all staged changes? - </AlertDialogTitle> - <AlertDialogDescription> - This will unstage and revert all staged changes. Staged new files - will be deleted. This action cannot be undone. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2"> - <Button - variant="ghost" - size="sm" - className="h-7 px-3 text-xs" - onClick={() => setShowDiscardStagedDialog(false)} - > - Cancel - </Button> - <Button - variant="destructive" - size="sm" - className="h-7 px-3 text-xs" - onClick={() => { - setShowDiscardStagedDialog(false); - discardAllStagedMutation.mutate({ - worktreePath: worktreePath || "", - }); - }} - > - Discard All - </Button> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> + title="Discard all staged changes?" + description="This will unstage and revert all staged changes. Staged new files will be deleted. This action cannot be undone." + onConfirm={() => + discardAllStagedMutation.mutate({ + worktreePath: worktreePath || "", + }) + } + confirmLabel="Discard All" + /> </div> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx index e2a04b918a5..2cd64a68524 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx @@ -1,3 +1,4 @@ +import type { GitHubStatus } from "@superset/local-db"; import { Button } from "@superset/ui/button"; import { ButtonGroup } from "@superset/ui/button-group"; import { @@ -23,6 +24,9 @@ import { import { electronTrpc } from "renderer/lib/electron-trpc"; import { useCreateOrOpenPR } from "renderer/screens/main/hooks"; import { getPrimaryAction } from "./utils/getPrimaryAction"; +import { getPushActionCopy } from "./utils/getPushActionCopy"; + +type CommitInputPullRequest = NonNullable<GitHubStatus["pr"]>; interface CommitInputProps { worktreePath: string; @@ -30,10 +34,9 @@ interface CommitInputProps { pushCount: number; pullCount: number; hasUpstream: boolean; - hasExistingPR: boolean; + pullRequest?: CommitInputPullRequest | null; canCreatePR: boolean; shouldAutoCreatePRAfterPublish: boolean; - prUrl?: string; onRefresh: () => void; } @@ -43,10 +46,9 @@ export function CommitInput({ pushCount, pullCount, hasUpstream, - hasExistingPR, + pullRequest, canCreatePR, shouldAutoCreatePRAfterPublish, - prUrl, onRefresh, }: CommitInputProps) { const [commitMessage, setCommitMessage] = useState(""); @@ -108,6 +110,13 @@ export function CommitInput({ fetchMutation.isPending; const canCommit = hasStagedChanges && commitMessage.trim(); + const hasExistingPR = Boolean(pullRequest); + const prUrl = pullRequest?.url; + const pushActionCopy = getPushActionCopy({ + hasUpstream, + pushCount, + pullRequest, + }); const handleCommit = () => { if (!canCommit) return; @@ -176,7 +185,7 @@ export function CommitInput({ pushCount, pullCount, hasUpstream, - hasExistingPR, + pushActionCopy, }); const primary = { @@ -290,9 +299,7 @@ export function CommitInput({ className="text-xs" > <VscArrowUp className="size-3.5" /> - <span className="flex-1"> - {hasUpstream || hasExistingPR ? "Push" : "Publish Branch"} - </span> + <span className="flex-1">{pushActionCopy.menuLabel}</span> {pushCount > 0 && ( <span className="text-[10px] text-muted-foreground"> {pushCount} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPrimaryAction.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPrimaryAction.test.ts index 0b11c0cb6ed..21328221bc3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPrimaryAction.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPrimaryAction.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { getPrimaryAction } from "./getPrimaryAction"; +import { getPushActionCopy } from "./getPushActionCopy"; describe("getPrimaryAction", () => { test("prioritizes commit when commit is possible", () => { @@ -10,7 +11,10 @@ describe("getPrimaryAction", () => { pushCount: 3, pullCount: 2, hasUpstream: true, - hasExistingPR: false, + pushActionCopy: getPushActionCopy({ + hasUpstream: true, + pushCount: 3, + }), }); expect(state.action).toBe("commit"); @@ -27,7 +31,10 @@ describe("getPrimaryAction", () => { pushCount: 2, pullCount: 1, hasUpstream: true, - hasExistingPR: false, + pushActionCopy: getPushActionCopy({ + hasUpstream: true, + pushCount: 2, + }), }); expect(state.action).toBe("sync"); @@ -43,7 +50,10 @@ describe("getPrimaryAction", () => { pushCount: 2, pullCount: 0, hasUpstream: true, - hasExistingPR: false, + pushActionCopy: getPushActionCopy({ + hasUpstream: true, + pushCount: 2, + }), }); expect(state.action).toBe("push"); @@ -59,7 +69,10 @@ describe("getPrimaryAction", () => { pushCount: 0, pullCount: 2, hasUpstream: true, - hasExistingPR: false, + pushActionCopy: getPushActionCopy({ + hasUpstream: true, + pushCount: 0, + }), }); expect(state.action).toBe("pull"); @@ -75,7 +88,10 @@ describe("getPrimaryAction", () => { pushCount: 0, pullCount: 0, hasUpstream: false, - hasExistingPR: false, + pushActionCopy: getPushActionCopy({ + hasUpstream: false, + pushCount: 0, + }), }); expect(state.action).toBe("push"); @@ -91,12 +107,19 @@ describe("getPrimaryAction", () => { pushCount: 0, pullCount: 0, hasUpstream: false, - hasExistingPR: true, + pushActionCopy: getPushActionCopy({ + hasUpstream: false, + pushCount: 0, + pullRequest: { + headRefName: "feature/pr-branch", + headRepositoryOwner: "Kitenite", + }, + }), }); expect(state.action).toBe("push"); - expect(state.label).toBe("Push"); - expect(state.tooltip).toBe("Push branch changes"); + expect(state.label).toBe("Push to PR"); + expect(state.tooltip).toBe("Push changes to Kitenite:feature/pr-branch"); }); test("falls back to disabled commit state", () => { @@ -107,7 +130,10 @@ describe("getPrimaryAction", () => { pushCount: 0, pullCount: 0, hasUpstream: true, - hasExistingPR: false, + pushActionCopy: getPushActionCopy({ + hasUpstream: true, + pushCount: 0, + }), }); expect(state.action).toBe("commit"); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPrimaryAction.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPrimaryAction.ts index 1fea97886b7..0a29991c706 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPrimaryAction.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPrimaryAction.ts @@ -1,3 +1,5 @@ +import type { PushActionCopy } from "./getPushActionCopy"; + export type PrimaryActionType = "commit" | "sync" | "push" | "pull"; export interface PrimaryActionInput { @@ -7,7 +9,7 @@ export interface PrimaryActionInput { pushCount: number; pullCount: number; hasUpstream: boolean; - hasExistingPR: boolean; + pushActionCopy: Pick<PushActionCopy, "label" | "tooltip">; } export interface PrimaryActionState { @@ -24,7 +26,7 @@ export function getPrimaryAction({ pushCount, pullCount, hasUpstream, - hasExistingPR, + pushActionCopy, }: PrimaryActionInput): PrimaryActionState { if (canCommit) { return { @@ -47,9 +49,9 @@ export function getPrimaryAction({ if (pushCount > 0) { return { action: "push", - label: "Push", + label: pushActionCopy.label, disabled: isPending, - tooltip: `Push ${pushCount} commit${pushCount !== 1 ? "s" : ""}`, + tooltip: pushActionCopy.tooltip, }; } @@ -65,11 +67,9 @@ export function getPrimaryAction({ if (!hasUpstream) { return { action: "push", - label: hasExistingPR ? "Push" : "Publish Branch", + label: pushActionCopy.label, disabled: isPending, - tooltip: hasExistingPR - ? "Push branch changes" - : "Publish branch to remote", + tooltip: pushActionCopy.tooltip, }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPushActionCopy.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPushActionCopy.test.ts new file mode 100644 index 00000000000..d4337f6ea8d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPushActionCopy.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { getPushActionCopy } from "./getPushActionCopy"; + +describe("getPushActionCopy", () => { + test("shows publish branch copy when no upstream or PR target exists", () => { + expect( + getPushActionCopy({ + hasUpstream: false, + pushCount: 0, + }), + ).toEqual({ + label: "Publish Branch", + menuLabel: "Publish Branch", + tooltip: "Publish branch to remote", + }); + }); + + test("shows generic push copy for tracked branches without a PR target", () => { + expect( + getPushActionCopy({ + hasUpstream: true, + pushCount: 2, + }), + ).toEqual({ + label: "Push", + menuLabel: "Push", + tooltip: "Push 2 commits", + }); + }); + + test("shows PR-specific push copy when an attached PR target exists", () => { + expect( + getPushActionCopy({ + hasUpstream: true, + pushCount: 1, + pullRequest: { + headRefName: "feature/pr-branch", + headRepositoryOwner: "Kitenite", + }, + }), + ).toEqual({ + label: "Push to PR", + menuLabel: "Push to PR", + tooltip: "Push 1 commit to Kitenite:feature/pr-branch", + }); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPushActionCopy.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPushActionCopy.ts new file mode 100644 index 00000000000..c074ebe79df --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/utils/getPushActionCopy.ts @@ -0,0 +1,63 @@ +import type { GitHubStatus } from "@superset/local-db"; + +type PushActionPullRequest = Pick< + NonNullable<GitHubStatus["pr"]>, + "headRefName" | "headRepositoryOwner" +>; + +export interface PushActionCopy { + label: string; + menuLabel: string; + tooltip: string; +} + +function formatPullRequestPushTarget( + pullRequest?: PushActionPullRequest | null, +): string | null { + const branch = pullRequest?.headRefName?.trim(); + if (!branch) { + return null; + } + + const owner = pullRequest?.headRepositoryOwner?.trim(); + return owner ? `${owner}:${branch}` : branch; +} + +export function getPushActionCopy({ + hasUpstream, + pushCount, + pullRequest, +}: { + hasUpstream: boolean; + pushCount: number; + pullRequest?: PushActionPullRequest | null; +}): PushActionCopy { + const pullRequestTarget = formatPullRequestPushTarget(pullRequest); + if (pullRequestTarget) { + return { + label: "Push to PR", + menuLabel: "Push to PR", + tooltip: + pushCount > 0 + ? `Push ${pushCount} commit${pushCount !== 1 ? "s" : ""} to ${pullRequestTarget}` + : `Push changes to ${pullRequestTarget}`, + }; + } + + if (!hasUpstream) { + return { + label: "Publish Branch", + menuLabel: "Publish Branch", + tooltip: "Publish branch to remote", + }; + } + + return { + label: "Push", + menuLabel: "Push", + tooltip: + pushCount > 0 + ? `Push ${pushCount} commit${pushCount !== 1 ? "s" : ""}` + : "Push branch changes", + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx index db3841ad710..2055c5aaf83 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/DiscardConfirmDialog/DiscardConfirmDialog.tsx @@ -1,10 +1,11 @@ import { AlertDialog, - AlertDialogContent, + AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + EnterEnabledAlertDialogContent, } from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; @@ -29,7 +30,7 @@ export function DiscardConfirmDialog({ }: DiscardConfirmDialogProps) { return ( <AlertDialog open={open} onOpenChange={onOpenChange}> - <AlertDialogContent className="max-w-[340px] gap-0 p-0"> + <EnterEnabledAlertDialogContent className="max-w-[340px] gap-0 p-0"> <AlertDialogHeader className="px-4 pt-4 pb-2"> <AlertDialogTitle className="font-medium">{title}</AlertDialogTitle> <AlertDialogDescription>{description}</AlertDialogDescription> @@ -43,20 +44,17 @@ export function DiscardConfirmDialog({ > Cancel </Button> - <Button + <AlertDialogAction variant="destructive" size="sm" className="h-7 px-3 text-xs" disabled={confirmDisabled} - onClick={() => { - onOpenChange(false); - onConfirm(); - }} + onClick={onConfirm} > {confirmLabel} - </Button> + </AlertDialogAction> </AlertDialogFooter> - </AlertDialogContent> + </EnterEnabledAlertDialogContent> </AlertDialog> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx index d97316e27d7..b579ff88926 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/FileItem/FileItem.tsx @@ -104,24 +104,32 @@ export function FileItem({ usePathActions({ absolutePath, relativePath: file.path, - cwd: worktreePath, + worktreePath, defaultApp, projectId, }); const fileDragProps = useFileDrag({ absolutePath }); - const handleClick = useCallback(() => { - if (clickTimeoutRef.current) { - clearTimeout(clickTimeoutRef.current); - clickTimeoutRef.current = null; - } + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (e.metaKey || e.ctrlKey) { + openInEditor(); + return; + } + + if (clickTimeoutRef.current) { + clearTimeout(clickTimeoutRef.current); + clickTimeoutRef.current = null; + } - clickTimeoutRef.current = setTimeout(() => { - clickTimeoutRef.current = null; - onClick(); - }, 300); - }, [onClick]); + clickTimeoutRef.current = setTimeout(() => { + clickTimeoutRef.current = null; + onClick(); + }, 300); + }, + [onClick, openInEditor], + ); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx index d9742f4e1f8..c12401e68d1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx @@ -8,25 +8,30 @@ import { import { Skeleton } from "@superset/ui/skeleton"; import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; -import type { ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; -import { LuArrowUpRight, LuCheck, LuCopy } from "react-icons/lu"; +import { + LuArrowUpRight, + LuCheck, + LuCheckCheck, + LuCopy, + LuLoaderCircle, + LuUndo2, +} from "react-icons/lu"; import { VscChevronRight } from "react-icons/vsc"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { PRIcon } from "renderer/screens/main/components/PRIcon"; +import { useTabsStore } from "renderer/stores/tabs/store"; import { ALL_COMMENTS_COPY_ACTION_KEY, buildAllCommentsClipboardText, buildCommentClipboardText, checkIconConfig, checkSummaryIconConfig, - countOpenPullRequestComments, formatShortAge, getCommentAvatarFallback, getCommentCopyActionKey, getCommentKindText, getCommentPreviewText, - prStateLabel, resolveCheckDestinationUrl, reviewDecisionConfig, splitPullRequestComments, @@ -37,6 +42,8 @@ interface ReviewPanelProps { comments?: PullRequestComment[]; isLoading?: boolean; isCommentsLoading?: boolean; + workspaceId?: string; + onCommentsChange?: () => void; } export function ReviewPanel({ @@ -44,17 +51,38 @@ export function ReviewPanel({ comments = [], isLoading = false, isCommentsLoading = false, + workspaceId, + onCommentsChange, }: ReviewPanelProps) { const [checksOpen, setChecksOpen] = useState(true); const [commentsOpen, setCommentsOpen] = useState(true); - const [openCommentsGroupOpen, setOpenCommentsGroupOpen] = useState(true); const [resolvedCommentsGroupOpen, setResolvedCommentsGroupOpen] = useState(false); const [copiedActionKey, setCopiedActionKey] = useState<string | null>(null); + const [resolvingThreadIds, setResolvingThreadIds] = useState<Set<string>>( + new Set(), + ); + const [isResolvingAll, setIsResolvingAll] = useState(false); const copiedActionResetTimeoutRef = useRef<ReturnType< typeof setTimeout > | null>(null); const copyToClipboardMutation = electronTrpc.external.copyText.useMutation(); + const resolveThreadMutation = + electronTrpc.workspaces.resolveReviewThread.useMutation(); + const openCommentPane = useTabsStore((s) => s.openCommentPane); + + const handleOpenComment = (comment: PullRequestComment) => { + if (!workspaceId) return; + openCommentPane(workspaceId, { + commentId: comment.id, + authorLogin: comment.authorLogin, + avatarUrl: comment.avatarUrl, + body: comment.body, + url: comment.url, + path: comment.path, + line: comment.line, + }); + }; useEffect(() => { return () => { @@ -102,41 +130,43 @@ export function ReviewPanel({ }); }; + const handleToggleResolve = (comment: PullRequestComment) => { + const threadId = comment.threadId; + if (!workspaceId || !threadId) return; + + setResolvingThreadIds((prev) => new Set(prev).add(threadId)); + resolveThreadMutation.mutate( + { + workspaceId, + threadId, + resolve: !comment.isResolved, + }, + { + onSuccess: () => { + onCommentsChange?.(); + }, + onError: (error) => { + const message = + error instanceof Error ? error.message : "Unknown error"; + toast.error( + `Failed to ${comment.isResolved ? "undo" : "mark as done"}: ${message}`, + ); + }, + onSettled: () => { + setResolvingThreadIds((prev) => { + const next = new Set(prev); + next.delete(threadId); + return next; + }); + }, + }, + ); + }; + if (isLoading && !pr) { return ( - <div className="flex h-full flex-col overflow-y-auto px-2 py-2"> - <div className="border-b border-border/70 px-0 pb-2"> - <div className="flex items-center gap-2 px-2"> - <Skeleton className="h-4 w-4 rounded-sm" /> - <Skeleton className="h-4 flex-1" /> - <Skeleton className="h-3 w-10" /> - </div> - <div className="mt-2 flex items-center gap-2 px-2"> - <Skeleton className="h-4 w-24 rounded-sm" /> - <Skeleton className="h-3 w-28" /> - </div> - </div> - <div className="border-b border-border/60 px-0 py-2"> - <div className="flex items-center justify-between px-2 pb-1"> - <Skeleton className="h-3 w-10" /> - <Skeleton className="h-3 w-24" /> - </div> - <div className="space-y-1 px-1"> - <Skeleton className="h-8 w-full rounded-sm" /> - <Skeleton className="h-8 w-full rounded-sm" /> - </div> - </div> - <div className="px-0 py-2"> - <div className="flex items-center justify-between px-2 pb-1"> - <Skeleton className="h-3 w-14" /> - <Skeleton className="h-3 w-6" /> - </div> - <div className="space-y-1 px-1"> - <Skeleton className="h-11 w-full rounded-sm" /> - <Skeleton className="h-11 w-full rounded-sm" /> - <Skeleton className="h-11 w-full rounded-sm" /> - </div> - </div> + <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> + Loading review... </div> ); } @@ -150,10 +180,6 @@ export function ReviewPanel({ } const requestedReviewers = pr.requestedReviewers ?? []; - const reviewLabel = - pr.reviewDecision === "pending" && requestedReviewers.length > 0 - ? `Awaiting ${requestedReviewers.join(", ")}` - : reviewDecisionConfig[pr.reviewDecision].label; const relevantChecks = pr.checks.filter( (check) => check.status !== "skipped" && check.status !== "cancelled", @@ -170,9 +196,7 @@ export function ReviewPanel({ const ChecksStatusIcon = checksStatusConfig.icon; const { active: activeComments, resolved: resolvedComments } = splitPullRequestComments(comments); - const commentsCountLabel = isCommentsLoading - ? "..." - : countOpenPullRequestComments(comments); + const commentsCountLabel = isCommentsLoading ? "..." : comments.length; const copyAllCommentsLabel = copiedActionKey === ALL_COMMENTS_COPY_ACTION_KEY ? "Copied" : "Copy all"; @@ -184,6 +208,49 @@ export function ReviewPanel({ }); }; + const uniqueResolvableThreadIds = [ + ...new Set( + activeComments.map((c) => c.threadId).filter((id): id is string => !!id), + ), + ]; + const handleResolveAll = async () => { + if (!workspaceId || uniqueResolvableThreadIds.length === 0) return; + + const batchIds = uniqueResolvableThreadIds; + setIsResolvingAll(true); + setResolvingThreadIds((prev) => new Set([...prev, ...batchIds])); + + try { + const results = await Promise.allSettled( + batchIds.map((threadId) => + resolveThreadMutation.mutateAsync({ + workspaceId, + threadId, + resolve: true, + }), + ), + ); + const failed = results.filter((r) => r.status === "rejected"); + if (results.some((r) => r.status === "fulfilled")) { + onCommentsChange?.(); + } + if (failed.length > 0) { + toast.error( + `Failed to mark ${failed.length} thread${failed.length === 1 ? "" : "s"} as done`, + ); + } + } finally { + setIsResolvingAll(false); + setResolvingThreadIds((prev) => { + const next = new Set(prev); + for (const id of batchIds) { + next.delete(id); + } + return next; + }); + } + }; + const renderCommentList = (list: PullRequestComment[]) => list.map((comment) => { const age = formatShortAge(comment.createdAt); @@ -207,6 +274,7 @@ export function ReviewPanel({ <span className="shrink-0 rounded border border-border/70 bg-muted/35 px-1 py-0 text-[9px] uppercase tracking-wide text-muted-foreground"> {getCommentKindText(comment)} </span> + <span className="flex-1" /> {age ? ( <span className="shrink-0 text-[10px] text-muted-foreground"> {age} @@ -223,37 +291,41 @@ export function ReviewPanel({ return ( <div key={comment.id} - className="group flex items-start gap-1 rounded-sm px-1.5 py-1 transition-colors hover:bg-accent/50" + className="group relative flex items-start gap-1 rounded-sm px-1.5 py-1 transition-colors hover:bg-accent/50" > - {comment.url ? ( - <a - href={comment.url} - target="_blank" - rel="noopener noreferrer" - className="flex min-w-0 flex-1 items-start gap-2" - > - {content} - </a> - ) : ( - <div className="flex min-w-0 flex-1 items-start gap-2"> - {content} - </div> - )} - <div className="mt-0.5 flex shrink-0 flex-col gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"> - {comment.url ? ( - <a - href={comment.url} - target="_blank" - rel="noopener noreferrer" - className="inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" - aria-label="Open comment on GitHub" + <button + type="button" + onClick={() => handleOpenComment(comment)} + className="flex min-w-0 flex-1 items-start gap-2 text-left" + aria-label={`View comment by ${comment.authorLogin}`} + > + {content} + </button> + <div className="absolute right-0.5 top-0.5 flex items-center gap-0.5 rounded-sm bg-background/90 px-0.5 py-0.5 shadow-sm opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"> + {comment.threadId && workspaceId ? ( + <button + type="button" + className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + handleToggleResolve(comment); + }} + disabled={resolvingThreadIds.has(comment.threadId)} + aria-label={comment.isResolved ? "Undo done" : "Mark as done"} > - <LuArrowUpRight className="size-3" /> - </a> + {resolvingThreadIds.has(comment.threadId) ? ( + <LuLoaderCircle className="size-3 animate-spin" /> + ) : comment.isResolved ? ( + <LuUndo2 className="size-3" /> + ) : ( + <LuCheckCheck className="size-3" /> + )} + </button> ) : null} <button type="button" - className="inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" onClick={(event) => { event.preventDefault(); event.stopPropagation(); @@ -267,96 +339,64 @@ export function ReviewPanel({ <LuCopy className="size-3" /> )} </button> + {comment.url ? ( + <a + href={comment.url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" + aria-label="Open comment on GitHub" + > + <LuArrowUpRight className="size-3" /> + </a> + ) : null} </div> </div> ); }); - const renderCommentSection = ({ - title, - comments, - isOpen, - onOpenChange, - action, - }: { - title: string; - comments: PullRequestComment[]; - isOpen: boolean; - onOpenChange: (open: boolean) => void; - action?: ReactNode; - }) => ( - <Collapsible - open={isOpen} - onOpenChange={onOpenChange} - className="min-w-0 overflow-hidden" - > - <div className="group flex min-w-0 items-center"> - <CollapsibleTrigger - className={cn( - "flex w-full min-w-0 items-center gap-1.5 px-1.5 py-1 text-left", - "hover:bg-accent/30 cursor-pointer transition-colors", - )} - > - <VscChevronRight - className={cn( - "size-3 text-muted-foreground shrink-0 transition-transform duration-150", - isOpen && "rotate-90", - )} - /> - <span className="text-xs font-medium truncate">{title}</span> - <span className="text-[10px] text-muted-foreground shrink-0"> - {comments.length} - </span> - </CollapsibleTrigger> - {action ? <div className="mr-1 shrink-0">{action}</div> : null} - </div> - <CollapsibleContent className="min-w-0 overflow-hidden"> - {renderCommentList(comments)} - </CollapsibleContent> - </Collapsible> - ); - return ( <div className="flex h-full min-h-0 flex-col overflow-y-auto"> - <div className="border-b border-border/70 px-2 py-2"> - <div className="flex items-center gap-1.5"> + <div className="px-2 py-2 space-y-1.5"> + <a + href={pr.url} + target="_blank" + rel="noopener noreferrer" + className="group flex items-center gap-1.5 cursor-pointer" + > <PRIcon state={pr.state} className="size-4 shrink-0" /> - <a - href={pr.url} - target="_blank" - rel="noopener noreferrer" - className="min-w-0 flex-1 truncate text-xs font-medium text-foreground hover:underline" + <span + className="min-w-0 flex-1 truncate text-xs font-medium text-foreground" title={pr.title} > {pr.title} - </a> - <span className="shrink-0 font-mono text-[10px] text-muted-foreground"> - #{pr.number} </span> - </div> - - <div className="mt-1.5 flex items-center gap-1.5"> + <LuArrowUpRight className="size-3.5 shrink-0 text-muted-foreground/70 opacity-0 transition-opacity group-hover:opacity-100" /> + </a> + <div className="flex items-center gap-1.5"> <span className={cn( - "shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium", + "shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] font-medium", reviewDecisionConfig[pr.reviewDecision].className, )} > {reviewDecisionConfig[pr.reviewDecision].label} </span> - <span className="truncate text-[10px] text-muted-foreground"> - {requestedReviewers.length > 0 - ? reviewLabel - : prStateLabel[pr.state]} - </span> + {requestedReviewers.length > 0 && ( + <span className="truncate text-[10px] text-muted-foreground"> + Awaiting {requestedReviewers.join(", ")} + </span> + )} </div> </div> + <div className="border-b border-border/70 my-1" /> + <Collapsible open={checksOpen} onOpenChange={setChecksOpen}> <CollapsibleTrigger className={cn( - "group flex w-full min-w-0 items-center justify-between gap-2 px-2 py-1 text-left", - "hover:bg-accent/50 cursor-pointer transition-colors", + "flex w-full min-w-0 items-center justify-between gap-2 px-2 py-1.5 text-left", + "hover:bg-accent/30 cursor-pointer transition-colors", )} > <div className="flex min-w-0 items-center gap-1.5"> @@ -388,10 +428,10 @@ export function ReviewPanel({ </span> </div> </CollapsibleTrigger> - <CollapsibleContent className="min-w-0 px-0.5 pb-1 overflow-hidden"> + <CollapsibleContent className="px-0.5 pb-1 min-w-0 overflow-hidden"> {relevantChecks.length === 0 ? ( <div className="px-1.5 py-1 text-xs text-muted-foreground"> - No active checks reported for this pull request yet. + No checks reported. </div> ) : ( relevantChecks.map((check) => { @@ -451,16 +491,18 @@ export function ReviewPanel({ </CollapsibleContent> </Collapsible> + <div className="border-b border-border/70 my-1" /> + <Collapsible open={commentsOpen} onOpenChange={setCommentsOpen} className="min-w-0" > - <div className="group flex min-w-0 items-center"> + <div className="flex min-w-0 items-center"> <CollapsibleTrigger className={cn( - "flex min-w-0 flex-1 items-center gap-1.5 px-2 py-1 text-left", - "hover:bg-accent/50 cursor-pointer transition-colors", + "flex flex-1 min-w-0 items-center gap-1.5 px-2 py-1.5 text-left", + "hover:bg-accent/30 cursor-pointer transition-colors", )} > <VscChevronRight @@ -474,62 +516,83 @@ export function ReviewPanel({ {commentsCountLabel} </span> </CollapsibleTrigger> + {activeComments.length > 0 && ( + <div className="mr-1.5 flex items-center gap-1"> + {uniqueResolvableThreadIds.length > 0 && workspaceId && ( + <button + type="button" + className="shrink-0 flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent/30 hover:text-foreground disabled:opacity-50" + onClick={() => void handleResolveAll()} + disabled={isResolvingAll} + > + {isResolvingAll ? ( + <LuLoaderCircle className="size-3 animate-spin" /> + ) : ( + <LuCheckCheck className="size-3" /> + )} + <span>Mark all done</span> + </button> + )} + <button + type="button" + className="shrink-0 flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent/30 hover:text-foreground" + onClick={handleCopyCommentsList} + > + {copiedActionKey === ALL_COMMENTS_COPY_ACTION_KEY ? ( + <LuCheck className="size-3" /> + ) : ( + <LuCopy className="size-3" /> + )} + <span>{copyAllCommentsLabel}</span> + </button> + </div> + )} </div> - <CollapsibleContent className="min-w-0 overflow-hidden"> - <div className="px-0.5 py-1"> - {isCommentsLoading ? ( - <div className="space-y-1 px-1"> - <Skeleton className="h-11 w-full rounded-sm" /> - <Skeleton className="h-11 w-full rounded-sm" /> - <Skeleton className="h-11 w-full rounded-sm" /> - </div> - ) : comments.length === 0 ? ( - <div className="px-1.5 py-1.5 text-xs text-muted-foreground"> - No comments yet. - </div> - ) : ( - <> - {activeComments.length > 0 - ? renderCommentSection({ - title: "Open", - comments: activeComments, - isOpen: openCommentsGroupOpen, - onOpenChange: setOpenCommentsGroupOpen, - action: ( - <button - type="button" - className="flex items-center gap-1 rounded-sm px-1.5 py-1 text-[10px] text-muted-foreground transition-colors hover:bg-accent/30 hover:text-foreground" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - handleCopyCommentsList(); - }} - > - {copiedActionKey === ALL_COMMENTS_COPY_ACTION_KEY ? ( - <LuCheck className="size-3" /> - ) : ( - <LuCopy className="size-3" /> - )} - <span>{copyAllCommentsLabel}</span> - </button> - ), - }) - : null} - {resolvedComments.length > 0 ? ( - <div className="pt-1"> - {renderCommentSection({ - title: "Resolved", - comments: resolvedComments, - isOpen: resolvedCommentsGroupOpen, - onOpenChange: setResolvedCommentsGroupOpen, - })} - </div> - ) : null} - </> - )} - </div> + <CollapsibleContent className="px-0.5 pb-1 min-w-0 overflow-hidden"> + {isCommentsLoading ? ( + <div className="space-y-1 px-1"> + <Skeleton className="h-11 w-full rounded-sm" /> + <Skeleton className="h-11 w-full rounded-sm" /> + <Skeleton className="h-11 w-full rounded-sm" /> + </div> + ) : comments.length === 0 ? ( + <div className="px-1.5 py-1 text-xs text-muted-foreground"> + No comments yet. + </div> + ) : ( + renderCommentList(activeComments) + )} </CollapsibleContent> </Collapsible> + + {resolvedComments.length > 0 && ( + <Collapsible + open={resolvedCommentsGroupOpen} + onOpenChange={setResolvedCommentsGroupOpen} + className="min-w-0" + > + <CollapsibleTrigger + className={cn( + "flex w-full min-w-0 items-center gap-1.5 px-2 py-1.5 text-left", + "hover:bg-accent/30 cursor-pointer transition-colors", + )} + > + <VscChevronRight + className={cn( + "size-3 text-muted-foreground shrink-0 transition-transform duration-150", + resolvedCommentsGroupOpen && "rotate-90", + )} + /> + <span className="text-xs font-medium truncate">Resolved</span> + <span className="text-[10px] text-muted-foreground shrink-0"> + {resolvedComments.length} + </span> + </CollapsibleTrigger> + <CollapsibleContent className="px-0.5 pb-1 min-w-0 overflow-hidden"> + {renderCommentList(resolvedComments)} + </CollapsibleContent> + </Collapsible> + )} </div> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts index 44c156c7ee4..ee8df271ccf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/hooks/usePathActions.ts @@ -7,8 +7,8 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; interface UsePathActionsProps { absolutePath: string | null; relativePath?: string; - /** For files: pass cwd to use openFileInEditor. For folders: omit to use openInApp */ - cwd?: string; + /** For files: pass worktreePath to use openFileInEditor. For folders: omit to use openInApp */ + worktreePath?: string; /** Pre-resolved app to avoid per-row default-app queries */ defaultApp?: ExternalApp | null; /** Project identifier for project-scoped actions/metadata */ @@ -18,7 +18,7 @@ interface UsePathActionsProps { export function usePathActions({ absolutePath, relativePath, - cwd, + worktreePath, defaultApp, projectId, }: UsePathActionsProps) { @@ -60,8 +60,12 @@ export function usePathActions({ const openInEditor = useCallback(() => { if (!absolutePath) return; - if (cwd) { - openFileInEditorMutation.mutate({ path: absolutePath, cwd, projectId }); + if (worktreePath) { + openFileInEditorMutation.mutate({ + path: absolutePath, + worktreePath, + projectId, + }); } else { // Avoid opening with an incorrect fallback before upstream default app query resolves. if (defaultApp === undefined) { @@ -87,7 +91,7 @@ export function usePathActions({ } }, [ absolutePath, - cwd, + worktreePath, projectId, defaultApp, openInAppMutation, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx index b6ef54d33ce..87058fbbce1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx @@ -431,7 +431,7 @@ export function FilesView() { if (!worktreePath) return; openFileInEditorMutation.mutate({ path: entry.path, - cwd: worktreePath, + worktreePath, projectId, }); }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx index c85be76f857..cd2238ad89c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileSearchResultItem/FileSearchResultItem.tsx @@ -77,7 +77,7 @@ export function FileSearchResultItem({ usePathActions({ absolutePath: entry.path, relativePath: entry.relativePath, - cwd: worktreePath, + worktreePath, projectId, }); @@ -85,7 +85,13 @@ export function FileSearchResultItem({ const handleClick = (e: React.MouseEvent) => { if (!entry.isDirectory) { - onActivate(entry, e.metaKey || e.ctrlKey ? true : undefined); + if (e.shiftKey) { + onActivate(entry, true); + } else if (e.metaKey || e.ctrlKey) { + onOpenInEditor(entry); + } else { + onActivate(entry); + } } }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx index 3a15f7c6c68..dc48b3a21a6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/components/FileTreeItem/FileTreeItem.tsx @@ -64,7 +64,7 @@ export function FileTreeItem({ usePathActions({ absolutePath: entry.path, relativePath: entry.relativePath, - cwd: worktreePath, + worktreePath, projectId, }); @@ -72,14 +72,18 @@ export function FileTreeItem({ const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); - if (isFolder) { + if (e.metaKey || e.ctrlKey) { + onOpenInEditor(entry); + } else if (isFolder) { if (isExpanded) { item.collapse(); } else { item.expand(); } + } else if (e.shiftKey) { + onActivate(entry, true); } else { - onActivate(entry, e.metaKey || e.ctrlKey ? true : undefined); + onActivate(entry); } }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants.ts index a86d5df4753..164383ede52 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/constants.ts @@ -1,6 +1,6 @@ export const ROW_HEIGHT = 28; export const SEARCH_RESULT_ROW_HEIGHT = 40; -export const TREE_INDENT = 16; +export const TREE_INDENT = 10; export const OVERSCAN_COUNT = 10; export const SEARCH_DEBOUNCE_MS = 150; export const SEARCH_RESULT_LIMIT = 200; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts index fd457184a40..d2041510508 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch.ts @@ -1,4 +1,3 @@ -import { useDebouncedValue } from "renderer/hooks/useDebouncedValue"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { SEARCH_RESULT_LIMIT } from "../../constants"; @@ -18,22 +17,18 @@ export function useFileSearch({ limit = SEARCH_RESULT_LIMIT, }: UseFileSearchParams) { const trimmedQuery = searchTerm.trim(); - const debouncedQuery = useDebouncedValue(trimmedQuery, 150); - const isDebouncing = - trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery; const { data: searchResults, isFetching } = electronTrpc.filesystem.searchFiles.useQuery( { workspaceId: workspaceId ?? "", - query: debouncedQuery, + query: trimmedQuery, includePattern, excludePattern, limit, }, { - enabled: Boolean(workspaceId) && debouncedQuery.length > 0, - staleTime: 1000, + enabled: Boolean(workspaceId) && trimmedQuery.length > 0, placeholderData: (previous) => previous ?? { matches: [] }, }, ); @@ -50,7 +45,7 @@ export function useFileSearch({ return { searchResults: results, - isFetching: isFetching || isDebouncing, + isFetching, hasQuery: trimmedQuery.length > 0, }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/new-item-paths.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/new-item-paths.ts index c3a07c8f5f6..c2b40d25926 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/new-item-paths.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils/new-item-paths.ts @@ -1,3 +1,7 @@ +import { getBaseName } from "renderer/lib/pathBasename"; + +export { getBaseName }; + export function getPathSeparator(absolutePath: string): string { return absolutePath.includes("\\") ? "\\" : "/"; } @@ -10,10 +14,6 @@ export function joinAbsolutePath( return `${parentAbsolutePath.replace(/[\\/]+$/, "")}${separator}${name}`; } -export function getBaseName(absolutePath: string): string { - return absolutePath.split(/[/\\]/).pop() ?? absolutePath; -} - export function getParentPath(absolutePath: string): string { const trimmedPath = absolutePath.replace(/[\\/]+$/, ""); const lastSeparatorIndex = Math.max( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/headerTabStyles.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/headerTabStyles.ts index 627cb742654..cf06bacf244 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/headerTabStyles.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/headerTabStyles.ts @@ -15,7 +15,7 @@ export function getSidebarHeaderTabButtonClassName({ "h-full shrink-0 transition-all", compact ? "flex w-10 items-center justify-center" - : "flex items-center gap-2 px-3 text-sm", + : "flex items-center gap-1.5 px-3 text-xs", isActive ? SIDEBAR_HEADER_TAB_ACTIVE_CLASS_NAME : SIDEBAR_HEADER_TAB_INACTIVE_CLASS_NAME, @@ -23,7 +23,7 @@ export function getSidebarHeaderTabButtonClassName({ } export const sidebarHeaderTabTriggerClassName = cn( - "flex h-full flex-none shrink-0 items-center gap-2 rounded-none border-0 bg-transparent px-3 text-sm font-normal shadow-none transition-all outline-none", + "flex h-full flex-none shrink-0 items-center gap-1.5 rounded-none border-0 bg-transparent px-3 text-xs font-normal shadow-none transition-all outline-none", "data-[state=active]:bg-border/30 data-[state=active]:text-foreground data-[state=active]:shadow-none", "data-[state=inactive]:text-muted-foreground/70 data-[state=inactive]:hover:bg-tertiary/20 data-[state=inactive]:hover:text-muted-foreground", ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx index 4fd295c53d0..55ec2680787 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -9,7 +9,7 @@ import { LuShrink, LuX, } from "react-icons/lu"; -import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import { HotkeyLabel } from "renderer/hotkeys"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { RightSidebarTab, @@ -196,9 +196,9 @@ export function RightSidebar() { </Button> </TooltipTrigger> <TooltipContent side="bottom" showArrow={false}> - <HotkeyTooltipContent + <HotkeyLabel label={isExpanded ? "Collapse sidebar" : "Expand sidebar"} - hotkeyId="TOGGLE_EXPAND_SIDEBAR" + id="OPEN_DIFF_VIEWER" /> </TooltipContent> </Tooltip> @@ -214,10 +214,7 @@ export function RightSidebar() { </Button> </TooltipTrigger> <TooltipContent side="bottom" showArrow={false}> - <HotkeyTooltipContent - label="Close sidebar" - hotkeyId="TOGGLE_SIDEBAR" - /> + <HotkeyLabel label="Close sidebar" id="TOGGLE_SIDEBAR" /> </TooltipContent> </Tooltip> </div> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/KeypadLoader.css b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/KeypadLoader.css new file mode 100644 index 00000000000..b61c77251ab --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/KeypadLoader.css @@ -0,0 +1,147 @@ +.keypad-loader { + --travel: 26; + position: relative; + aspect-ratio: 400 / 310; + display: flex; + place-items: center; + width: clamp(260px, 34vw, 420px); + transform-style: preserve-3d; + user-select: none; +} + +.keypad-loader__base { + position: absolute; + bottom: 0; + width: 100%; + pointer-events: none; +} + +.keypad-loader__base img { + width: 100%; + display: block; +} + +.keypad-loader__key { + position: absolute; + width: 21%; + height: 24%; + transform-style: preserve-3d; + clip-path: polygon( + 0 0, + 54% 0, + 89% 24%, + 100% 70%, + 54% 100%, + 46% 100%, + 0 69%, + 12% 23%, + 47% 0% + ); + mask: url("../assets/key-single.png") 50% 50% / 100% 100%; + -webkit-mask: url("../assets/key-single.png") 50% 50% / 100% 100%; +} + +.keypad-loader__key--one { + left: 13.5%; + bottom: 57.2%; +} +.keypad-loader__key--two { + left: 25.8%; + bottom: 48.5%; +} +.keypad-loader__key--three { + left: 38%; + bottom: 39.2%; +} +.keypad-loader__key--four { + left: 50.4%; + bottom: 30.2%; +} +.keypad-loader__key--five { + left: 62.7%; + bottom: 21%; +} + +.keypad-loader__mask { + width: 100%; + height: 100%; + display: inline-block; +} + +.keypad-loader__content { + width: 100%; + height: 100%; + display: inline-block; + position: relative; + container-type: inline-size; + transition: + translate 0.7s cubic-bezier(0.22, 1, 0.36, 1), + filter 0.7s ease-out; +} + +.keypad-loader__content img { + position: absolute; + top: 0; + left: 50%; + width: 96%; + translate: -50% 1%; + pointer-events: none; + /* Shift the purple base image toward Superset orange (#f97316, hue ~25°). */ + filter: hue-rotate(118deg) saturate(1.15) brightness(0.92); + transition: filter 0.7s ease-out; +} + +.keypad-loader__key[data-pressed="true"] .keypad-loader__content { + translate: 0 calc(var(--travel) * 1%); +} + +.keypad-loader__key[data-pressed="true"] .keypad-loader__content img { + filter: hue-rotate(118deg) saturate(1.35) brightness(1.05); +} + +.keypad-loader__key[data-active="true"] .keypad-loader__content { + animation: keypad-loader-bob 2.2s ease-in-out infinite; +} + +@keyframes keypad-loader-bob { + 0%, + 100% { + translate: 0 0; + } + 50% { + translate: 0 calc(var(--travel) * 0.28%); + } +} + +.keypad-loader__text { + position: absolute; + top: 5%; + left: 0; + width: 52%; + height: 62%; + z-index: 21; + font-size: 18cqi; + color: #fff; + translate: 45% -16%; + transform: rotateX(36deg) rotateY(45deg) rotateX(-90deg); + display: grid; + place-items: center; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45)); +} + +.keypad-loader__text svg { + width: 62%; + height: 62%; +} + +.keypad-loader__key[data-pressed="true"] .keypad-loader__text { + opacity: 0.85; +} + +@media (prefers-reduced-motion: reduce) { + .keypad-loader__content, + .keypad-loader__key[data-active="true"] .keypad-loader__content { + transition: none; + animation: none; + } +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/KeypadLoader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/KeypadLoader.tsx new file mode 100644 index 00000000000..3ba68bef301 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/KeypadLoader.tsx @@ -0,0 +1,209 @@ +import { cn } from "@superset/ui/utils"; +import type { ComponentType } from "react"; +import { useEffect, useRef, useState } from "react"; +import { + LuDatabase, + LuDownload, + LuFileCog, + LuGitBranch, + LuRefreshCw, +} from "react-icons/lu"; +import { + getStepIndex, + type WorkspaceInitStep, +} from "shared/types/workspace-init"; +import clickSoundUrl from "../assets/click.mp3"; +import keySingleUrl from "../assets/key-single.png"; +import keypadBaseUrl from "../assets/keypad-base.png"; +import "./KeypadLoader.css"; + +type KeyId = "one" | "two" | "three" | "four" | "five"; + +interface KeyDef { + id: KeyId; + /** Key is considered "pressed" once currentStep has advanced past this step. */ + pressedAfter: WorkspaceInitStep; + /** Steps during which this key should animate as "currently being pressed". */ + activeSteps: readonly WorkspaceInitStep[]; + Icon: ComponentType<{ className?: string }>; + label: string; +} + +// 6 underlying steps are collapsed into 5 keys by merging syncing + verifying. +const KEYS: readonly KeyDef[] = [ + { + id: "one", + pressedAfter: "verifying", + // Include "pending" so the keypad shows immediate activity before the + // first progress event arrives from the backend. + activeSteps: ["pending", "syncing", "verifying"], + Icon: LuRefreshCw, + label: "Syncing", + }, + { + id: "two", + pressedAfter: "fetching", + activeSteps: ["fetching"], + Icon: LuDownload, + label: "Fetching", + }, + { + id: "three", + pressedAfter: "creating_worktree", + activeSteps: ["creating_worktree"], + Icon: LuGitBranch, + label: "Creating worktree", + }, + { + id: "four", + pressedAfter: "copying_config", + activeSteps: ["copying_config"], + Icon: LuFileCog, + label: "Copying config", + }, + { + id: "five", + pressedAfter: "finalizing", + activeSteps: ["finalizing"], + Icon: LuDatabase, + label: "Finalizing", + }, +]; + +interface KeypadLoaderProps { + currentStep: WorkspaceInitStep; + className?: string; + muted?: boolean; + /** 0–1 click-sound volume. Clamped and ignored if muted. */ + volume?: number; +} + +const DEFAULT_CLICK_VOLUME = 0.35; + +export function KeypadLoader({ + currentStep, + className, + muted = false, + volume = DEFAULT_CLICK_VOLUME, +}: KeypadLoaderProps) { + const audioRef = useRef<HTMLAudioElement | null>(null); + const prevStepRef = useRef<WorkspaceInitStep>(currentStep); + const [reducedMotion, setReducedMotion] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReducedMotion(mq.matches); + const onChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + + // Reduced-motion implies reduced-audio — always auto-mute when it's on, + // even if the caller didn't pass muted. + const effectiveMuted = muted || reducedMotion; + const clampedVolume = Math.max(0, Math.min(1, volume)); + + useEffect(() => { + if (!audioRef.current) { + const audio = new Audio(clickSoundUrl); + audio.preload = "auto"; + audioRef.current = audio; + } + audioRef.current.muted = effectiveMuted; + audioRef.current.volume = clampedVolume; + }, [effectiveMuted, clampedVolume]); + + useEffect(() => { + return () => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.src = ""; + audioRef.current = null; + } + }; + }, []); + + useEffect(() => { + const prevStep = prevStepRef.current; + prevStepRef.current = currentStep; + if (prevStep === currentStep) return; + + const prevIdx = getStepIndex(prevStep); + const curIdx = getStepIndex(currentStep); + if (curIdx <= prevIdx) return; + + // Play one click per key crossed (usually one, but handle step skipping). + const crossed = KEYS.filter((k) => { + const t = getStepIndex(k.pressedAfter); + return prevIdx <= t && curIdx > t; + }); + + if (crossed.length === 0 || effectiveMuted || !audioRef.current) return; + + // Cap rapid-fire clicks (e.g. on a huge step skip) to avoid audio spam. + const clicksToPlay = Math.min(crossed.length, 2); + const scheduled: number[] = []; + for (let i = 0; i < clicksToPlay; i++) { + const id = window.setTimeout(() => { + try { + // Re-check mute at fire time — the user may have toggled the + // notification-mute setting in the 0–280ms since we scheduled. + const current = audioRef.current; + if (!current || current.muted) return; + // Clone per click so overlapping plays don't cancel each other + // via currentTime=0 while the previous play() Promise is pending. + const player = current.cloneNode() as HTMLAudioElement; + player.volume = clampedVolume; + void player.play().catch(() => {}); + } catch { + // ignore — audio is best-effort + } + }, i * 140); + scheduled.push(id); + } + + return () => { + for (const id of scheduled) window.clearTimeout(id); + }; + }, [currentStep, effectiveMuted, clampedVolume]); + + const currentIdx = getStepIndex(currentStep); + + return ( + <div + className={cn("keypad-loader", className)} + role="img" + aria-label={`Setup in progress: ${ + KEYS.find((k) => k.activeSteps.includes(currentStep))?.label ?? + "Preparing" + }`} + > + <div className="keypad-loader__base"> + <img src={keypadBaseUrl} alt="" /> + </div> + {KEYS.map(({ id, pressedAfter, activeSteps, Icon }) => { + const thresholdIdx = getStepIndex(pressedAfter); + const isPressed = currentIdx > thresholdIdx; + const isActive = activeSteps.includes(currentStep); + return ( + <div + key={id} + className={`keypad-loader__key keypad-loader__key--${id}`} + data-pressed={isPressed ? "true" : undefined} + data-active={isActive ? "true" : undefined} + > + <span className="keypad-loader__mask"> + <span className="keypad-loader__content"> + <span className="keypad-loader__text"> + <Icon /> + </span> + <img src={keySingleUrl} alt="" /> + </span> + </span> + </div> + ); + })} + </div> + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/index.ts new file mode 100644 index 00000000000..d2bb8f2de8f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/KeypadLoader/index.ts @@ -0,0 +1 @@ +export { KeypadLoader } from "./KeypadLoader"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/StepProgress.css b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/StepProgress.css new file mode 100644 index 00000000000..828f1e522b9 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/StepProgress.css @@ -0,0 +1,123 @@ +.step-progress { + position: relative; + width: 100%; + max-width: 20rem; + height: 1.75rem; + overflow: hidden; + margin: 0 auto; + mask-image: linear-gradient( + to bottom, + transparent, + black 35%, + black 65%, + transparent + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black 35%, + black 65%, + transparent + ); +} + +.step-progress__list { + position: relative; + width: 100%; + height: 100%; +} + +.step-progress__item { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: + opacity 0.55s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.55s cubic-bezier(0.22, 1, 0.36, 1), + color 0.3s ease-out; + will-change: transform, opacity; +} + +.step-progress__icon { + display: grid; + place-items: center; + font-size: 0.875rem; + flex-shrink: 0; +} + +.step-progress__check-stroke { + stroke: var(--background, #fff); +} + +.step-progress__title { + font-size: 0.8125rem; + font-weight: 500; + line-height: 1; + display: inline-flex; + align-items: baseline; +} + +.step-progress__ellipsis { + display: inline-flex; + width: 0.9em; + margin-left: 0.05em; + letter-spacing: 0.05em; +} + +.step-progress__ellipsis-dot { + visibility: hidden; + animation: step-progress-dot-1 1.6s steps(1, end) infinite; +} + +.step-progress__ellipsis-dot:nth-child(2) { + animation-name: step-progress-dot-2; +} + +.step-progress__ellipsis-dot:nth-child(3) { + animation-name: step-progress-dot-3; +} + +@keyframes step-progress-dot-1 { + 0% { + visibility: hidden; + } + 25%, + 100% { + visibility: visible; + } +} + +@keyframes step-progress-dot-2 { + 0%, + 25% { + visibility: hidden; + } + 50%, + 100% { + visibility: visible; + } +} + +@keyframes step-progress-dot-3 { + 0%, + 50% { + visibility: hidden; + } + 75%, + 100% { + visibility: visible; + } +} + +@media (prefers-reduced-motion: reduce) { + .step-progress__item { + transition: none; + } + .step-progress__ellipsis-dot { + animation: none; + visibility: visible; + } +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/StepProgress.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/StepProgress.tsx new file mode 100644 index 00000000000..7286dcb51ab --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/StepProgress.tsx @@ -0,0 +1,190 @@ +import { cn } from "@superset/ui/utils"; +import { useEffect, useState } from "react"; +import { + getStepIndex, + INIT_STEP_MESSAGES, + INIT_STEP_ORDER, + type WorkspaceInitStep, +} from "shared/types/workspace-init"; +import "./StepProgress.css"; + +// Hold a just-completed step centered with the green check for this long before +// sliding to the next step, so the transition is readable. +const DONE_HOLD_MS = 750; + +// Show every step except the terminal "ready" state. +const DISPLAY_STEPS: readonly WorkspaceInitStep[] = INIT_STEP_ORDER.filter( + (s) => s !== "ready", +); + +type StepState = "waiting" | "progress" | "done"; + +interface StepProgressProps { + currentStep: WorkspaceInitStep; +} + +export function StepProgress({ currentStep }: StepProgressProps) { + const targetIdx = getStepIndex(currentStep); + const [renderIdx, setRenderIdx] = useState(targetIdx); + const [holdDoneIdx, setHoldDoneIdx] = useState<number | null>(null); + + useEffect(() => { + if (targetIdx === renderIdx) { + setHoldDoneIdx(null); + return; + } + if (targetIdx < renderIdx) { + // Unexpected backward jump — snap to the new target. + setRenderIdx(targetIdx); + setHoldDoneIdx(null); + return; + } + // Hold the just-completed step centered with the done icon, then advance + // one step at a time so skipped steps still get a visible beat. + setHoldDoneIdx(renderIdx); + const t = window.setTimeout(() => { + setHoldDoneIdx(null); + setRenderIdx((prev) => Math.min(prev + 1, targetIdx)); + }, DONE_HOLD_MS); + return () => window.clearTimeout(t); + }, [targetIdx, renderIdx]); + + return ( + <div className="step-progress" aria-live="polite"> + <div className="step-progress__list"> + {DISPLAY_STEPS.map((step) => { + const idx = getStepIndex(step); + const distance = idx - renderIdx; + const isHeldDone = holdDoneIdx === idx; + const state: StepState = isHeldDone + ? "done" + : distance < 0 + ? "done" + : distance === 0 + ? "progress" + : "waiting"; + const fade = Math.abs(distance); + + return ( + <div + key={step} + className="step-progress__item text-foreground/85" + style={{ + transform: `translateY(${distance * 100}%)`, + opacity: Math.max(0, 1 - fade * 0.35), + }} + > + <span + className={cn( + "step-progress__icon", + state === "waiting" && "text-muted-foreground/50", + state === "progress" && "text-orange-500", + state === "done" && "text-green-500", + )} + > + <StepIcon state={state} /> + </span> + <span className="step-progress__title"> + {stripEllipsis(INIT_STEP_MESSAGES[step])} + {state === "progress" ? <Ellipsis /> : null} + </span> + </div> + ); + })} + </div> + </div> + ); +} + +function stripEllipsis(s: string) { + return s.replace(/[.…]+$/, ""); +} + +function StepIcon({ state }: { state: StepState }) { + if (state === "done") { + return <CheckCircle />; + } + if (state === "progress") { + return <HalfCircle />; + } + return <EmptyCircle />; +} + +function CheckCircle() { + return ( + <svg + width="1em" + height="1em" + viewBox="0 0 16 16" + aria-hidden="true" + role="presentation" + > + <circle fill="currentColor" cx="8" cy="8" r="8" /> + <polyline + className="step-progress__check-stroke" + fill="none" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth="1.75" + points="4 8,7 11,12 5" + /> + </svg> + ); +} + +function EmptyCircle() { + const angles = Array.from({ length: 16 }, (_, i) => (360 / 16) * i); + return ( + <svg + width="1em" + height="1em" + viewBox="0 0 16 16" + aria-hidden="true" + role="presentation" + > + <g fill="currentColor" transform="translate(8,8)"> + {angles.map((a) => ( + <rect + key={a} + x="-1" + width="2" + height="2" + transform={`rotate(${a}) translate(0,6)`} + /> + ))} + </g> + </svg> + ); +} + +function HalfCircle() { + return ( + <svg + width="1em" + height="1em" + viewBox="0 0 16 16" + aria-hidden="true" + role="presentation" + > + <circle + fill="none" + stroke="currentColor" + strokeWidth="2" + cx="8" + cy="8" + r="7" + /> + <path fill="currentColor" d="M8 3 A5 5 0 0 1 8 13 Z" /> + </svg> + ); +} + +function Ellipsis() { + return ( + <span className="step-progress__ellipsis" aria-hidden="true"> + <span className="step-progress__ellipsis-dot">.</span> + <span className="step-progress__ellipsis-dot">.</span> + <span className="step-progress__ellipsis-dot">.</span> + </span> + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/index.ts new file mode 100644 index 00000000000..18126b94971 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/StepProgress/index.ts @@ -0,0 +1 @@ +export { StepProgress } from "./StepProgress"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx index 08d35a08a08..9b72497aea7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/WorkspaceInitializingView.tsx @@ -7,10 +7,9 @@ import { AlertDialogTitle, } from "@superset/ui/alert-dialog"; import { Button } from "@superset/ui/button"; -import { cn } from "@superset/ui/utils"; import { useEffect, useState } from "react"; import { HiExclamationTriangle } from "react-icons/hi2"; -import { LuCheck, LuCircle, LuGitBranch, LuLoader } from "react-icons/lu"; +import { LuGitBranch, LuLoader } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { useDeleteWorkspace } from "renderer/react-query/workspaces"; import { deleteWithToast } from "renderer/routes/_authenticated/components/TeardownLogsDialog"; @@ -18,12 +17,8 @@ import { useHasWorkspaceFailed, useWorkspaceInitProgress, } from "renderer/stores/workspace-init"; -import { - INIT_STEP_MESSAGES, - INIT_STEP_ORDER, - isStepComplete, - type WorkspaceInitStep, -} from "shared/types/workspace-init"; +import { KeypadLoader } from "./KeypadLoader"; +import { StepProgress } from "./StepProgress"; interface WorkspaceInitializingViewProps { workspaceId: string; @@ -32,11 +27,6 @@ interface WorkspaceInitializingViewProps { isInterrupted?: boolean; } -// Steps to display in the progress view (skip pending and ready) -const DISPLAY_STEPS: WorkspaceInitStep[] = INIT_STEP_ORDER.filter( - (step) => step !== "pending" && step !== "ready", -); - const DUPLICATE_BRANCH_ERROR_PATTERNS = [ "a branch named", "already checked out", @@ -78,6 +68,14 @@ export function WorkspaceInitializingView({ const deleteWorkspace = useDeleteWorkspace(); const utils = electronTrpc.useUtils(); + // Honor the user's notification-mute preference and volume for the keypad + // click sound. Default to muted while the setting loads so we never play a + // click for a user who has it disabled before the query resolves. + const { data: notificationSoundsMuted = true } = + electronTrpc.settings.getNotificationSoundsMuted.useQuery(); + const { data: notificationVolume = 100 } = + electronTrpc.settings.getNotificationVolume.useQuery(); + const handleRetry = (deduplicateBranchName = false) => { retryMutation.mutate( { workspaceId, deduplicateBranchName }, @@ -122,7 +120,12 @@ export function WorkspaceInitializingView({ <h2 className="text-lg font-medium text-foreground"> Setup incomplete </h2> - <p className="text-sm text-muted-foreground">{workspaceName}</p> + <p + className="line-clamp-3 max-w-full break-words text-sm text-muted-foreground [overflow-wrap:anywhere]" + title={workspaceName} + > + {workspaceName} + </p> <p className="text-xs text-muted-foreground/80 mt-2"> Workspace setup didn't finish. You can retry or remove it. </p> @@ -213,7 +216,12 @@ export function WorkspaceInitializingView({ <h2 className="text-lg font-medium text-foreground"> Workspace setup failed </h2> - <p className="text-sm text-muted-foreground">{workspaceName}</p> + <p + className="line-clamp-3 max-w-full break-words text-sm text-muted-foreground [overflow-wrap:anywhere]" + title={workspaceName} + > + {workspaceName} + </p> {progress?.error && ( <p className="text-xs text-destructive/80 mt-2 bg-destructive/5 rounded-md px-3 py-2 select-text cursor-text break-words"> {progress.error} @@ -304,64 +312,22 @@ export function WorkspaceInitializingView({ // Initializing state return ( <div className="flex flex-col items-center justify-center h-full w-full px-8"> - <div className="flex flex-col items-center max-w-sm text-center space-y-6"> - {/* Icon with pulse animation */} - <div className="relative"> - <div className="absolute inset-0 animate-ping rounded-full bg-primary/20" /> - <div className="relative flex items-center justify-center size-16 rounded-full bg-primary/10"> - <LuGitBranch className="size-8 text-primary" /> - </div> - </div> + <div className="flex flex-col items-center max-w-md text-center space-y-5"> + <KeypadLoader + currentStep={currentStep} + muted={notificationSoundsMuted} + volume={0.35 * (notificationVolume / 100)} + /> - {/* Title and description */} - <div className="space-y-2"> + <div className="space-y-1"> <h2 className="text-lg font-medium text-foreground"> Setting up workspace </h2> <p className="text-sm text-muted-foreground">{workspaceName}</p> </div> - {/* Step list */} - <div className="w-full space-y-2"> - {DISPLAY_STEPS.map((step) => { - const isComplete = isStepComplete(step, currentStep); - const isCurrent = step === currentStep; - - return ( - <div - key={step} - className={cn( - "flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors", - isComplete && "bg-muted/30", - isCurrent && "bg-primary/5", - )} - > - {/* Step icon */} - {isComplete ? ( - <LuCheck className="size-4 text-green-500 shrink-0" /> - ) : isCurrent ? ( - <LuLoader className="size-4 text-primary animate-spin shrink-0" /> - ) : ( - <LuCircle className="size-4 text-muted-foreground/40 shrink-0" /> - )} - - {/* Step label */} - <span - className={cn( - "text-left flex-1", - isComplete && "text-muted-foreground line-through", - isCurrent && "text-foreground font-medium", - !isComplete && !isCurrent && "text-muted-foreground/60", - )} - > - {INIT_STEP_MESSAGES[step]} - </span> - </div> - ); - })} - </div> + <StepProgress currentStep={currentStep} /> - {/* Helper text */} <p className="text-xs text-muted-foreground/60"> Takes 10s to a few minutes depending on the size of your repo </p> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/click.mp3 b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/click.mp3 new file mode 100644 index 00000000000..841a0ac1510 Binary files /dev/null and b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/click.mp3 differ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/key-single.png b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/key-single.png new file mode 100644 index 00000000000..251661fa55d Binary files /dev/null and b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/key-single.png differ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/keypad-base.png b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/keypad-base.png new file mode 100644 index 00000000000..b3dc1ddf578 Binary files /dev/null and b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView/assets/keypad-base.png differ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx index 475d6496400..1aeae0e15cc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/components/CodeEditor/CodeEditor.tsx @@ -23,8 +23,9 @@ import { lineNumbers, } from "@codemirror/view"; import { cn } from "@superset/ui/utils"; +import { useQuery } from "@tanstack/react-query"; import { type MutableRefObject, useEffect, useRef } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; import type { CodeEditorAdapter } from "renderer/screens/main/components/WorkspaceView/ContentView/components"; import { getCodeSyntaxHighlighting } from "renderer/screens/main/components/WorkspaceView/utils/code-theme"; import { useResolvedTheme } from "renderer/stores/theme"; @@ -179,12 +180,11 @@ export function CodeEditor({ const onSaveRef = useRef(onSave); // Guards against re-entrant onChange calls triggered by the value-sync effect's own dispatch. const isExternalUpdateRef = useRef(false); - const { data: fontSettings } = electronTrpc.settings.getFontSettings.useQuery( - undefined, - { - staleTime: 30_000, - }, - ); + const { data: fontSettings } = useQuery({ + queryKey: ["electron", "settings", "getFontSettings"], + queryFn: () => electronTrpcClient.settings.getFontSettings.query(), + staleTime: 30_000, + }); const editorFontFamily = fontSettings?.editorFontFamily ?? undefined; const editorFontSize = fontSettings?.editorFontSize ?? undefined; const activeTheme = useResolvedTheme(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx index f9fe3ea6608..509e4608ef9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspaceRow/WorkspaceRow.tsx @@ -4,17 +4,18 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@superset/ui/context-menu"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { useState } from "react"; import { LuArrowRight, + LuExternalLink, LuFolder, LuFolderGit2, LuRotateCw, } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { getGitHubStatusQueryPolicy } from "renderer/lib/githubQueryPolicy"; +import { useHoverGitHubStatus } from "renderer/lib/githubQueryPolicy"; import { useWorkspaceDeleteHandler } from "renderer/react-query/workspaces/useWorkspaceDeleteHandler"; import { STROKE_WIDTH } from "../../WorkspaceSidebar/constants"; import { DeleteWorkspaceDialog } from "../../WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog"; @@ -36,21 +37,27 @@ export function WorkspaceRow({ isOpening, }: WorkspaceRowProps) { const isBranch = workspace.type === "branch"; - const [hasHovered, setHasHovered] = useState(false); + const { githubStatus, onMouseEnter: onGithubMouseEnter } = + useHoverGitHubStatus({ + workspaceId: workspace.workspaceId, + surface: "workspace-row", + isWorktree: workspace.type === "worktree", + }); const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = useWorkspaceDeleteHandler(); - const githubStatusQueryPolicy = getGitHubStatusQueryPolicy("workspace-row", { - hasWorkspaceId: !!workspace.workspaceId, - isActive: - hasHovered && workspace.type === "worktree" && !!workspace.workspaceId, + const openFileInEditor = electronTrpc.external.openFileInEditor.useMutation({ + onError: (error) => + toast.error(`Failed to open in editor: ${error.message}`), }); - // Lazy-load GitHub status on hover to avoid N+1 queries - const { data: githubStatus } = - electronTrpc.workspaces.getGitHubStatus.useQuery( - { workspaceId: workspace.workspaceId ?? "" }, - githubStatusQueryPolicy, - ); + const handleOpenInEditor = () => { + if (workspace.worktreePath) { + openFileInEditor.mutate({ + path: workspace.worktreePath, + projectId: workspace.projectId, + }); + } + }; const pr = githubStatus?.pr; const showDiffStats = pr && (pr.additions > 0 || pr.deletions > 0); @@ -72,7 +79,7 @@ export function WorkspaceRow({ type="button" onClick={handleClick} disabled={isOpening} - onMouseEnter={() => !hasHovered && setHasHovered(true)} + onMouseEnter={onGithubMouseEnter} className={cn( "flex items-center gap-3 w-full px-4 py-2 group text-left", "hover:bg-background/50 transition-colors", @@ -193,6 +200,13 @@ export function WorkspaceRow({ <ContextMenu> <ContextMenuTrigger asChild>{button}</ContextMenuTrigger> <ContextMenuContent> + <ContextMenuItem onSelect={handleOpenInEditor}> + <LuExternalLink + className="size-4 mr-2" + strokeWidth={STROKE_WIDTH} + /> + Open in Editor + </ContextMenuItem> <ContextMenuItem onSelect={() => handleDeleteClick()} className="text-destructive focus:text-destructive" diff --git a/apps/desktop/src/renderer/stores/add-repository-modal.ts b/apps/desktop/src/renderer/stores/add-repository-modal.ts new file mode 100644 index 00000000000..a5ea6adfd9f --- /dev/null +++ b/apps/desktop/src/renderer/stores/add-repository-modal.ts @@ -0,0 +1,64 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +export interface NewProjectResult { + projectId: string; +} + +type ActiveModal = { kind: "none" } | { kind: "new-project" }; + +interface AddRepositoryModalState { + active: ActiveModal; + /** + * Opens the modal and resolves with the created project (or `null` if the + * user closed it). Only one open call can be in flight at a time — calling + * again while a previous open is pending resolves the prior promise to + * `null` before opening fresh. Safe today because there is only one global + * `NewProjectModal` instance. + */ + openNewProject: () => Promise<NewProjectResult | null>; + resolveNewProject: (result: NewProjectResult | null) => void; + close: () => void; +} + +// Module-level resolver so callbacks aren't stored in zustand state. The store +// drives the modal's open/close UI; the resolver bridges the imperative open() +// call back to its caller. +let pendingResolve: ((result: NewProjectResult | null) => void) | null = null; + +export const useAddRepositoryModalStore = create<AddRepositoryModalState>()( + devtools( + (set) => ({ + active: { kind: "none" }, + openNewProject: () => { + pendingResolve?.(null); + return new Promise<NewProjectResult | null>((resolve) => { + pendingResolve = resolve; + set({ active: { kind: "new-project" } }); + }); + }, + resolveNewProject: (result) => { + const resolve = pendingResolve; + pendingResolve = null; + set({ active: { kind: "none" } }); + resolve?.(result); + }, + close: () => { + const resolve = pendingResolve; + pendingResolve = null; + set({ active: { kind: "none" } }); + resolve?.(null); + }, + }), + { name: "add-repository-modal" }, + ), +); + +export const useAddRepositoryModalActive = () => + useAddRepositoryModalStore((state) => state.active); +export const useOpenNewProjectModal = () => + useAddRepositoryModalStore((state) => state.openNewProject); +export const useResolveNewProjectModal = () => + useAddRepositoryModalStore((state) => state.resolveNewProject); +export const useCloseAddRepositoryModal = () => + useAddRepositoryModalStore((state) => state.close); diff --git a/apps/desktop/src/renderer/stores/hotkeys/index.ts b/apps/desktop/src/renderer/stores/hotkeys/index.ts deleted file mode 100644 index f5990c259bc..00000000000 --- a/apps/desktop/src/renderer/stores/hotkeys/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./store"; diff --git a/apps/desktop/src/renderer/stores/hotkeys/store.ts b/apps/desktop/src/renderer/stores/hotkeys/store.ts deleted file mode 100644 index 0a6f010872d..00000000000 --- a/apps/desktop/src/renderer/stores/hotkeys/store.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; -import { electronTrpc } from "renderer/lib/electron-trpc"; -import { electronTrpcClient } from "renderer/lib/trpc-client"; -import { - setSkipNextHotkeysPersist, - trpcHotkeysStorage, -} from "renderer/lib/trpc-storage"; -import { - canonicalizeHotkeyForPlatform, - formatHotkeyDisplay, - formatHotkeyText, - getCurrentPlatform, - getDefaultHotkey, - getEffectiveHotkey, - getEffectiveHotkeysMap, - HOTKEYS, - HOTKEYS_STATE_VERSION, - type HotkeyCategory, - type HotkeyDefinition, - type HotkeyId, - type HotkeyPlatform, - type HotkeysState, - hotkeyFromKeyboardEvent, - isValidAppHotkey, - matchesHotkeyEvent, -} from "shared/hotkeys"; -import { create } from "zustand"; -import { devtools, persist } from "zustand/middleware"; - -interface HotkeysStoreState { - hotkeysState: HotkeysState; - platform: HotkeyPlatform; - setHotkey: (id: HotkeyId, keys: string | null) => void; - setHotkeysBatch: (updates: Partial<Record<HotkeyId, string | null>>) => void; - resetHotkey: (id: HotkeyId) => void; - resetAllHotkeys: () => void; - replaceHotkeysState: (state: HotkeysState) => void; -} - -const DEFAULT_STATE: HotkeysState = { - version: HOTKEYS_STATE_VERSION, - byPlatform: { darwin: {}, win32: {}, linux: {} }, -}; - -function getOverridesForPlatform( - state: HotkeysState, - platform: HotkeyPlatform, -): Record<HotkeyId, string | null> { - return (state.byPlatform[platform] ?? {}) as Record<HotkeyId, string | null>; -} - -function updateOverrides( - state: HotkeysState, - platform: HotkeyPlatform, - next: Partial<Record<HotkeyId, string | null>>, -): HotkeysState { - return { - ...state, - byPlatform: { - ...state.byPlatform, - [platform]: next, - }, - }; -} - -export const useHotkeysStore = create<HotkeysStoreState>()( - devtools( - persist( - (set, get) => ({ - hotkeysState: DEFAULT_STATE, - platform: getCurrentPlatform(), - - setHotkey: (id, keys) => { - const platform = get().platform; - const canonical = - keys === null - ? null - : canonicalizeHotkeyForPlatform(keys, platform); - if (keys !== null && !canonical) return; - // App hotkeys must include ctrl or meta (or be function keys) to work in terminal - if (canonical !== null && !isValidAppHotkey(canonical)) return; - - const defaultValue = getDefaultHotkey(id, platform); - const overrides = getOverridesForPlatform( - get().hotkeysState, - platform, - ); - const nextOverrides = { ...overrides }; - - if (canonical === defaultValue) { - delete nextOverrides[id]; - } else { - nextOverrides[id] = canonical; - } - - set((state) => ({ - hotkeysState: updateOverrides( - state.hotkeysState, - platform, - nextOverrides, - ), - })); - }, - - setHotkeysBatch: (updates) => { - const platform = get().platform; - const overrides = getOverridesForPlatform( - get().hotkeysState, - platform, - ); - const nextOverrides = { ...overrides }; - - for (const [id, keys] of Object.entries(updates)) { - const hotkeyId = id as HotkeyId; - const canonical = - keys === null - ? null - : canonicalizeHotkeyForPlatform(keys, platform); - if (keys !== null && !canonical) continue; - // App hotkeys must include ctrl or meta (or be function keys) to work in terminal - if (canonical !== null && !isValidAppHotkey(canonical)) continue; - const defaultValue = getDefaultHotkey(hotkeyId, platform); - if (canonical === defaultValue) { - delete nextOverrides[hotkeyId]; - } else { - nextOverrides[hotkeyId] = canonical; - } - } - - set((state) => ({ - hotkeysState: updateOverrides( - state.hotkeysState, - platform, - nextOverrides, - ), - })); - }, - - resetHotkey: (id) => { - const platform = get().platform; - const overrides = getOverridesForPlatform( - get().hotkeysState, - platform, - ); - if (!(id in overrides)) return; - const nextOverrides = { ...overrides }; - delete nextOverrides[id]; - set((state) => ({ - hotkeysState: updateOverrides( - state.hotkeysState, - platform, - nextOverrides, - ), - })); - }, - - resetAllHotkeys: () => { - const platform = get().platform; - set((state) => ({ - hotkeysState: { - ...state.hotkeysState, - byPlatform: { - ...state.hotkeysState.byPlatform, - [platform]: {}, - }, - }, - })); - }, - - replaceHotkeysState: (state) => { - set({ hotkeysState: state }); - }, - }), - { - name: "hotkeys-storage", - storage: trpcHotkeysStorage, - partialize: (state) => ({ hotkeysState: state.hotkeysState }), - }, - ), - { name: "HotkeysStore" }, - ), -); - -export function useHotkeyKeys(id: HotkeyId): string | null { - return useHotkeysStore((state) => { - const overrides = getOverridesForPlatform( - state.hotkeysState, - state.platform, - ); - return getEffectiveHotkey(id, overrides, state.platform); - }); -} - -export function getHotkeyKeys(id: HotkeyId): string | null { - const state = useHotkeysStore.getState(); - const overrides = getOverridesForPlatform(state.hotkeysState, state.platform); - return getEffectiveHotkey(id, overrides, state.platform); -} - -export function useHotkeyDisplay(id: HotkeyId): string[] { - const platform = useHotkeysStore((state) => state.platform); - const keys = useHotkeyKeys(id); - return useMemo(() => formatHotkeyDisplay(keys, platform), [keys, platform]); -} - -export function useHotkeyText(id: HotkeyId): string { - return useHotkeysStore((state) => { - const overrides = getOverridesForPlatform( - state.hotkeysState, - state.platform, - ); - const keys = getEffectiveHotkey(id, overrides, state.platform); - return formatHotkeyText(keys, state.platform); - }); -} - -export function useEffectiveHotkeysMap(): Record<HotkeyId, string | null> { - const platform = useHotkeysStore((state) => state.platform); - const hotkeysState = useHotkeysStore((state) => state.hotkeysState); - return useMemo(() => { - const overrides = getOverridesForPlatform(hotkeysState, platform); - return getEffectiveHotkeysMap(overrides, platform); - }, [hotkeysState, platform]); -} - -export function useHotkeysByCategory(options?: { - includeHidden?: boolean; -}): Record<HotkeyCategory, Array<HotkeyDefinition & { id: HotkeyId }>> { - return useMemo(() => { - const grouped: Record< - HotkeyCategory, - Array<HotkeyDefinition & { id: HotkeyId }> - > = { - Navigation: [], - Workspace: [], - Layout: [], - Terminal: [], - Window: [], - Help: [], - }; - - for (const [id, hotkey] of Object.entries(HOTKEYS)) { - if (!options?.includeHidden && hotkey.isHidden) continue; - grouped[hotkey.category].push({ id: id as HotkeyId, ...hotkey }); - } - return grouped; - }, [options?.includeHidden]); -} - -export function isAppHotkeyEvent(event: KeyboardEvent): boolean { - const state = useHotkeysStore.getState(); - const overrides = getOverridesForPlatform(state.hotkeysState, state.platform); - const effective = getEffectiveHotkeysMap(overrides, state.platform); - return (Object.keys(effective) as HotkeyId[]).some((id) => { - const keys = effective[id]; - if (!keys) return false; - return matchesHotkeyEvent(event, keys); - }); -} - -export function getHotkeyConflict( - keys: string, - excludeId?: HotkeyId, -): HotkeyId | null { - const state = useHotkeysStore.getState(); - const overrides = getOverridesForPlatform(state.hotkeysState, state.platform); - const effective = getEffectiveHotkeysMap(overrides, state.platform); - const canonical = canonicalizeHotkeyForPlatform(keys, state.platform); - if (!canonical) return null; - - for (const [id, value] of Object.entries(effective)) { - if (id === excludeId) continue; - if (value === canonical) return id as HotkeyId; - } - return null; -} - -export function useHotkeysSync() { - const platform = useHotkeysStore((state) => state.platform); - const replace = useHotkeysStore((state) => state.replaceHotkeysState); - - electronTrpc.uiState.hotkeys.subscribe.useSubscription(undefined, { - onData: () => { - electronTrpcClient.uiState.hotkeys.get - .query() - .then((state: HotkeysState) => { - // Guard against null/undefined state from storage - if (!state) { - console.warn( - "[hotkeys] Storage returned null/undefined state, skipping sync", - ); - return; - } - const current = useHotkeysStore.getState().hotkeysState; - // Use structural comparison that's order-independent - const currentStr = JSON.stringify( - current, - Object.keys(current).sort(), - ); - const newStr = JSON.stringify(state, Object.keys(state).sort()); - if (currentStr === newStr) { - return; - } - // Skip persistence to avoid echo writes back to storage - setSkipNextHotkeysPersist(true); - replace(state); - }) - .catch((error: unknown) => { - console.error("[hotkeys] Failed to sync hotkeys:", error); - }); - }, - }); - - return platform; -} - -export function captureHotkeyFromEvent( - event: KeyboardEvent, - platform: HotkeyPlatform, -): string | null { - return hotkeyFromKeyboardEvent(event, platform); -} - -export function useAppHotkey( - id: HotkeyId, - callback: (event: KeyboardEvent, handler: unknown) => void, - options?: { enabled?: boolean; preventDefault?: boolean }, - // Deprecated: callback refs keep handlers fresh without listener re-registration. - _deps: unknown[] = [], -) { - const keys = useHotkeyKeys(id); - const enabled = Boolean(keys) && (options?.enabled ?? true); - const preventDefault = options?.preventDefault ?? false; - const callbackRef = useRef(callback); - callbackRef.current = callback; - - useEffect(() => { - if (!enabled || !keys) return; - if ( - typeof document === "undefined" || - typeof document.addEventListener !== "function" - ) { - return; - } - - const onKeyDown = (event: KeyboardEvent) => { - if (!matchesHotkeyEvent(event, keys)) return; - if (preventDefault) event.preventDefault(); - callbackRef.current(event, undefined); - }; - - document.addEventListener("keydown", onKeyDown); - return () => { - document.removeEventListener("keydown", onKeyDown); - }; - }, [enabled, keys, preventDefault]); -} diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index 5360d23e4cc..58edc717ed3 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -1,5 +1,4 @@ export * from "./chat-preferences"; -export * from "./hotkeys"; export * from "./markdown-preferences"; export * from "./ports"; export * from "./ringtone"; diff --git a/apps/desktop/src/renderer/stores/new-workspace-modal.ts b/apps/desktop/src/renderer/stores/new-workspace-modal.ts index 22d6af20810..bd7175988f0 100644 --- a/apps/desktop/src/renderer/stores/new-workspace-modal.ts +++ b/apps/desktop/src/renderer/stores/new-workspace-modal.ts @@ -8,10 +8,25 @@ interface PendingWorkspace { status: "preparing" | "generating-branch" | "creating"; } +/** Snapshot of the draft stashed before modal close, restored on failure. */ +export interface StashedDraft { + selectedProjectId: string | null; + prompt: string; + workspaceName: string; + workspaceNameEdited: boolean; + branchName: string; + branchNameEdited: boolean; + compareBaseBranch: string | null; + runSetupScript: boolean; + linkedIssues: unknown[]; + linkedPR: unknown | null; +} + interface NewWorkspaceModalState { isOpen: boolean; preSelectedProjectId: string | null; pendingWorkspace: PendingWorkspace | null; + stashedDraft: StashedDraft | null; openModal: (projectId?: string) => void; closeModal: () => void; setPendingWorkspace: (workspace: PendingWorkspace | null) => void; @@ -20,14 +35,18 @@ interface NewWorkspaceModalState { id: string, status: PendingWorkspace["status"], ) => void; + stashDraft: (draft: StashedDraft) => void; + clearStashedDraft: () => void; + restoreStashedDraft: () => StashedDraft | null; } export const useNewWorkspaceModalStore = create<NewWorkspaceModalState>()( devtools( - (set) => ({ + (set, get) => ({ isOpen: false, preSelectedProjectId: null, pendingWorkspace: null, + stashedDraft: null, openModal: (projectId?: string) => { set({ isOpen: true, preSelectedProjectId: projectId ?? null }); @@ -43,26 +62,40 @@ export const useNewWorkspaceModalStore = create<NewWorkspaceModalState>()( clearPendingWorkspace: (id) => { set((state) => { - if (state.pendingWorkspace?.id !== id) { - return {}; - } + if (state.pendingWorkspace?.id !== id) return {}; return { pendingWorkspace: null }; }); }, setPendingWorkspaceStatus: (id, status) => { set((state) => { - if (state.pendingWorkspace?.id !== id) { - return {}; - } + if (state.pendingWorkspace?.id !== id) return {}; return { - pendingWorkspace: { - ...state.pendingWorkspace, - status, - }, + pendingWorkspace: { ...state.pendingWorkspace, status }, }; }); }, + + stashDraft: (draft: StashedDraft) => { + set({ stashedDraft: draft }); + }, + + clearStashedDraft: () => { + set({ stashedDraft: null }); + }, + + /** Pops the stash: returns it and clears. Also reopens the modal. */ + restoreStashedDraft: () => { + const stashed = get().stashedDraft; + if (stashed) { + set({ + stashedDraft: null, + isOpen: true, + preSelectedProjectId: stashed.selectedProjectId, + }); + } + return stashed; + }, }), { name: "NewWorkspaceModalStore" }, ), @@ -84,3 +117,9 @@ export const useClearPendingWorkspace = () => useNewWorkspaceModalStore((state) => state.clearPendingWorkspace); export const useSetPendingWorkspaceStatus = () => useNewWorkspaceModalStore((state) => state.setPendingWorkspaceStatus); +export const useStashDraft = () => + useNewWorkspaceModalStore((state) => state.stashDraft); +export const useClearStashedDraft = () => + useNewWorkspaceModalStore((state) => state.clearStashedDraft); +export const useRestoreStashedDraft = () => + useNewWorkspaceModalStore((state) => state.restoreStashedDraft); diff --git a/apps/desktop/src/renderer/stores/settings-state.ts b/apps/desktop/src/renderer/stores/settings-state.ts index 68984f6171e..e4c8146c4f9 100644 --- a/apps/desktop/src/renderer/stores/settings-state.ts +++ b/apps/desktop/src/renderer/stores/settings-state.ts @@ -11,13 +11,16 @@ export type SettingsSection = | "git" | "agents" | "terminal" + | "links" | "models" + | "experimental" | "integrations" | "billing" - | "devices" | "apikeys" | "permissions" - | "project"; + | "security" + | "project" + | "hosts"; interface SettingsState { activeSection: SettingsSection; diff --git a/apps/desktop/src/renderer/stores/settings.ts b/apps/desktop/src/renderer/stores/settings.ts new file mode 100644 index 00000000000..15268021211 --- /dev/null +++ b/apps/desktop/src/renderer/stores/settings.ts @@ -0,0 +1,20 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface Settings { + diffStyle: "split" | "unified"; +} + +interface SettingsStore extends Settings { + update: <K extends keyof Settings>(key: K, value: Settings[K]) => void; +} + +export const useSettings = create<SettingsStore>()( + persist( + (set) => ({ + diffStyle: "split", + update: (key, value) => set({ [key]: value }), + }), + { name: "settings" }, + ), +); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 8fc0f13a086..cb00b6090e1 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -22,6 +22,7 @@ import { import type { AddFileViewerPaneOptions, AddTabWithMultiplePanesOptions, + CommentPaneData, TabsState, TabsStore, } from "./types"; @@ -34,6 +35,7 @@ import { createBrowserTabWithPane, createChatPane, createChatTabWithPane, + createCommentTabWithPane, createDevToolsPane, createFileViewerPane, createPane, @@ -756,19 +758,18 @@ export const useTabsStore = create<TabsStore>()( const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); const reuseExisting = options.reuseExisting ?? "workspace"; - const canReuseExistingPane = - !options.openInNewTab && reuseExisting !== "none"; - const existingFileViewerPane = canReuseExistingPane - ? findReusableFileViewerPane({ - workspaceId, - activeTabId: activeTab.id, - tabs: state.tabs, - panes: state.panes, - tabHistoryStacks: state.tabHistoryStacks, - reuseExisting, - options, - }) - : null; + const existingFileViewerPane = + reuseExisting !== "none" + ? findReusableFileViewerPane({ + workspaceId, + activeTabId: activeTab.id, + tabs: state.tabs, + panes: state.panes, + tabHistoryStacks: state.tabHistoryStacks, + reuseExisting, + options, + }) + : null; if (existingFileViewerPane) { const nextPane = applyFileViewerOpenOptionsToPane( @@ -828,7 +829,11 @@ export const useTabsStore = create<TabsStore>()( // If we found an unpinned (preview) file-viewer pane, reuse it // (skip reuse when explicitly requesting a new tab, e.g. cmd+click) - if (fileViewerPanes.length > 0 && canReuseExistingPane) { + if ( + fileViewerPanes.length > 0 && + !options.openInNewTab && + reuseExisting !== "none" + ) { const paneToReuse = fileViewerPanes[0]; const existingFileViewer = paneToReuse.fileViewer; if (!existingFileViewer) { @@ -1598,6 +1603,89 @@ export const useTabsStore = create<TabsStore>()( set(withDerivedTabNames(result, [targetTabId])); }, + // Comment operations + openCommentPane: (workspaceId: string, comment: CommentPaneData) => { + const state = get(); + + // Reuse an existing comment pane in this workspace if one exists + const workspaceTabIds = new Set( + state.tabs + .filter((t) => t.workspaceId === workspaceId) + .map((t) => t.id), + ); + const existingPane = Object.values(state.panes).find( + (p) => p.type === "comment" && workspaceTabIds.has(p.tabId), + ); + + if (existingPane) { + const newPanes = { + ...state.panes, + [existingPane.id]: { + ...existingPane, + name: `@${comment.authorLogin}`, + comment, + }, + }; + const tabName = deriveTabName(newPanes, existingPane.tabId); + const nextTabs = state.tabs.map((t) => + t.id === existingPane.tabId ? { ...t, name: tabName } : t, + ); + const activationState = activatePaneInWorkspace({ + workspaceId, + paneId: existingPane.id, + tabs: nextTabs, + panes: newPanes, + activeTabIds: state.activeTabIds, + focusedPaneIds: state.focusedPaneIds, + tabHistoryStacks: state.tabHistoryStacks, + }); + + if (!activationState) { + set({ panes: newPanes, tabs: nextTabs }); + return { tabId: existingPane.tabId, paneId: existingPane.id }; + } + + set({ ...activationState, tabs: nextTabs }); + return { tabId: existingPane.tabId, paneId: existingPane.id }; + } + + const { tab, pane } = createCommentTabWithPane(workspaceId, comment); + + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [ + currentActiveId, + ...historyStack.filter((id) => id !== currentActiveId), + ] + : historyStack; + + set({ + tabs: [...state.tabs, tab], + panes: { ...state.panes, [pane.id]: pane }, + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: tab.id, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [tab.id]: pane.id, + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }); + + posthog.capture("panel_opened", { + panel_type: "comment", + workspace_id: workspaceId, + pane_id: pane.id, + }); + + return { tabId: tab.id, paneId: pane.id }; + }, + // Browser operations addBrowserTab: (workspaceId: string, url?: string) => { const state = get(); diff --git a/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts b/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts index 3616445b1c6..96e4b4f064b 100644 --- a/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts +++ b/apps/desktop/src/renderer/stores/tabs/terminal-callbacks.ts @@ -5,10 +5,6 @@ interface TerminalCallbacksState { scrollToBottomCallbacks: Map<string, () => void>; getSelectionCallbacks: Map<string, () => string>; pasteCallbacks: Map<string, (text: string) => void>; - restartCallbacks: Map< - string, - (options?: { command?: string; forceRestart?: boolean }) => Promise<void> - >; registerClearCallback: (paneId: string, callback: () => void) => void; unregisterClearCallback: (paneId: string) => void; getClearCallback: (paneId: string) => (() => void) | undefined; @@ -30,22 +26,6 @@ interface TerminalCallbacksState { ) => void; unregisterPasteCallback: (paneId: string) => void; getPasteCallback: (paneId: string) => ((text: string) => void) | undefined; - registerRestartCallback: ( - paneId: string, - callback: (options?: { - command?: string; - forceRestart?: boolean; - }) => Promise<void>, - ) => void; - unregisterRestartCallback: (paneId: string) => void; - getRestartCallback: ( - paneId: string, - ) => - | ((options?: { - command?: string; - forceRestart?: boolean; - }) => Promise<void>) - | undefined; } export const useTerminalCallbacksStore = create<TerminalCallbacksState>()( @@ -54,7 +34,6 @@ export const useTerminalCallbacksStore = create<TerminalCallbacksState>()( scrollToBottomCallbacks: new Map(), getSelectionCallbacks: new Map(), pasteCallbacks: new Map(), - restartCallbacks: new Map(), registerClearCallback: (paneId, callback) => { set((state) => { @@ -135,25 +114,5 @@ export const useTerminalCallbacksStore = create<TerminalCallbacksState>()( getPasteCallback: (paneId) => { return get().pasteCallbacks.get(paneId); }, - - registerRestartCallback: (paneId, callback) => { - set((state) => { - const newCallbacks = new Map(state.restartCallbacks); - newCallbacks.set(paneId, callback); - return { restartCallbacks: newCallbacks }; - }); - }, - - unregisterRestartCallback: (paneId) => { - set((state) => { - const newCallbacks = new Map(state.restartCallbacks); - newCallbacks.delete(paneId); - return { restartCallbacks: newCallbacks }; - }); - }, - - getRestartCallback: (paneId) => { - return get().restartCallbacks.get(paneId); - }, }), ); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index dd344bf3812..633be41ffbd 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -5,6 +5,7 @@ import type { BaseTabsState, BrowserLoadError, ChatLaunchConfig, + CommentPaneState, FileViewerMode, Pane, PaneStatus, @@ -15,6 +16,11 @@ import type { // Re-export shared types export type { Pane, PaneStatus, PaneType }; +/** + * Data required to open a comment pane + */ +export type CommentPaneData = CommentPaneState; + /** * Snapshot of a closed tab + its panes, used for "reopen closed tab". */ @@ -197,6 +203,17 @@ export interface TabsStore extends TabsState { position: MosaicDropPosition, ) => void; + // Comment operations + /** + * Open a PR/review comment in a pane. Reuses an existing comment pane in + * the workspace if one is found; otherwise creates a new tab with a + * comment pane. + */ + openCommentPane: ( + workspaceId: string, + comment: CommentPaneData, + ) => { tabId: string; paneId: string }; + // Browser operations addBrowserTab: ( workspaceId: string, diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index fcde2bf4ccd..62642d77a90 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -1,5 +1,4 @@ import { useNavigate } from "@tanstack/react-router"; -import { useRef } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { NOTIFICATION_EVENTS } from "shared/constants"; @@ -32,21 +31,31 @@ import { resolveNotificationTarget } from "./utils/resolve-notification-target"; * Note: Terminal exit detection (in Terminal.tsx) provides a reliable fallback * for clearing stuck indicators when agent hooks fail to fire. */ -export function useAgentHookListener() { - const navigate = useNavigate(); - // Ref avoids stale closure; parsed from URL since hook runs in _authenticated/layout - const currentWorkspaceIdRef = useRef<string | null>(null); +/** + * Returns the current workspace ID from the live URL hash. + * The app uses hash routing: file:///.../index.html#/workspace/<id> + * We must read window.location.hash (not pathname) at event time since the + * _authenticated layout does not re-render on workspace navigation. + */ +function getCurrentWorkspaceId(): string | null { try { - const match = window.location.pathname.match(/\/workspace\/([^/]+)/); - currentWorkspaceIdRef.current = match ? match[1] : null; + const match = window.location.hash.match(/\/workspace\/([^/?#]+)/); + return match ? match[1] : null; } catch { - currentWorkspaceIdRef.current = null; + return null; } +} + +export function useAgentHookListener() { + const navigate = useNavigate(); electronTrpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { if (!event.data) return; + if (event.type === NOTIFICATION_EVENTS.FOCUS_V2_NOTIFICATION_SOURCE) { + return; + } const state = useTabsStore.getState(); const target = resolveNotificationTarget(event.data, state); @@ -64,24 +73,43 @@ export function useAgentHookListener() { if (eventType === "Start") { state.setPaneStatus(paneId, "working"); - } else if (eventType === "PermissionRequest") { + } else if ( + eventType === "PermissionRequest" || + eventType === "PendingQuestion" + ) { state.setPaneStatus(paneId, "permission"); } else if (eventType === "Stop") { const activeTabId = state.activeTabIds[workspaceId]; const pane = state.panes[paneId]; + const tabId = pane?.tabId; + // Tab must be active for this workspace + const isTabActive = tabId != null && tabId === activeTabId; + // User is on this workspace if the URL hash matches OR if they have this + // pane focused (more reliable than URL parsing which can lag behind navigation) + const isPaneFocused = + tabId != null && state.focusedPaneIds[tabId] === paneId; const isInActiveTab = - currentWorkspaceIdRef.current === workspaceId && - pane?.tabId === activeTabId; + isTabActive && + (getCurrentWorkspaceId() === workspaceId || isPaneFocused); + + // If stopping from a pending question state, always go idle (user already engaged) + const nextStatus = + pane?.status === "permission" + ? "idle" + : isInActiveTab + ? "idle" + : "review"; debugLog("agent-hooks", "Stop event:", { isInActiveTab, activeTabId, paneTabId: pane?.tabId, paneId, - willSetTo: isInActiveTab ? "idle" : "review", + paneStatus: pane?.status, + willSetTo: nextStatus, }); - state.setPaneStatus(paneId, isInActiveTab ? "idle" : "review"); + state.setPaneStatus(paneId, nextStatus); } } else if (event.type === NOTIFICATION_EVENTS.TERMINAL_EXIT) { // Clear transient status for unmounted panes (mounted panes handle this via stream subscription) diff --git a/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts b/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts index 6d2dd0d2a28..fc240200ba4 100644 --- a/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts +++ b/apps/desktop/src/renderer/stores/tabs/useTabsWithPresets.ts @@ -103,13 +103,17 @@ export function useTabsWithPresets(projectId?: string | null) { }, []); const launchPresetCommand = useCallback( - ({ paneId, tabId, workspaceId, command, cwd }: PresetPaneLaunch) => { + ( + { paneId, tabId, workspaceId, command, cwd }: PresetPaneLaunch, + options?: { waitForMountedSession?: boolean }, + ) => { void launchCommandInPane({ paneId, tabId, workspaceId, command, cwd, + waitForMountedSession: options?.waitForMountedSession, createOrAttach: (input) => createOrAttach.mutateAsync(input), write: (input) => writeToTerminal.mutateAsync(input), }).catch((error) => { @@ -125,9 +129,12 @@ export function useTabsWithPresets(projectId?: string | null) { ); const launchPresetCommands = useCallback( - (launches: PresetPaneLaunch[]) => { + ( + launches: PresetPaneLaunch[], + options?: { waitForMountedSession?: boolean }, + ) => { for (const launch of launches) { - launchPresetCommand(launch); + launchPresetCommand(launch, options); } }, [launchPresetCommand], @@ -145,13 +152,16 @@ export function useTabsWithPresets(projectId?: string | null) { if (firstPresetCommand === null) return; const workspaceId = resolveWorkspaceIdForTab(tabId); if (!workspaceId) return; - launchPresetCommand({ - paneId, - tabId, - workspaceId, - command: firstPresetCommand, - cwd: firstPreset?.cwd || undefined, - }); + launchPresetCommand( + { + paneId, + tabId, + workspaceId, + command: firstPresetCommand, + cwd: firstPreset?.cwd || undefined, + }, + { waitForMountedSession: true }, + ); }, [ firstPreset, @@ -162,20 +172,27 @@ export function useTabsWithPresets(projectId?: string | null) { ); const launchFirstPresetInFocusedPane = useCallback( - (tabId: string, previousFocusedPaneId: string | undefined) => { + ( + tabId: string, + previousFocusedPaneId: string | undefined, + options?: { waitForMountedSession?: boolean }, + ) => { if (firstPresetCommand === null) return; const state = useTabsStore.getState(); const paneId = state.focusedPaneIds[tabId]; if (!paneId || paneId === previousFocusedPaneId) return; const tab = state.tabs.find((tabItem) => tabItem.id === tabId); if (!tab) return; - launchPresetCommand({ - paneId, - tabId, - workspaceId: tab.workspaceId, - command: firstPresetCommand, - cwd: firstPreset?.cwd || undefined, - }); + launchPresetCommand( + { + paneId, + tabId, + workspaceId: tab.workspaceId, + command: firstPresetCommand, + cwd: firstPreset?.cwd || undefined, + }, + options, + ); }, [firstPreset, firstPresetCommand, launchPresetCommand], ); @@ -302,7 +319,7 @@ export function useTabsWithPresets(projectId?: string | null) { ]; }, ); - launchPresetCommands(launches); + launchPresetCommands(launches, { waitForMountedSession: true }); return { tabId: activeTabId, paneId: paneIds[0] }; } return executePresetInNewTab(workspaceId, preset); @@ -315,13 +332,16 @@ export function useTabsWithPresets(projectId?: string | null) { }); if (paneId) { if (command !== null) { - launchPresetCommand({ - paneId, - tabId: activeTabId, - workspaceId, - command, - cwd: preset.initialCwd, - }); + launchPresetCommand( + { + paneId, + tabId: activeTabId, + workspaceId, + command, + cwd: preset.initialCwd, + }, + { waitForMountedSession: true }, + ); } return { tabId: activeTabId, paneId }; } @@ -436,7 +456,9 @@ export function useTabsWithPresets(projectId?: string | null) { const previousFocusedPaneId = useTabsStore.getState().focusedPaneIds[tabId]; storeSplitPaneVertical(tabId, sourcePaneId, path, firstPresetOptions); - launchFirstPresetInFocusedPane(tabId, previousFocusedPaneId); + launchFirstPresetInFocusedPane(tabId, previousFocusedPaneId, { + waitForMountedSession: true, + }); }, [ storeSplitPaneVertical, @@ -458,7 +480,9 @@ export function useTabsWithPresets(projectId?: string | null) { const previousFocusedPaneId = useTabsStore.getState().focusedPaneIds[tabId]; storeSplitPaneHorizontal(tabId, sourcePaneId, path, firstPresetOptions); - launchFirstPresetInFocusedPane(tabId, previousFocusedPaneId); + launchFirstPresetInFocusedPane(tabId, previousFocusedPaneId, { + waitForMountedSession: true, + }); }, [ storeSplitPaneHorizontal, @@ -493,7 +517,9 @@ export function useTabsWithPresets(projectId?: string | null) { path, firstPresetOptions, ); - launchFirstPresetInFocusedPane(tabId, previousFocusedPaneId); + launchFirstPresetInFocusedPane(tabId, previousFocusedPaneId, { + waitForMountedSession: true, + }); }, [storeSplitPaneAuto, firstPresetOptions, launchFirstPresetInFocusedPane], ); diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 8217f208ef4..ab26f7c5b8d 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -13,6 +13,7 @@ import { hasRenderedPreview, isImageFile } from "shared/file-types"; import { acknowledgedStatus, type BrowserPaneState, + type CommentPaneState, type DevToolsPaneState, type DiffLayout, type FileViewerMode, @@ -358,6 +359,42 @@ export const createChatTabWithPane = ( return { tab, pane }; }; +/** + * Creates a new comment pane (PR review / conversation comment viewer) + */ +export const createCommentPane = ( + tabId: string, + comment: CommentPaneState, +): Pane => { + const id = generateId("pane"); + return { + id, + tabId, + type: "comment", + name: `@${comment.authorLogin}`, + comment, + }; +}; + +/** + * Creates a new tab with a comment pane atomically + */ +export const createCommentTabWithPane = ( + workspaceId: string, + comment: CommentPaneState, +): { tab: Tab; pane: Pane } => { + const tabId = generateId("tab"); + const pane = createCommentPane(tabId, comment); + const tab: Tab = { + id: tabId, + name: `@${comment.authorLogin}`, + workspaceId, + layout: pane.id, + createdAt: Date.now(), + }; + return { tab, pane }; +}; + /** * Generates a static tab name based on existing tabs * (e.g., "Terminal 1", "Terminal 2", finding the next available number) @@ -506,42 +543,6 @@ export const getFirstPaneId = (layout: MosaicNode<string>): string => { return getFirstPaneId(layout.first); }; -/** - * Gets the next pane ID in visual order (left-to-right, top-to-bottom), - * wrapping around to the first if at the end. - */ -export const getNextPaneId = ( - layout: MosaicNode<string>, - currentPaneId: string, -): string | null => { - const paneIds = getPaneIdsInVisualOrder(layout); - if (paneIds.length <= 1) return null; - - const currentIndex = paneIds.indexOf(currentPaneId); - if (currentIndex === -1) return paneIds[0]; - - const nextIndex = (currentIndex + 1) % paneIds.length; - return paneIds[nextIndex]; -}; - -/** - * Gets the previous pane ID in visual order (right-to-left, bottom-to-top), - * wrapping around to the last if at the beginning. - */ -export const getPreviousPaneId = ( - layout: MosaicNode<string>, - currentPaneId: string, -): string | null => { - const paneIds = getPaneIdsInVisualOrder(layout); - if (paneIds.length <= 1) return null; - - const currentIndex = paneIds.indexOf(currentPaneId); - if (currentIndex === -1) return paneIds[paneIds.length - 1]; - - const prevIndex = (currentIndex - 1 + paneIds.length) % paneIds.length; - return paneIds[prevIndex]; -}; - /** * Gets the adjacent pane ID for focus fallback when a pane is closed. * Prefers the next pane in visual order, falls back to previous if at the end. @@ -591,6 +592,70 @@ export const findPanePath = ( return null; }; +export type FocusDirection = "left" | "right" | "up" | "down"; + +const findEdgeMosaicPaneId = ( + node: MosaicNode<string>, + dir: FocusDirection, + alignmentPath: MosaicBranch[] = [], +): string => { + if (typeof node === "string") return node; + const axis: "row" | "column" = + dir === "left" || dir === "right" ? "row" : "column"; + if (node.direction === axis) { + const nearEdge: MosaicBranch = + dir === "right" || dir === "down" ? "first" : "second"; + return findEdgeMosaicPaneId(node[nearEdge], dir, alignmentPath); + } + const [alignedBranch = "first", ...rest] = alignmentPath; + return findEdgeMosaicPaneId(node[alignedBranch], dir, rest); +}; + +const getMosaicNodeAtPath = ( + node: MosaicNode<string>, + path: MosaicBranch[], +): MosaicNode<string> | null => { + let current: MosaicNode<string> = node; + for (const branch of path) { + if (typeof current === "string") return null; + current = current[branch]; + } + return current; +}; + +/** + * Visually adjacent pane in `dir`, or null at the outer edge of the grid. + * Preserves cross-axis alignment when descending through perpendicular splits. + */ +export const getSpatialNeighborMosaicPaneId = ( + root: MosaicNode<string>, + paneId: string, + dir: FocusDirection, +): string | null => { + const path = findPanePath(root, paneId); + if (!path) return null; + + const axis: "row" | "column" = + dir === "left" || dir === "right" ? "row" : "column"; + const wantSecond = dir === "right" || dir === "down"; + + for (let i = path.length - 1; i >= 0; i--) { + const ancestor = getMosaicNodeAtPath(root, path.slice(0, i)); + if (!ancestor || typeof ancestor === "string") continue; + if (ancestor.direction !== axis) continue; + const cameFrom = path[i]; + if (wantSecond && cameFrom !== "first") continue; + if (!wantSecond && cameFrom !== "second") continue; + const siblingBranch: MosaicBranch = wantSecond ? "second" : "first"; + return findEdgeMosaicPaneId( + ancestor[siblingBranch], + dir, + path.slice(i + 1), + ); + } + return null; +}; + /** * Adds a pane to an existing layout by creating a split */ diff --git a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts index 741943df81b..a6b151dd39d 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts @@ -1,9 +1,14 @@ +import { rejectTerminalSessionReady } from "../../../lib/terminal/session-readiness"; import { electronTrpcClient } from "../../../lib/trpc-client"; /** * Uses standalone tRPC client to avoid React hook dependencies */ export const killTerminalForPane = (paneId: string): void => { + rejectTerminalSessionReady( + paneId, + new Error("Terminal pane was closed before the session became ready"), + ); electronTrpcClient.terminal.kill.mutate({ paneId }).catch((error) => { console.warn(`Failed to kill terminal for pane ${paneId}:`, error); }); diff --git a/apps/desktop/src/renderer/stores/theme/index.ts b/apps/desktop/src/renderer/stores/theme/index.ts index e404a411cae..63267d79cc8 100644 --- a/apps/desktop/src/renderer/stores/theme/index.ts +++ b/apps/desktop/src/renderer/stores/theme/index.ts @@ -1,7 +1,10 @@ export { SYSTEM_THEME_ID, useResolvedTheme, + useSetSystemThemePreference, useSetTheme, + useSystemDarkThemeId, + useSystemLightThemeId, useTerminalTheme, useTheme, useThemeId, diff --git a/apps/desktop/src/renderer/stores/theme/store.ts b/apps/desktop/src/renderer/stores/theme/store.ts index 104c1edf332..163ecfef64b 100644 --- a/apps/desktop/src/renderer/stores/theme/store.ts +++ b/apps/desktop/src/renderer/stores/theme/store.ts @@ -15,6 +15,10 @@ import { applyUIColors, toXtermTheme, updateThemeClass } from "./utils"; /** Special theme ID for system preference (follows OS dark/light mode) */ export const SYSTEM_THEME_ID = "system"; +/** Built-in fallback theme IDs for system mode */ +const DEFAULT_LIGHT_THEME_ID = "light"; +const DEFAULT_DARK_THEME_ID = "dark"; + interface ThemeState { /** Current active theme ID (can be "system" or a specific theme ID) */ activeThemeId: string; @@ -22,6 +26,12 @@ interface ThemeState { /** List of custom (user-imported) themes */ customThemes: Theme[]; + /** Theme ID to use for light mode when "system" is active */ + systemLightThemeId: string; + + /** Theme ID to use for dark mode when "system" is active */ + systemDarkThemeId: string; + /** The currently active theme object (resolved from system preference if needed) */ activeTheme: Theme | null; @@ -31,6 +41,9 @@ interface ThemeState { /** Set the active theme by ID (can be "system" or a specific theme ID) */ setTheme: (themeId: string) => void; + /** Set which theme to use for a given system mode (light or dark) */ + setSystemThemePreference: (mode: "light" | "dark", themeId: string) => void; + /** Add a custom theme */ addCustomTheme: (theme: Theme) => void; /** Add or replace custom themes by ID */ @@ -62,11 +75,27 @@ function getSystemPreferredThemeType(): "dark" | "light" { /** * Resolve a theme ID to the actual theme ID to use. - * If "system" is passed, resolves to "dark" or "light" based on OS preference. + * If "system" is passed, resolves based on OS preference and user-configured system theme preferences. + * Validates that the resolved system theme ID exists; falls back to built-in light/dark if stale. */ -function resolveThemeId(themeId: string): string { +function resolveThemeId( + themeId: string, + systemLightThemeId: string, + systemDarkThemeId: string, + customThemes: Theme[] = [], +): string { if (themeId === SYSTEM_THEME_ID) { - return getSystemPreferredThemeType(); + const prefersDark = getSystemPreferredThemeType() === "dark"; + const preferredId = prefersDark ? systemDarkThemeId : systemLightThemeId; + const fallbackId = prefersDark + ? DEFAULT_DARK_THEME_ID + : DEFAULT_LIGHT_THEME_ID; + + // Validate that the preferred ID still references an existing theme + if (findTheme(preferredId, customThemes)) { + return preferredId; + } + return fallbackId; } return themeId; } @@ -127,13 +156,19 @@ export const useThemeStore = create<ThemeState>()( (set, get) => ({ activeThemeId: DEFAULT_THEME_ID, customThemes: [], + systemLightThemeId: DEFAULT_LIGHT_THEME_ID, + systemDarkThemeId: DEFAULT_DARK_THEME_ID, activeTheme: null, terminalTheme: null, setTheme: (themeId: string) => { const state = get(); - // Resolve system theme to actual theme ID - const resolvedId = resolveThemeId(themeId); + const resolvedId = resolveThemeId( + themeId, + state.systemLightThemeId, + state.systemDarkThemeId, + state.customThemes, + ); const theme = findTheme(resolvedId, state.customThemes); if (!theme) { @@ -144,12 +179,48 @@ export const useThemeStore = create<ThemeState>()( const { terminalTheme } = applyTheme(theme); set({ - activeThemeId: themeId, // Store the original ID (could be "system") - activeTheme: theme, // Store the resolved theme + activeThemeId: themeId, + activeTheme: theme, terminalTheme, }); }, + setSystemThemePreference: (mode: "light" | "dark", themeId: string) => { + const state = get(); + if ( + themeId === SYSTEM_THEME_ID || + !findTheme(themeId, state.customThemes) + ) { + return; + } + const prefUpdate = + mode === "light" + ? { systemLightThemeId: themeId } + : { systemDarkThemeId: themeId }; + + // Re-resolve if system theme is currently active, batching into a single set() + if (state.activeThemeId === SYSTEM_THEME_ID) { + const newLightId = + mode === "light" ? themeId : state.systemLightThemeId; + const newDarkId = + mode === "dark" ? themeId : state.systemDarkThemeId; + const resolvedId = resolveThemeId( + SYSTEM_THEME_ID, + newLightId, + newDarkId, + state.customThemes, + ); + const theme = findTheme(resolvedId, state.customThemes); + if (theme) { + const { terminalTheme } = applyTheme(theme); + set({ ...prefUpdate, activeTheme: theme, terminalTheme }); + return; + } + } + + set(prefUpdate); + }, + addCustomTheme: (theme: Theme) => { get().upsertCustomThemes([theme]); }, @@ -184,7 +255,12 @@ export const useThemeStore = create<ThemeState>()( } const customThemes = Array.from(customThemesById.values()); - const resolvedId = resolveThemeId(state.activeThemeId); + const resolvedId = resolveThemeId( + state.activeThemeId, + state.systemLightThemeId, + state.systemDarkThemeId, + customThemes, + ); const resolvedTheme = findTheme(resolvedId, customThemes); if (!resolvedTheme) { @@ -210,9 +286,43 @@ export const useThemeStore = create<ThemeState>()( state.setTheme(DEFAULT_THEME_ID); } - set((state) => ({ - customThemes: state.customThemes.filter((t) => t.id !== themeId), - })); + // Reset system preferences if they reference the deleted theme + const newLightId = + state.systemLightThemeId === themeId + ? DEFAULT_LIGHT_THEME_ID + : state.systemLightThemeId; + const newDarkId = + state.systemDarkThemeId === themeId + ? DEFAULT_DARK_THEME_ID + : state.systemDarkThemeId; + + const customThemes = state.customThemes.filter( + (t) => t.id !== themeId, + ); + + const baseUpdate = { + customThemes, + systemLightThemeId: newLightId, + systemDarkThemeId: newDarkId, + }; + + // Re-resolve active theme if system mode is active, batching into a single set() + if (state.activeThemeId === SYSTEM_THEME_ID) { + const resolvedId = resolveThemeId( + SYSTEM_THEME_ID, + newLightId, + newDarkId, + customThemes, + ); + const theme = findTheme(resolvedId, customThemes); + if (theme) { + const { terminalTheme } = applyTheme(theme); + set({ ...baseUpdate, activeTheme: theme, terminalTheme }); + return; + } + } + + set(baseUpdate); }, getAllThemes: () => { @@ -230,7 +340,36 @@ export const useThemeStore = create<ThemeState>()( initializeTheme: () => { const state = get(); - const resolvedId = resolveThemeId(state.activeThemeId); + + // Normalize stale system theme IDs before resolving + const lightExists = findTheme( + state.systemLightThemeId, + state.customThemes, + ); + const darkExists = findTheme( + state.systemDarkThemeId, + state.customThemes, + ); + const normalizedLightId = lightExists + ? state.systemLightThemeId + : DEFAULT_LIGHT_THEME_ID; + const normalizedDarkId = darkExists + ? state.systemDarkThemeId + : DEFAULT_DARK_THEME_ID; + + if (!lightExists || !darkExists) { + set({ + systemLightThemeId: normalizedLightId, + systemDarkThemeId: normalizedDarkId, + }); + } + + const resolvedId = resolveThemeId( + state.activeThemeId, + normalizedLightId, + normalizedDarkId, + state.customThemes, + ); const theme = findTheme(resolvedId, state.customThemes); if (theme) { @@ -265,6 +404,8 @@ export const useThemeStore = create<ThemeState>()( partialize: (state) => ({ activeThemeId: state.activeThemeId, customThemes: state.customThemes, + systemLightThemeId: state.systemLightThemeId, + systemDarkThemeId: state.systemDarkThemeId, }), onRehydrateStorage: () => (state) => { if (state) { @@ -285,3 +426,9 @@ export const useTerminalTheme = () => useThemeStore((state) => state.terminalTheme); export const useSetTheme = () => useThemeStore((state) => state.setTheme); export const useThemeId = () => useThemeStore((state) => state.activeThemeId); +export const useSystemLightThemeId = () => + useThemeStore((state) => state.systemLightThemeId); +export const useSystemDarkThemeId = () => + useThemeStore((state) => state.systemDarkThemeId); +export const useSetSystemThemePreference = () => + useThemeStore((state) => state.setSystemThemePreference); diff --git a/apps/desktop/src/renderer/stores/v2-local-override.ts b/apps/desktop/src/renderer/stores/v2-local-override.ts new file mode 100644 index 00000000000..016a2bc5733 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-local-override.ts @@ -0,0 +1,21 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +interface V2LocalOverrideState { + /** When true, the user has opted into v2. v2 is gated behind both the remote flag and this opt-in. */ + optInV2: boolean; + setOptInV2: (optInV2: boolean) => void; +} + +export const useV2LocalOverrideStore = create<V2LocalOverrideState>()( + devtools( + persist( + (set) => ({ + optInV2: false, + setOptInV2: (optInV2) => set({ optInV2 }), + }), + { name: "v2-local-override-v2" }, + ), + { name: "V2LocalOverrideStore" }, + ), +); diff --git a/apps/desktop/src/renderer/stores/v2-notifications/index.ts b/apps/desktop/src/renderer/stores/v2-notifications/index.ts new file mode 100644 index 00000000000..5917869b71c --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/index.ts @@ -0,0 +1,31 @@ +export { + getV2ChatNotificationSource, + getV2ManualNotificationSource, + getV2NotificationSourceKey, + getV2NotificationSourcesForPane, + getV2NotificationSourcesForTab, + getV2TerminalNotificationSource, + selectV2ChatNotificationStatus, + selectV2PaneNotificationStatus, + selectV2SourcesNotificationStatus, + selectV2TabNotificationStatus, + selectV2TerminalNotificationStatus, + selectV2WorkspaceIsUnread, + selectV2WorkspaceNotificationStatus, + useV2ChatNotificationStatus, + useV2NotificationStore, + useV2PaneNotificationStatus, + useV2SourcesNotificationStatus, + useV2TabNotificationStatus, + useV2TerminalNotificationStatus, + useV2WorkspaceIsUnread, + useV2WorkspaceNotificationStatus, + type V2NotificationPaneLike, + type V2NotificationSource, + type V2NotificationSourceInput, + type V2NotificationSourceKey, + type V2NotificationSourceType, + type V2NotificationState, + type V2NotificationStatusEntry, + type V2NotificationTabLike, +} from "./store"; diff --git a/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts b/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts new file mode 100644 index 00000000000..57ccf626ddc --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { + getV2NotificationSourcesForPane, + getV2NotificationSourcesForTab, + selectV2ChatNotificationStatus, + selectV2PaneNotificationStatus, + selectV2SourcesNotificationStatus, + selectV2TabNotificationStatus, + selectV2TerminalNotificationStatus, + selectV2WorkspaceNotificationStatus, + useV2NotificationStore, +} from "./store"; + +const terminalPane = { + id: "pane-1", + kind: "terminal", + data: { terminalId: "terminal-1" }, +}; +const secondTerminalPane = { + id: "pane-2", + kind: "terminal", + data: { terminalId: "terminal-2" }, +}; +const chatPane = { + id: "pane-3", + kind: "chat", + data: { sessionId: "session-1" }, +}; +const tab = { + id: "tab-1", + createdAt: 0, + activePaneId: "pane-1", + layout: { type: "pane", paneId: "pane-1" } as const, + panes: { + "pane-1": terminalPane, + "pane-2": secondTerminalPane, + "pane-3": chatPane, + }, +}; + +describe("v2 notification store", () => { + beforeEach(() => { + useV2NotificationStore.setState({ sources: {} }); + }); + + it("maps panes and tabs to typed notification sources", () => { + expect(getV2NotificationSourcesForPane(terminalPane)).toEqual([ + { type: "terminal", id: "terminal-1" }, + ]); + expect(getV2NotificationSourcesForPane(chatPane)).toEqual([ + { type: "chat", id: "session-1" }, + ]); + expect(getV2NotificationSourcesForTab(tab)).toEqual([ + { type: "terminal", id: "terminal-1" }, + { type: "terminal", id: "terminal-2" }, + { type: "chat", id: "session-1" }, + ]); + }); + + it("derives workspace, tab, pane, terminal, and chat status from sources", () => { + const store = useV2NotificationStore.getState(); + store.setTerminalStatus("terminal-1", "workspace-1", "working", 100); + store.setTerminalStatus("terminal-2", "workspace-1", "permission", 101); + store.setTerminalStatus("terminal-3", "workspace-2", "review", 102); + store.setChatStatus("session-1", "workspace-1", "review", 103); + + const state = useV2NotificationStore.getState(); + expect(selectV2WorkspaceNotificationStatus("workspace-1")(state)).toBe( + "permission", + ); + expect(selectV2TabNotificationStatus("workspace-1", tab)(state)).toBe( + "permission", + ); + expect( + selectV2PaneNotificationStatus("workspace-1", terminalPane)(state), + ).toBe("working"); + expect(selectV2PaneNotificationStatus("workspace-1", chatPane)(state)).toBe( + "review", + ); + expect( + selectV2TerminalNotificationStatus("workspace-1", "terminal-2")(state), + ).toBe("permission"); + expect( + selectV2ChatNotificationStatus("workspace-1", "session-1")(state), + ).toBe("review"); + expect( + selectV2SourcesNotificationStatus("workspace-1", [ + { type: "terminal", id: "terminal-1" }, + { type: "terminal", id: "terminal-2" }, + ])(state), + ).toBe("permission"); + expect( + selectV2TerminalNotificationStatus("workspace-1", "terminal-3")(state), + ).toBeNull(); + }); + + it("clears only review attention for a source", () => { + const store = useV2NotificationStore.getState(); + store.setTerminalStatus("terminal-1", "workspace-1", "review", 100); + store.setTerminalStatus("terminal-2", "workspace-1", "permission", 101); + + store.clearSourceAttention( + { type: "terminal", id: "terminal-1" }, + "workspace-1", + ); + store.clearSourceAttention( + { type: "terminal", id: "terminal-2" }, + "workspace-1", + ); + + const state = useV2NotificationStore.getState(); + expect(state.sources["terminal:terminal-1"]).toBeUndefined(); + expect(state.sources["terminal:terminal-2"]?.status).toBe("permission"); + }); + + it("clears only review attention for a workspace", () => { + const store = useV2NotificationStore.getState(); + store.setTerminalStatus("terminal-1", "workspace-1", "review", 100); + store.setTerminalStatus("terminal-2", "workspace-1", "working", 101); + store.setChatStatus("session-1", "workspace-1", "permission", 102); + store.setTerminalStatus("terminal-3", "workspace-2", "review", 103); + + store.clearWorkspaceAttention("workspace-1"); + + const state = useV2NotificationStore.getState(); + expect(state.sources["terminal:terminal-1"]).toBeUndefined(); + expect(state.sources["terminal:terminal-2"]?.status).toBe("working"); + expect(state.sources["chat:session-1"]?.status).toBe("permission"); + expect(state.sources["terminal:terminal-3"]?.status).toBe("review"); + }); +}); diff --git a/apps/desktop/src/renderer/stores/v2-notifications/store.ts b/apps/desktop/src/renderer/stores/v2-notifications/store.ts new file mode 100644 index 00000000000..26617b97e97 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-notifications/store.ts @@ -0,0 +1,397 @@ +import type { Pane, Tab } from "@superset/panes"; +import { + type ActivePaneStatus, + getHighestPriorityStatus, +} from "shared/tabs-types"; +import { create } from "zustand"; + +export type V2NotificationPaneLike = Pick<Pane<unknown>, "kind" | "data">; +export type V2NotificationTabLike = Pick<Tab<unknown>, "panes">; + +export type V2NotificationSource = + | { type: "terminal"; id: string } + | { type: "chat"; id: string } + | { type: "manual"; id: string }; + +export type V2NotificationSourceType = V2NotificationSource["type"]; +export type V2NotificationSourceKey = `${V2NotificationSourceType}:${string}`; +export type V2NotificationSourceInput = + | V2NotificationSource + | V2NotificationSourceKey; + +export interface V2NotificationStatusEntry { + sourceKey: V2NotificationSourceKey; + source: V2NotificationSource; + workspaceId: string; + status: ActivePaneStatus; + occurredAt: number; +} + +export interface V2NotificationState { + sources: Record<string, V2NotificationStatusEntry>; + setSourceStatus: ( + source: V2NotificationSource, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; + setTerminalStatus: ( + terminalId: string, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; + setChatStatus: ( + chatId: string, + workspaceId: string, + status: ActivePaneStatus, + occurredAt?: number, + ) => void; + setManualUnread: (workspaceId: string) => void; + clearSourceStatus: ( + source: V2NotificationSourceInput, + workspaceId?: string, + ) => void; + clearSourceStatuses: ( + sources: Iterable<V2NotificationSourceInput>, + workspaceId?: string, + ) => void; + clearSourceAttention: ( + source: V2NotificationSourceInput, + workspaceId?: string, + ) => void; + clearWorkspaceStatuses: (workspaceId: string) => void; + clearWorkspaceAttention: (workspaceId: string) => void; +} + +export const useV2NotificationStore = create<V2NotificationState>()((set) => ({ + sources: {}, + setSourceStatus: (source, workspaceId, status, occurredAt = Date.now()) => { + const sourceKey = getV2NotificationSourceKey(source); + set((state) => ({ + sources: { + ...state.sources, + [sourceKey]: { + sourceKey, + source, + workspaceId, + status, + occurredAt, + }, + }, + })); + }, + setTerminalStatus: (terminalId, workspaceId, status, occurredAt) => { + useV2NotificationStore + .getState() + .setSourceStatus( + getV2TerminalNotificationSource(terminalId), + workspaceId, + status, + occurredAt, + ); + }, + setChatStatus: (chatId, workspaceId, status, occurredAt) => { + useV2NotificationStore + .getState() + .setSourceStatus( + getV2ChatNotificationSource(chatId), + workspaceId, + status, + occurredAt, + ); + }, + setManualUnread: (workspaceId) => { + useV2NotificationStore + .getState() + .setSourceStatus( + getV2ManualNotificationSource(workspaceId), + workspaceId, + "review", + ); + }, + clearSourceStatus: (source, workspaceId) => { + const sourceKey = getV2NotificationSourceKey(source); + set((state) => { + const entry = state.sources[sourceKey]; + if (!entry || (workspaceId && entry.workspaceId !== workspaceId)) { + return state; + } + const { [sourceKey]: _removed, ...sources } = state.sources; + return { sources }; + }); + }, + clearSourceStatuses: (sourceInputs, workspaceId) => { + set((state) => { + const sourceKeys = new Set( + [...sourceInputs].map(getV2NotificationSourceKey), + ); + const sources: Record<string, V2NotificationStatusEntry> = {}; + let changed = false; + for (const [sourceKey, source] of Object.entries(state.sources)) { + if ( + sourceKeys.has(sourceKey as V2NotificationSourceKey) && + (!workspaceId || source.workspaceId === workspaceId) + ) { + changed = true; + continue; + } + sources[sourceKey] = source; + } + return changed ? { sources } : state; + }); + }, + clearSourceAttention: (source, workspaceId) => { + const sourceKey = getV2NotificationSourceKey(source); + set((state) => { + const entry = state.sources[sourceKey]; + if ( + !entry || + entry.status !== "review" || + (workspaceId && entry.workspaceId !== workspaceId) + ) { + return state; + } + const { [sourceKey]: _removed, ...sources } = state.sources; + return { sources }; + }); + }, + clearWorkspaceStatuses: (workspaceId) => { + set((state) => { + const sources: Record<string, V2NotificationStatusEntry> = {}; + let changed = false; + for (const [sourceKey, source] of Object.entries(state.sources)) { + if (source.workspaceId === workspaceId) { + changed = true; + continue; + } + sources[sourceKey] = source; + } + return changed ? { sources } : state; + }); + }, + clearWorkspaceAttention: (workspaceId) => { + set((state) => { + const sources: Record<string, V2NotificationStatusEntry> = {}; + let changed = false; + for (const [sourceKey, source] of Object.entries(state.sources)) { + if (source.workspaceId === workspaceId && source.status === "review") { + changed = true; + continue; + } + sources[sourceKey] = source; + } + return changed ? { sources } : state; + }); + }, +})); + +export function getV2NotificationSourceKey( + source: V2NotificationSourceInput, +): V2NotificationSourceKey { + if (typeof source === "string") return source; + return `${source.type}:${source.id}`; +} + +export function getV2TerminalNotificationSource( + terminalId: string, +): V2NotificationSource { + return { type: "terminal", id: terminalId }; +} + +export function getV2ChatNotificationSource( + chatId: string, +): V2NotificationSource { + return { type: "chat", id: chatId }; +} + +export function getV2ManualNotificationSource( + workspaceId: string, +): V2NotificationSource { + return { type: "manual", id: workspaceId }; +} + +export function getV2NotificationSourcesForPane( + pane: V2NotificationPaneLike | null | undefined, +): V2NotificationSource[] { + const terminalId = getTerminalIdForPane(pane); + if (terminalId) return [getV2TerminalNotificationSource(terminalId)]; + const chatId = getChatIdForPane(pane); + if (chatId) return [getV2ChatNotificationSource(chatId)]; + return []; +} + +export function getV2NotificationSourcesForTab( + tab: V2NotificationTabLike | null | undefined, +): V2NotificationSource[] { + if (!tab) return []; + const sources = new Map<V2NotificationSourceKey, V2NotificationSource>(); + for (const pane of Object.values(tab.panes)) { + for (const source of getV2NotificationSourcesForPane(pane)) { + sources.set(getV2NotificationSourceKey(source), source); + } + } + return [...sources.values()]; +} + +export function selectV2WorkspaceNotificationStatus(workspaceId: string) { + return (state: V2NotificationState) => { + function* statuses() { + for (const source of Object.values(state.sources)) { + if (source.workspaceId === workspaceId) { + yield source.status; + } + } + } + return getHighestPriorityStatus(statuses()); + }; +} + +export function selectV2TabNotificationStatus( + workspaceId: string, + tab: V2NotificationTabLike | null | undefined, +) { + return selectV2SourcesNotificationStatus( + workspaceId, + getV2NotificationSourcesForTab(tab), + ); +} + +export function selectV2PaneNotificationStatus( + workspaceId: string, + pane: V2NotificationPaneLike | null | undefined, +) { + return selectV2SourcesNotificationStatus( + workspaceId, + getV2NotificationSourcesForPane(pane), + ); +} + +export function selectV2TerminalNotificationStatus( + workspaceId: string, + terminalId: string | null | undefined, +) { + return selectV2SourcesNotificationStatus( + workspaceId, + terminalId ? [getV2TerminalNotificationSource(terminalId)] : [], + ); +} + +export function selectV2ChatNotificationStatus( + workspaceId: string, + chatId: string | null | undefined, +) { + return selectV2SourcesNotificationStatus( + workspaceId, + chatId ? [getV2ChatNotificationSource(chatId)] : [], + ); +} + +export function selectV2SourcesNotificationStatus( + workspaceId: string, + sources: Iterable<V2NotificationSourceInput>, +) { + const sourceKeys = [...new Set([...sources].map(getV2NotificationSourceKey))]; + return (state: V2NotificationState) => + selectStatusForSourceKeys(state, workspaceId, sourceKeys); +} + +export function useV2WorkspaceNotificationStatus(workspaceId: string) { + return useV2NotificationStore( + selectV2WorkspaceNotificationStatus(workspaceId), + ); +} + +export function selectV2WorkspaceIsUnread(workspaceId: string) { + return (state: V2NotificationState) => { + for (const entry of Object.values(state.sources)) { + if (entry.workspaceId === workspaceId && entry.status === "review") { + return true; + } + } + return false; + }; +} + +export function useV2WorkspaceIsUnread(workspaceId: string) { + return useV2NotificationStore(selectV2WorkspaceIsUnread(workspaceId)); +} + +export function useV2TabNotificationStatus( + workspaceId: string, + tab: V2NotificationTabLike | null | undefined, +) { + return useV2NotificationStore( + selectV2TabNotificationStatus(workspaceId, tab), + ); +} + +export function useV2PaneNotificationStatus( + workspaceId: string, + pane: V2NotificationPaneLike | null | undefined, +) { + return useV2NotificationStore( + selectV2PaneNotificationStatus(workspaceId, pane), + ); +} + +export function useV2TerminalNotificationStatus( + workspaceId: string, + terminalId: string | null | undefined, +) { + return useV2NotificationStore( + selectV2TerminalNotificationStatus(workspaceId, terminalId), + ); +} + +export function useV2ChatNotificationStatus( + workspaceId: string, + chatId: string | null | undefined, +) { + return useV2NotificationStore( + selectV2ChatNotificationStatus(workspaceId, chatId), + ); +} + +export function useV2SourcesNotificationStatus( + workspaceId: string, + sources: Iterable<V2NotificationSourceInput>, +) { + return useV2NotificationStore( + selectV2SourcesNotificationStatus(workspaceId, sources), + ); +} + +function selectStatusForSourceKeys( + state: V2NotificationState, + workspaceId: string, + sourceKeys: Iterable<V2NotificationSourceKey>, +) { + function* statuses() { + for (const sourceKey of sourceKeys) { + const source = state.sources[sourceKey]; + if (source?.workspaceId === workspaceId) { + yield source.status; + } + } + } + return getHighestPriorityStatus(statuses()); +} + +function getTerminalIdForPane( + pane: V2NotificationPaneLike | null | undefined, +): string | null { + if (!pane || pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const terminalId = (pane.data as { terminalId?: unknown }).terminalId; + return typeof terminalId === "string" && terminalId ? terminalId : null; +} + +function getChatIdForPane( + pane: V2NotificationPaneLike | null | undefined, +): string | null { + if (!pane || pane.kind !== "chat") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const sessionId = (pane.data as { sessionId?: unknown }).sessionId; + return typeof sessionId === "string" && sessionId ? sessionId : null; +} diff --git a/apps/desktop/src/renderer/stores/v2-workspace-create-defaults.ts b/apps/desktop/src/renderer/stores/v2-workspace-create-defaults.ts new file mode 100644 index 00000000000..25f40660eb3 --- /dev/null +++ b/apps/desktop/src/renderer/stores/v2-workspace-create-defaults.ts @@ -0,0 +1,69 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +export type V2WorkspaceCreateBaseBranchSource = "local" | "remote-tracking"; + +export interface V2WorkspaceCreateBaseBranchDefault { + branchName: string; + source: V2WorkspaceCreateBaseBranchSource; +} + +export type V2WorkspaceCreateHostTarget = + | { kind: "local" } + | { kind: "host"; hostId: string }; + +interface V2WorkspaceCreateDefaultsState { + lastProjectId: string | null; + baseBranchesByProjectId: Record<string, V2WorkspaceCreateBaseBranchDefault>; + lastHostTarget: V2WorkspaceCreateHostTarget | null; + + setLastProjectId: (projectId: string | null) => void; + setBaseBranchDefault: ( + projectId: string, + branchName: string, + source: V2WorkspaceCreateBaseBranchSource, + ) => void; + clearBaseBranchDefault: (projectId: string) => void; + setLastHostTarget: (target: V2WorkspaceCreateHostTarget) => void; +} + +export const useV2WorkspaceCreateDefaultsStore = + create<V2WorkspaceCreateDefaultsState>()( + devtools( + persist( + (set) => ({ + lastProjectId: null, + baseBranchesByProjectId: {}, + lastHostTarget: null, + + setLastProjectId: (projectId) => set({ lastProjectId: projectId }), + + setBaseBranchDefault: (projectId, branchName, source) => { + const trimmed = branchName.trim(); + if (!trimmed) return; + set((state) => ({ + baseBranchesByProjectId: { + ...state.baseBranchesByProjectId, + [projectId]: { branchName: trimmed, source }, + }, + })); + }, + + clearBaseBranchDefault: (projectId) => + set((state) => { + if (!(projectId in state.baseBranchesByProjectId)) return state; + const next = { ...state.baseBranchesByProjectId }; + delete next[projectId]; + return { baseBranchesByProjectId: next }; + }), + + setLastHostTarget: (target) => set({ lastHostTarget: target }), + }), + { + name: "v2-workspace-create-defaults", + version: 1, + }, + ), + { name: "V2WorkspaceCreateDefaultsStore" }, + ), + ); diff --git a/apps/desktop/src/renderer/stores/workspace-init.ts b/apps/desktop/src/renderer/stores/workspace-init.ts index 256b9e6852d..b75068407b7 100644 --- a/apps/desktop/src/renderer/stores/workspace-init.ts +++ b/apps/desktop/src/renderer/stores/workspace-init.ts @@ -19,6 +19,13 @@ export interface PendingTerminalSetup { interface WorkspaceInitState { initProgress: Record<string, WorkspaceInitProgress>; pendingTerminalSetups: Record<string, PendingTerminalSetup>; + /** + * Workspace IDs we witnessed reach "ready" during this app session. Outlives + * `initProgress` entries (which get cleared after terminal setup runs) so + * consumers can reliably tell that a workspace is not "stuck mid-init" even + * after the progress record has been wiped. + */ + completedInits: Record<string, true>; updateProgress: (progress: WorkspaceInitProgress) => void; clearProgress: (workspaceId: string) => void; addPendingTerminalSetup: (setup: PendingTerminalSetup) => void; @@ -30,6 +37,7 @@ export const useWorkspaceInitStore = create<WorkspaceInitState>()( (set, get) => ({ initProgress: {}, pendingTerminalSetups: {}, + completedInits: {}, updateProgress: (progress) => { set((state) => ({ @@ -37,6 +45,13 @@ export const useWorkspaceInitStore = create<WorkspaceInitState>()( ...state.initProgress, [progress.workspaceId]: progress, }, + completedInits: + progress.step === "ready" + ? { + ...state.completedInits, + [progress.workspaceId]: true, + } + : state.completedInits, })); if (progress.step === "ready") { @@ -97,3 +112,6 @@ export const useHasWorkspaceFailed = (workspaceId: string) => const progress = state.initProgress[workspaceId]; return progress?.step === "failed"; }); + +export const useHasCompletedInitThisSession = (workspaceId: string) => + useWorkspaceInitStore((state) => state.completedInits[workspaceId] === true); diff --git a/apps/desktop/src/resources/build/icons/icon-canary.icns b/apps/desktop/src/resources/build/icons/icon-canary.icns index 9979c557092..c05bb9d2996 100644 Binary files a/apps/desktop/src/resources/build/icons/icon-canary.icns and b/apps/desktop/src/resources/build/icons/icon-canary.icns differ diff --git a/apps/desktop/src/resources/build/icons/icon-canary.ico b/apps/desktop/src/resources/build/icons/icon-canary.ico index 70a4daa1ef9..e5bcff052fc 100644 Binary files a/apps/desktop/src/resources/build/icons/icon-canary.ico and b/apps/desktop/src/resources/build/icons/icon-canary.ico differ diff --git a/apps/desktop/src/resources/build/icons/icon-canary.png b/apps/desktop/src/resources/build/icons/icon-canary.png index b3f3337e44a..5cfab3f3ba7 100644 Binary files a/apps/desktop/src/resources/build/icons/icon-canary.png and b/apps/desktop/src/resources/build/icons/icon-canary.png differ diff --git a/apps/desktop/src/resources/build/icons/icon-dev.icns b/apps/desktop/src/resources/build/icons/icon-dev.icns new file mode 100644 index 00000000000..ab9acd9b682 Binary files /dev/null and b/apps/desktop/src/resources/build/icons/icon-dev.icns differ diff --git a/apps/desktop/src/resources/build/icons/icon-dev.ico b/apps/desktop/src/resources/build/icons/icon-dev.ico new file mode 100644 index 00000000000..e5bc7f9f6ce Binary files /dev/null and b/apps/desktop/src/resources/build/icons/icon-dev.ico differ diff --git a/apps/desktop/src/resources/build/icons/icon-dev.png b/apps/desktop/src/resources/build/icons/icon-dev.png new file mode 100644 index 00000000000..6e8a2b8cde3 Binary files /dev/null and b/apps/desktop/src/resources/build/icons/icon-dev.png differ diff --git a/apps/desktop/src/resources/build/icons/icon.icns b/apps/desktop/src/resources/build/icons/icon.icns index 27082bc1a5c..c4249b96cda 100644 Binary files a/apps/desktop/src/resources/build/icons/icon.icns and b/apps/desktop/src/resources/build/icons/icon.icns differ diff --git a/apps/desktop/src/resources/build/icons/icon.ico b/apps/desktop/src/resources/build/icons/icon.ico index aea4682ff7f..2f2babb1520 100644 Binary files a/apps/desktop/src/resources/build/icons/icon.ico and b/apps/desktop/src/resources/build/icons/icon.ico differ diff --git a/apps/desktop/src/resources/build/icons/icon.png b/apps/desktop/src/resources/build/icons/icon.png index 3b6373301d0..f83930055c5 100644 Binary files a/apps/desktop/src/resources/build/icons/icon.png and b/apps/desktop/src/resources/build/icons/icon.png differ diff --git a/apps/desktop/src/resources/build/installer/background.tiff b/apps/desktop/src/resources/build/installer/background.tiff new file mode 100644 index 00000000000..fe6576d55d8 Binary files /dev/null and b/apps/desktop/src/resources/build/installer/background.tiff differ diff --git a/apps/desktop/src/resources/tray/iconTemplate.png b/apps/desktop/src/resources/tray/iconTemplate.png index c62c39e0717..463d63e71e2 100644 Binary files a/apps/desktop/src/resources/tray/iconTemplate.png and b/apps/desktop/src/resources/tray/iconTemplate.png differ diff --git a/apps/desktop/src/shared/ai/provider-status.test.ts b/apps/desktop/src/shared/ai/provider-status.test.ts index 4a2ed63d85e..4c0e1e99267 100644 --- a/apps/desktop/src/shared/ai/provider-status.test.ts +++ b/apps/desktop/src/shared/ai/provider-status.test.ts @@ -1,23 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { - deriveModelProviderStatus, - type ProviderDiagnostic, -} from "./provider-status"; +import { deriveModelProviderStatus } from "./provider-status"; describe("deriveModelProviderStatus", () => { - it("keeps a connected provider connected when only capability diagnostics fail", () => { - const diagnostic: ProviderDiagnostic = { - providerId: "openai", - issue: { - code: "missing_scope", - capability: "small_model_tasks", - remediation: "check_permissions", - scope: "api.responses.write", - message: "OpenAI needs permission api.responses.write", - }, - updatedAt: Date.now(), - }; - + it("marks an authenticated provider without issues as connected", () => { const status = deriveModelProviderStatus({ providerId: "openai", authStatus: { @@ -26,14 +11,15 @@ describe("deriveModelProviderStatus", () => { source: "managed", issue: null, }, - diagnostic, }); expect(status.connectionState).toBe("connected"); - expect(status.issue?.code).toBe("missing_scope"); - expect(status.capabilities.canUseChat).toBe(true); - expect(status.capabilities.canGenerateWorkspaceTitle).toBe(false); - expect(status.capabilities.canUseSmallModelTasks).toBe(false); + expect(status.issue).toBeNull(); + expect(status.capabilities).toEqual({ + canUseChat: true, + canGenerateWorkspaceTitle: true, + canUseSmallModelTasks: true, + }); }); it("treats expired auth as needs attention and disables all capabilities", () => { @@ -55,4 +41,20 @@ describe("deriveModelProviderStatus", () => { canUseSmallModelTasks: false, }); }); + + it("reports disconnected for providers with no source and no auth", () => { + const status = deriveModelProviderStatus({ + providerId: "openai", + authStatus: { + authenticated: false, + method: null, + source: null, + issue: null, + }, + }); + + expect(status.connectionState).toBe("disconnected"); + expect(status.issue).toBeNull(); + expect(status.capabilities.canUseChat).toBe(false); + }); }); diff --git a/apps/desktop/src/shared/ai/provider-status.ts b/apps/desktop/src/shared/ai/provider-status.ts index c74e27088af..910ebc2f659 100644 --- a/apps/desktop/src/shared/ai/provider-status.ts +++ b/apps/desktop/src/shared/ai/provider-status.ts @@ -5,40 +5,14 @@ export type ProviderConnectionState = | "disconnected" | "needs_attention"; -export type ProviderCapability = - | "chat" - | "workspace_titles" - | "small_model_tasks"; +export type ProviderRemediation = "reconnect" | "add_api_key"; -export type ProviderRemediation = - | "reconnect" - | "check_permissions" - | "check_billing" - | "add_api_key" - | "try_again"; - -export type ProviderIssueCode = - | "expired" - | "missing_scope" - | "forbidden" - | "quota_exceeded" - | "network_error" - | "unsupported_credentials" - | "empty_result" - | "unknown_error"; +export type ProviderIssueCode = "expired"; export interface ProviderIssue { code: ProviderIssueCode; message: string; - capability?: ProviderCapability; remediation?: ProviderRemediation; - scope?: string | null; -} - -export interface ProviderDiagnostic { - providerId: ProviderId; - issue: ProviderIssue | null; - updatedAt: number | null; } export interface AuthStatusLike { @@ -69,72 +43,6 @@ export function getProviderName(providerId: ProviderId): string { return providerId === "anthropic" ? "Anthropic" : "OpenAI"; } -export function classifyProviderIssue(params: { - providerId: ProviderId; - errorMessage: string; -}): ProviderIssue { - const { providerId, errorMessage } = params; - const normalized = errorMessage.trim(); - const lower = normalized.toLowerCase(); - - const missingScopeMatch = normalized.match( - /Missing scopes:\s*([A-Za-z0-9._,\s-]+)/i, - ); - if (missingScopeMatch || lower.includes("insufficient permissions")) { - const scope = - missingScopeMatch?.[1]?.trim().replace(/[.,;:]+$/, "") ?? null; - const providerName = getProviderName(providerId); - return { - code: "missing_scope", - capability: "small_model_tasks", - remediation: "check_permissions", - scope, - message: scope - ? `${providerName} needs permission ${scope}` - : `${providerName} is missing permission for this action`, - }; - } - - if (lower.includes("quota") || lower.includes("insufficient_quota")) { - return { - code: "quota_exceeded", - capability: "small_model_tasks", - remediation: "check_billing", - message: `${getProviderName(providerId)} quota or billing needs attention`, - }; - } - - if (lower.includes("forbidden") || lower.includes("status: 403")) { - return { - code: "forbidden", - capability: "small_model_tasks", - remediation: "check_permissions", - message: `${getProviderName(providerId)} denied this request`, - }; - } - - if ( - lower.includes("timed out") || - lower.includes("network") || - lower.includes("econn") || - lower.includes("fetch failed") - ) { - return { - code: "network_error", - capability: "small_model_tasks", - remediation: "try_again", - message: `${getProviderName(providerId)} request failed due to a network error`, - }; - } - - return { - code: "unknown_error", - capability: "small_model_tasks", - remediation: "try_again", - message: `${getProviderName(providerId)} could not complete this request`, - }; -} - function getIssueFromAuthStatus( providerId: ProviderId, authStatus: AuthStatusLike, @@ -142,7 +50,6 @@ function getIssueFromAuthStatus( if (authStatus.issue === "expired") { return { code: "expired", - capability: "chat", remediation: "reconnect", message: `${getProviderName(providerId)} session expired`, }; @@ -154,53 +61,24 @@ function getIssueFromAuthStatus( export function deriveModelProviderStatus(params: { providerId: ProviderId; authStatus: AuthStatusLike; - diagnostic?: ProviderDiagnostic | null; }): ModelProviderStatus { - const { providerId, authStatus, diagnostic } = params; - const authIssue = getIssueFromAuthStatus(providerId, authStatus); - const issue = authIssue ?? diagnostic?.issue ?? null; + const { providerId, authStatus } = params; + const issue = getIssueFromAuthStatus(providerId, authStatus); let connectionState: ProviderConnectionState = "disconnected"; if (authStatus.authenticated) { - connectionState = authIssue ? "needs_attention" : "connected"; - } else if (authIssue || authStatus.source !== null) { + connectionState = issue ? "needs_attention" : "connected"; + } else if (issue || authStatus.source !== null) { connectionState = "needs_attention"; } + const canUse = authStatus.authenticated && !issue; const capabilities: ProviderCapabilities = { - canUseChat: authStatus.authenticated, - canGenerateWorkspaceTitle: authStatus.authenticated, - canUseSmallModelTasks: authStatus.authenticated, + canUseChat: canUse, + canGenerateWorkspaceTitle: canUse, + canUseSmallModelTasks: canUse, }; - if (issue) { - switch (issue.code) { - case "expired": - capabilities.canUseChat = false; - capabilities.canGenerateWorkspaceTitle = false; - capabilities.canUseSmallModelTasks = false; - break; - case "missing_scope": - case "forbidden": - case "quota_exceeded": - case "network_error": - case "unsupported_credentials": - case "empty_result": - case "unknown_error": - if (issue.capability === "chat") { - capabilities.canUseChat = false; - } - if ( - issue.capability === "small_model_tasks" || - issue.capability === "workspace_titles" - ) { - capabilities.canGenerateWorkspaceTitle = false; - capabilities.canUseSmallModelTasks = false; - } - break; - } - } - return { providerId, connectionState, diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index c19264f803c..ea8434bd0e7 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -31,6 +31,7 @@ export const CONFIG_TEMPLATE = `{ export const NOTIFICATION_EVENTS = { AGENT_LIFECYCLE: "agent-lifecycle", FOCUS_TAB: "focus-tab", + FOCUS_V2_NOTIFICATION_SOURCE: "focus-v2-notification-source", TERMINAL_EXIT: "terminal-exit", } as const; @@ -50,6 +51,7 @@ export const DEFAULT_USE_COMPACT_TERMINAL_ADD_BUTTON = true; export const DEFAULT_TELEMETRY_ENABLED = true; export const DEFAULT_SHOW_RESOURCE_MONITOR = true; export const DEFAULT_OPEN_LINKS_IN_APP = false; +export const DEFAULT_EXPOSE_HOST_SERVICE_VIA_RELAY = false; // External links (documentation, help resources, etc.) export const EXTERNAL_LINKS = { diff --git a/apps/desktop/src/shared/constants/project-colors.test.ts b/apps/desktop/src/shared/constants/project-colors.test.ts index 10ae785e603..f1a957b1abb 100644 --- a/apps/desktop/src/shared/constants/project-colors.test.ts +++ b/apps/desktop/src/shared/constants/project-colors.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { - PROJECT_COLOR_DEFAULT, - PROJECT_COLORS, - PROJECT_CUSTOM_COLORS, -} from "./project-colors"; +import { PROJECT_COLORS, PROJECT_CUSTOM_COLORS } from "./project-colors"; function hexToRgb(hex: string) { const normalizedHex = hex.replace("#", ""); @@ -32,7 +28,7 @@ describe("PROJECT_COLORS", () => { expect(new Set(colorNames).size).toBe(colorNames.length); expect(new Set(colorValues).size).toBe(colorValues.length); - expect(PROJECT_COLORS[0]?.value).toBe(PROJECT_COLOR_DEFAULT); + expect(PROJECT_COLORS.length).toBeGreaterThan(0); }); it("keeps custom swatches visually distinct", () => { diff --git a/apps/desktop/src/shared/constants/project-colors.ts b/apps/desktop/src/shared/constants/project-colors.ts index a4142db1525..0341c438e22 100644 --- a/apps/desktop/src/shared/constants/project-colors.ts +++ b/apps/desktop/src/shared/constants/project-colors.ts @@ -2,7 +2,6 @@ export const PROJECT_COLOR_DEFAULT = "default"; export const PROJECT_COLORS = [ - { name: "Default", value: PROJECT_COLOR_DEFAULT }, { name: "Red", value: "#ef4444" }, { name: "Orange", value: "#f97316" }, { name: "Yellow", value: "#eab308" }, @@ -17,9 +16,7 @@ export const PROJECT_COLORS = [ { name: "Slate", value: "#64748b" }, ] as const; -export const PROJECT_CUSTOM_COLORS = PROJECT_COLORS.filter( - (color) => color.value !== PROJECT_COLOR_DEFAULT, -); +export const PROJECT_CUSTOM_COLORS = PROJECT_COLORS; export const PROJECT_COLOR_VALUES: string[] = PROJECT_COLORS.map( (color) => color.value, diff --git a/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts b/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts new file mode 100644 index 00000000000..4fd7729fdc6 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/attachment.logs-txt.ts @@ -0,0 +1,23 @@ +import type { AttachmentFile } from "../types"; + +export const attachmentLogsTxt: AttachmentFile = { + data: new TextEncoder().encode( + "2026-04-14 ERROR auth.ts:42 token decrypt failed\n", + ), + mediaType: "text/plain", + filename: "logs.txt", +}; + +export const attachmentScreenshotPng: AttachmentFile = { + // 1x1 transparent PNG + data: new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]), + mediaType: "image/png", + filename: "screenshot.png", +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts b/apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts new file mode 100644 index 00000000000..45ae55962cc --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/githubIssue.auth-middleware.ts @@ -0,0 +1,17 @@ +import type { GitHubIssueContent } from "../types"; + +export const githubIssueAuthMiddleware: GitHubIssueContent = { + number: 123, + url: "https://github.com/acme/repo/issues/123", + title: "Auth middleware stores tokens in plaintext", + body: "Legal flagged this. Sessions written to disk without encryption.", + slug: "auth-middleware-stores-tokens-in-plaintext", +}; + +export const githubIssueTokenRotation: GitHubIssueContent = { + number: 124, + url: "https://github.com/acme/repo/issues/124", + title: "Rotate session tokens on password change", + body: "Follow-up for #123.", + slug: "rotate-session-tokens-on-password-change", +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts b/apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts new file mode 100644 index 00000000000..811f3d655ed --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/githubPr.auth-rewrite.ts @@ -0,0 +1,9 @@ +import type { GitHubPullRequestContent } from "../types"; + +export const githubPrAuthRewrite: GitHubPullRequestContent = { + number: 200, + url: "https://github.com/acme/repo/pull/200", + title: "Rewrite auth middleware", + body: "Replaces plaintext token storage with encrypted KV.", + branch: "fix/auth-encryption", +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/index.ts b/apps/desktop/src/shared/context/__fixtures__/index.ts new file mode 100644 index 00000000000..c50e05c0b50 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/index.ts @@ -0,0 +1,6 @@ +export * from "./attachment.logs-txt"; +export * from "./githubIssue.auth-middleware"; +export * from "./githubPr.auth-rewrite"; +export * from "./internalTask.refactor-auth"; +export * from "./launchContext.multi-source"; +export * from "./launchContext.prompt-only"; diff --git a/apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts b/apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts new file mode 100644 index 00000000000..928a5ce9997 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/internalTask.refactor-auth.ts @@ -0,0 +1,9 @@ +import type { InternalTaskContent } from "../types"; + +export const internalTaskRefactorAuth: InternalTaskContent = { + id: "TASK-42", + slug: "refactor-auth", + title: "Refactor auth middleware", + description: + "Split session-token storage from request handling so we can encrypt at rest.", +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts new file mode 100644 index 00000000000..13106bdd6d9 --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.multi-source.ts @@ -0,0 +1,119 @@ +import type { LaunchContext, LaunchSource } from "../types"; +import { + attachmentLogsTxt, + attachmentScreenshotPng, +} from "./attachment.logs-txt"; +import { + githubIssueAuthMiddleware, + githubIssueTokenRotation, +} from "./githubIssue.auth-middleware"; +import { githubPrAuthRewrite } from "./githubPr.auth-rewrite"; +import { internalTaskRefactorAuth } from "./internalTask.refactor-auth"; + +const sources: LaunchSource[] = [ + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + { kind: "internal-task", id: internalTaskRefactorAuth.id }, + { kind: "github-issue", url: githubIssueAuthMiddleware.url }, + { kind: "github-issue", url: githubIssueTokenRotation.url }, + { kind: "github-pr", url: githubPrAuthRewrite.url }, + { kind: "attachment", file: attachmentLogsTxt }, + { kind: "attachment", file: attachmentScreenshotPng }, +]; + +export const launchContextMultiSource: LaunchContext = { + projectId: "project-1", + sources, + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + { + id: `task:${internalTaskRefactorAuth.id}`, + kind: "internal-task", + label: `Task ${internalTaskRefactorAuth.id} — ${internalTaskRefactorAuth.title}`, + content: [ + { + type: "text", + text: `# ${internalTaskRefactorAuth.title}\n\n${internalTaskRefactorAuth.description}`, + }, + ], + meta: { taskSlug: internalTaskRefactorAuth.slug }, + }, + { + id: `issue:${githubIssueAuthMiddleware.number}`, + kind: "github-issue", + label: `Issue #${githubIssueAuthMiddleware.number} — ${githubIssueAuthMiddleware.title}`, + content: [ + { + type: "text", + text: `# ${githubIssueAuthMiddleware.title}\n\n${githubIssueAuthMiddleware.body}`, + }, + ], + meta: { + url: githubIssueAuthMiddleware.url, + taskSlug: githubIssueAuthMiddleware.slug, + }, + }, + { + id: `issue:${githubIssueTokenRotation.number}`, + kind: "github-issue", + label: `Issue #${githubIssueTokenRotation.number} — ${githubIssueTokenRotation.title}`, + content: [ + { + type: "text", + text: `# ${githubIssueTokenRotation.title}\n\n${githubIssueTokenRotation.body}`, + }, + ], + meta: { + url: githubIssueTokenRotation.url, + taskSlug: githubIssueTokenRotation.slug, + }, + }, + { + id: `pr:${githubPrAuthRewrite.number}`, + kind: "github-pr", + label: `PR #${githubPrAuthRewrite.number} — ${githubPrAuthRewrite.title}`, + content: [ + { + type: "text", + text: `# ${githubPrAuthRewrite.title}\n\nBranch: \`${githubPrAuthRewrite.branch}\`\n\n${githubPrAuthRewrite.body}`, + }, + ], + meta: { url: githubPrAuthRewrite.url }, + }, + { + id: "attachment:logs.txt", + kind: "attachment", + label: "logs.txt", + content: [ + { + type: "file", + data: attachmentLogsTxt.data, + mediaType: attachmentLogsTxt.mediaType, + filename: attachmentLogsTxt.filename, + }, + ], + }, + { + id: "attachment:screenshot.png", + kind: "attachment", + label: "screenshot.png", + content: [ + { + type: "image", + data: attachmentScreenshotPng.data, + mediaType: attachmentScreenshotPng.mediaType, + }, + ], + }, + ], + failures: [], + taskSlug: internalTaskRefactorAuth.slug, + agent: { id: "claude", config: undefined }, +}; diff --git a/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts new file mode 100644 index 00000000000..c8b6ef4d0af --- /dev/null +++ b/apps/desktop/src/shared/context/__fixtures__/launchContext.prompt-only.ts @@ -0,0 +1,24 @@ +import type { LaunchContext, LaunchSource } from "../types"; + +const sources: LaunchSource[] = [ + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, +]; + +export const launchContextPromptOnly: LaunchContext = { + projectId: "project-1", + sources, + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + ], + failures: [], + taskSlug: undefined, + agent: { id: "claude", config: undefined }, +}; diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.test.ts b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts new file mode 100644 index 00000000000..67d7f059c27 --- /dev/null +++ b/apps/desktop/src/shared/context/buildLaunchSpec.test.ts @@ -0,0 +1,442 @@ +import { describe, expect, test } from "bun:test"; +import { + indexResolvedAgentConfigs, + type ResolvedAgentConfig, + resolveAgentConfigs, +} from "@superset/shared/agent-settings"; +import { launchContextMultiSource } from "./__fixtures__"; +import { buildLaunchSpec } from "./buildLaunchSpec"; +import type { AttachmentFile, LaunchContext } from "./types"; + +function getConfig(id: string): ResolvedAgentConfig { + const configs = indexResolvedAgentConfigs(resolveAgentConfigs({})); + const config = configs.get(id as never); + if (!config) throw new Error(`agent not found: ${id}`); + return config; +} + +function baseCtx(overrides: Partial<LaunchContext> = {}): LaunchContext { + return { + projectId: "p", + sources: [], + sections: [], + failures: [], + agent: { id: "claude" }, + ...overrides, + }; +} + +const PNG_BYTES = new Uint8Array([137, 80, 78, 71]); +const TXT_ATTACHMENT: AttachmentFile = { + data: new Uint8Array([1, 2, 3]), + mediaType: "text/plain", + filename: "logs.txt", +}; + +describe("buildLaunchSpec", () => { + test("returns null when agent.id is 'none'", () => { + const spec = buildLaunchSpec(baseCtx({ agent: { id: "none" } }), undefined); + expect(spec).toBeNull(); + }); + + test("returns null when agentConfig is missing", () => { + const spec = buildLaunchSpec(baseCtx(), undefined); + expect(spec).toBeNull(); + }); + + test("agentId + taskSlug flow through", () => { + const spec = buildLaunchSpec( + baseCtx({ + taskSlug: "refactor-auth", + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "hello" }], + }, + ], + }), + getConfig("claude"), + ); + expect(spec?.agentId).toBe("claude"); + expect(spec?.taskSlug).toBe("refactor-auth"); + }); + + test("all builtin agents share the default markdown template (no XML)", () => { + const section = { + id: "user-prompt", + kind: "user-prompt" as const, + label: "Prompt", + content: [ + { type: "text" as const, text: "refactor the auth middleware" }, + ], + }; + const claudeSpec = buildLaunchSpec( + baseCtx({ sections: [section] }), + getConfig("claude"), + ); + const codexSpec = buildLaunchSpec( + baseCtx({ sections: [section], agent: { id: "codex" } }), + getConfig("codex"), + ); + const claudeText = (claudeSpec?.user[0] as { type: "text"; text: string }) + .text; + const codexText = (codexSpec?.user[0] as { type: "text"; text: string }) + .text; + expect(claudeText).toBe("refactor the auth middleware"); + expect(claudeText).toBe(codexText); + expect(claudeText).not.toContain("<user-request>"); + }); + + test("empty system template produces empty system content array", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "hi" }], + }, + ], + }), + getConfig("claude"), + ); + expect(spec?.system).toEqual([]); + }); + + test("issues section body is dropped into {{issues}} variable", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "refactor" }], + }, + { + id: "issue:123", + kind: "github-issue", + label: "Issue #123 — Auth", + content: [ + { + type: "text", + text: "# Auth\n\nLegal flagged this.", + }, + ], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain("refactor"); + expect(userText).toContain("# Auth"); + expect(userText).toContain("Legal flagged this."); + }); + + test("multiple tasks of the same kind join with a separator", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "plan" }], + }, + { + id: "task:T-1", + kind: "internal-task", + label: "Task T-1", + content: [{ type: "text", text: "# T-1\n\nOne." }], + }, + { + id: "task:T-2", + kind: "internal-task", + label: "Task T-2", + content: [{ type: "text", text: "# T-2\n\nTwo." }], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain("# T-1"); + expect(userText).toContain("# T-2"); + expect(userText.indexOf("T-1")).toBeLessThan(userText.indexOf("T-2")); + }); + + test("attachment sections are listed in {{attachments}} + file parts collected separately", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "fix the bug" }], + }, + { + id: "attachment:logs.txt", + kind: "attachment", + label: "logs.txt", + content: [ + { + type: "file", + data: TXT_ATTACHMENT.data, + mediaType: TXT_ATTACHMENT.mediaType, + filename: TXT_ATTACHMENT.filename, + }, + ], + }, + { + id: "attachment:screen.png", + kind: "attachment", + label: "screen.png", + content: [ + { type: "image", data: PNG_BYTES, mediaType: "image/png" }, + ], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain(".superset/attachments/logs.txt"); + expect(userText).toContain(".superset/attachments/screen.png"); + expect(spec?.attachments).toHaveLength(2); + expect(spec?.attachments[0]?.type).toBe("file"); + expect(spec?.attachments[1]?.type).toBe("image"); + }); + + test("inline non-text parts from user-prompt stay inline in spec.user", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [ + { type: "text", text: "see this:" }, + { type: "image", data: PNG_BYTES, mediaType: "image/png" }, + { type: "text", text: "and fix" }, + ], + }, + ], + }), + getConfig("codex"), + ); + + // Inline order preserved: text, image, text reach the agent in sequence + // so chat agents render the image between the two text chunks. + expect(spec?.user).toHaveLength(3); + expect(spec?.user[0]).toEqual({ type: "text", text: "see this:" }); + expect(spec?.user[1]).toEqual({ + type: "image", + data: PNG_BYTES, + mediaType: "image/png", + }); + expect(spec?.user[2]?.type).toBe("text"); + expect((spec?.user[2] as { type: "text"; text: string }).text).toContain( + "and fix", + ); + + // Explicit attachment-kind sections land in spec.attachments; inline + // user-prompt parts do not. + expect(spec?.attachments).toEqual([]); + }); + + test("inline non-text parts still appear in the {{attachments}} list for CLIs", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [ + { type: "text", text: "check this log:" }, + { + type: "file", + data: new Uint8Array([1, 2]), + mediaType: "text/plain", + filename: "trace.log", + }, + ], + }, + ], + }), + getConfig("codex"), + ); + const lastText = ( + spec?.user[spec.user.length - 1] as { type: "text"; text: string } + )?.text; + // Attachments list renders after the inline parts so a CLI agent + // reading just the flattened text still has the file path reference. + expect(lastText).toContain(".superset/attachments/trace.log"); + }); + + test("empty userPrompt still renders system = [] and drops empty user template cleanly", () => { + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "issue:1", + kind: "github-issue", + label: "Issue #1", + content: [{ type: "text", text: "# Issue\n\nbody" }], + }, + ], + }), + getConfig("codex"), + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toContain("# Issue"); + expect(userText).not.toMatch(/^\n/); + expect(userText).not.toMatch(/\n$/); + }); + + test("no sections at all yields empty system + empty user", () => { + const spec = buildLaunchSpec(baseCtx(), getConfig("codex")); + expect(spec?.system).toEqual([]); + expect(spec?.user).toEqual([]); + expect(spec?.attachments).toEqual([]); + }); + + test("canonical multi-source fixture → claude XML spec (snapshot)", () => { + const spec = buildLaunchSpec(launchContextMultiSource, getConfig("claude")); + expect({ + agentId: spec?.agentId, + system: spec?.system, + userText: (spec?.user[0] as { type: "text"; text: string })?.text, + attachmentKinds: spec?.attachments.map((p) => p.type), + taskSlug: spec?.taskSlug, + }).toMatchInlineSnapshot(` +{ + "agentId": "claude", + "attachmentKinds": [ + "file", + "image", + ], + "system": [], + "taskSlug": "refactor-auth", + "userText": +"refactor the auth middleware + +# Refactor auth middleware + +Split session-token storage from request handling so we can encrypt at rest. + +# Auth middleware stores tokens in plaintext + +Legal flagged this. Sessions written to disk without encryption. + +# Rotate session tokens on password change + +Follow-up for #123. + +# Rewrite auth middleware + +Branch: \`fix/auth-encryption\` + +Replaces plaintext token storage with encrypted KV. + +# Attached files + +The user attached these files alongside the prompt. They've been +written into the worktree at \`.superset/attachments/\`. Read them +to understand the request — they're part of the task, not +optional reference. + +- .superset/attachments/logs.txt +- .superset/attachments/screenshot.png" +, +} +`); + }); + + test("canonical multi-source fixture → codex markdown spec (snapshot)", () => { + const spec = buildLaunchSpec(launchContextMultiSource, getConfig("codex")); + expect({ + agentId: spec?.agentId, + userText: (spec?.user[0] as { type: "text"; text: string })?.text, + attachmentKinds: spec?.attachments.map((p) => p.type), + taskSlug: spec?.taskSlug, + }).toMatchInlineSnapshot(` +{ + "agentId": "claude", + "attachmentKinds": [ + "file", + "image", + ], + "taskSlug": "refactor-auth", + "userText": +"refactor the auth middleware + +# Refactor auth middleware + +Split session-token storage from request handling so we can encrypt at rest. + +# Auth middleware stores tokens in plaintext + +Legal flagged this. Sessions written to disk without encryption. + +# Rotate session tokens on password change + +Follow-up for #123. + +# Rewrite auth middleware + +Branch: \`fix/auth-encryption\` + +Replaces plaintext token storage with encrypted KV. + +# Attached files + +The user attached these files alongside the prompt. They've been +written into the worktree at \`.superset/attachments/\`. Read them +to understand the request — they're part of the task, not +optional reference. + +- .superset/attachments/logs.txt +- .superset/attachments/screenshot.png" +, +} +`); + }); + + test("agent-side template override replaces the user template", () => { + const configs = resolveAgentConfigs({ + overrideEnvelope: { + version: 1, + presets: [ + { + id: "claude", + contextPromptTemplateUser: "CUSTOM {{userPrompt}} END", + }, + ], + }, + }); + const claude = indexResolvedAgentConfigs(configs).get("claude"); + if (!claude) throw new Error("claude missing"); + const spec = buildLaunchSpec( + baseCtx({ + sections: [ + { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "hi" }], + }, + ], + }), + claude, + ); + const userText = (spec?.user[0] as { type: "text"; text: string }).text; + expect(userText).toBe("CUSTOM hi END"); + }); +}); diff --git a/apps/desktop/src/shared/context/buildLaunchSpec.ts b/apps/desktop/src/shared/context/buildLaunchSpec.ts new file mode 100644 index 00000000000..b7366cbc43c --- /dev/null +++ b/apps/desktop/src/shared/context/buildLaunchSpec.ts @@ -0,0 +1,236 @@ +import { renderPromptTemplate } from "@superset/shared/agent-prompt-template"; +import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; +import type { + AgentLaunchSpec, + ContentPart, + ContextSection, + LaunchContext, + LaunchSourceKind, +} from "./types"; + +const USER_PROMPT_PLACEHOLDER_RE = /\{\{\s*userPrompt\s*\}\}/; +const PLACEHOLDER_RE = /\{\{\s*([^}]+?)\s*\}\}/g; + +/** + * Build a V2-native AgentLaunchSpec from a resolved LaunchContext and the + * selected agent's config. + * + * - Returns null for the "none" agent or when config is missing (matches + * V1's buildPromptAgentLaunchRequest semantics). + * - The user-prompt section's content parts are spliced into spec.user + * *in place* at the template's {{userPrompt}} position — so a + * text + image + text rich-editor prompt keeps its inline ordering for + * chat agents. Terminal adapters flatten later (step 7) by rendering + * file/image parts as markdown refs at their inline position. + * - Text from {{tasks}}/{{issues}}/{{prs}}/{{attachments}} renders as + * surrounding text parts. + * - spec.attachments carries only *explicit* attachment-kind sections + * (dragged/dropped files). Inline file/image parts from the user + * prompt stay inline in spec.user. + */ +export function buildLaunchSpec( + ctx: LaunchContext, + agentConfig: ResolvedAgentConfig | undefined, +): AgentLaunchSpec | null { + if (ctx.agent.id === "none" || !agentConfig) return null; + + const nonUserVariables = buildNonUserPromptVariables(ctx.sections); + const userPromptParts = collectUserPromptContent(ctx.sections); + + const system = renderScalarTemplate( + agentConfig.contextPromptTemplateSystem, + nonUserVariables, + ); + const user = renderUserTemplate( + agentConfig.contextPromptTemplateUser, + userPromptParts, + nonUserVariables, + ); + + return { + agentId: ctx.agent.id, + system, + user, + attachments: collectExplicitAttachments(ctx.sections), + taskSlug: ctx.taskSlug, + }; +} + +function renderScalarTemplate( + template: string, + variables: Record<string, string>, +): ContentPart[] { + const text = renderPromptTemplate(template, { ...variables, userPrompt: "" }); + return text ? [{ type: "text", text }] : []; +} + +/** + * Render the user template as a ContentPart sequence. The template is + * split on {{userPrompt}}; the text before/after has its other + * placeholders substituted raw (no trim / no line collapse) so + * whitespace around {{userPrompt}} is preserved. The user-prompt + * section's content parts are spliced in at that position. A final + * pass collapses excess blank lines and trims document boundaries. + */ +function renderUserTemplate( + template: string, + userPromptParts: ContentPart[], + nonUserVariables: Record<string, string>, +): ContentPart[] { + // Whitespace-tolerant split on {{userPrompt}} / {{ userPrompt }} / etc. + const match = template.match(USER_PROMPT_PLACEHOLDER_RE); + const splitIndex = match?.index ?? -1; + const [beforeRaw, afterRaw] = + splitIndex === -1 || !match + ? ["", template] + : [ + template.slice(0, splitIndex), + template.slice(splitIndex + match[0].length), + ]; + + const beforeText = substituteVariables(beforeRaw, nonUserVariables); + const afterText = substituteVariables(afterRaw, nonUserVariables); + + const parts: ContentPart[] = []; + if (splitIndex === -1) { + // Template doesn't reference userPrompt: prepend parts so they + // still reach the agent (rare; misconfigured template). + parts.push(...userPromptParts); + if (afterText) parts.push({ type: "text", text: afterText }); + } else { + if (beforeText) parts.push({ type: "text", text: beforeText }); + parts.push(...userPromptParts); + if (afterText) parts.push({ type: "text", text: afterText }); + } + + return finalize(mergeAdjacentTextParts(parts)); +} + +/** + * Placeholder substitution with no trim / no newline collapse — for + * template halves where surrounding whitespace is structural. + */ +function substituteVariables( + template: string, + variables: Record<string, string>, +): string { + return template.replace(PLACEHOLDER_RE, (match, rawKey: string) => { + const key = rawKey.trim(); + return Object.hasOwn(variables, key) ? variables[key] : match; + }); +} + +/** + * Normalize runs of 3+ newlines to 2 inside each text part, trim + * leading whitespace on the first text part, trim trailing whitespace + * on the last text part. Drops text parts that become empty. + */ +function finalize(parts: ContentPart[]): ContentPart[] { + const out: ContentPart[] = []; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!part) continue; + if (part.type !== "text") { + out.push(part); + continue; + } + let text = part.text.replace(/\n{3,}/g, "\n\n"); + if (i === 0) text = text.replace(/^\s+/, ""); + if (i === parts.length - 1) text = text.replace(/\s+$/, ""); + if (text.length === 0) continue; + out.push({ type: "text", text }); + } + return out; +} + +function buildNonUserPromptVariables( + sections: ContextSection[], +): Record<string, string> { + return { + tasks: renderKindBlock(sectionsOfKind(sections, "internal-task")), + issues: renderKindBlock(sectionsOfKind(sections, "github-issue")), + prs: renderKindBlock(sectionsOfKind(sections, "github-pr")), + attachments: renderAttachmentsList(sections), + }; +} + +function sectionsOfKind( + sections: ContextSection[], + kind: LaunchSourceKind, +): ContextSection[] { + return sections.filter((s) => s.kind === kind); +} + +function textPartsOf(section: ContextSection): string[] { + return section.content + .filter( + (p): p is Extract<ContentPart, { type: "text" }> => p.type === "text", + ) + .map((p) => p.text); +} + +function collectUserPromptContent(sections: ContextSection[]): ContentPart[] { + return sectionsOfKind(sections, "user-prompt").flatMap((s) => s.content); +} + +function renderKindBlock(sections: ContextSection[]): string { + if (sections.length === 0) return ""; + return sections + .map((s) => textPartsOf(s).join("\n\n")) + .filter(Boolean) + .join("\n\n"); +} + +/** + * Attachments block covers (a) explicit attachment-kind sections and + * (b) inline non-text parts from the user prompt — so CLI agents + * reading just the prompt text still see a reference to every + * file/image, with a framing header cueing the agent to actually read + * them rather than treating them as passive metadata. + */ +function renderAttachmentsList(sections: ContextSection[]): string { + const refs: string[] = []; + for (const section of sectionsOfKind(sections, "attachment")) { + refs.push(`- .superset/attachments/${section.label}`); + } + for (const section of sectionsOfKind(sections, "user-prompt")) { + for (const part of section.content) { + if (part.type === "text") continue; + const label = part.type === "file" ? part.filename : undefined; + refs.push(`- .superset/attachments/${label ?? "inline-attachment"}`); + } + } + if (refs.length === 0) return ""; + return [ + "# Attached files", + "", + "The user attached these files alongside the prompt. They've been", + "written into the worktree at `.superset/attachments/`. Read them", + "to understand the request — they're part of the task, not", + "optional reference.", + "", + refs.join("\n"), + ].join("\n"); +} + +function collectExplicitAttachments(sections: ContextSection[]): ContentPart[] { + return sectionsOfKind(sections, "attachment").flatMap((s) => + s.content.filter((p) => p.type !== "text"), + ); +} + +function mergeAdjacentTextParts(parts: ContentPart[]): ContentPart[] { + const merged: ContentPart[] = []; + for (const part of parts) { + const last = merged[merged.length - 1]; + if (part.type === "text" && last?.type === "text") { + merged[merged.length - 1] = { + type: "text", + text: last.text + part.text, + }; + } else { + merged.push(part); + } + } + return merged; +} diff --git a/apps/desktop/src/shared/context/composer.integration.test.ts b/apps/desktop/src/shared/context/composer.integration.test.ts new file mode 100644 index 00000000000..9cc5bfd34e4 --- /dev/null +++ b/apps/desktop/src/shared/context/composer.integration.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { + attachmentLogsTxt, + githubIssueAuthMiddleware, + githubIssueTokenRotation, + githubPrAuthRewrite, + internalTaskRefactorAuth, +} from "./__fixtures__"; +import { buildLaunchContext } from "./composer"; +import { defaultContributorRegistry } from "./contributors"; +import type { ResolveCtx } from "./types"; + +const resolveCtx: ResolveCtx = { + projectId: "project-1", + signal: new AbortController().signal, + fetchIssue: async (url) => { + if (url === githubIssueAuthMiddleware.url) return githubIssueAuthMiddleware; + if (url === githubIssueTokenRotation.url) return githubIssueTokenRotation; + throw Object.assign(new Error("not found"), { status: 404 }); + }, + fetchPullRequest: async (url) => { + if (url === githubPrAuthRewrite.url) return githubPrAuthRewrite; + throw Object.assign(new Error("not found"), { status: 404 }); + }, + fetchInternalTask: async (id) => { + if (id === internalTaskRefactorAuth.id) return internalTaskRefactorAuth; + throw Object.assign(new Error("not found"), { status: 404 }); + }, +}; + +describe("composer + default registry (integration)", () => { + test("composes a multi-source launch end-to-end", async () => { + const ctx = await buildLaunchContext( + { + projectId: "project-1", + sources: [ + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + { kind: "internal-task", id: internalTaskRefactorAuth.id }, + { kind: "github-issue", url: githubIssueAuthMiddleware.url }, + { kind: "github-issue", url: githubIssueTokenRotation.url }, + { kind: "github-pr", url: githubPrAuthRewrite.url }, + { kind: "attachment", file: attachmentLogsTxt }, + ], + agent: { id: "claude" }, + }, + { contributors: defaultContributorRegistry, resolveCtx }, + ); + + expect(ctx.failures).toEqual([]); + expect(ctx.sections.map((s) => s.kind)).toEqual([ + "user-prompt", + "internal-task", + "github-issue", + "github-issue", + "github-pr", + "attachment", + ]); + expect(ctx.taskSlug).toBe(internalTaskRefactorAuth.slug); + }); + + test("missing issue is a non-fatal null (not a failure)", async () => { + const ctx = await buildLaunchContext( + { + projectId: "project-1", + sources: [ + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + { + kind: "github-issue", + url: "https://github.com/acme/repo/issues/99999", + }, + ], + agent: { id: "none" }, + }, + { contributors: defaultContributorRegistry, resolveCtx }, + ); + expect(ctx.sections.map((s) => s.kind)).toEqual(["user-prompt"]); + expect(ctx.failures).toEqual([]); + }); +}); diff --git a/apps/desktop/src/shared/context/composer.test.ts b/apps/desktop/src/shared/context/composer.test.ts new file mode 100644 index 00000000000..7184fbc4799 --- /dev/null +++ b/apps/desktop/src/shared/context/composer.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, test } from "bun:test"; +import { buildLaunchContext, CONTRIBUTOR_TIMEOUT_MS } from "./composer"; +import type { + ContextContributor, + ContextSection, + ContributorRegistry, + LaunchSource, + ResolveCtx, +} from "./types"; + +function makeContributor<K extends LaunchSource["kind"]>( + kind: K, + resolver: ( + source: Extract<LaunchSource, { kind: K }>, + ) => Promise<ContextSection | null>, +): ContextContributor<Extract<LaunchSource, { kind: K }>> { + return { + kind, + displayName: kind, + description: kind, + requiresQuery: false, + resolve: (source) => resolver(source), + } as ContextContributor<Extract<LaunchSource, { kind: K }>>; +} + +function registry( + overrides: Partial<{ + [K in LaunchSource["kind"]]: ContextContributor< + Extract<LaunchSource, { kind: K }> + >; + }>, +): ContributorRegistry { + const defaults: ContributorRegistry = { + "user-prompt": makeContributor("user-prompt", async (s) => ({ + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: s.content, + })), + "github-issue": makeContributor("github-issue", async (s) => ({ + id: `issue:${s.url}`, + kind: "github-issue", + label: s.url, + content: [{ type: "text", text: s.url }], + meta: { url: s.url, taskSlug: `slug-${s.url}` }, + })), + "github-pr": makeContributor("github-pr", async (s) => ({ + id: `pr:${s.url}`, + kind: "github-pr", + label: s.url, + content: [{ type: "text", text: s.url }], + meta: { url: s.url }, + })), + "internal-task": makeContributor("internal-task", async (s) => ({ + id: `task:${s.id}`, + kind: "internal-task", + label: s.id, + content: [{ type: "text", text: s.id }], + meta: { taskSlug: `task-slug-${s.id}` }, + })), + attachment: makeContributor("attachment", async (s) => ({ + id: `attachment:${s.file.filename ?? "unnamed"}`, + kind: "attachment", + label: s.file.filename ?? "attachment", + content: [ + { + type: "file", + data: s.file.data, + mediaType: s.file.mediaType, + filename: s.file.filename, + }, + ], + })), + }; + + return { ...defaults, ...overrides }; +} + +const resolveCtx: ResolveCtx = { + projectId: "project-1", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("not used in tests"); + }, + fetchPullRequest: async () => { + throw new Error("not used in tests"); + }, + fetchInternalTask: async () => { + throw new Error("not used in tests"); + }, +}; + +describe("buildLaunchContext", () => { + test("empty sources produce empty sections", async () => { + const ctx = await buildLaunchContext( + { projectId: "p", sources: [], agent: { id: "none" } }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.sections).toEqual([]); + expect(ctx.failures).toEqual([]); + expect(ctx.taskSlug).toBeUndefined(); + }); + + test("dedups github-issue sources by url before dispatch", async () => { + let calls = 0; + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/1" }, + { kind: "github-issue", url: "https://x/issues/1" }, // dup + ], + agent: { id: "none" }, + }, + { + contributors: registry({ + "github-issue": makeContributor("github-issue", async (s) => { + calls++; + return { + id: `issue:${s.url}`, + kind: "github-issue", + label: s.url, + content: [{ type: "text", text: s.url }], + }; + }), + }), + resolveCtx, + }, + ); + expect(calls).toBe(1); + expect(ctx.sections).toHaveLength(1); + }); + + test("preserves input order within a kind", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/2" }, + { kind: "github-issue", url: "https://x/issues/1" }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.sections.map((s) => s.id)).toEqual([ + "issue:https://x/issues/2", + "issue:https://x/issues/1", + ]); + }); + + test("applies default kind group order across sections", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-pr", url: "https://x/pull/1" }, + { + kind: "attachment", + file: { + data: new Uint8Array([0]), + mediaType: "text/plain", + filename: "a.txt", + }, + }, + { kind: "github-issue", url: "https://x/issues/1" }, + { kind: "internal-task", id: "T-1" }, + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.sections.map((s) => s.kind)).toEqual([ + "user-prompt", + "internal-task", + "github-issue", + "github-pr", + "attachment", + ]); + }); + + test("taskSlug: first internal-task wins", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/1" }, + { kind: "internal-task", id: "T-1" }, + { kind: "internal-task", id: "T-2" }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.taskSlug).toBe("task-slug-T-1"); + }); + + test("taskSlug falls back to first github-issue when no task", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/2" }, + { kind: "github-issue", url: "https://x/issues/1" }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.taskSlug).toBe("slug-https://x/issues/2"); + }); + + test("taskSlug undefined when no task or issue", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.taskSlug).toBeUndefined(); + }); + + test("per-source failure populates failures[] and launch continues", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { kind: "github-issue", url: "https://x/issues/1" }, + { kind: "github-issue", url: "https://x/issues/2" }, + { kind: "user-prompt", content: [{ type: "text", text: "hi" }] }, + ], + agent: { id: "none" }, + }, + { + contributors: registry({ + "github-issue": makeContributor("github-issue", async (s) => { + if (s.url.endsWith("/2")) throw new Error("boom"); + return { + id: `issue:${s.url}`, + kind: "github-issue", + label: s.url, + content: [{ type: "text", text: s.url }], + }; + }), + }), + resolveCtx, + }, + ); + expect(ctx.sections).toHaveLength(2); // issue 1 + prompt + expect(ctx.failures).toHaveLength(1); + expect(ctx.failures[0]?.error).toBe("boom"); + }); + + test("contributor returning null is dropped silently (not a failure)", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [{ kind: "github-issue", url: "https://x/issues/1" }], + agent: { id: "none" }, + }, + { + contributors: registry({ + "github-issue": makeContributor("github-issue", async () => null), + }), + resolveCtx, + }, + ); + expect(ctx.sections).toEqual([]); + expect(ctx.failures).toEqual([]); + }); + + test("contributor exceeding timeout is a failure", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [{ kind: "github-issue", url: "https://x/issues/1" }], + agent: { id: "none" }, + }, + { + contributors: registry({ + "github-issue": makeContributor( + "github-issue", + () => new Promise(() => {}), // never resolves + ), + }), + resolveCtx, + timeoutMs: 10, + }, + ); + expect(ctx.sections).toEqual([]); + expect(ctx.failures).toHaveLength(1); + expect(ctx.failures[0]?.error).toMatch(/timeout/i); + }); + + test("default timeout is 10s", () => { + expect(CONTRIBUTOR_TIMEOUT_MS).toBe(10_000); + }); + + test("attachments are not deduped even without filename", async () => { + const ctx = await buildLaunchContext( + { + projectId: "p", + sources: [ + { + kind: "attachment", + file: { data: new Uint8Array([1]), mediaType: "text/plain" }, + }, + { + kind: "attachment", + file: { data: new Uint8Array([2]), mediaType: "text/plain" }, + }, + ], + agent: { id: "none" }, + }, + { contributors: registry({}), resolveCtx }, + ); + expect(ctx.sections).toHaveLength(2); + }); +}); diff --git a/apps/desktop/src/shared/context/composer.ts b/apps/desktop/src/shared/context/composer.ts new file mode 100644 index 00000000000..17de2ec10a0 --- /dev/null +++ b/apps/desktop/src/shared/context/composer.ts @@ -0,0 +1,159 @@ +import type { + BuildLaunchContextInputs, + ContextSection, + ContributorRegistry, + LaunchContext, + LaunchSource, + LaunchSourceKind, + ResolveCtx, +} from "./types"; + +export const CONTRIBUTOR_TIMEOUT_MS = 10_000; + +/** + * Order sections are grouped into when no consumer overrides. Matches the + * rendering order used by agent prompt templates. + */ +const KIND_ORDER: readonly LaunchSourceKind[] = [ + "user-prompt", + "internal-task", + "github-issue", + "github-pr", + "attachment", +] as const; + +export interface BuildLaunchContextDeps { + contributors: ContributorRegistry; + resolveCtx: ResolveCtx; + timeoutMs?: number; +} + +export async function buildLaunchContext( + inputs: BuildLaunchContextInputs, + deps: BuildLaunchContextDeps, +): Promise<LaunchContext> { + const timeoutMs = deps.timeoutMs ?? CONTRIBUTOR_TIMEOUT_MS; + const deduped = dedupeSources(inputs.sources); + const resolutions = await Promise.all( + deduped.map((source) => + resolveOne(source, deps.contributors, deps.resolveCtx, timeoutMs), + ), + ); + + const sections: ContextSection[] = []; + const failures: LaunchContext["failures"] = []; + for (let i = 0; i < deduped.length; i++) { + const result = resolutions[i]; + const source = deduped[i]; + if (!result || !source) continue; + if (result.kind === "section" && result.section) { + sections.push(result.section); + } else if (result.kind === "error") { + failures.push({ source, error: result.error }); + } + } + + sections.sort((a, b) => kindRank(a.kind) - kindRank(b.kind)); + + return { + projectId: inputs.projectId, + sources: deduped, + sections, + failures, + taskSlug: deriveTaskSlug(sections), + agent: inputs.agent, + }; +} + +type ResolveResult = + | { kind: "section"; section: ContextSection | null } + | { kind: "error"; error: string }; + +async function resolveOne( + source: LaunchSource, + contributors: ContributorRegistry, + resolveCtx: ResolveCtx, + timeoutMs: number, +): Promise<ResolveResult> { + const contributor = contributors[source.kind] as + | ContributorRegistry[LaunchSourceKind] + | undefined; + if (!contributor) { + return { kind: "error", error: `No contributor for kind ${source.kind}` }; + } + + try { + const section = await withTimeout( + // biome-ignore lint/suspicious/noExplicitAny: registry dispatch is verified by discriminated kind above + contributor.resolve(source as any, resolveCtx), + timeoutMs, + ); + return { kind: "section", section }; + } catch (err) { + return { + kind: "error", + error: err instanceof Error ? err.message : String(err), + }; + } +} + +function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> { + let timer: ReturnType<typeof setTimeout> | undefined; + const timeout = new Promise<never>((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Contributor timeout after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer); + }) as Promise<T>; +} + +function kindRank(kind: LaunchSourceKind): number { + const idx = KIND_ORDER.indexOf(kind); + return idx === -1 ? KIND_ORDER.length : idx; +} + +/** + * Kind-specific identity: URL/id-based kinds dedup on their identifier. + * Attachments never dedup — users dragging N files mean N files. + */ +function sourceIdentity(source: LaunchSource): string | null { + switch (source.kind) { + case "user-prompt": + return "user-prompt"; + case "github-issue": + return `github-issue:${source.url}`; + case "github-pr": + return `github-pr:${source.url}`; + case "internal-task": + return `internal-task:${source.id}`; + case "attachment": + return null; // never dedup + } +} + +function dedupeSources(sources: LaunchSource[]): LaunchSource[] { + const seen = new Set<string>(); + const out: LaunchSource[] = []; + for (const source of sources) { + const id = sourceIdentity(source); + if (id === null) { + out.push(source); + continue; + } + if (seen.has(id)) continue; + seen.add(id); + out.push(source); + } + return out; +} + +function deriveTaskSlug(sections: ContextSection[]): string | undefined { + const firstTask = sections.find((s) => s.kind === "internal-task"); + if (firstTask?.meta?.taskSlug) return firstTask.meta.taskSlug; + const firstIssue = sections.find((s) => s.kind === "github-issue"); + if (firstIssue?.meta?.taskSlug) return firstIssue.meta.taskSlug; + return undefined; +} diff --git a/apps/desktop/src/shared/context/contributors/attachment.test.ts b/apps/desktop/src/shared/context/contributors/attachment.test.ts new file mode 100644 index 00000000000..8ae55713420 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/attachment.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import type { ResolveCtx } from "../types"; +import { attachmentContributor } from "./attachment"; + +const resolveCtx = {} as ResolveCtx; + +describe("attachmentContributor", () => { + test("metadata is set", () => { + expect(attachmentContributor.kind).toBe("attachment"); + expect(attachmentContributor.requiresQuery).toBe(false); + }); + + test("resolves a text/plain attachment to a file ContentPart", async () => { + const section = await attachmentContributor.resolve( + { + kind: "attachment", + file: { + data: new Uint8Array([1, 2, 3]), + mediaType: "text/plain", + filename: "notes.txt", + }, + }, + resolveCtx, + ); + expect(section?.kind).toBe("attachment"); + expect(section?.label).toBe("notes.txt"); + expect(section?.content).toEqual([ + { + type: "file", + data: new Uint8Array([1, 2, 3]), + mediaType: "text/plain", + filename: "notes.txt", + }, + ]); + }); + + test("resolves an image to an image ContentPart", async () => { + const section = await attachmentContributor.resolve( + { + kind: "attachment", + file: { + data: new Uint8Array([137, 80, 78, 71]), + mediaType: "image/png", + filename: "screen.png", + }, + }, + resolveCtx, + ); + expect(section?.content).toEqual([ + { + type: "image", + data: new Uint8Array([137, 80, 78, 71]), + mediaType: "image/png", + }, + ]); + }); + + test("unnamed attachment gets a fallback label and stable id", async () => { + const section = await attachmentContributor.resolve( + { + kind: "attachment", + file: { + data: new Uint8Array([9]), + mediaType: "application/octet-stream", + }, + }, + resolveCtx, + ); + expect(section?.id).toBe("attachment:unnamed"); + expect(section?.label).toBe("attachment"); + }); + + test("id uses filename when present", async () => { + const section = await attachmentContributor.resolve( + { + kind: "attachment", + file: { + data: new Uint8Array([1]), + mediaType: "text/plain", + filename: "logs.txt", + }, + }, + resolveCtx, + ); + expect(section?.id).toBe("attachment:logs.txt"); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/attachment.ts b/apps/desktop/src/shared/context/contributors/attachment.ts new file mode 100644 index 00000000000..ef820cf6e93 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/attachment.ts @@ -0,0 +1,29 @@ +import type { AttachmentFile, ContentPart, ContextContributor } from "../types"; + +export const attachmentContributor: ContextContributor<{ + kind: "attachment"; + file: AttachmentFile; +}> = { + kind: "attachment", + displayName: "Attachment", + description: "A file or image uploaded by the user.", + requiresQuery: false, + async resolve(source) { + const { file } = source; + const part: ContentPart = file.mediaType.startsWith("image/") + ? { type: "image", data: file.data, mediaType: file.mediaType } + : { + type: "file", + data: file.data, + mediaType: file.mediaType, + filename: file.filename, + }; + + return { + id: `attachment:${file.filename ?? "unnamed"}`, + kind: "attachment", + label: file.filename ?? "attachment", + content: [part], + }; + }, +}; diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.test.ts b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts new file mode 100644 index 00000000000..e632f202558 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubIssue.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import type { GitHubIssueContent, ResolveCtx } from "../types"; +import { githubIssueContributor } from "./githubIssue"; + +function makeCtx( + fetchIssue: (url: string) => Promise<GitHubIssueContent>, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue, + fetchPullRequest: async () => { + throw new Error("unused"); + }, + fetchInternalTask: async () => { + throw new Error("unused"); + }, + }; +} + +const ISSUE: GitHubIssueContent = { + number: 123, + url: "https://github.com/acme/repo/issues/123", + title: "Auth stores tokens in plaintext", + body: "Legal flagged this.", + slug: "auth-stores-tokens-in-plaintext", +}; + +describe("githubIssueContributor", () => { + test("metadata", () => { + expect(githubIssueContributor.kind).toBe("github-issue"); + expect(githubIssueContributor.requiresQuery).toBe(true); + }); + + test("resolves to a section with explicit kind + number in header", async () => { + const section = await githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => ISSUE), + ); + expect(section?.id).toBe(`issue:${ISSUE.number}`); + expect(section?.label).toBe(`Issue #${ISSUE.number} — ${ISSUE.title}`); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toContain(`# GitHub Issue #${ISSUE.number} — ${ISSUE.title}`); + expect(text).toContain(ISSUE.body); + expect(section?.meta).toEqual({ url: ISSUE.url, taskSlug: ISSUE.slug }); + }); + + test("returns null on fetch 404 (non-fatal)", async () => { + const section = await githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => { + throw Object.assign(new Error("not found"), { status: 404 }); + }), + ); + expect(section).toBeNull(); + }); + + test("propagates non-404 errors", async () => { + await expect( + githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => { + throw new Error("network"); + }), + ), + ).rejects.toThrow("network"); + }); + + test("omits body block when empty", async () => { + const section = await githubIssueContributor.resolve( + { kind: "github-issue", url: ISSUE.url }, + makeCtx(async () => ({ ...ISSUE, body: "" })), + ); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toBe(`# GitHub Issue #${ISSUE.number} — ${ISSUE.title}`); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/githubIssue.ts b/apps/desktop/src/shared/context/contributors/githubIssue.ts new file mode 100644 index 00000000000..3a509c29974 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubIssue.ts @@ -0,0 +1,40 @@ +import type { ContextContributor, GitHubIssueContent } from "../types"; + +function isNotFound(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: number }).status === 404 + ); +} + +export const githubIssueContributor: ContextContributor<{ + kind: "github-issue"; + url: string; +}> = { + kind: "github-issue", + displayName: "GitHub Issue", + description: "Full issue body fetched and inlined as context.", + requiresQuery: true, + async resolve(source, ctx) { + let issue: GitHubIssueContent; + try { + issue = await ctx.fetchIssue(source.url); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const body = issue.body.trim(); + const heading = `# GitHub Issue #${issue.number} — ${issue.title}`; + const text = body ? `${heading}\n\n${body}` : heading; + return { + id: `issue:${issue.number}`, + kind: "github-issue", + label: `Issue #${issue.number} — ${issue.title}`, + content: [{ type: "text", text }], + meta: { url: issue.url, taskSlug: issue.slug }, + }; + }, +}; diff --git a/apps/desktop/src/shared/context/contributors/githubPr.test.ts b/apps/desktop/src/shared/context/contributors/githubPr.test.ts new file mode 100644 index 00000000000..cd9f2fcf70c --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubPr.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import type { GitHubPullRequestContent, ResolveCtx } from "../types"; +import { githubPrContributor } from "./githubPr"; + +function makeCtx( + fetchPullRequest: (url: string) => Promise<GitHubPullRequestContent>, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("unused"); + }, + fetchPullRequest, + fetchInternalTask: async () => { + throw new Error("unused"); + }, + }; +} + +const PR: GitHubPullRequestContent = { + number: 200, + url: "https://github.com/acme/repo/pull/200", + title: "Rewrite auth middleware", + body: "Replaces plaintext token storage.", + branch: "fix/auth-encryption", +}; + +describe("githubPrContributor", () => { + test("metadata", () => { + expect(githubPrContributor.kind).toBe("github-pr"); + expect(githubPrContributor.requiresQuery).toBe(true); + }); + + test("resolves to a user section with title + body + branch meta", async () => { + const section = await githubPrContributor.resolve( + { kind: "github-pr", url: PR.url }, + makeCtx(async () => PR), + ); + expect(section?.id).toBe(`pr:${PR.number}`); + expect(section?.label).toBe(`PR #${PR.number} — ${PR.title}`); + expect(section?.meta).toEqual({ url: PR.url }); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toContain(`# PR #${PR.number} — ${PR.title}`); + expect(text).toContain(`This PR is checked out`); + expect(text).toContain(PR.body); + }); + + test("returns null on 404", async () => { + const section = await githubPrContributor.resolve( + { kind: "github-pr", url: PR.url }, + makeCtx(async () => { + throw Object.assign(new Error("not found"), { status: 404 }); + }), + ); + expect(section).toBeNull(); + }); + + test("omits body block when empty", async () => { + const section = await githubPrContributor.resolve( + { kind: "github-pr", url: PR.url }, + makeCtx(async () => ({ ...PR, body: "" })), + ); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toContain(`# PR #${PR.number} — ${PR.title}`); + expect(text).toContain("checked out"); + expect(text).not.toContain("Replaces"); // body not present + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/githubPr.ts b/apps/desktop/src/shared/context/contributors/githubPr.ts new file mode 100644 index 00000000000..9e23c8c04e2 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/githubPr.ts @@ -0,0 +1,50 @@ +import type { ContextContributor, GitHubPullRequestContent } from "../types"; + +function isNotFound(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: number }).status === 404 + ); +} + +export const githubPrContributor: ContextContributor<{ + kind: "github-pr"; + url: string; +}> = { + kind: "github-pr", + displayName: "GitHub Pull Request", + description: "Full PR metadata fetched and inlined as context.", + requiresQuery: true, + async resolve(source, ctx) { + let pr: GitHubPullRequestContent; + try { + pr = await ctx.fetchPullRequest(source.url); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const body = pr.body.trim(); + // When a workspace is created from a linked PR, the PR's head + // branch is checked out into the worktree. Tell the agent so + // it doesn't start a new branch or open another PR — commits + // here continue this PR's history. + const branchLine = pr.branch + ? `This PR is checked out in this workspace on branch \`${pr.branch}\`. Commits you make here will be added to this PR.` + : ""; + const headerParts = [`# PR #${pr.number} — ${pr.title}`, branchLine].filter( + Boolean, + ); + const header = headerParts.join("\n\n"); + const text = body ? `${header}\n\n${body}` : header; + return { + id: `pr:${pr.number}`, + kind: "github-pr", + label: `PR #${pr.number} — ${pr.title}`, + content: [{ type: "text", text }], + meta: { url: pr.url }, + }; + }, +}; diff --git a/apps/desktop/src/shared/context/contributors/index.ts b/apps/desktop/src/shared/context/contributors/index.ts new file mode 100644 index 00000000000..ee9b02e42ca --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/index.ts @@ -0,0 +1,22 @@ +import type { ContributorRegistry } from "../types"; +import { attachmentContributor } from "./attachment"; +import { githubIssueContributor } from "./githubIssue"; +import { githubPrContributor } from "./githubPr"; +import { internalTaskContributor } from "./internalTask"; +import { userPromptContributor } from "./userPrompt"; + +export const defaultContributorRegistry: ContributorRegistry = { + "user-prompt": userPromptContributor, + attachment: attachmentContributor, + "github-issue": githubIssueContributor, + "github-pr": githubPrContributor, + "internal-task": internalTaskContributor, +}; + +export { + attachmentContributor, + githubIssueContributor, + githubPrContributor, + internalTaskContributor, + userPromptContributor, +}; diff --git a/apps/desktop/src/shared/context/contributors/internalTask.test.ts b/apps/desktop/src/shared/context/contributors/internalTask.test.ts new file mode 100644 index 00000000000..0651d5449ec --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/internalTask.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import type { InternalTaskContent, ResolveCtx } from "../types"; +import { internalTaskContributor } from "./internalTask"; + +function makeCtx( + fetchInternalTask: (id: string) => Promise<InternalTaskContent>, +): ResolveCtx { + return { + projectId: "p", + signal: new AbortController().signal, + fetchIssue: async () => { + throw new Error("unused"); + }, + fetchPullRequest: async () => { + throw new Error("unused"); + }, + fetchInternalTask, + }; +} + +const TASK: InternalTaskContent = { + id: "TASK-42", + slug: "refactor-auth", + title: "Refactor auth middleware", + description: "Split session-token storage from request handling.", +}; + +describe("internalTaskContributor", () => { + test("metadata", () => { + expect(internalTaskContributor.kind).toBe("internal-task"); + expect(internalTaskContributor.requiresQuery).toBe(true); + }); + + test("resolves to a section with explicit kind + id in header", async () => { + const section = await internalTaskContributor.resolve( + { kind: "internal-task", id: TASK.id }, + makeCtx(async () => TASK), + ); + expect(section?.id).toBe(`task:${TASK.id}`); + expect(section?.label).toBe(`Task ${TASK.id} — ${TASK.title}`); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toContain(`# Task ${TASK.id} — ${TASK.title}`); + if (TASK.description) expect(text).toContain(TASK.description); + expect(section?.meta).toEqual({ taskSlug: TASK.slug }); + }); + + test("omits description when null", async () => { + const section = await internalTaskContributor.resolve( + { kind: "internal-task", id: TASK.id }, + makeCtx(async () => ({ ...TASK, description: null })), + ); + const text = (section?.content[0] as { type: "text"; text: string }).text; + expect(text).toBe(`# Task ${TASK.id} — ${TASK.title}`); + }); + + test("returns null on 404", async () => { + const section = await internalTaskContributor.resolve( + { kind: "internal-task", id: TASK.id }, + makeCtx(async () => { + throw Object.assign(new Error("not found"), { status: 404 }); + }), + ); + expect(section).toBeNull(); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/internalTask.ts b/apps/desktop/src/shared/context/contributors/internalTask.ts new file mode 100644 index 00000000000..34304592a5a --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/internalTask.ts @@ -0,0 +1,40 @@ +import type { ContextContributor, InternalTaskContent } from "../types"; + +function isNotFound(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: number }).status === 404 + ); +} + +export const internalTaskContributor: ContextContributor<{ + kind: "internal-task"; + id: string; +}> = { + kind: "internal-task", + displayName: "Task", + description: "Internal task spec inlined as context.", + requiresQuery: true, + async resolve(source, ctx) { + let task: InternalTaskContent; + try { + task = await ctx.fetchInternalTask(source.id); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + + const description = task.description?.trim() ?? ""; + const heading = `# Task ${task.id} — ${task.title}`; + const text = description ? `${heading}\n\n${description}` : heading; + return { + id: `task:${task.id}`, + kind: "internal-task", + label: `Task ${task.id} — ${task.title}`, + content: [{ type: "text", text }], + meta: { taskSlug: task.slug }, + }; + }, +}; diff --git a/apps/desktop/src/shared/context/contributors/userPrompt.test.ts b/apps/desktop/src/shared/context/contributors/userPrompt.test.ts new file mode 100644 index 00000000000..b75c9dd1288 --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/userPrompt.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import type { ResolveCtx } from "../types"; +import { userPromptContributor } from "./userPrompt"; + +const resolveCtx = {} as ResolveCtx; // not used by this contributor + +describe("userPromptContributor", () => { + test("metadata is set", () => { + expect(userPromptContributor.kind).toBe("user-prompt"); + expect(userPromptContributor.displayName).toBeTruthy(); + expect(userPromptContributor.description).toBeTruthy(); + expect(userPromptContributor.requiresQuery).toBe(true); + }); + + test("resolves a single text part", async () => { + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }, + resolveCtx, + ); + expect(section).toEqual({ + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content: [{ type: "text", text: "refactor the auth middleware" }], + }); + }); + + test("returns null for empty content", async () => { + const section = await userPromptContributor.resolve( + { kind: "user-prompt", content: [] }, + resolveCtx, + ); + expect(section).toBeNull(); + }); + + test("returns null when only whitespace text parts are present", async () => { + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [ + { type: "text", text: " " }, + { type: "text", text: "\n\n" }, + ], + }, + resolveCtx, + ); + expect(section).toBeNull(); + }); + + test("trims surrounding whitespace on bookend text parts", async () => { + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [{ type: "text", text: " hello " }], + }, + resolveCtx, + ); + expect(section?.content).toEqual([{ type: "text", text: "hello" }]); + }); + + test("preserves interleaved multimodal content (text + image + text)", async () => { + const imageBytes = new Uint8Array([1, 2, 3]); + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [ + { type: "text", text: "Reproduce this bug:" }, + { type: "image", data: imageBytes, mediaType: "image/png" }, + { type: "text", text: "with the attached logs." }, + ], + }, + resolveCtx, + ); + expect(section?.content).toEqual([ + { type: "text", text: "Reproduce this bug:" }, + { type: "image", data: imageBytes, mediaType: "image/png" }, + { type: "text", text: "with the attached logs." }, + ]); + }); + + test("drops empty text parts between non-empty ones", async () => { + const section = await userPromptContributor.resolve( + { + kind: "user-prompt", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "" }, + { type: "text", text: "second" }, + ], + }, + resolveCtx, + ); + expect(section?.content).toEqual([ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ]); + }); +}); diff --git a/apps/desktop/src/shared/context/contributors/userPrompt.ts b/apps/desktop/src/shared/context/contributors/userPrompt.ts new file mode 100644 index 00000000000..3234ea7615e --- /dev/null +++ b/apps/desktop/src/shared/context/contributors/userPrompt.ts @@ -0,0 +1,64 @@ +import type { ContentPart, ContextContributor, LaunchSource } from "../types"; + +/** + * Convenience builder for plain-text prompts. Callers that already have a + * mixed ContentPart[] (rich editor output, dropped-in images) should pass + * that directly; this helper is just sugar for the common text-only case. + */ +export function userPromptFromText( + text: string, +): Extract<LaunchSource, { kind: "user-prompt" }> { + return { kind: "user-prompt", content: [{ type: "text", text }] }; +} + +/** + * Drop empty text parts and trim surrounding whitespace on text parts that + * bookend the content. File/image parts are kept as-is. + */ +function normalize(content: ContentPart[]): ContentPart[] { + const normalized: ContentPart[] = []; + for (const part of content) { + if (part.type === "text") { + const text = part.text; + if (!text.trim()) continue; // drop empty text parts entirely + normalized.push({ type: "text", text }); + } else { + normalized.push(part); + } + } + + // Trim whitespace on the first and last text parts so leading/trailing + // whitespace from editor markup doesn't leak through. + const first = normalized[0]; + if (first?.type === "text") { + normalized[0] = { type: "text", text: first.text.trimStart() }; + } + const last = normalized[normalized.length - 1]; + if (last?.type === "text") { + normalized[normalized.length - 1] = { + type: "text", + text: last.text.trimEnd(), + }; + } + return normalized; +} + +export const userPromptContributor: ContextContributor<{ + kind: "user-prompt"; + content: ContentPart[]; +}> = { + kind: "user-prompt", + displayName: "Prompt", + description: "The user's free-form prompt for this launch.", + requiresQuery: true, + async resolve(source) { + const content = normalize(source.content); + if (content.length === 0) return null; + return { + id: "user-prompt", + kind: "user-prompt", + label: "Prompt", + content, + }; + }, +}; diff --git a/apps/desktop/src/shared/context/types.ts b/apps/desktop/src/shared/context/types.ts new file mode 100644 index 00000000000..fe688dc9707 --- /dev/null +++ b/apps/desktop/src/shared/context/types.ts @@ -0,0 +1,154 @@ +import type { AgentDefinitionId } from "@superset/shared/agent-catalog"; +import type { ResolvedAgentConfig } from "@superset/shared/agent-settings"; + +/** + * Discriminated union of every kind of source that can contribute context + * to a workspace launch. Extending this is the first step to adding a new + * source (e.g. Linear tickets, Notion pages): add a variant, add a + * contributor, register it. + */ +export type LaunchSource = + | { kind: "user-prompt"; content: ContentPart[] } + | { kind: "github-issue"; url: string } + | { kind: "github-pr"; url: string } + | { kind: "internal-task"; id: string } + | { kind: "attachment"; file: AttachmentFile }; + +export type LaunchSourceKind = LaunchSource["kind"]; + +/** + * An attachment carried through composition. Stored as raw bytes — not + * base64 — so we skip the 33% overhead internally. Base64 encoding happens + * only at the chat provider API boundary. + */ +export interface AttachmentFile { + data: Uint8Array; + mediaType: string; + filename?: string; +} + +/** + * AI SDK v3 / Anthropic-aligned content part. A section's content is + * always an array so text, files, and images can coexist without + * flattening. + */ +export type ContentPart = + | { type: "text"; text: string } + | { + type: "file"; + data: Uint8Array; + mediaType: string; + filename?: string; + } + | { type: "image"; data: Uint8Array; mediaType: string }; + +/** + * A resolved contribution from a single source. Every contributor + * produces one of these (or null on non-fatal failure). + */ +export interface ContextSection { + id: string; // stable, e.g. "issue:123" + kind: LaunchSourceKind; + label: string; + content: ContentPart[]; + meta?: { + taskSlug?: string; + url?: string; + }; +} + +/** + * Collaborators handed to every contributor. Kept small and explicit — + * contributors should not reach into globals. + */ +export interface ResolveCtx { + projectId: string; + signal: AbortSignal; + fetchIssue: (url: string) => Promise<GitHubIssueContent>; + fetchPullRequest: (url: string) => Promise<GitHubPullRequestContent>; + fetchInternalTask: (id: string) => Promise<InternalTaskContent>; +} + +export interface GitHubIssueContent { + number: number; + url: string; + title: string; + body: string; // already sanitized and truncated + slug: string; +} + +export interface GitHubPullRequestContent { + number: number; + url: string; + title: string; + body: string; + branch: string; +} + +export interface InternalTaskContent { + id: string; + slug: string; + title: string; + description: string | null; +} + +/** + * A contributor resolves one kind of LaunchSource into a ContextSection. + * Metadata (displayName/description/requiresQuery) is lifted from + * Continue.dev's context provider interface for future UI rendering. + */ +export interface ContextContributor<S extends LaunchSource = LaunchSource> { + kind: S["kind"]; + displayName: string; + description: string; + requiresQuery: boolean; + resolve(source: S, ctx: ResolveCtx): Promise<ContextSection | null>; +} + +export type ContributorRegistry = { + readonly [K in LaunchSourceKind]: ContextContributor< + Extract<LaunchSource, { kind: K }> + >; +}; + +/** + * Composer output. Agent-agnostic: feeds into any consumer + * (buildLaunchSpec, buildBranchNameContext, renderLaunchPreview, ...). + */ +export interface LaunchContext { + projectId: string; + sources: LaunchSource[]; + sections: ContextSection[]; + failures: Array<{ source: LaunchSource; error: string }>; + taskSlug?: string; + agent: { + id: AgentDefinitionId | "none"; + config?: ResolvedAgentConfig; + }; +} + +/** + * V2-native launch spec. Replaces the V1 `AgentLaunchRequest` shape + * (which flattened prompt to a single string). Maps cleanly to: + * - Anthropic Messages API: system blocks + user content parts. + * - AI SDK v3: ModelMessage with ContentPart[]. + * - Terminal adapters: flatten system+user to prompt text, write + * attachments to .superset/attachments/, reference by path. + */ +export interface AgentLaunchSpec { + agentId: AgentDefinitionId; + system: ContentPart[]; + user: ContentPart[]; + attachments: ContentPart[]; + taskSlug?: string; +} + +/** + * Inputs passed to buildLaunchContext. Contributors, resolvers, and + * timeout are passed as collaborators via ResolveCtx / options. + */ +export interface BuildLaunchContextInputs { + projectId: string; + sources: LaunchSource[]; + agent: LaunchContext["agent"]; +} diff --git a/apps/desktop/src/shared/detect-language.ts b/apps/desktop/src/shared/detect-language.ts index 7f305cd2ba7..d4f42df7647 100644 --- a/apps/desktop/src/shared/detect-language.ts +++ b/apps/desktop/src/shared/detect-language.ts @@ -13,6 +13,7 @@ export function detectLanguage(filePath: string): string { // Web html: "html", htm: "html", + astro: "html", css: "css", scss: "scss", less: "less", diff --git a/apps/desktop/src/shared/env.shared.ts b/apps/desktop/src/shared/env.shared.ts index 218a329c016..44335d9bbbb 100644 --- a/apps/desktop/src/shared/env.shared.ts +++ b/apps/desktop/src/shared/env.shared.ts @@ -20,7 +20,6 @@ const envSchema = z.object({ DESKTOP_VITE_PORT: z.coerce.number().default(5173), DESKTOP_NOTIFICATIONS_PORT: z.coerce.number().default(51741), ELECTRIC_PORT: z.coerce.number().default(5133), - DESKTOP_AUTOMATION_PORT: z.coerce.number().default(41729), // Workspace name for instance isolation SUPERSET_WORKSPACE_NAME: z.string().default("superset"), }); @@ -37,7 +36,6 @@ export const env = envSchema.parse({ DESKTOP_VITE_PORT: process.env.DESKTOP_VITE_PORT, DESKTOP_NOTIFICATIONS_PORT: process.env.DESKTOP_NOTIFICATIONS_PORT, ELECTRIC_PORT: process.env.ELECTRIC_PORT, - DESKTOP_AUTOMATION_PORT: process.env.DESKTOP_AUTOMATION_PORT, SUPERSET_WORKSPACE_NAME: process.env.SUPERSET_WORKSPACE_NAME, }); diff --git a/apps/desktop/src/shared/file-types.test.ts b/apps/desktop/src/shared/file-types.test.ts new file mode 100644 index 00000000000..b96852ae227 --- /dev/null +++ b/apps/desktop/src/shared/file-types.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { + getImageExtensionFromMimeType, + getImageMimeType, + parseBase64DataUrl, +} from "./file-types"; + +const PNG_BASE64 = Buffer.from("png").toString("base64"); + +describe("file-types", () => { + test("maps image file paths to MIME types", () => { + expect(getImageMimeType("logo.svg")).toBe("image/svg+xml"); + expect(getImageMimeType("logo.ico")).toBe("image/x-icon"); + expect(getImageMimeType("logo.unknown")).toBeNull(); + }); + + test("maps image MIME types to preferred extensions", () => { + expect(getImageExtensionFromMimeType("image/jpeg")).toBe("jpg"); + expect(getImageExtensionFromMimeType("image/vnd.microsoft.icon")).toBe( + "ico", + ); + expect(getImageExtensionFromMimeType("image/webp")).toBe("webp"); + expect(getImageExtensionFromMimeType("image/avif")).toBeNull(); + }); + + test("parses base64 data URLs with extra MIME parameters", () => { + expect( + parseBase64DataUrl( + `data:image/svg+xml;charset=utf-8;base64,${PNG_BASE64}`, + ), + ).toEqual({ + base64Data: PNG_BASE64, + mimeType: "image/svg+xml", + }); + }); + + test("rejects malformed base64 data URLs", () => { + expect(() => parseBase64DataUrl("not-a-data-url")).toThrow( + "Invalid data URL format", + ); + }); +}); diff --git a/apps/desktop/src/shared/file-types.ts b/apps/desktop/src/shared/file-types.ts index cbac5fcc766..ac6bfe3f71a 100644 --- a/apps/desktop/src/shared/file-types.ts +++ b/apps/desktop/src/shared/file-types.ts @@ -27,6 +27,19 @@ const IMAGE_MIME_TYPES: Record<string, string> = { ico: "image/x-icon", }; +/** Extensions for supported image MIME types */ +const IMAGE_MIME_TYPE_EXTENSIONS: Record<string, string> = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", + "image/bmp": "bmp", + "image/x-icon": "ico", + "image/vnd.microsoft.icon": "ico", +}; + /** Markdown extensions */ const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdx"]); @@ -53,6 +66,38 @@ export function getImageMimeType(filePath: string): string | null { return IMAGE_MIME_TYPES[ext] ?? null; } +/** + * Gets the preferred file extension for an image MIME type. + * Returns null if not a supported image type. + */ +export function getImageExtensionFromMimeType(mimeType: string): string | null { + return IMAGE_MIME_TYPE_EXTENSIONS[mimeType.toLowerCase()] ?? null; +} + +/** + * Parses a base64 data URL and returns its MIME type and base64 payload. + */ +export function parseBase64DataUrl(dataUrl: string): { + base64Data: string; + mimeType: string; +} { + const separatorIndex = dataUrl.indexOf(","); + if (separatorIndex === -1) { + throw new Error("Invalid data URL format"); + } + + const header = dataUrl.slice(0, separatorIndex); + const base64Data = dataUrl.slice(separatorIndex + 1); + const mimeMatch = header.match(/^data:([^;,]+)(?:;[^,]*)*;base64$/i); + const mimeType = mimeMatch?.[1]?.toLowerCase(); + + if (!mimeType) { + throw new Error("Invalid data URL format"); + } + + return { base64Data, mimeType }; +} + /** * Checks if a file is markdown based on extension */ diff --git a/apps/desktop/src/shared/hotkeys.test.ts b/apps/desktop/src/shared/hotkeys.test.ts deleted file mode 100644 index 0a2f1ab405b..00000000000 --- a/apps/desktop/src/shared/hotkeys.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - canonicalizeHotkey, - canonicalizeHotkeyForPlatform, - deriveNonMacDefault, - hotkeyFromKeyboardEvent, - isTerminalReservedEvent, - toElectronAccelerator, -} from "./hotkeys"; - -describe("canonicalizeHotkey", () => { - it("normalizes modifier order", () => { - expect(canonicalizeHotkey("shift+meta+k")).toBe("meta+shift+k"); - }); - - it("rejects invalid hotkeys", () => { - expect(canonicalizeHotkey("shift+meta+k+x")).toBeNull(); - }); -}); - -describe("canonicalizeHotkeyForPlatform", () => { - it("rejects meta on non-mac platforms", () => { - expect(canonicalizeHotkeyForPlatform("meta+k", "win32")).toBeNull(); - }); -}); - -describe("deriveNonMacDefault", () => { - it("returns null for null input", () => { - expect(deriveNonMacDefault(null)).toBeNull(); - }); - - it("returns null for invalid hotkey", () => { - expect(deriveNonMacDefault("invalid+key+combo+extra")).toBeNull(); - }); - - it("returns unchanged hotkey when no meta modifier present", () => { - expect(deriveNonMacDefault("ctrl+k")).toBe("ctrl+k"); - }); - - it("maps meta+key to ctrl+shift+key (simple meta case)", () => { - expect(deriveNonMacDefault("meta+k")).toBe("ctrl+shift+k"); - }); - - it("maps meta+shift to ctrl+alt+shift (adds alt for shifted defaults)", () => { - expect(deriveNonMacDefault("meta+shift+w")).toBe("ctrl+alt+shift+w"); - }); - - it("maps meta+alt to ctrl+alt+shift", () => { - expect(deriveNonMacDefault("meta+alt+k")).toBe("ctrl+alt+shift+k"); - }); -}); - -describe("hotkeyFromKeyboardEvent", () => { - it("captures a simple meta hotkey on mac", () => { - const keys = hotkeyFromKeyboardEvent( - { - key: "k", - code: "KeyK", - metaKey: true, - ctrlKey: false, - altKey: false, - shiftKey: false, - }, - "darwin", - ); - expect(keys).toBe("meta+k"); - }); -}); - -describe("toElectronAccelerator", () => { - it("converts to electron accelerator for mac", () => { - expect(toElectronAccelerator("meta+shift+w", "darwin")).toBe( - "Command+Shift+W", - ); - }); - - it("returns null for meta on non-mac", () => { - expect(toElectronAccelerator("meta+w", "win32")).toBeNull(); - }); -}); - -describe("isTerminalReservedEvent", () => { - it("detects ctrl+c", () => { - expect( - isTerminalReservedEvent({ - key: "c", - ctrlKey: true, - shiftKey: false, - altKey: false, - metaKey: false, - }), - ).toBe(true); - }); -}); diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts deleted file mode 100644 index 2b1f07b2bf9..00000000000 --- a/apps/desktop/src/shared/hotkeys.ts +++ /dev/null @@ -1,1039 +0,0 @@ -/** - * Centralized hotkey definitions for the desktop app. - * Used both for registering shortcuts and displaying in the hotkey modal. - */ - -import { PLATFORM } from "./constants"; - -export type HotkeyPlatform = "darwin" | "win32" | "linux"; - -export type HotkeyCategory = - | "Navigation" - | "Workspace" - | "Layout" - | "Terminal" - | "Window" - | "Help"; - -export interface HotkeyDefinition { - /** Human-readable label for display */ - label: string; - /** Category for grouping in the modal */ - category: HotkeyCategory; - /** Optional description for more detail */ - description?: string; - /** Per-platform defaults */ - defaults: Record<HotkeyPlatform, string | null>; - /** Hide from settings list (reserved for future use) */ - isHidden?: boolean; -} - -export type HotkeyId = keyof typeof HOTKEYS; - -export type HotkeyWithId = HotkeyDefinition & { id: HotkeyId }; - -export interface HotkeysState { - version: number; - byPlatform: Record<HotkeyPlatform, Partial<Record<HotkeyId, string | null>>>; -} - -export interface HotkeysExportFile { - schemaVersion: number; - exportedAt: string; - app: string; - hotkeys: Record<HotkeyPlatform, Partial<Record<HotkeyId, string | null>>>; -} - -export const HOTKEYS_STATE_VERSION = 1; - -const MODIFIER_ORDER: Array<"meta" | "ctrl" | "alt" | "shift"> = [ - "meta", - "ctrl", - "alt", - "shift", -]; - -const KEY_ALIAS_MAP: Record<string, string> = { - cmd: "meta", - command: "meta", - opt: "alt", - option: "alt", - control: "ctrl", - ctl: "ctrl", - esc: "escape", - return: "enter", - arrowleft: "left", - arrowright: "right", - arrowup: "up", - arrowdown: "down", - " ": "space", - spacebar: "space", - slash: "slash", - "/": "slash", - "?": "slash", -}; - -const MODIFIER_DISPLAY_MAP: Record<HotkeyPlatform, Record<string, string>> = { - darwin: { meta: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧" }, - win32: { meta: "Win", ctrl: "Ctrl", alt: "Alt", shift: "Shift" }, - linux: { meta: "Super", ctrl: "Ctrl", alt: "Alt", shift: "Shift" }, -}; - -const KEY_DISPLAY_MAP: Record<string, string> = { - enter: "↵", - backspace: "⌫", - delete: "⌦", - escape: "⎋", - tab: "⇥", - up: "↑", - down: "↓", - left: "←", - right: "→", - space: "␣", - slash: "/", -}; - -const ELECTRON_KEY_MAP: Record<string, string> = { - enter: "Enter", - backspace: "Backspace", - delete: "Delete", - escape: "Escape", - tab: "Tab", - up: "Up", - down: "Down", - left: "Left", - right: "Right", - space: "Space", - slash: "/", - f1: "F1", - f2: "F2", - f3: "F3", - f4: "F4", - f5: "F5", - f6: "F6", - f7: "F7", - f8: "F8", - f9: "F9", - f10: "F10", - f11: "F11", - f12: "F12", -}; - -const TERMINAL_RESERVED_CHORDS = new Set<string>([ - "ctrl+c", - "ctrl+d", - "ctrl+z", - "ctrl+s", - "ctrl+q", - "ctrl+\\", -]); - -function isFunctionKey(key: string): boolean { - return /^f([1-9]|1[0-2])$/.test(key); -} - -const OS_RESERVED_CHORDS: Record<HotkeyPlatform, string[]> = { - darwin: ["meta+q", "meta+space", "meta+tab"], - win32: ["alt+f4", "alt+tab", "ctrl+alt+delete"], - linux: ["alt+f4", "alt+tab"], -}; - -export interface KeyboardEventLike { - key: string; - code?: string; - ctrlKey: boolean; - shiftKey: boolean; - altKey: boolean; - metaKey: boolean; -} - -function normalizeKey(raw: string): string { - const trimmed = raw.trim(); - const lower = trimmed === "" && raw !== "" ? raw : trimmed.toLowerCase(); - return KEY_ALIAS_MAP[lower] ?? lower; -} - -function parseHotkeyString(keys: string): { - modifiers: Set<string>; - key: string | null; -} { - const parts = keys - .split("+") - .map((part) => normalizeKey(part)) - .filter(Boolean); - const modifiers = new Set<string>(); - let primary: string | null = null; - - for (const part of parts) { - if (MODIFIER_ORDER.includes(part as (typeof MODIFIER_ORDER)[number])) { - modifiers.add(part); - continue; - } - if (primary) { - return { modifiers, key: null }; - } - primary = part; - } - - return { modifiers, key: primary }; -} - -function formatHotkeyString(modifiers: Set<string>, key: string): string { - const ordered = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)); - return [...ordered, key].join("+"); -} - -export function getCurrentPlatform(): HotkeyPlatform { - if (PLATFORM.IS_MAC) return "darwin"; - if (PLATFORM.IS_WINDOWS) return "win32"; - return "linux"; -} - -export function canonicalizeHotkey(keys: string): string | null { - const parsed = parseHotkeyString(keys); - if (!parsed.key) return null; - return formatHotkeyString(parsed.modifiers, parsed.key); -} - -export function canonicalizeHotkeyForPlatform( - keys: string, - platform: HotkeyPlatform, -): string | null { - const canonical = canonicalizeHotkey(keys); - if (!canonical) return null; - if (platform !== "darwin" && canonical.includes("meta+")) return null; - return canonical; -} - -export function formatHotkeyDisplay( - keys: string | null, - platform: HotkeyPlatform, -): string[] { - if (!keys) return ["Unassigned"]; - const canonical = canonicalizeHotkey(keys); - if (!canonical) return ["Unassigned"]; - - const { modifiers, key } = parseHotkeyString(canonical); - if (!key) return ["Unassigned"]; - - const modifierSymbols = MODIFIER_ORDER.filter((modifier) => - modifiers.has(modifier), - ).map((modifier) => MODIFIER_DISPLAY_MAP[platform][modifier]); - - const keyDisplay = KEY_DISPLAY_MAP[key] ?? key.toUpperCase(); - return [...modifierSymbols, keyDisplay]; -} - -export function formatHotkeyText( - keys: string | null, - platform: HotkeyPlatform, -): string { - const display = formatHotkeyDisplay(keys, platform); - if (display.length === 1 && display[0] === "Unassigned") { - return "Unassigned"; - } - return platform === "darwin" ? display.join("") : display.join("+"); -} - -export function matchesHotkeyEvent( - event: KeyboardEventLike, - keys: string, -): boolean { - const canonical = canonicalizeHotkey(keys); - if (!canonical) return false; - - const { modifiers, key } = parseHotkeyString(canonical); - if (!key) return false; - - const requiresMeta = modifiers.has("meta"); - const requiresCtrl = modifiers.has("ctrl"); - const requiresAlt = modifiers.has("alt"); - const requiresShift = modifiers.has("shift"); - - if (requiresMeta !== event.metaKey) return false; - if (requiresCtrl !== event.ctrlKey) return false; - if (requiresAlt !== event.altKey) return false; - if (requiresShift !== event.shiftKey) return false; - - const eventKey = normalizeKey(event.key); - const eventCode = event.code ? normalizeKey(event.code) : ""; - - if (key === "slash" && (eventKey === "slash" || eventCode === "slash")) { - return true; - } - - if (key === "left" && eventKey === "arrowleft") return true; - if (key === "right" && eventKey === "arrowright") return true; - if (key === "up" && eventKey === "arrowup") return true; - if (key === "down" && eventKey === "arrowdown") return true; - - // On Mac, Option+number produces special characters (e.g., Option+1 = ¡) - // Use event.code to match digit keys when alt is pressed - if (/^[1-9]$/.test(key) && eventCode === `digit${key}`) return true; - - return eventKey === key; -} - -export function hotkeyFromKeyboardEvent( - event: KeyboardEventLike, - platform: HotkeyPlatform, -): string | null { - const normalizedKey = normalizeKey(event.key); - if ( - normalizedKey === "shift" || - normalizedKey === "ctrl" || - normalizedKey === "alt" || - normalizedKey === "meta" - ) { - return null; - } - if (normalizedKey === "dead" || normalizedKey === "unidentified") { - return null; - } - - // App hotkeys must include ctrl or meta (or be function keys F1-F12) - // to avoid conflicts with terminal input and ensure they work when the terminal is focused - if (!isFunctionKey(normalizedKey) && !event.ctrlKey && !event.metaKey) { - return null; - } - - const primary = normalizedKey; - - const modifiers = new Set<string>(); - if (event.metaKey) modifiers.add("meta"); - if (event.ctrlKey) modifiers.add("ctrl"); - if (event.altKey) modifiers.add("alt"); - if (event.shiftKey) modifiers.add("shift"); - - const canonical = formatHotkeyString(modifiers, primary); - return canonicalizeHotkeyForPlatform(canonical, platform); -} - -export function isTerminalReservedHotkey(keys: string): boolean { - const canonical = canonicalizeHotkey(keys); - if (!canonical) return false; - return TERMINAL_RESERVED_CHORDS.has(canonical); -} - -export function isTerminalReservedEvent(event: KeyboardEventLike): boolean { - for (const reserved of TERMINAL_RESERVED_CHORDS) { - if (matchesHotkeyEvent(event, reserved)) return true; - } - return false; -} - -export function isOsReservedHotkey( - keys: string, - platform: HotkeyPlatform, -): boolean { - const canonical = canonicalizeHotkey(keys); - if (!canonical) return false; - return OS_RESERVED_CHORDS[platform].includes(canonical); -} - -/** - * Checks if a hotkey is valid for app-level use. - * App hotkeys must include ctrl or meta (or be function keys F1-F12) - * to avoid conflicts with terminal input and ensure they work when the terminal is focused. - */ -export function isValidAppHotkey(keys: string): boolean { - const parsed = parseHotkeyString(keys); - // Function keys are allowed without modifiers - if (parsed.key && isFunctionKey(parsed.key)) { - return true; - } - return parsed.modifiers.has("ctrl") || parsed.modifiers.has("meta"); -} - -export function deriveNonMacDefault(keys: string | null): string | null { - if (!keys) return null; - const canonical = canonicalizeHotkey(keys); - if (!canonical) return null; - const parsed = parseHotkeyString(canonical); - if (!parsed.key) return null; - const modifiers = new Set(parsed.modifiers); - const hadMeta = modifiers.delete("meta"); - if (!hadMeta) { - return formatHotkeyString(modifiers, parsed.key); - } - modifiers.add("ctrl"); - modifiers.add("shift"); - if (parsed.modifiers.has("shift")) { - modifiers.add("alt"); - } - return formatHotkeyString(modifiers, parsed.key); -} - -function defineHotkey(def: { - keys: string | null; - label: string; - category: HotkeyCategory; - description?: string; - defaults?: Partial<Record<HotkeyPlatform, string | null>>; - isHidden?: boolean; -}): HotkeyDefinition { - const darwin = def.keys; - const win32 = def.defaults?.win32 ?? deriveNonMacDefault(darwin); - const linux = def.defaults?.linux ?? deriveNonMacDefault(darwin); - return { - label: def.label, - category: def.category, - description: def.description, - defaults: { - darwin, - win32: win32 ?? null, - linux: linux ?? null, - }, - isHidden: def.isHidden, - }; -} - -export const HOTKEYS = { - // Navigation - browser-style back/forward - NAVIGATE_BACK: defineHotkey({ - keys: "meta+[", - label: "Navigate Back", - category: "Navigation", - description: "Go back to the previous page in history", - }), - NAVIGATE_FORWARD: defineHotkey({ - keys: "meta+]", - label: "Navigate Forward", - category: "Navigation", - description: "Go forward to the next page in history", - }), - - // Workspace - switch with ⌘+1-9 - JUMP_TO_WORKSPACE_1: defineHotkey({ - keys: "meta+1", - label: "Switch to Workspace 1", - category: "Workspace", - }), - JUMP_TO_WORKSPACE_2: defineHotkey({ - keys: "meta+2", - label: "Switch to Workspace 2", - category: "Workspace", - }), - JUMP_TO_WORKSPACE_3: defineHotkey({ - keys: "meta+3", - label: "Switch to Workspace 3", - category: "Workspace", - }), - JUMP_TO_WORKSPACE_4: defineHotkey({ - keys: "meta+4", - label: "Switch to Workspace 4", - category: "Workspace", - }), - JUMP_TO_WORKSPACE_5: defineHotkey({ - keys: "meta+5", - label: "Switch to Workspace 5", - category: "Workspace", - }), - JUMP_TO_WORKSPACE_6: defineHotkey({ - keys: "meta+6", - label: "Switch to Workspace 6", - category: "Workspace", - }), - JUMP_TO_WORKSPACE_7: defineHotkey({ - keys: "meta+7", - label: "Switch to Workspace 7", - category: "Workspace", - }), - JUMP_TO_WORKSPACE_8: defineHotkey({ - keys: "meta+8", - label: "Switch to Workspace 8", - category: "Workspace", - }), - JUMP_TO_WORKSPACE_9: defineHotkey({ - keys: "meta+9", - label: "Switch to Workspace 9", - category: "Workspace", - }), - PREV_WORKSPACE: defineHotkey({ - keys: "meta+alt+up", - label: "Previous Workspace", - category: "Workspace", - }), - NEXT_WORKSPACE: defineHotkey({ - keys: "meta+alt+down", - label: "Next Workspace", - category: "Workspace", - }), - - // Layout - TOGGLE_SIDEBAR: defineHotkey({ - keys: "meta+l", - label: "Toggle Changes Tab", - category: "Layout", - }), - TOGGLE_EXPAND_SIDEBAR: defineHotkey({ - keys: "meta+shift+l", - label: "Toggle Expand Sidebar", - category: "Layout", - }), - TOGGLE_WORKSPACE_SIDEBAR: defineHotkey({ - keys: "meta+b", - label: "Toggle Workspaces Sidebar", - category: "Layout", - }), - SPLIT_RIGHT: defineHotkey({ - keys: "meta+d", - label: "Split Right", - category: "Layout", - description: "Split the current pane to the right", - }), - SPLIT_DOWN: defineHotkey({ - keys: "meta+shift+d", - label: "Split Down", - category: "Layout", - description: "Split the current pane downward", - }), - SPLIT_AUTO: defineHotkey({ - keys: "meta+e", - label: "Split Pane Auto", - category: "Layout", - description: "Split the current pane along its longer side", - }), - SPLIT_WITH_CHAT: defineHotkey({ - keys: "meta+shift+e", - label: "Split with New Chat", - category: "Layout", - description: "Split the current pane and open a new chat pane", - defaults: { - win32: "ctrl+alt+e", - linux: "ctrl+alt+e", - }, - }), - SPLIT_WITH_BROWSER: defineHotkey({ - keys: "meta+shift+s", - label: "Split with New Browser", - category: "Layout", - description: "Split the current pane and open a new browser pane", - defaults: { - win32: "ctrl+shift+alt+s", - linux: "ctrl+shift+alt+s", - }, - }), - EQUALIZE_PANE_SPLITS: defineHotkey({ - keys: "meta+shift+0", - label: "Equalize Pane Splits", - category: "Layout", - description: "Make all panes equal size", - defaults: { - win32: "ctrl+shift+0", - linux: "ctrl+shift+0", - }, - }), - CLOSE_PANE: defineHotkey({ - keys: "meta+w", - label: "Close Pane", - category: "Layout", - description: "Close the current pane", - }), - - // Terminal - FIND_IN_TERMINAL: defineHotkey({ - keys: "meta+f", - label: "Find in Terminal", - category: "Terminal", - description: "Search text in the active terminal", - }), - FIND_IN_FILE_VIEWER: defineHotkey({ - keys: "meta+f", - label: "Find in File Viewer", - category: "Terminal", - description: "Search text in the rendered file viewer", - }), - NEW_GROUP: defineHotkey({ - keys: "meta+t", - label: "New Terminal", - category: "Terminal", - }), - NEW_CHAT: defineHotkey({ - keys: "meta+shift+t", - label: "New Chat", - category: "Terminal", - }), - REOPEN_TAB: defineHotkey({ - keys: "meta+shift+r", - label: "Reopen Closed Tab", - category: "Terminal", - }), - NEW_BROWSER: defineHotkey({ - keys: "meta+shift+b", - label: "New Browser", - category: "Terminal", - }), - CLOSE_TERMINAL: defineHotkey({ - keys: "meta+w", - label: "Close Terminal", - category: "Terminal", - }), - CLOSE_TAB: defineHotkey({ - keys: "meta+shift+w", - label: "Close Tab", - category: "Terminal", - description: "Close the current tab", - }), - CLEAR_TERMINAL: defineHotkey({ - keys: "meta+k", - label: "Clear Terminal", - category: "Terminal", - }), - SCROLL_TO_BOTTOM: defineHotkey({ - keys: "meta+shift+down", - label: "Scroll to Bottom", - category: "Terminal", - description: "Scroll the active terminal to the bottom", - }), - PREV_TAB: defineHotkey({ - keys: "meta+alt+left", - label: "Previous Tab", - category: "Terminal", - }), - NEXT_TAB: defineHotkey({ - keys: "meta+alt+right", - label: "Next Tab", - category: "Terminal", - }), - PREV_TAB_ALT: defineHotkey({ - keys: "ctrl+shift+tab", - label: "Previous Tab (Alt)", - category: "Terminal", - }), - NEXT_TAB_ALT: defineHotkey({ - keys: "ctrl+tab", - label: "Next Tab (Alt)", - category: "Terminal", - }), - PREV_PANE: defineHotkey({ - keys: "meta+shift+left", - label: "Previous Pane", - category: "Terminal", - description: "Focus the previous pane in the current tab", - }), - NEXT_PANE: defineHotkey({ - keys: "meta+shift+right", - label: "Next Pane", - category: "Terminal", - description: "Focus the next pane in the current tab", - }), - JUMP_TO_TAB_1: defineHotkey({ - keys: "meta+alt+1", - label: "Switch to Tab 1", - category: "Terminal", - }), - JUMP_TO_TAB_2: defineHotkey({ - keys: "meta+alt+2", - label: "Switch to Tab 2", - category: "Terminal", - }), - JUMP_TO_TAB_3: defineHotkey({ - keys: "meta+alt+3", - label: "Switch to Tab 3", - category: "Terminal", - }), - JUMP_TO_TAB_4: defineHotkey({ - keys: "meta+alt+4", - label: "Switch to Tab 4", - category: "Terminal", - }), - JUMP_TO_TAB_5: defineHotkey({ - keys: "meta+alt+5", - label: "Switch to Tab 5", - category: "Terminal", - }), - JUMP_TO_TAB_6: defineHotkey({ - keys: "meta+alt+6", - label: "Switch to Tab 6", - category: "Terminal", - }), - JUMP_TO_TAB_7: defineHotkey({ - keys: "meta+alt+7", - label: "Switch to Tab 7", - category: "Terminal", - }), - JUMP_TO_TAB_8: defineHotkey({ - keys: "meta+alt+8", - label: "Switch to Tab 8", - category: "Terminal", - }), - JUMP_TO_TAB_9: defineHotkey({ - keys: "meta+alt+9", - label: "Switch to Tab 9", - category: "Terminal", - }), - OPEN_PRESET_1: defineHotkey({ - keys: "ctrl+1", - label: "Open Preset 1", - category: "Terminal", - }), - OPEN_PRESET_2: defineHotkey({ - keys: "ctrl+2", - label: "Open Preset 2", - category: "Terminal", - }), - OPEN_PRESET_3: defineHotkey({ - keys: "ctrl+3", - label: "Open Preset 3", - category: "Terminal", - }), - OPEN_PRESET_4: defineHotkey({ - keys: "ctrl+4", - label: "Open Preset 4", - category: "Terminal", - }), - OPEN_PRESET_5: defineHotkey({ - keys: "ctrl+5", - label: "Open Preset 5", - category: "Terminal", - }), - OPEN_PRESET_6: defineHotkey({ - keys: "ctrl+6", - label: "Open Preset 6", - category: "Terminal", - }), - OPEN_PRESET_7: defineHotkey({ - keys: "ctrl+7", - label: "Open Preset 7", - category: "Terminal", - }), - OPEN_PRESET_8: defineHotkey({ - keys: "ctrl+8", - label: "Open Preset 8", - category: "Terminal", - }), - OPEN_PRESET_9: defineHotkey({ - keys: "ctrl+9", - label: "Open Preset 9", - category: "Terminal", - }), - - // Workspace creation - NEW_WORKSPACE: defineHotkey({ - keys: "meta+n", - label: "New Workspace", - category: "Workspace", - description: "Open the new workspace modal", - }), - QUICK_CREATE_WORKSPACE: defineHotkey({ - keys: "meta+shift+n", - label: "Quick Create Workspace", - category: "Workspace", - description: "Quickly create a workspace in the current project", - }), - RUN_WORKSPACE_COMMAND: defineHotkey({ - keys: "meta+g", - label: "Run Workspace Command", - category: "Workspace", - description: "Start or stop the workspace run command", - }), - FOCUS_TASK_SEARCH: defineHotkey({ - keys: "meta+f", - label: "Focus Task Search", - category: "Workspace", - description: "Focus the search input in the tasks view", - }), - OPEN_PROJECT: defineHotkey({ - keys: "meta+shift+o", - label: "Open Project", - category: "Workspace", - description: "Open an existing project folder", - }), - OPEN_PR: defineHotkey({ - keys: "meta+shift+p", - label: "Open Pull Request", - category: "Workspace", - description: "Open existing PR or create a new one on GitHub", - }), - - // Window - NEW_WINDOW: defineHotkey({ - keys: null, - label: "New Window", - category: "Window", - isHidden: true, - }), - CLOSE_WINDOW: defineHotkey({ - keys: "meta+shift+q", - label: "Close Window", - category: "Window", - }), - OPEN_IN_APP: defineHotkey({ - keys: "meta+o", - label: "Open in App", - category: "Window", - description: "Open workspace in external app (Cursor, VS Code, etc.)", - }), - COPY_PATH: defineHotkey({ - keys: "meta+shift+c", - label: "Copy Path", - category: "Window", - description: "Copy the workspace path to the clipboard", - }), - - QUICK_OPEN: defineHotkey({ - keys: "meta+p", - label: "Quick Open File", - category: "Navigation", - description: "Search and open files in the current workspace", - }), - KEYWORD_SEARCH: defineHotkey({ - keys: "meta+shift+f", - label: "Keyword Search", - category: "Navigation", - description: - "Search for keyword matches across files in the current workspace", - }), - - // Chat - FIND_IN_CHAT: defineHotkey({ - keys: "meta+f", - label: "Find in Chat", - category: "Terminal", - description: "Search text in the active chat", - }), - FOCUS_CHAT_INPUT: defineHotkey({ - keys: "meta+j", - label: "Focus Chat Input", - category: "Terminal", - }), - CHAT_ADD_ATTACHMENT: defineHotkey({ - keys: "meta+u", - label: "Add Attachment", - category: "Terminal", - }), - CHAT_LINK_ISSUE: defineHotkey({ - keys: "meta+i", - label: "Link Issue", - category: "Terminal", - }), - - // Window - RELOAD_WINDOW: defineHotkey({ - keys: "meta+r", - label: "Reload Window", - category: "Window", - description: "Reload the current window", - }), - - // Help - OPEN_SETTINGS: defineHotkey({ - keys: "meta+,", - label: "Open Settings", - category: "Help", - defaults: { - darwin: "meta+,", - win32: "ctrl+,", - linux: "ctrl+,", - }, - }), - SHOW_HOTKEYS: defineHotkey({ - keys: "meta+slash", - label: "Show Keyboard Shortcuts", - category: "Help", - }), -} as const satisfies Record<string, HotkeyDefinition>; - -export function getVisibleHotkeys(): HotkeyId[] { - return (Object.keys(HOTKEYS) as HotkeyId[]).filter( - (id) => !HOTKEYS[id].isHidden, - ); -} - -export function getHotkeysByCategory(options?: { - includeHidden?: boolean; -}): Record<HotkeyCategory, HotkeyWithId[]> { - const grouped: Record<HotkeyCategory, HotkeyWithId[]> = { - Navigation: [], - Workspace: [], - Layout: [], - Terminal: [], - Window: [], - Help: [], - }; - - for (const [id, hotkey] of Object.entries(HOTKEYS)) { - if (!options?.includeHidden && hotkey.isHidden) continue; - grouped[hotkey.category].push({ id: id as HotkeyId, ...hotkey }); - } - - return grouped; -} - -export function getDefaultHotkey( - id: HotkeyId, - platform: HotkeyPlatform, -): string | null { - return HOTKEYS[id].defaults[platform]; -} - -/** - * Get the hotkey binding for the current platform. - * Convenience wrapper around getDefaultHotkey. - * Returns empty string if no hotkey is defined (safe for useHotkeys). - */ -export function getHotkey(id: HotkeyId): string { - return getDefaultHotkey(id, getCurrentPlatform()) ?? ""; -} - -export function getEffectiveHotkey( - id: HotkeyId, - overrides: Partial<Record<HotkeyId, string | null>>, - platform: HotkeyPlatform, -): string | null { - if (overrides[id] !== undefined) return overrides[id] ?? null; - return getDefaultHotkey(id, platform); -} - -export function getEffectiveHotkeysMap( - overrides: Partial<Record<HotkeyId, string | null>>, - platform: HotkeyPlatform, -): Record<HotkeyId, string | null> { - const map = {} as Record<HotkeyId, string | null>; - for (const id of Object.keys(HOTKEYS) as HotkeyId[]) { - map[id] = getEffectiveHotkey(id, overrides, platform); - } - return map; -} - -export function buildOverridesFromBindings( - bindings: Partial<Record<HotkeyId, string | null>>, - platform: HotkeyPlatform, -): Partial<Record<HotkeyId, string | null>> { - const overrides: Partial<Record<HotkeyId, string | null>> = {}; - for (const id of Object.keys(HOTKEYS) as HotkeyId[]) { - if (!(id in bindings)) continue; - const value = bindings[id]; - if (value === undefined) continue; - const canonical = - value === null ? null : canonicalizeHotkeyForPlatform(value, platform); - if (canonical === null && value !== null) { - continue; - } - // App hotkeys must include ctrl or meta (or be function keys) to work in terminal - if (canonical !== null && !isValidAppHotkey(canonical)) { - continue; - } - const defaultValue = getDefaultHotkey(id, platform); - if (canonical === defaultValue) continue; - overrides[id] = canonical; - } - return overrides; -} - -export function normalizeBindingsWithDefaults( - bindings: Partial<Record<HotkeyId, string | null>>, - platform: HotkeyPlatform, -): Record<HotkeyId, string | null> { - const map = getEffectiveHotkeysMap({}, platform); - for (const id of Object.keys(HOTKEYS) as HotkeyId[]) { - if (!(id in bindings)) continue; - const value = bindings[id]; - if (value === undefined) continue; - if (value === null) { - map[id] = null; - continue; - } - const canonical = canonicalizeHotkeyForPlatform(value, platform); - if (canonical) { - map[id] = canonical; - } - } - return map; -} - -export function createDefaultHotkeysState(): HotkeysState { - return { - version: HOTKEYS_STATE_VERSION, - byPlatform: { - darwin: {}, - win32: {}, - linux: {}, - }, - }; -} - -export function createHotkeysExport( - hotkeysState: HotkeysState, -): HotkeysExportFile { - return { - schemaVersion: HOTKEYS_STATE_VERSION, - exportedAt: new Date().toISOString(), - app: "@superset/desktop", - hotkeys: { - darwin: getEffectiveHotkeysMap(hotkeysState.byPlatform.darwin, "darwin"), - win32: getEffectiveHotkeysMap(hotkeysState.byPlatform.win32, "win32"), - linux: getEffectiveHotkeysMap(hotkeysState.byPlatform.linux, "linux"), - }, - }; -} - -export function buildHotkeysStateFromExport( - exportFile: HotkeysExportFile, -): HotkeysState { - return { - version: HOTKEYS_STATE_VERSION, - byPlatform: { - darwin: buildOverridesFromBindings( - exportFile.hotkeys.darwin ?? {}, - "darwin", - ), - win32: buildOverridesFromBindings( - exportFile.hotkeys.win32 ?? {}, - "win32", - ), - linux: buildOverridesFromBindings( - exportFile.hotkeys.linux ?? {}, - "linux", - ), - }, - }; -} - -export function getHotkeysSummary(bindings: Record<HotkeyId, string | null>): { - assigned: number; - disabled: number; -} { - let assigned = 0; - let disabled = 0; - for (const id of Object.keys(bindings) as HotkeyId[]) { - const value = bindings[id]; - if (value === null) { - disabled += 1; - } else { - assigned += 1; - } - } - return { assigned, disabled }; -} - -export function toElectronAccelerator( - keys: string | null, - platform: HotkeyPlatform, -): string | null { - if (!keys) return null; - const canonical = canonicalizeHotkey(keys); - if (!canonical) return null; - if (platform !== "darwin" && canonical.includes("meta+")) return null; - - const { modifiers, key } = parseHotkeyString(canonical); - if (!key) return null; - - const modifierTokens = MODIFIER_ORDER.filter((modifier) => - modifiers.has(modifier), - ).map((modifier) => { - if (modifier === "meta") return "Command"; - if (modifier === "ctrl") return "Ctrl"; - if (modifier === "alt") return "Alt"; - return "Shift"; - }); - - const mappedKey = - ELECTRON_KEY_MAP[key] ?? - (key.length === 1 - ? key.toUpperCase() - : `${key.charAt(0).toUpperCase()}${key.slice(1)}`); - - return [...modifierTokens, mappedKey].join("+"); -} diff --git a/apps/desktop/src/shared/notification-types.ts b/apps/desktop/src/shared/notification-types.ts index 6bf29f58450..4e68e6a7979 100644 --- a/apps/desktop/src/shared/notification-types.ts +++ b/apps/desktop/src/shared/notification-types.ts @@ -11,5 +11,14 @@ export interface NotificationIds { } export interface AgentLifecycleEvent extends NotificationIds { - eventType: "Start" | "Stop" | "PermissionRequest"; + eventType: "Start" | "Stop" | "PermissionRequest" | "PendingQuestion"; +} + +export type V2NotificationSource = + | { type: "terminal"; id: string } + | { type: "chat"; id: string }; + +export interface V2NotificationSourceFocusTarget { + workspaceId: string; + source: V2NotificationSource; } diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index d02b081ba9c..2098afbd98e 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -13,7 +13,8 @@ export type PaneType = | "webview" | "file-viewer" | "chat" - | "devtools"; + | "devtools" + | "comment"; /** * Pane status for agent lifecycle indicators @@ -142,6 +143,7 @@ export interface Pane { chat?: ChatPaneState; // For chat panes browser?: BrowserPaneState; // For browser (webview) panes devtools?: DevToolsPaneState; // For devtools panes + comment?: CommentPaneState; // For comment panes workspaceRun?: { workspaceId: string; state: "running" | "stopped-by-user" | "stopped-by-exit"; @@ -151,6 +153,11 @@ export interface Pane { export type WorkspaceRunState = NonNullable<Pane["workspaceRun"]>["state"]; +// TODO: `initialFiles` stores base64 data URLs inline. This bloats +// the pane layout state in localStorage (v2WorkspaceLocalState +// collection). Migrate to IndexedDB blob storage — store file +// references here, actual blobs in IndexedDB keyed by session/pane ID. +// See renderer/lib/pending-attachment-store.ts for the IndexedDB pattern. export interface ChatLaunchConfig { initialPrompt?: string; draftInput?: string; @@ -215,6 +222,19 @@ export interface DevToolsPaneState { targetPaneId: string; } +/** + * Comment pane-specific properties (PR review / conversation comment viewer) + */ +export interface CommentPaneState { + commentId: string; + authorLogin: string; + avatarUrl?: string; + body: string; + url?: string; + path?: string; + line?: number; +} + /** * Base Tab interface - shared fields without layout */ diff --git a/apps/desktop/src/shared/themes/editor-theme.ts b/apps/desktop/src/shared/themes/editor-theme.ts index d2b88c58579..1994d695692 100644 --- a/apps/desktop/src/shared/themes/editor-theme.ts +++ b/apps/desktop/src/shared/themes/editor-theme.ts @@ -15,10 +15,7 @@ export function getEditorTheme(theme: Theme): EditorTheme { cursor: terminal?.cursor ?? theme.ui.foreground, gutterBackground: theme.ui.background, gutterForeground: theme.ui.mutedForeground, - activeLine: withAlpha( - theme.ui.foreground, - theme.type === "dark" ? 0.04 : 0.06, - ), + activeLine: withAlpha(theme.ui.accent, 0.5), selection: terminal?.selectionBackground ?? withAlpha(theme.ui.primary, theme.type === "dark" ? 0.28 : 0.18), diff --git a/apps/desktop/src/shared/themes/index.ts b/apps/desktop/src/shared/themes/index.ts index 7de01dbb6a7..dcd057c3f3c 100644 --- a/apps/desktop/src/shared/themes/index.ts +++ b/apps/desktop/src/shared/themes/index.ts @@ -26,3 +26,4 @@ export { getDefaultTerminalColors, getTerminalColors, } from "./types"; +export { withAlpha } from "./utils"; diff --git a/apps/desktop/src/shared/types/ports.ts b/apps/desktop/src/shared/types/ports.ts index 967a9a2a20d..1ceaec4c373 100644 --- a/apps/desktop/src/shared/types/ports.ts +++ b/apps/desktop/src/shared/types/ports.ts @@ -1,12 +1,6 @@ -export interface DetectedPort { - port: number; - pid: number; - processName: string; - paneId: string; - workspaceId: string; - detectedAt: number; - address: string; -} +export type { DetectedPort } from "@superset/port-scanner"; + +import type { DetectedPort } from "@superset/port-scanner"; export interface StaticPort { port: number; @@ -22,4 +16,7 @@ export interface StaticPortsResult { export interface EnrichedPort extends DetectedPort { label: string | null; + // null → port belongs to the local Electron port manager. + // string → URL of the remote host-service that owns this port; kill routes there. + hostUrl: string | null; } diff --git a/apps/desktop/src/shared/utils/agent-launch-request.test.ts b/apps/desktop/src/shared/utils/agent-launch-request.test.ts deleted file mode 100644 index 0b880eeab2f..00000000000 --- a/apps/desktop/src/shared/utils/agent-launch-request.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - buildPromptAgentLaunchRequest, - buildTaskAgentLaunchRequest, -} from "./agent-launch-request"; -import { - indexResolvedAgentConfigs, - resolveAgentConfigs, -} from "./agent-settings"; - -const TASK = { - id: "task-1", - slug: "demo-task", - title: "Demo Task", - description: null, - priority: "medium", - statusName: "Todo", - labels: ["desktop"], -}; - -describe("buildPromptAgentLaunchRequest", () => { - test("returns null for no selection", () => { - const request = buildPromptAgentLaunchRequest({ - workspaceId: "workspace-1", - source: "new-workspace", - selectedAgent: "none", - prompt: "hello", - configsById: new Map(), - }); - - expect(request).toBeNull(); - }); - - test("uses the saved no-prompt command for terminal agents", () => { - const configsById = indexResolvedAgentConfigs(resolveAgentConfigs({})); - const request = buildPromptAgentLaunchRequest({ - workspaceId: "workspace-1", - source: "new-workspace", - selectedAgent: "codex", - prompt: "", - configsById, - }); - - expect(request).toMatchObject({ - kind: "terminal", - agentType: "codex", - terminal: { - command: - 'codex -c model_reasoning_effort="high" --dangerously-bypass-approvals-and-sandbox -c model_reasoning_summary="detailed" -c model_supports_reasoning_summaries=true', - }, - }); - }); - - test("passes files and task slug through for chat agents", () => { - const configsById = indexResolvedAgentConfigs(resolveAgentConfigs({})); - const request = buildPromptAgentLaunchRequest({ - workspaceId: "workspace-1", - source: "new-workspace", - selectedAgent: "superset-chat", - prompt: "hello", - initialFiles: [ - { - data: "data:text/plain;base64,aGVsbG8=", - mediaType: "text/plain", - filename: "hello.txt", - }, - ], - taskSlug: "demo-task", - configsById, - }); - - expect(request).toMatchObject({ - kind: "chat", - agentType: "superset-chat", - chat: { - initialPrompt: "hello", - initialFiles: [ - { - data: "data:text/plain;base64,aGVsbG8=", - mediaType: "text/plain", - filename: "hello.txt", - }, - ], - taskSlug: "demo-task", - }, - }); - }); -}); - -describe("buildTaskAgentLaunchRequest", () => { - test("returns null for no selection", () => { - const request = buildTaskAgentLaunchRequest({ - workspaceId: "workspace-1", - source: "open-in-workspace", - selectedAgent: "none", - task: TASK, - autoRun: false, - configsById: new Map(), - }); - - expect(request).toBeNull(); - }); - - test("uses the chat template configured for superset chat", () => { - const configsById = indexResolvedAgentConfigs( - resolveAgentConfigs({ - overrideEnvelope: { - version: 1, - presets: [ - { - id: "superset-chat", - taskPromptTemplate: "Chat {{title}} / {{slug}}", - }, - ], - }, - }), - ); - const request = buildTaskAgentLaunchRequest({ - workspaceId: "workspace-1", - source: "open-in-workspace", - selectedAgent: "superset-chat", - task: TASK, - autoRun: true, - configsById, - }); - - expect(request).toMatchObject({ - kind: "chat", - chat: { - initialPrompt: "Chat Demo Task / demo-task", - autoExecute: true, - taskSlug: "demo-task", - }, - }); - }); - - test("builds terminal task launches from resolved config", () => { - const configsById = indexResolvedAgentConfigs( - resolveAgentConfigs({ - overrideEnvelope: { - version: 1, - presets: [ - { - id: "codex", - taskPromptTemplate: "Implement {{slug}}", - }, - ], - }, - }), - ); - const request = buildTaskAgentLaunchRequest({ - workspaceId: "workspace-1", - source: "open-in-workspace", - selectedAgent: "codex", - task: TASK, - autoRun: false, - configsById, - }); - - expect(request).toMatchObject({ - kind: "terminal", - terminal: { - taskPromptContent: "Implement demo-task", - taskPromptFileName: "task-demo-task.md", - autoExecute: false, - }, - }); - }); - - test("rejects disabled agents", () => { - const configsById = indexResolvedAgentConfigs( - resolveAgentConfigs({ - overrideEnvelope: { - version: 1, - presets: [ - { - id: "codex", - enabled: false, - }, - ], - }, - }), - ); - - expect(() => - buildTaskAgentLaunchRequest({ - workspaceId: "workspace-1", - source: "open-in-workspace", - selectedAgent: "codex", - task: TASK, - autoRun: false, - configsById, - }), - ).toThrow('Agent "codex" is disabled'); - }); -}); diff --git a/apps/desktop/src/shared/utils/agent-settings.test.ts b/apps/desktop/src/shared/utils/agent-settings.test.ts deleted file mode 100644 index 5b5c671c8f2..00000000000 --- a/apps/desktop/src/shared/utils/agent-settings.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { getBuiltinAgentDefinition } from "@superset/shared/agent-catalog"; -import { - createOverrideEnvelopeWithPatch, - resolveAgentConfigs, -} from "./agent-settings"; - -describe("resolveAgentConfigs", () => { - test("resolves built-in terminal and chat configs with overrides", () => { - const presets = resolveAgentConfigs({ - overrideEnvelope: { - version: 1, - presets: [ - { - id: "claude", - label: "Claude Custom", - command: "claude-custom", - promptCommand: "claude-custom --prompt", - enabled: false, - }, - { - id: "superset-chat", - taskPromptTemplate: "Chat {{slug}}", - }, - ], - }, - }); - - const claude = presets.find((preset) => preset.id === "claude"); - const chat = presets.find((preset) => preset.id === "superset-chat"); - - expect(claude).toMatchObject({ - id: "claude", - kind: "terminal", - label: "Claude Custom", - command: "claude-custom", - promptCommand: "claude-custom --prompt", - enabled: false, - }); - expect(claude?.overriddenFields).toEqual( - expect.arrayContaining(["label", "command", "promptCommand", "enabled"]), - ); - - expect(chat).toMatchObject({ - id: "superset-chat", - kind: "chat", - taskPromptTemplate: "Chat {{slug}}", - }); - }); - - test("includes pi as a built-in terminal config", () => { - const pi = resolveAgentConfigs({}).find((preset) => preset.id === "pi"); - - expect(pi).toMatchObject({ - id: "pi", - kind: "terminal", - label: "Pi", - command: "pi", - promptCommand: "pi", - enabled: true, - }); - }); -}); - -describe("createOverrideEnvelopeWithPatch", () => { - test("drops fields that match defaults and persists explicit clears", () => { - const definition = getBuiltinAgentDefinition("claude"); - const overrides = createOverrideEnvelopeWithPatch({ - definition, - currentOverrides: { - version: 1, - presets: [], - }, - id: "claude", - patch: { - label: definition.defaultLabel, - description: null, - }, - }); - - expect(overrides).toEqual({ - version: 1, - presets: [ - { - id: "claude", - description: null, - }, - ], - }); - }); - - test("preserves unrelated existing overrides when patching one field", () => { - const definition = getBuiltinAgentDefinition("claude"); - const overrides = createOverrideEnvelopeWithPatch({ - definition, - currentOverrides: { - version: 1, - presets: [ - { - id: "claude", - enabled: false, - command: "claude-custom", - }, - ], - }, - id: "claude", - patch: { - label: "Claude Team", - }, - }); - - expect(overrides).toEqual({ - version: 1, - presets: [ - { - id: "claude", - enabled: false, - command: "claude-custom", - label: "Claude Team", - }, - ], - }); - }); -}); diff --git a/apps/desktop/src/shared/utils/agent-settings.ts b/apps/desktop/src/shared/utils/agent-settings.ts deleted file mode 100644 index ecfaa50cc80..00000000000 --- a/apps/desktop/src/shared/utils/agent-settings.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { - type AgentCustomDefinition, - type AgentPresetField, - type AgentPresetOverride, - type AgentPresetOverrideEnvelope, - agentCustomDefinitionSchema, - agentPresetOverrideEnvelopeSchema, -} from "@superset/local-db"; -import { - type AgentDefinition, - type AgentDefinitionId, - BUILTIN_AGENT_DEFINITIONS, - isTerminalAgentDefinition, - type TerminalAgentDefinition, -} from "@superset/shared/agent-catalog"; -import type { TaskInput } from "@superset/shared/agent-command"; -import { - DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, - DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, - getSupportedTaskPromptVariables, - renderTaskPromptTemplate, - validateTaskPromptTemplate, -} from "@superset/shared/agent-prompt-template"; - -const TERMINAL_OVERRIDE_FIELDS = [ - "enabled", - "label", - "description", - "command", - "promptCommand", - "promptCommandSuffix", - "taskPromptTemplate", -] as const satisfies readonly AgentPresetField[]; - -const CHAT_OVERRIDE_FIELDS = [ - "enabled", - "label", - "description", - "taskPromptTemplate", - "model", -] as const satisfies readonly AgentPresetField[]; - -const EMPTY_AGENT_PRESET_OVERRIDE_ENVELOPE: AgentPresetOverrideEnvelope = { - version: 1, - presets: [], -}; - -export type TerminalResolvedAgentConfig = { - id: AgentDefinitionId; - source: "builtin" | "user"; - kind: "terminal"; - label: string; - description?: string; - enabled: boolean; - command: string; - promptCommand: string; - promptCommandSuffix?: string; - taskPromptTemplate: string; - overriddenFields: AgentPresetField[]; -}; - -export type ChatResolvedAgentConfig = { - id: AgentDefinitionId; - source: "builtin" | "user"; - kind: "chat"; - label: string; - description?: string; - enabled: boolean; - taskPromptTemplate: string; - model?: string; - overriddenFields: AgentPresetField[]; -}; - -export type ResolvedAgentConfig = - | TerminalResolvedAgentConfig - | ChatResolvedAgentConfig; - -export type AgentPresetPatch = Partial<{ - enabled: boolean; - label: string; - description: string | null; - command: string; - promptCommand: string; - promptCommandSuffix: string | null; - taskPromptTemplate: string; - model: string | null; -}>; - -function toCustomAgentDefinition( - customDefinition: AgentCustomDefinition, -): TerminalAgentDefinition { - return { - id: customDefinition.id as `custom:${string}`, - source: "user", - kind: "terminal", - defaultLabel: customDefinition.label, - defaultDescription: customDefinition.description, - defaultCommand: customDefinition.command, - defaultPromptCommand: customDefinition.promptCommand, - defaultPromptCommandSuffix: customDefinition.promptCommandSuffix, - defaultTaskPromptTemplate: customDefinition.taskPromptTemplate, - defaultEnabled: customDefinition.enabled ?? true, - }; -} - -function readCustomDefinitions( - customDefinitions: AgentCustomDefinition[] | null | undefined, -): AgentCustomDefinition[] { - return (customDefinitions ?? []).flatMap((definition) => { - const parsed = agentCustomDefinitionSchema.safeParse(definition); - return parsed.success ? [parsed.data] : []; - }); -} - -export function readAgentPresetOverrides( - overrideEnvelope: AgentPresetOverrideEnvelope | null | undefined, -): AgentPresetOverrideEnvelope { - const parsed = agentPresetOverrideEnvelopeSchema.safeParse( - overrideEnvelope ?? EMPTY_AGENT_PRESET_OVERRIDE_ENVELOPE, - ); - return parsed.success ? parsed.data : EMPTY_AGENT_PRESET_OVERRIDE_ENVELOPE; -} - -export function getAgentDefinitions( - customDefinitions: AgentCustomDefinition[] | null | undefined, -): AgentDefinition[] { - return [ - ...BUILTIN_AGENT_DEFINITIONS, - ...readCustomDefinitions(customDefinitions).map((definition) => - toCustomAgentDefinition(definition), - ), - ]; -} - -function getOverriddenFields( - override: AgentPresetOverride | undefined, - definition: AgentDefinition, -): AgentPresetField[] { - if (!override) return []; - - const fields = - definition.kind === "terminal" - ? TERMINAL_OVERRIDE_FIELDS - : CHAT_OVERRIDE_FIELDS; - - return fields.filter((field) => Object.hasOwn(override, field)); -} - -function resolveDescription( - defaultDescription: string | undefined, - override: AgentPresetOverride | undefined, -): string | undefined { - if (!override || !Object.hasOwn(override, "description")) { - return defaultDescription; - } - - return override.description ?? undefined; -} - -function resolvePromptCommandSuffix( - defaultSuffix: string | undefined, - override: AgentPresetOverride | undefined, -): string | undefined { - if (!override || !Object.hasOwn(override, "promptCommandSuffix")) { - return defaultSuffix; - } - - return override.promptCommandSuffix ?? undefined; -} - -function resolveModel( - defaultModel: string | undefined, - override: AgentPresetOverride | undefined, -): string | undefined { - if (!override || !Object.hasOwn(override, "model")) { - return defaultModel; - } - - return override.model?.trim() || undefined; -} - -function resolveAgentConfig( - definition: AgentDefinition, - override: AgentPresetOverride | undefined, -): ResolvedAgentConfig { - if (isTerminalAgentDefinition(definition)) { - return { - id: definition.id, - source: definition.source, - kind: "terminal", - label: override?.label ?? definition.defaultLabel, - description: resolveDescription(definition.defaultDescription, override), - enabled: override?.enabled ?? definition.defaultEnabled, - command: override?.command ?? definition.defaultCommand, - promptCommand: override?.promptCommand ?? definition.defaultPromptCommand, - promptCommandSuffix: resolvePromptCommandSuffix( - definition.defaultPromptCommandSuffix, - override, - ), - taskPromptTemplate: - override?.taskPromptTemplate ?? definition.defaultTaskPromptTemplate, - overriddenFields: getOverriddenFields(override, definition), - }; - } - - return { - id: definition.id, - source: definition.source, - kind: "chat", - label: override?.label ?? definition.defaultLabel, - description: resolveDescription(definition.defaultDescription, override), - enabled: override?.enabled ?? definition.defaultEnabled, - taskPromptTemplate: - override?.taskPromptTemplate ?? definition.defaultTaskPromptTemplate, - model: resolveModel(definition.defaultModel, override), - overriddenFields: getOverriddenFields(override, definition), - }; -} - -export function resolveAgentConfigs({ - customDefinitions, - overrideEnvelope, -}: { - customDefinitions?: AgentCustomDefinition[] | null; - overrideEnvelope?: AgentPresetOverrideEnvelope | null; -}): ResolvedAgentConfig[] { - const overridesById = new Map( - readAgentPresetOverrides(overrideEnvelope).presets.map((preset) => [ - preset.id, - preset, - ]), - ); - - return getAgentDefinitions(customDefinitions).map((definition) => - resolveAgentConfig(definition, overridesById.get(definition.id)), - ); -} - -export function getAgentDefinitionById({ - customDefinitions, - id, -}: { - customDefinitions?: AgentCustomDefinition[] | null; - id: AgentDefinitionId; -}): AgentDefinition | null { - return ( - getAgentDefinitions(customDefinitions).find( - (definition) => definition.id === id, - ) ?? null - ); -} - -export function indexResolvedAgentConfigs( - configs: ResolvedAgentConfig[], -): Map<AgentDefinitionId, ResolvedAgentConfig> { - return new Map(configs.map((config) => [config.id, config])); -} - -export function getEnabledAgentConfigs( - configs: ResolvedAgentConfig[], -): ResolvedAgentConfig[] { - return configs.filter((config) => config.enabled); -} - -export function getFallbackAgentId( - configs: ResolvedAgentConfig[], -): AgentDefinitionId | null { - const enabledConfigs = getEnabledAgentConfigs(configs); - if (enabledConfigs.length === 0) return null; - - const preferredClaude = enabledConfigs.find( - (config) => config.id === "claude", - ); - return preferredClaude?.id ?? enabledConfigs[0]?.id ?? null; -} - -function buildHeredoc( - prompt: string, - delimiter: string, - command: string, - suffix?: string, -): string { - const closing = suffix ? `)" ${suffix}` : ')"'; - return [ - `${command} "$(cat <<'${delimiter}'`, - prompt, - delimiter, - closing, - ].join("\n"); -} - -function buildFileCommand( - filePath: string, - command: string, - suffix?: string, -): string { - const escapedPath = filePath.replaceAll("'", "'\\''"); - return `${command} "$(cat '${escapedPath}')"${suffix ? ` ${suffix}` : ""}`; -} - -export function getCommandFromAgentConfig( - config: TerminalResolvedAgentConfig, -): string | null { - const command = config.command.trim(); - return command.length > 0 ? command : null; -} - -export function buildPromptCommandFromAgentConfig({ - prompt, - randomId, - config, -}: { - prompt: string; - randomId: string; - config: TerminalResolvedAgentConfig; -}): string | null { - const promptCommand = config.promptCommand.trim() || config.command.trim(); - if (!promptCommand) return null; - - let delimiter = `SUPERSET_PROMPT_${randomId.replaceAll("-", "")}`; - while (prompt.includes(delimiter)) { - delimiter = `${delimiter}_X`; - } - - const suffix = config.promptCommandSuffix?.trim() || undefined; - return buildHeredoc(prompt, delimiter, promptCommand, suffix); -} - -export function buildFileCommandFromAgentConfig({ - filePath, - config, -}: { - filePath: string; - config: TerminalResolvedAgentConfig; -}): string | null { - const promptCommand = config.promptCommand.trim() || config.command.trim(); - if (!promptCommand) return null; - - const suffix = config.promptCommandSuffix?.trim() || undefined; - return buildFileCommand(filePath, promptCommand, suffix); -} - -export function buildDefaultTerminalTaskPrompt(task: TaskInput): string { - return renderTaskPromptTemplate(DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, task); -} - -export function buildDefaultChatTaskPrompt(task: TaskInput): string { - return renderTaskPromptTemplate(DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, task); -} - -export { - DEFAULT_CHAT_TASK_PROMPT_TEMPLATE, - DEFAULT_TERMINAL_TASK_PROMPT_TEMPLATE, - getSupportedTaskPromptVariables, - renderTaskPromptTemplate, - validateTaskPromptTemplate, -}; - -export function createOverrideEnvelopeWithPatch({ - definition, - currentOverrides, - id, - patch, -}: { - definition: AgentDefinition; - currentOverrides: AgentPresetOverrideEnvelope | null | undefined; - id: AgentDefinitionId; - patch: AgentPresetPatch; -}): AgentPresetOverrideEnvelope { - const envelope = readAgentPresetOverrides(currentOverrides); - const nextOverrides = new Map( - envelope.presets.map((preset) => [preset.id, preset]), - ); - const current = nextOverrides.get(id) ?? { id }; - const next: AgentPresetOverride = { ...current, id }; - - const setOrDelete = ( - field: keyof AgentPresetOverride, - value: AgentPresetOverride[keyof AgentPresetOverride], - shouldPersist: boolean, - ) => { - if (shouldPersist) { - (next as Record<string, unknown>)[field] = value; - return; - } - delete (next as Record<string, unknown>)[field]; - }; - - const hasField = <TField extends keyof AgentPresetPatch>(field: TField) => - Object.hasOwn(patch, field); - - if (hasField("enabled")) { - setOrDelete( - "enabled", - patch.enabled, - patch.enabled !== definition.defaultEnabled, - ); - } - if (hasField("label")) { - setOrDelete("label", patch.label, patch.label !== definition.defaultLabel); - } - if (hasField("description")) { - const defaultDescription = definition.defaultDescription; - const shouldPersist = - patch.description === null - ? defaultDescription !== undefined - : patch.description !== defaultDescription; - setOrDelete("description", patch.description, shouldPersist); - } - if (hasField("taskPromptTemplate")) { - setOrDelete( - "taskPromptTemplate", - patch.taskPromptTemplate, - patch.taskPromptTemplate !== definition.defaultTaskPromptTemplate, - ); - } - - if (definition.kind === "terminal") { - if (hasField("command")) { - setOrDelete( - "command", - patch.command, - patch.command !== definition.defaultCommand, - ); - } - if (hasField("promptCommand")) { - setOrDelete( - "promptCommand", - patch.promptCommand, - patch.promptCommand !== definition.defaultPromptCommand, - ); - } - if (hasField("promptCommandSuffix")) { - const shouldPersist = - patch.promptCommandSuffix === null - ? definition.defaultPromptCommandSuffix !== undefined - : patch.promptCommandSuffix !== definition.defaultPromptCommandSuffix; - setOrDelete( - "promptCommandSuffix", - patch.promptCommandSuffix, - shouldPersist, - ); - } - } else if (hasField("model")) { - const shouldPersist = - patch.model === null - ? definition.defaultModel !== undefined - : patch.model !== definition.defaultModel; - setOrDelete("model", patch.model ?? undefined, shouldPersist); - } - - const fields = Object.keys(next).filter((field) => field !== "id"); - if (fields.length === 0) { - nextOverrides.delete(id); - } else { - nextOverrides.set(id, next); - } - - return { - version: 1, - presets: Array.from(nextOverrides.values()), - }; -} - -export function resetAgentPresetOverride({ - currentOverrides, - id, -}: { - currentOverrides: AgentPresetOverrideEnvelope | null | undefined; - id: AgentDefinitionId; -}): AgentPresetOverrideEnvelope { - const envelope = readAgentPresetOverrides(currentOverrides); - return { - version: 1, - presets: envelope.presets.filter((preset) => preset.id !== id), - }; -} - -export function resetAllAgentPresetOverrides(): AgentPresetOverrideEnvelope { - return EMPTY_AGENT_PRESET_OVERRIDE_ENVELOPE; -} -export type { AgentDefinitionId } from "@superset/shared/agent-catalog"; diff --git a/apps/desktop/src/shared/utils/branch.ts b/apps/desktop/src/shared/utils/branch.ts deleted file mode 100644 index 46717b2cdba..00000000000 --- a/apps/desktop/src/shared/utils/branch.ts +++ /dev/null @@ -1,146 +0,0 @@ -export const DEFAULT_BRANCH_SEGMENT_MAX_LENGTH = 50; -export const DEFAULT_BRANCH_NAME_MAX_LENGTH = 100; - -interface SanitizeSegmentOptions { - preserveCase?: boolean; -} - -interface SanitizeBranchNameOptions { - preserveFirstSegmentCase?: boolean; - preserveCase?: boolean; -} - -export function sanitizeSegment( - text: string, - maxLength = DEFAULT_BRANCH_SEGMENT_MAX_LENGTH, - { preserveCase = false }: SanitizeSegmentOptions = {}, -): string { - const normalized = preserveCase ? text : text.toLowerCase(); - const allowedCharacters = preserveCase - ? /[^a-zA-Z0-9._+@-]/g - : /[^a-z0-9._+@-]/g; - - return normalized - .trim() - .replace(/\s+/g, "-") - .replace(allowedCharacters, "") - .replace(/\.{2,}/g, ".") - .replace(/@\{/g, "@") - .replace(/-+/g, "-") - .replace(/^[-.]|[-.]+$/g, "") - .replace(/\.lock$/g, "") - .slice(0, maxLength); -} - -export function sanitizeAuthorPrefix(name: string): string { - return sanitizeSegment(name, DEFAULT_BRANCH_SEGMENT_MAX_LENGTH, { - preserveCase: true, - }); -} - -export function sanitizeBranchName( - name: string, - { - preserveFirstSegmentCase = false, - preserveCase = false, - }: SanitizeBranchNameOptions = {}, -): string { - return name - .split("/") - .map((segment, index) => - sanitizeSegment(segment, DEFAULT_BRANCH_SEGMENT_MAX_LENGTH, { - preserveCase: preserveCase || (preserveFirstSegmentCase && index === 0), - }), - ) - .filter(Boolean) - .join("/"); -} - -export function truncateBranchName( - branchName: string, - maxLength = DEFAULT_BRANCH_NAME_MAX_LENGTH, -): string { - return branchName.slice(0, maxLength).replace(/\/+$/g, ""); -} - -export function sanitizeBranchNameWithMaxLength( - name: string, - maxLength = DEFAULT_BRANCH_NAME_MAX_LENGTH, - options?: SanitizeBranchNameOptions, -): string { - return truncateBranchName(sanitizeBranchName(name, options), maxLength); -} - -/** - * Returns a branch name that does not collide with existing names. - * If the candidate already exists, appends numeric suffixes (-1, -2, ...) - * to the last path segment until an available name is found. - */ -export function deduplicateBranchName( - candidate: string, - existingBranchNames: string[], -): string { - const normalizedCandidate = candidate.trim(); - if (!normalizedCandidate) { - return normalizedCandidate; - } - - const existingSet = new Set(existingBranchNames.map((b) => b.toLowerCase())); - if (!existingSet.has(normalizedCandidate.toLowerCase())) { - return normalizedCandidate; - } - - const segments = normalizedCandidate.split("/"); - const lastSegment = segments.at(-1) ?? normalizedCandidate; - const prefix = segments.slice(0, -1).join("/"); - - const strippedBase = lastSegment.replace(/-\d+$/, ""); - const baseSegment = strippedBase || lastSegment; - const append = (suffix: number) => - prefix ? `${prefix}/${baseSegment}-${suffix}` : `${baseSegment}-${suffix}`; - - for (let suffix = 1; suffix < 10_000; suffix++) { - const deduplicated = append(suffix); - if (!existingSet.has(deduplicated.toLowerCase())) { - return deduplicated; - } - } - - return prefix - ? `${prefix}/${baseSegment}-${Date.now()}` - : `${baseSegment}-${Date.now()}`; -} - -export function resolveBranchPrefix({ - mode, - customPrefix, - authorPrefix, - githubUsername, -}: { - mode: "github" | "author" | "custom" | "none" | null | undefined; - customPrefix?: string | null; - authorPrefix?: string | null; - githubUsername?: string | null; -}): string | null { - let prefix: string | null = null; - switch (mode) { - case "none": - return null; - case "custom": - prefix = customPrefix || null; - break; - case "author": - prefix = authorPrefix || null; - break; - case "github": - prefix = githubUsername || authorPrefix || null; - break; - default: - return null; - } - return prefix - ? sanitizeSegment(prefix, DEFAULT_BRANCH_SEGMENT_MAX_LENGTH, { - preserveCase: true, - }) - : null; -} diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index f9c6f088c21..8b5b107685a 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -33,6 +33,8 @@ const mockHead = { // biome-ignore lint/suspicious/noExplicitAny: Test setup requires extending globalThis (globalThis as any).document = { + addEventListener: mock(() => {}), + removeEventListener: mock(() => {}), documentElement: { style: { setProperty: (key: string, value: string) => mockStyleMap.set(key, value), @@ -65,6 +67,19 @@ const mockHead = { })), }; +// Ensure window has addEventListener/removeEventListener for react-hotkeys-hook's IIFE +if (typeof globalThis.window !== "undefined") { + const win = globalThis.window as Record<string, unknown>; + if (!win.addEventListener) win.addEventListener = mock(() => {}); + if (!win.removeEventListener) win.removeEventListener = mock(() => {}); +} else { + // biome-ignore lint/suspicious/noExplicitAny: Test setup requires extending globalThis + (globalThis as any).window = { + addEventListener: mock(() => {}), + removeEventListener: mock(() => {}), + }; +} + // ============================================================================= // Electron Preload Mocks (exposed via contextBridge in real app) // ============================================================================= @@ -161,6 +176,8 @@ const agentPresetOverrideSchema = z.object({ promptCommand: z.string().optional(), promptCommandSuffix: z.string().nullable().optional(), taskPromptTemplate: z.string().optional(), + contextPromptTemplateSystem: z.string().optional(), + contextPromptTemplateUser: z.string().optional(), model: z.string().optional(), }); @@ -175,9 +192,12 @@ const agentCustomDefinitionSchema = z.object({ label: z.string(), description: z.string().optional(), command: z.string(), - promptCommand: z.string(), + promptCommand: z.string().optional(), promptCommandSuffix: z.string().optional(), + promptTransport: z.enum(["argv", "stdin"]).optional(), taskPromptTemplate: z.string(), + contextPromptTemplateSystem: z.string().optional(), + contextPromptTemplateUser: z.string().optional(), enabled: z.boolean().optional(), }); @@ -194,6 +214,7 @@ const localDbMock = () => ({ agentPresetOverrideSchema, agentPresetOverrideEnvelopeSchema, agentCustomDefinitionSchema, + PROMPT_TRANSPORTS: ["argv", "stdin"], EXTERNAL_APPS: [], EXECUTION_MODES: ["sequential", "parallel"], BRANCH_PREFIX_MODES: ["none", "github", "author", "custom"], diff --git a/apps/desktop/vite/helpers.ts b/apps/desktop/vite/helpers.ts index 93aada3766f..b2552e2dba6 100644 --- a/apps/desktop/vite/helpers.ts +++ b/apps/desktop/vite/helpers.ts @@ -88,6 +88,10 @@ export function htmlEnvTransformPlugin(): Plugin { .replace( /%NEXT_PUBLIC_STREAMS_URL%/g, process.env.NEXT_PUBLIC_STREAMS_URL || "https://streams.superset.sh", + ) + .replace( + /%RELAY_URL%/g, + process.env.RELAY_URL || "https://relay.superset.sh", ); }, }; diff --git a/apps/docs/content/docs/agent-integration.mdx b/apps/docs/content/docs/agent-integration.mdx index 6461bc89b6e..5b8a9fad693 100644 --- a/apps/docs/content/docs/agent-integration.mdx +++ b/apps/docs/content/docs/agent-integration.mdx @@ -11,6 +11,7 @@ Run AI coding agents in isolated workspaces. Each agent works independently with ## Supported Agents +- **Amp** - Amp Code CLI - **Claude Code** - Anthropic's CLI - **Codex** - OpenAI's assistant - **OpenCode** - Open-source alternative diff --git a/apps/docs/content/docs/automations.mdx b/apps/docs/content/docs/automations.mdx new file mode 100644 index 00000000000..56fabc8df76 --- /dev/null +++ b/apps/docs/content/docs/automations.mdx @@ -0,0 +1,77 @@ +--- +title: Automations +description: Schedule agent sessions to run on a recurring basis +--- + +## Overview + +Automations run an agent session against a project on a schedule — like a cron job, but the output is a live workspace you (or a teammate) can open, review, and continue interactively. + +Typical uses: + +- **Standups** — a nightly summary of PRs, issues, and activity +- **Release notes** — generate a draft from merged commits +- **Audits** — scan for security issues or deprecated APIs each morning +- **Dependency sweeps** — check for outdated packages weekly + +## Requirements + +- **Paid plan** — Automations are only available on paid plans. The sidebar entry is hidden for free-plan orgs. +- **A v2 project** — Automations run against a project that's already linked to a GitHub repo. +- **A target device** — The run fires on the device you pick when creating the automation. + +## Create an Automation + +Click **Automations → New automation** and fill in: + +- **Title** — how the automation appears in the list +- **Prompt** — the instructions the agent will receive at dispatch time. Supports markdown, `@file/path` mentions (scoped to the selected project), `:emoji:` shortcodes, and `/` slash commands. +- **Device** — which host runs the session. Defaults to this device. +- **Project** — the v2 project the session runs against. +- **Schedule** — pick a preset or enter a custom RRule. All times are in your local timezone. +- **Agent** — Claude, Codex, Amp, etc. — anything you've set up under **Settings → Agents**. + +## Schedules + +Schedules are stored as [RFC 5545 RRules](https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5). The schedule picker has presets for common cadences; use **Custom RRule…** for anything else. + +Examples: + +- `FREQ=DAILY;BYHOUR=9;BYMINUTE=0` — every day at 9:00 AM +- `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0` — weekdays at 9:00 AM +- `FREQ=WEEKLY;BYDAY=FR;BYHOUR=17;BYMINUTE=0` — every Friday at 5:00 PM + +## Runs + +Each time an automation fires, a **run** is recorded. Runs show up on the automation's detail page with status: + +- **dispatched** — the session was successfully handed off to the target device +- **skipped (offline)** — the target device wasn't online at fire time; the run was skipped and the schedule advanced +- **dispatch failed** — something broke during handoff; check the error message + +When a run dispatches in "new workspace" mode, a fresh workspace is created from the project's repo for that run. Open it from the run row to see the agent's work. + +## Pause, Run Now, Delete + +From the automation's detail page: + +- **Pause** — stops scheduling until you resume. Existing runs are kept. +- **Run now** — dispatches immediately. The run appears in **Previous runs** within seconds. +- **Delete** — removes the automation and its run history. + +## Limits + +- **At-least-once delivery** — Automations may dispatch more than once in rare cases (e.g. dispatcher retries). Design prompts that are safe to re-run. +- **No completion tracking (v1)** — Runs land at `dispatched`; the detail page doesn't show success/failure of the agent itself. Open the workspace to see what happened. +- **Offline hosts skip silently** — If your device is offline at fire time, the run is marked `skipped_offline` and the next occurrence is scheduled normally. +- **One prompt per automation** — Each automation has one prompt. To run multiple prompts on a schedule, create multiple automations. + +## CLI + +```bash +superset automations list +superset automations create --name "Nightly audit" --rrule "FREQ=DAILY;BYHOUR=3;BYMINUTE=0" --project <projectId> --prompt-file prompt.md --agent claude +superset automations run <id> +superset automations logs <id> +superset automations delete <id> +``` diff --git a/apps/docs/content/docs/custom-themes.mdx b/apps/docs/content/docs/custom-themes.mdx index 40926980212..2e87877c751 100644 --- a/apps/docs/content/docs/custom-themes.mdx +++ b/apps/docs/content/docs/custom-themes.mdx @@ -3,16 +3,20 @@ title: Custom Themes description: Build, edit, and import your own Superset theme files --- -You can create your own Superset theme file and import it from desktop settings. +import { COMPANY } from "@superset/shared/constants"; + +You can import a theme file from the Marketplace or create your own from a base file in desktop settings. ## Quick Workflow 1. Open `Settings → Appearance → Theme`. -2. Click `Download Base File`. -3. Edit the JSON manually or ask an agent to customize it. +2. Either click `Marketplace` to download a shared theme, or click `Download Base File` to start from your current theme. +3. If you downloaded a base file, edit the JSON manually or ask an agent to customize it. 4. Click `Import Theme` and select the file. 5. Pick the imported theme from the theme grid. +Marketplace themes are available from the <a href={`${COMPANY.MARKETING_URL}/marketplace/themes`}>Marketplace</a> page or the `Marketplace` link in `Settings → Appearance → Theme`. + ## Ask An Agent To Customize It After downloading the base file, you can prompt an agent with something like: diff --git a/apps/docs/content/docs/customization.mdx b/apps/docs/content/docs/customization.mdx index 72105423c1a..836c8c784f0 100644 --- a/apps/docs/content/docs/customization.mdx +++ b/apps/docs/content/docs/customization.mdx @@ -42,9 +42,9 @@ Settings → Terminal → Manage Presets ## Custom Themes -Download a base theme JSON, ask an agent to customize it, then import it in desktop settings. +Download a theme from the Marketplace, or start from a base theme JSON and customize it before importing. -Settings → Appearance → Theme → Download Base File / Import Theme +Settings → Appearance → Theme → Marketplace / Download Base File / Import Theme See full guide: [Custom Themes](/custom-themes) diff --git a/apps/docs/content/docs/mcp.mdx b/apps/docs/content/docs/mcp.mdx index 38730a3536f..93f0b1ec346 100644 --- a/apps/docs/content/docs/mcp.mdx +++ b/apps/docs/content/docs/mcp.mdx @@ -15,13 +15,18 @@ Superset provides an [MCP (Model Context Protocol)](https://modelcontextprotocol | **Workspaces** | Create, update, switch, delete, list, navigate workspaces | | **Devices** | List devices, projects, and app context | | **Organization** | List members and task statuses | -| **AI Sessions** | Start autonomous AI agent sessions (Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent) and subagents | +| **AI Sessions** | Start autonomous AI agent sessions (Amp, Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent) and subagents | ## Setup ### CLI Options -<Tabs items={["Claude Code", "Codex", "Gemini CLI", "Open Code"]}> +<Tabs items={["Amp CLI", "Claude Code", "Codex", "Gemini CLI", "Open Code"]}> +<Tab value="Amp CLI"> +```bash title="terminal" +amp mcp add --workspace superset https://api.superset.sh/api/agent/mcp +``` +</Tab> <Tab value="Claude Code"> ```bash title="terminal" claude mcp add superset --transport http https://api.superset.sh/api/agent/mcp @@ -72,7 +77,20 @@ opencode mcp add Alternatively, you can manually configure the MCP server for each client: -<Tabs items={["Claude Code", "Claude Desktop", "Cursor", "Codex", "Gemini CLI", "Open Code"]}> +<Tabs items={["Amp CLI", "Claude Code", "Claude Desktop", "Cursor", "Codex", "Gemini CLI", "Open Code"]}> +<Tab value="Amp CLI"> +Add to `.amp/settings.json` in your project root: + +```json title=".amp/settings.json" +{ + "amp.mcpServers": { + "superset": { + "url": "https://api.superset.sh/api/agent/mcp" + } + } +} +``` +</Tab> <Tab value="Claude Code"> Add a `.mcp.json` to your project root. Claude Code auto-discovers this file and handles OAuth authentication. @@ -230,7 +248,7 @@ API keys grant full access to your organization. Keep them secret and never comm | Tool | Description | |------|-------------| -| `list_devices` | List online devices in your organization | +| `list_devices` | List registered devices in your organization | | `list_projects` | List all projects on a device | | `get_app_context` | Get current app state (active workspace, pathname) | | `list_members` | List organization members | @@ -239,12 +257,12 @@ API keys grant full access to your organization. Keep them secret and never comm | Tool | Description | |------|-------------| -| `start_agent_session` | Start an autonomous AI agent session for a task in an existing workspace. Requires `taskId`. Supports Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent, and Superset Chat (defaults to Claude). When `paneId` is provided, adds a new pane to the tab containing that pane instead of initializing a new tab. | -| `start_agent_session_with_prompt` | Start an autonomous AI agent session in an existing workspace using a direct `prompt` instead of a task. Supports Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent, and Superset Chat (defaults to Claude). When `paneId` is provided, adds a new pane to the tab containing that pane instead of initializing a new tab. | +| `start_agent_session` | Start an autonomous AI agent session for a task in an existing workspace. Requires `taskId`. Supports Amp, Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent, and Superset Chat (defaults to Claude). When `paneId` is provided, adds a new pane to the tab containing that pane instead of initializing a new tab. | +| `start_agent_session_with_prompt` | Start an autonomous AI agent session in an existing workspace using a direct `prompt` instead of a task. Supports Amp, Claude, Codex, Gemini, OpenCode, Pi, Copilot, Cursor Agent, and Superset Chat (defaults to Claude). When `paneId` is provided, adds a new pane to the tab containing that pane instead of initializing a new tab. | ## Chat Integration -In the built-in chat panel, use the `/mcp` slash command to see your workspace's configured MCP servers — their names, transport type (local/remote), and current state (enabled/disabled/invalid). This reads from your workspace MCP config (`.mastracode/mcp.json` or `.mcp.json`). +In the built-in chat panel, use the `/mcp` slash command to see your workspace's configured MCP servers — their names, transport type (local/remote), and current state (enabled/disabled/invalid). This reads from your workspace MCP config (`.mastracode/mcp.json`, `.amp/settings.json`, or `.mcp.json`). ## Example Usage diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index 438533732bf..0394824d20d 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -12,6 +12,7 @@ "ports", "browser", "agent-integration", + "automations", "mcp", "---BookOpen Guides---", "setup-teardown-scripts", diff --git a/apps/docs/content/docs/ports.mdx b/apps/docs/content/docs/ports.mdx index 668b5e99498..3ec399859ed 100644 --- a/apps/docs/content/docs/ports.mdx +++ b/apps/docs/content/docs/ports.mdx @@ -14,14 +14,16 @@ Superset does not assign per-workspace port ranges. It discovers listening ports - **View active ports** - See which processes are using which ports - **Kill processes** - Stop a process by clicking its port - **Workspace grouping** - Ports are grouped by the workspace that owns the process +- **Terminal focus** - Select a port to jump back to the terminal that owns it +- **Browser actions** - Open local web servers in the in-app browser or externally from the port action -## Static Port Configuration +## Port Labels -Override automatic port discovery with a static configuration file. Useful for: +Add friendly names to automatically detected ports with a workspace +configuration file. Useful for: -- Documenting ports that aren't auto-detected (databases, external services) - Providing meaningful labels for your team -- Projects where dynamic scanning doesn't work well +- Making common dev-server ports easier to scan in the UI Create `.superset/ports.json` in your repository: @@ -40,18 +42,26 @@ Create `.superset/ports.json` in your repository: - `label` - Display text shown in tooltip **Behavior:** -- Static config replaces dynamic port discovery -- Each workspace reads from its own worktree's file +- Dynamic port discovery is still authoritative +- `ports.json` only labels ports that Superset already detects as listening +- Ports without a matching label still appear +- Label entries for ports that are not currently listening are ignored +- Each workspace reads labels from its own worktree's file - Changes are detected automatically -- Ports open `localhost:PORT` in browser when clicked +- Ports can be opened at `localhost:PORT` from the browser action **Error Handling:** If `ports.json` is malformed: -- Error toast appears with details -- No ports displayed until fixed -- Dynamic detection is NOT used as fallback +- Labels from that file are ignored until fixed +- Detected ports still appear without those labels **Tips:** - Commit `.superset/ports.json` to share port labels with your team - **Pro tip:** If you want deterministic per-workspace port ranges, implement it in setup/teardown scripts by reserving a range in a shared file (for example `~/.superset/port-allocations.json`) during setup and releasing it during teardown. See this repo's examples: [`.superset/setup.sh`](https://github.com/superset-sh/superset/blob/main/.superset/setup.sh) and [`.superset/teardown.sh`](https://github.com/superset-sh/superset/blob/main/.superset/teardown.sh). + +## Discovery and Updates + +Port discovery runs in each host service, not in the desktop renderer. The host service watches terminal process trees, scans for listening ports, resolves any matching port label, and publishes `port:changed` events when ports appear or disappear. + +The desktop sidebar keeps one ports query per online host. It patches that cached host snapshot from port events, batching bursts so updates stay responsive without refetching every time a port changes. A slower fallback refetch still runs so the UI recovers if an event is missed during reconnect. diff --git a/apps/docs/content/docs/setup-teardown-scripts.mdx b/apps/docs/content/docs/setup-teardown-scripts.mdx index add4829323a..a43e048d7db 100644 --- a/apps/docs/content/docs/setup-teardown-scripts.mdx +++ b/apps/docs/content/docs/setup-teardown-scripts.mdx @@ -1,27 +1,44 @@ --- -title: Setup & Teardown Scripts -description: Automate workspace initialization +title: Setup, Teardown & Run Scripts +description: Automate workspace initialization and dev servers --- ## Overview -Run commands automatically when creating or deleting workspaces. +Run commands automatically when creating or deleting workspaces, and define dev server commands that launch via the Run button. Create `.superset/config.json` in your project: ```json { "setup": ["bun install", "cp \"$SUPERSET_ROOT_PATH/.env\" .env"], - "teardown": ["docker-compose down"] + "teardown": ["docker-compose down"], + "run": ["./.superset/run.sh"] } ``` ## How It Works -1. Create workspace → setup commands run -2. Delete workspace → teardown commands run +1. Create workspace → **setup** commands run in a terminal +2. Delete workspace → **teardown** commands run +3. Click the **Run** button → **run** commands execute in a dedicated pane -Commands run sequentially in the workspace directory. +Setup and teardown commands run sequentially in the workspace directory. + +## Run Script + +The `run` field defines commands that start your dev server or other long-running processes. Unlike setup/teardown, run commands are: + +- **On-demand** — triggered by the Run button, not automatically on workspace creation +- **Restartable** — stop and restart from the UI without recreating the workspace +- **Dedicated pane** — runs in its own terminal pane separate from your shell + +```json +{ + "setup": ["npm install"], + "run": ["./.superset/run.sh"] +} +``` ## Environment Variables @@ -29,6 +46,7 @@ Commands run sequentially in the workspace directory. |----------|-------------| | `SUPERSET_ROOT_PATH` | Path to root repository | | `SUPERSET_WORKSPACE_NAME` | Current workspace name | +| `SUPERSET_WORKSPACE_PATH` | Path to the workspace worktree | ## Examples @@ -45,9 +63,18 @@ Commands run sequentially in the workspace directory. } ``` +**Full config:** +```json +{ + "setup": ["./.superset/setup.sh"], + "teardown": ["./.superset/teardown.sh"], + "run": ["./.superset/run.sh"] +} +``` + ## User Overrides -Override project setup/teardown scripts without modifying the repo by placing a `config.json` in your home directory: +Override project scripts without modifying the repo by placing a `config.json` in your home directory: ``` ~/.superset/projects/<project-id>/config.json @@ -57,7 +84,7 @@ Where `<project-id>` is your project's unique ID in Superset (visible in the app ### Priority Order -Config is resolved in this order (first found wins, for both setup and teardown): +Config is resolved in this order (first found wins, for setup, teardown, and run): 1. `~/.superset/projects/<project-id>/config.json` — user override 2. `<worktree>/.superset/config.json` — worktree-specific 3. `<repo>/.superset/config.json` — project default @@ -142,3 +169,4 @@ When teardown fails: - Use shell scripts for complex logic: `"setup": ["./.superset/setup.sh"]` - Use `config.local.json` to add personal steps without touching the team's config - Use user overrides (`~/.superset/projects/`) to replace scripts entirely per-project +- Use `run` for dev servers instead of putting them in `setup`—run scripts are restartable and don't block workspace creation diff --git a/apps/docs/content/docs/terminal-presets.mdx b/apps/docs/content/docs/terminal-presets.mdx index 811fcf76256..6078dbcac80 100644 --- a/apps/docs/content/docs/terminal-presets.mdx +++ b/apps/docs/content/docs/terminal-presets.mdx @@ -41,14 +41,17 @@ Presets are parallel by default. ## Quick-Add Templates -Pre-configured presets for popular AI agents: - -- **claude** - `claude --dangerously-skip-permissions` -- **codex** - Full danger mode with high reasoning effort -- **gemini** - `gemini --yolo` -- **pi** - `pi` -- **cursor-agent** - Cursor AI agent -- **opencode** - Open-source AI coding agent +Pre-configured presets for popular AI agents. Defaults are safe-by-default — agents can read and edit files, but still prompt before running shell commands or touching files outside your workspace. Edit any preset to opt into a more permissive mode. + +- **amp** - `amp` (built-in permission rules auto-deny destructive ops) +- **claude** - `claude --permission-mode acceptEdits` +- **codex** - `codex ... --full-auto` (workspace-sandboxed) +- **gemini** - `gemini --approval-mode=auto_edit` +- **copilot** - `copilot --allow-tool=write` +- **cursor-agent** - `cursor-agent` (prompts for every action) +- **mastracode** - Mastra's coding agent (opt-in: auto-approves all actions by default at the CLI; no startup flag to restrict) +- **opencode** - Open-source AI coding agent (opt-in: full file and shell access by default at the CLI; no startup flag to restrict) +- **pi** - Minimal terminal coding harness (opt-in: auto-approves all actions by default at the CLI; no startup flag to restrict) ## Preset Bar diff --git a/apps/electric-proxy/package.json b/apps/electric-proxy/package.json index 9fa85989d94..0b797be11bd 100644 --- a/apps/electric-proxy/package.json +++ b/apps/electric-proxy/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@superset/db": "workspace:*", - "drizzle-orm": "0.45.1", + "drizzle-orm": "0.45.2", "jose": "^6.1.3" }, "devDependencies": { diff --git a/apps/electric-proxy/src/index.ts b/apps/electric-proxy/src/index.ts index 81960dd1dd4..fc431acd7e2 100644 --- a/apps/electric-proxy/src/index.ts +++ b/apps/electric-proxy/src/index.ts @@ -24,6 +24,7 @@ function addCorsHeaders(response: Response): Response { for (const [key, value] of Object.entries(CORS_HEADERS)) { headers.set(key, value); } + headers.set("Vary", "Authorization"); return new Response(response.body, { status: response.status, statusText: response.statusText, diff --git a/apps/electric-proxy/src/where.ts b/apps/electric-proxy/src/where.ts index b7ce63acc3c..12c31e66b20 100644 --- a/apps/electric-proxy/src/where.ts +++ b/apps/electric-proxy/src/where.ts @@ -1,5 +1,7 @@ import { agentCommands, + automationRuns, + automations, chatSessions, devicePresence, githubPullRequests, @@ -9,14 +11,13 @@ import { members, organizations, projects, - sessionHosts, subscriptions, taskStatuses, tasks, - v2DevicePresence, - v2Devices, + v2Clients, + v2Hosts, v2Projects, - v2UsersDevices, + v2UsersHosts, v2Workspaces, workspaces, } from "@superset/db/schema"; @@ -55,22 +56,14 @@ export function buildWhereClause( case "v2_projects": return build(v2Projects, v2Projects.organizationId, organizationId); - case "v2_devices": - return build(v2Devices, v2Devices.organizationId, organizationId); + case "v2_hosts": + return build(v2Hosts, v2Hosts.organizationId, organizationId); - case "v2_device_presence": - return build( - v2DevicePresence, - v2DevicePresence.organizationId, - organizationId, - ); + case "v2_clients": + return build(v2Clients, v2Clients.organizationId, organizationId); - case "v2_users_devices": - return build( - v2UsersDevices, - v2UsersDevices.organizationId, - organizationId, - ); + case "v2_users_hosts": + return build(v2UsersHosts, v2UsersHosts.organizationId, organizationId); case "v2_workspaces": return build(v2Workspaces, v2Workspaces.organizationId, organizationId); @@ -135,9 +128,6 @@ export function buildWhereClause( case "chat_sessions": return build(chatSessions, chatSessions.organizationId, organizationId); - case "session_hosts": - return build(sessionHosts, sessionHosts.organizationId, organizationId); - case "github_repositories": return build( githubRepositories, @@ -152,6 +142,16 @@ export function buildWhereClause( organizationId, ); + case "automations": + return build(automations, automations.organizationId, organizationId); + + case "automation_runs": + return build( + automationRuns, + automationRuns.organizationId, + organizationId, + ); + default: return null; } diff --git a/apps/electric-proxy/wrangler.jsonc b/apps/electric-proxy/wrangler.jsonc index 514b498c2f3..75066000e0e 100644 --- a/apps/electric-proxy/wrangler.jsonc +++ b/apps/electric-proxy/wrangler.jsonc @@ -2,5 +2,12 @@ "name": "electric-proxy", "main": "src/index.ts", "compatibility_date": "2025-01-01", - "compatibility_flags": ["request_cf_overrides_cache_rules"] + "compatibility_flags": ["request_cf_overrides_cache_rules"], + "observability": { + "enabled": true, + "head_sampling_rate": 1, + "logs": { + "invocation_logs": true + } + } } diff --git a/apps/marketing/content/changelog/2026-03-30-mobile-agents-themes-editor.mdx b/apps/marketing/content/changelog/2026-03-30-mobile-agents-themes-editor.mdx new file mode 100644 index 00000000000..7d9f5865f48 --- /dev/null +++ b/apps/marketing/content/changelog/2026-03-30-mobile-agents-themes-editor.mdx @@ -0,0 +1,41 @@ +--- +title: Mobile agents, SOC 2 pen test passed, and theme marketplace +date: 2026-03-30 +image: /changelog/mobile-agents.png +--- + +## Mobile Agents Experience (WIP) <PRBadge url="https://github.com/superset-sh/superset/pull/2595" /> + +We're building out a mobile-first agents experience in the web app. This is still a work in progress, but you can already browse, start, and monitor agents from your phone with an interface designed for smaller screens. More to come here. + +## SOC 2 Compliance & Pen Test Passed + +The big news this week: Superset now passes its independent penetration test and meets SOC 2 compliance requirements. We shipped a round of security hardening based on findings from the pen test. This is a major milestone for teams that need enterprise-grade security assurances. + +## Theme Marketplace <PRBadge url="https://github.com/superset-sh/superset/pull/2966" /> + +![Theme Marketplace](/changelog/marketplace.png) + +Browse and install themes from the new theme marketplace. When your system is set to auto mode, you can now independently configure which theme is used for light and dark <PRBadge url="https://github.com/superset-sh/superset/pull/2557" />. + +## Improvements +- **Open in editor from worktrees** - Jump straight from a worktree into your external editor <PRBadge url="https://github.com/superset-sh/superset/pull/2999" /> +- **Cmd-click file paths** - File paths in chat or output can be Cmd-clicked to open in your editor <PRBadge url="https://github.com/superset-sh/superset/pull/2903" /> +- **New workspace modal UX** - Cleaner layout with icon buttons and server-side PR search <PRBadge url="https://github.com/superset-sh/superset/pull/2980" /> <PRBadge url="https://github.com/superset-sh/superset/pull/2909" /> +- **Close workspace hotkey** - `Cmd+W` to close a workspace <PRBadge url="https://github.com/superset-sh/superset/pull/2907" /> +- **Settings search indicator** - Visual indicator when a search filter is active in settings <PRBadge url="https://github.com/superset-sh/superset/pull/2997" /> +- **Terminal flow control** - Hardened backlog and flow control to prevent output drops under heavy load <PRBadge url="https://github.com/superset-sh/superset/pull/3006" /> + +--- + +**Bug fixes** + +- Fixed PTY spawn failure leaving zombie sessions that blocked new terminals <PRBadge url="https://github.com/superset-sh/superset/pull/2963" /> +- Batched PTY output to prevent bun dev crashes in terminal <PRBadge url="https://github.com/superset-sh/superset/pull/3001" /> +- Preserved chat cursor position <PRBadge url="https://github.com/superset-sh/superset/pull/2993" /> +- Restored Codex loading state <PRBadge url="https://github.com/superset-sh/superset/pull/2998" /> +- Fixed workspace search regressions <PRBadge url="https://github.com/superset-sh/superset/pull/2979" /> +- Honored linked PR push targets <PRBadge url="https://github.com/superset-sh/superset/pull/2977" /> +- Fixed worktree deletion <PRBadge url="https://github.com/superset-sh/superset/pull/2929" /> +- Removed spurious terminal backpressure warning <PRBadge url="https://github.com/superset-sh/superset/pull/2996" /> +- Restored native text editing shortcuts in chat <PRBadge url="https://github.com/superset-sh/superset/pull/2676" /> diff --git a/apps/marketing/content/changelog/2026-04-20-v2-workspace-brand-cli.mdx b/apps/marketing/content/changelog/2026-04-20-v2-workspace-brand-cli.mdx new file mode 100644 index 00000000000..1df0c6a894b --- /dev/null +++ b/apps/marketing/content/changelog/2026-04-20-v2-workspace-brand-cli.mdx @@ -0,0 +1,177 @@ +--- +title: Chat UX overhaul, v2 early access, brand refresh, and standalone CLI +date: 2026-04-20 +image: /changelog/chat-ux.png +--- + +A big update covering the last three weeks. The chat interface got a top-to-bottom UX overhaul with a new rich-text prompt editor, slash-command and file-mention chips, and a redesigned tool-call system. The v2 workspace is now in early access, with panes, a diff viewer, a file editor, a review tab, and a browser pane. The Superset CLI shipped and then grew a self-contained tarball that can run the host-service anywhere. The desktop app got a full brand refresh, and GitHub integration is now free on every plan. + +## Chat UX overhaul <PRBadge url="https://github.com/superset-sh/superset/pull/3039" /> + +### Tiptap prompt input + +The prompt is now a ProseMirror-based rich text editor with inline chips for commands and file references. + +- **Slash command chips** — type `/` to open a command menu; the command becomes an inline chip anywhere in the message instead of submitting immediately. Chips with an `argumentHint` expose an inline editable input with auto-sizing and keyboard nav; `/model` shows a dropdown of available models. +- **File mention chips** — type `@` to open a file-search popover anchored to the cursor. Picking a file inserts a chip that serializes to `@path` on send. +- **Skill preload** — `/command` chips are extracted before the LLM turn so the harness loads the right skill first. A `SkillToolCall` row shows in the message while the skill loads. +- **Preview on hover** — hovering a chip shows a popover with its description (hidden while an arg input is focused). +- IME composition guard prevents submit while CJK input is pending; Tab no longer auto-selects commands (Enter or click only). + +### Redesigned tool calls + +- Compact, monospaced `ToolInput` / `ToolOutput` / `ToolHeader` layout with a braille spinner and left-side icons +- Subagent activity rendered inline as a collapsible tool wrapper, with full markdown for task prompt and response +- Clickable file names on every file-related tool call row (read, write, LSP inspect) — clicking opens the file in the editor pane +- Syntax-highlighted read-file output with filename header and line-range label; expand/collapse and copy on every code block + +### ask_user flow + +- Footer overlay with pinned header/footer and scrollable option buttons — no more inline question UI +- Status chips (Awaiting Response / Answered / Cancelled) with a collapsible answer bubble; cancelled shows immediately when aborted +- Optimistic dismiss on submit; prompt textarea auto-focuses after answering +- `ask_user` is now required for all Superset questions — no plain-text fallbacks + +### Workspace status and notifications + +- A pending question puts an orange dot on the workspace nav and fires a native OS toast, driven server-side through the lifecycle event pipeline so it works when the tab is unfocused +- Orange dot clears immediately on answer submit +- Auto-scroll to bottom on message send, new question arrival, and answer submit + +## The v2 workspace is in early access + +![v2 workspace early access](/changelog/v2-early-access.png) + +v2 is a ground-up rebuild of the workspace aimed at cloud. We tore out v1's accumulated cruft, rewrote the terminal from scratch to fix the rendering glitches, and re-architected the app like a real IDE — Tab → Split → Pane, a proper file tree with git decorations, a full editor, and a diff viewer that handles large changesets. Opt-in for now; here's what's in the box: + +### Pane layout system <PRBadge url="https://github.com/superset-sh/superset/pull/3088" /> + +A new flexible Tab → Split → Pane structure built on the `@superset/panes` package, with weighted resizable splits. + +- Drag and drop panes to rearrange them <PRBadge url="https://github.com/superset-sh/superset/pull/3090" /> +- Drag to reorder tabs within a tab group <PRBadge url="https://github.com/superset-sh/superset/pull/3094" /> +- Right-click pane headers for split / close / close others <PRBadge url="https://github.com/superset-sh/superset/pull/3196" /> +- Double-click a split separator to equalize pane sizes <PRBadge url="https://github.com/superset-sh/superset/pull/3101" /> +- Cmd+Alt+Arrow moves focus between panes <PRBadge url="https://github.com/superset-sh/superset/pull/3403" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3460" /> +- Closing the active pane focuses the nearest sibling <PRBadge url="https://github.com/superset-sh/superset/pull/3198" /> + +### Diff viewer <PRBadge url="https://github.com/superset-sh/superset/pull/3384" /> + +![v2 diff viewer](/changelog/v2-diff.png) + +A GitHub-style multi-file diff pane with lazy loading, a persistent "Viewed" state synced to the sidebar Changes list, unified/split view toggles, and header actions for collapse, expand unchanged, copy, and revert. Open it in its own tab with ⌘⇧L <PRBadge url="https://github.com/superset-sh/superset/pull/3420" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3556" />. + +### File editor <PRBadge url="https://github.com/superset-sh/superset/pull/3526" /> + +![v2 file tree and editor](/changelog/v2-file-tree.png) + +A full editor pane — foundation, views, and a large stability pass. The file tree shows git decorations inline <PRBadge url="https://github.com/superset-sh/superset/pull/3320" />. + +### Review tab <PRBadge url="https://github.com/superset-sh/superset/pull/3463" /> + +PR info, checks, and comments rendered inline in the v2 workspace, matching the v1 review experience. + +### PR checkout and launch context <PRBadge url="https://github.com/superset-sh/superset/pull/3525" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3467" /> + +Check out a PR directly from v2 via a widened checkout procedure, and compose a full launch context (selected files, instructions, branch state) before starting work. + +### Browser pane <PRBadge url="https://github.com/superset-sh/superset/pull/3346" /> + +Pages stay loaded across tab switches and workspace navigations — no reloads, no lost scroll or form state. URL bar, back/forward/reload, history autocomplete, DevTools, screenshots, and hard reload from the overflow menu. + +### Git Changes sidebar <PRBadge url="https://github.com/superset-sh/superset/pull/3177" /> + +A Changes tab in the right sidebar showing ahead/behind status, commit count, and a filterable file list. Filter by all changes, uncommitted only, a single commit, or a commit range. Searchable base-branch picker and inline rename for unpushed branches. + +## Superset CLI <PRBadge url="https://github.com/superset-sh/superset/pull/3194" /> + +{/* TODO: Screenshot of the CLI in action */} + +The `superset` CLI lets you manage Superset from your terminal, and ships as a self-contained tarball <PRBadge url="https://github.com/superset-sh/superset/pull/3298" /> so you can run the host-service on a remote server, CI box, or cloud VM without the desktop app. + +```bash +tar -xzf superset-darwin-arm64.tar.gz -C ~/superset +export PATH="$HOME/superset/bin:$PATH" + +superset auth login # OAuth PKCE + loopback +superset host start --daemon # connects to relay.superset.sh +superset tasks list # full task CRUD +superset org switch # manage organizations +``` + +Binaries for macOS (arm64, x64) and Linux x64. `--json` output mode for scripting and agent use. + +## Brand refresh <PRBadge url="https://github.com/superset-sh/superset/pull/3367" /> + +![Brand refresh — new logo and icon treatment](/changelog/brand-refresh.png) + +New logo and icon treatment across the desktop app, DMG installer background, tray icon, and wordmark. In dev builds, each workspace gets a color-coded corner fold on the dock icon so open workspaces are distinguishable at a glance. + +## GitHub integration is free <PRBadge url="https://github.com/superset-sh/superset/pull/3152" /> + +PR review, issue linking, and push-from-desktop are available on every plan — no Pro subscription required. + +## Improvements + +- **V2 file editor foundation** — editor views plus a large stability pass <PRBadge url="https://github.com/superset-sh/superset/pull/3526" /> +- **V2 review tab** — PR info, checks, and comments inline <PRBadge url="https://github.com/superset-sh/superset/pull/3463" /> +- **V2 PR checkout** — checkout a PR directly in a v2 workspace <PRBadge url="https://github.com/superset-sh/superset/pull/3525" /> +- **V2 launch context composition** — prep files, instructions, and branch state before launching <PRBadge url="https://github.com/superset-sh/superset/pull/3467" /> +- **Recently Viewed in Quick Open** — surfaces files you were just looking at <PRBadge url="https://github.com/superset-sh/superset/pull/3488" /> +- **Paginated branch picker** — scales to thousands of branches with inline checkout + open actions <PRBadge url="https://github.com/superset-sh/superset/pull/3397" /> +- **Modifier-keyed terminal file links** — ⌘/Ctrl+click opens in external editor or new tab <PRBadge url="https://github.com/superset-sh/superset/pull/3512" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3398" /> +- **Drag-and-drop into v2 terminal panes** — drop files to insert their paths <PRBadge url="https://github.com/superset-sh/superset/pull/3542" /> +- **Tasks link restored in v2 dashboard sidebar** <PRBadge url="https://github.com/superset-sh/superset/pull/3553" /> +- **Escape closes settings** <PRBadge url="https://github.com/superset-sh/superset/pull/3466" /> +- **Hotkey defaults** — unbound defaults plus restored prev/next tab and workspace shortcuts <PRBadge url="https://github.com/superset-sh/superset/pull/3422" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3472" /> +- **Allow hotkeys in editable content** <PRBadge url="https://github.com/superset-sh/superset/pull/3418" /> +- **Terminal search** — ⌘F / Ctrl+Shift+F in the v2 terminal <PRBadge url="https://github.com/superset-sh/superset/pull/3289" /> +- **Kitty keyboard protocol** — better modifier support in neovim, fish 4+, and others <PRBadge url="https://github.com/superset-sh/superset/pull/3289" /> +- **Terminal theme and font settings** in v2 (parity with v1) <PRBadge url="https://github.com/superset-sh/superset/pull/3155" /> +- **Fast file search** — VS Code's path-aware fuzzy scorer with pre-warmed index <PRBadge url="https://github.com/superset-sh/superset/pull/3136" /> +- **V2 top bar** — right sidebar toggle, org dropdown, unified "Open In" button <PRBadge url="https://github.com/superset-sh/superset/pull/3197" /> +- **Git decorations in v2 file tree** <PRBadge url="https://github.com/superset-sh/superset/pull/3320" /> +- **V2 preset parity + setup scripts** <PRBadge url="https://github.com/superset-sh/superset/pull/3354" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3359" /> +- **V1/v2 toggle from the top bar**, preference persists <PRBadge url="https://github.com/superset-sh/superset/pull/3347" /> +- **Host service durability** — survives app quits, re-adopted on next launch <PRBadge url="https://github.com/superset-sh/superset/pull/3157" /> +- **Relay security setting** — Settings → Security controls whether this machine is exposed; defaults to off <PRBadge url="https://github.com/superset-sh/superset/pull/3304" /> +- **Unified workspace delete** — v2 delete goes through the host-service <PRBadge url="https://github.com/superset-sh/superset/pull/3443" /> +- **Focus neighbor on workspace delete** — drops you on an adjacent workspace, not `/` <PRBadge url="https://github.com/superset-sh/superset/pull/3401" /> +- **Create Section Below** promoted to top-level on the workspace menu <PRBadge url="https://github.com/superset-sh/superset/pull/3537" /> +- **Event-driven tray menu** showing the real org name <PRBadge url="https://github.com/superset-sh/superset/pull/3458" /> +- **Notification sound volume** dropdown in settings <PRBadge url="https://github.com/superset-sh/superset/pull/3073" /> +- **Resolve review comments** directly in the review tab <PRBadge url="https://github.com/superset-sh/superset/pull/3078" /> +- **Public roadmap** at superset.sh/roadmap <PRBadge url="https://github.com/superset-sh/superset/pull/3074" /> + +--- + +**Bug fixes** + +- Security — bumped drizzle-orm and better-auth to patch CVEs <PRBadge url="https://github.com/superset-sh/superset/pull/3560" />; pinned axios against a known supply-chain vector <PRBadge url="https://github.com/superset-sh/superset/pull/3043" /> +- Chat — cut display polling to 4fps and restored query cache defaults <PRBadge url="https://github.com/superset-sh/superset/pull/3562" /> +- Chat — prevent keyboard shortcuts from leaking characters into the chat input <PRBadge url="https://github.com/superset-sh/superset/pull/3520" /> +- Terminal — recover from non-monospace font crash <PRBadge url="https://github.com/superset-sh/superset/pull/3554" /> +- Terminal — unblock v1 input during shell init <PRBadge url="https://github.com/superset-sh/superset/pull/3550" /> +- Terminal — sync v1 dimensions to backend on connect <PRBadge url="https://github.com/superset-sh/superset/pull/3545" /> +- Terminal — match VS Code clipboard handling <PRBadge url="https://github.com/superset-sh/superset/pull/3415" /> +- Terminal — restore ⌘+click for v1 file links; refresh v2 link tooltip editor <PRBadge url="https://github.com/superset-sh/superset/pull/3457" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3552" /> +- Terminal — correct dimensions sent after attach (fixes TUI apps) <PRBadge url="https://github.com/superset-sh/superset/pull/3154" /> +- Terminal — garbling fix via tRPC-first sessions <PRBadge url="https://github.com/superset-sh/superset/pull/3252" /> +- Auto-updater — restored on macOS; recover from corrupt update cache; spinner while pending; guard repeat clicks <PRBadge url="https://github.com/superset-sh/superset/pull/3291" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3278" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3495" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3561" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3549" /> +- macOS quit — ⌘Q and Dock Quit now fully exit instead of backgrounding to tray <PRBadge url="https://github.com/superset-sh/superset/pull/3205" /> +- macOS — trigger Local Network permission on startup <PRBadge url="https://github.com/superset-sh/superset/pull/3551" /> +- Port scanner — stop excessive `lsof` spawning <PRBadge url="https://github.com/superset-sh/superset/pull/3547" /> +- V2 workspace — prevent "workspace not found" flash after create; gate children on collection readiness; derive base branch from git config <PRBadge url="https://github.com/superset-sh/superset/pull/3494" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3464" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3492" /> +- V2 file tree — no longer blocks on `git.getStatus` in shared tRPC batch; file icons resolve on nested routes <PRBadge url="https://github.com/superset-sh/superset/pull/3400" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3199" /> +- V2 sidebar — LOC badge hidden when no changes; section count matches visual grouping; native clipboard for copy path <PRBadge url="https://github.com/superset-sh/superset/pull/3399" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3544" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3462" /> +- V2 diff sidebar — removed viewed checkboxes; right sidebar toggle is reactive <PRBadge url="https://github.com/superset-sh/superset/pull/3480" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3421" /> +- V2 — Open In editor is project-scoped, not workspace-scoped; duplicate panes on new-tab file opens; modal focus trap on workspace dialogs <PRBadge url="https://github.com/superset-sh/superset/pull/3393" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3093" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3392" /> +- V1 — fix Cmd+O firing open-in twice; fix split pane startup sizing; `--no-track` in createWorktree <PRBadge url="https://github.com/superset-sh/superset/pull/3511" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3416" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3548" /> +- Keyboard — Ctrl bindings, `event.code` unification, terminal override respect <PRBadge url="https://github.com/superset-sh/superset/pull/3391" /> +- IME composition in new-workspace Enter handlers <PRBadge url="https://github.com/superset-sh/superset/pull/3486" /> +- File attachments pass through the prompt input <PRBadge url="https://github.com/superset-sh/superset/pull/3334" /> +- Close-workspace shortcut no longer conflicts with tab close — now ⌘⇧⌫ <PRBadge url="https://github.com/superset-sh/superset/pull/3037" /> +- Pending failure error messages are selectable <PRBadge url="https://github.com/superset-sh/superset/pull/3432" /> +- Font-settings query no longer silently routed through host-service <PRBadge url="https://github.com/superset-sh/superset/pull/3394" /> +- MCP — accept resource URL as valid OAuth audience; fix devices incorrectly classified as offline <PRBadge url="https://github.com/superset-sh/superset/pull/3459" /> <PRBadge url="https://github.com/superset-sh/superset/pull/3299" /> +- CLI auth switched to OAuth PKCE + loopback <PRBadge url="https://github.com/superset-sh/superset/pull/3318" /> diff --git a/apps/marketing/package.json b/apps/marketing/package.json index fc4221af181..ffe95475b11 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@paper-design/shaders-react": "^0.0.76", "@react-three/drei": "^10.7.6", "@react-three/fiber": "^9.4.0", "@sentry/nextjs": "^10.36.0", @@ -25,7 +26,7 @@ "import-in-the-middle": "2.0.1", "lucide-react": "^0.563.0", "next": "^16.0.10", - "next-mdx-remote": "^5.0.0", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "posthog-js": "1.310.1", "react": "19.2.0", diff --git a/apps/marketing/public/changelog/brand-refresh.png b/apps/marketing/public/changelog/brand-refresh.png new file mode 100644 index 00000000000..fef8e5ee83c Binary files /dev/null and b/apps/marketing/public/changelog/brand-refresh.png differ diff --git a/apps/marketing/public/changelog/chat-ux.png b/apps/marketing/public/changelog/chat-ux.png new file mode 100644 index 00000000000..adea7a86ebf Binary files /dev/null and b/apps/marketing/public/changelog/chat-ux.png differ diff --git a/apps/marketing/public/changelog/marketplace.png b/apps/marketing/public/changelog/marketplace.png new file mode 100644 index 00000000000..ce28033bd8e Binary files /dev/null and b/apps/marketing/public/changelog/marketplace.png differ diff --git a/apps/marketing/public/changelog/mobile-agents.png b/apps/marketing/public/changelog/mobile-agents.png new file mode 100644 index 00000000000..d43c1785484 Binary files /dev/null and b/apps/marketing/public/changelog/mobile-agents.png differ diff --git a/apps/marketing/public/changelog/v2-diff.png b/apps/marketing/public/changelog/v2-diff.png new file mode 100644 index 00000000000..adf42e58a80 Binary files /dev/null and b/apps/marketing/public/changelog/v2-diff.png differ diff --git a/apps/marketing/public/changelog/v2-early-access.png b/apps/marketing/public/changelog/v2-early-access.png new file mode 100644 index 00000000000..8536fde75d7 Binary files /dev/null and b/apps/marketing/public/changelog/v2-early-access.png differ diff --git a/apps/marketing/public/changelog/v2-file-tree.png b/apps/marketing/public/changelog/v2-file-tree.png new file mode 100644 index 00000000000..83c3e4852c7 Binary files /dev/null and b/apps/marketing/public/changelog/v2-file-tree.png differ diff --git a/apps/marketing/public/cli/install.sh b/apps/marketing/public/cli/install.sh new file mode 100755 index 00000000000..8409e912367 --- /dev/null +++ b/apps/marketing/public/cli/install.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# Superset CLI installer +# +# Usage: +# curl -fsSL https://superset.sh/cli/install.sh | sh +# +# Installs the Superset CLI and host-service to ~/superset/. +# Adds ~/superset/bin to PATH via your shell profile. + +set -eu + +REPO="superset-sh/superset" +INSTALL_DIR="${SUPERSET_HOME:-$HOME/superset}" +TAG="${SUPERSET_VERSION:-latest}" + +BOLD='\033[1m' +GREEN='\033[32m' +YELLOW='\033[33m' +RED='\033[31m' +RESET='\033[0m' + +info() { printf "${GREEN}==>${RESET} %s\n" "$1" >&2; } +warn() { printf "${YELLOW}warning:${RESET} %s\n" "$1" >&2; } +error() { printf "${RED}error:${RESET} %s\n" "$1" >&2; exit 1; } + +detect_target() { + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Darwin) + case "$arch" in + arm64) echo "darwin-arm64" ;; + x86_64) error "Intel Macs are not supported. Apple Silicon (arm64) only." ;; + *) error "Unsupported macOS architecture: $arch" ;; + esac + ;; + Linux) + case "$arch" in + x86_64) echo "linux-x64" ;; + *) error "Unsupported Linux architecture: $arch (only x64 is supported)" ;; + esac + ;; + *) + error "Unsupported OS: $os (only macOS and Linux are supported)" + ;; + esac +} + +download_tarball() { + target="$1" + tarball="superset-${target}.tar.gz" + + if [ "$TAG" = "latest" ]; then + url="https://github.com/${REPO}/releases/latest/download/${tarball}" + else + url="https://github.com/${REPO}/releases/download/${TAG}/${tarball}" + fi + + info "Downloading $url" + tmp="$(mktemp -t superset-install.XXXXXX)" + if ! curl -fsSL -o "$tmp" "$url"; then + rm -f "$tmp" + error "Failed to download $url" + fi + echo "$tmp" +} + +extract_tarball() { + tarball="$1" + info "Extracting to $INSTALL_DIR" + mkdir -p "$INSTALL_DIR" + tar -xzf "$tarball" -C "$INSTALL_DIR" + rm -f "$tarball" +} + +detect_shell_profile() { + case "${SHELL:-}" in + */zsh) echo "$HOME/.zshrc" ;; + */bash) + if [ -f "$HOME/.bash_profile" ]; then + echo "$HOME/.bash_profile" + else + echo "$HOME/.bashrc" + fi + ;; + */fish) echo "$HOME/.config/fish/config.fish" ;; + *) echo "" ;; + esac +} + +update_path() { + bin_dir="$INSTALL_DIR/bin" + + # Check if it's already in PATH + case ":$PATH:" in + *":$bin_dir:"*) + info "$bin_dir is already in PATH" + return + ;; + esac + + profile="$(detect_shell_profile)" + if [ -z "$profile" ]; then + warn "Could not detect your shell profile. Add this to your shell config:" + printf " export PATH=\"%s:\$PATH\"\n" "$bin_dir" + return + fi + + export_line="export PATH=\"$bin_dir:\$PATH\"" + if [ "$profile" = "$HOME/.config/fish/config.fish" ]; then + export_line="set -gx PATH $bin_dir \$PATH" + fi + + if [ -f "$profile" ] && grep -Fq "$bin_dir" "$profile"; then + info "PATH already configured in $profile" + return + fi + + info "Adding $bin_dir to PATH in $profile" + mkdir -p "$(dirname "$profile")" + { + printf "\n# Superset CLI\n" + printf "%s\n" "$export_line" + } >> "$profile" +} + +main() { + printf "${BOLD}Installing Superset CLI${RESET}\n\n" + + target="$(detect_target)" + info "Platform: $target" + + tarball="$(download_tarball "$target")" + extract_tarball "$tarball" + + # Verify binaries exist and are executable. Tarball already ships them + # with +x, so this is a sanity check, not a chmod fallback. + for bin in superset superset-host; do + path="$INSTALL_DIR/bin/$bin" + if [ ! -f "$path" ] || [ ! -x "$path" ]; then + error "Expected executable file not found: $path" + fi + done + + update_path + + printf "\n${GREEN}${BOLD}Installed!${RESET}\n" + printf "Run ${BOLD}superset auth login${RESET} to get started.\n" + printf "You may need to restart your shell (or run \`source <your-profile>\`) for the PATH to take effect.\n" +} + +main "$@" diff --git a/apps/marketing/public/favicon-192.png b/apps/marketing/public/favicon-192.png index c3d37155891..94b86ed1106 100644 Binary files a/apps/marketing/public/favicon-192.png and b/apps/marketing/public/favicon-192.png differ diff --git a/apps/marketing/public/logos/perplexity-wordmark.svg b/apps/marketing/public/logos/perplexity-wordmark.svg new file mode 100644 index 00000000000..bed888a3ae3 --- /dev/null +++ b/apps/marketing/public/logos/perplexity-wordmark.svg @@ -0,0 +1,11 @@ +<svg width="1588" height="400" viewBox="0 0 1588 400" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M101.008 42L190.99 124.905L190.99 124.886L190.99 42.1913H208.506L208.506 125.276L298.891 42V136.524L336 136.524V272.866H299.005V357.035L208.506 277.525L208.506 357.948H190.99L190.99 278.836L101.11 358V272.866H64V136.524H101.008V42ZM177.785 153.826H81.5159V255.564H101.088V223.472L177.785 153.826ZM118.625 231.149V319.392L190.99 255.655L190.99 165.421L118.625 231.149ZM209.01 254.812V165.336L281.396 231.068V272.866H281.489V318.491L209.01 254.812ZM299.005 255.564H318.484V153.826L222.932 153.826L299.005 222.751V255.564ZM281.375 136.524V81.7983L221.977 136.524L281.375 136.524ZM177.921 136.524H118.524V81.7983L177.921 136.524Z" fill="black"/> +<g clip-path="url(#clip0_313_533)"> +<path d="M768.761 134.448H779.882V157.366H765.486C754.204 157.366 745.79 160.08 740.213 165.524C734.669 170.951 731.881 179.877 731.881 192.284V267.432H709.153V134.971H731.881V156.108C731.881 157.301 732.481 157.89 733.648 157.89C734.313 157.89 734.815 157.726 735.172 157.383C735.528 157.039 735.836 156.369 736.193 155.339C740.57 141.428 751.448 134.464 768.777 134.464H768.761V134.448ZM919.945 162.843C925.911 173.452 928.91 186.236 928.91 201.177C928.91 216.118 925.927 228.902 919.945 239.511C913.963 250.121 906.214 258.065 896.698 263.329C887.183 268.593 876.953 271.225 866.011 271.225C844.45 271.225 829.293 262.561 820.539 245.233C819.874 243.876 819.015 243.189 818.01 243.189C817.005 243.189 816.486 243.696 816.486 244.726V315.804H793.758V134.954H816.486V157.628C816.486 158.641 816.989 159.165 818.01 159.165C819.031 159.165 819.858 158.494 820.539 157.121C829.293 139.793 844.45 131.129 866.011 131.129C876.953 131.129 887.183 133.761 896.698 139.025C906.214 144.289 913.947 152.233 919.945 162.843ZM906.198 201.177C906.198 185.549 902.032 173.37 893.699 164.625C885.367 155.879 874.392 151.514 860.726 151.514C847.06 151.514 836.085 155.895 827.753 164.625C819.404 173.37 816.357 185.565 816.357 201.177C816.357 216.789 819.42 228.984 827.753 237.729C836.085 246.492 847.076 250.84 860.726 250.84C874.376 250.84 885.367 246.459 893.699 237.729C902.032 229 906.198 216.789 906.198 201.177ZM537.17 163.039C543.136 173.648 546.135 186.432 546.135 201.373C546.135 216.315 543.152 229.098 537.17 239.707C531.188 250.317 523.44 258.262 513.924 263.525C504.408 268.789 494.179 271.421 483.236 271.421C461.676 271.421 446.518 262.757 437.764 245.429C437.1 244.072 436.241 243.386 435.235 243.386C434.23 243.386 433.712 243.892 433.712 244.922V316H411V135.151H433.728V157.824C433.728 158.838 434.23 159.361 435.252 159.361C436.273 159.361 437.1 158.691 437.781 157.317C446.535 139.989 461.692 131.325 483.253 131.325C494.195 131.325 504.424 133.957 513.94 139.221C523.456 144.485 531.189 152.43 537.187 163.039H537.17ZM523.407 201.373C523.407 185.745 519.241 173.567 510.909 164.821C502.576 156.091 491.585 151.71 477.935 151.71C464.286 151.71 453.295 156.091 444.962 164.821C436.63 173.583 433.566 185.762 433.566 201.373C433.566 216.985 436.63 229.18 444.962 237.926C453.295 246.688 464.269 251.036 477.935 251.036C491.601 251.036 502.576 246.655 510.909 237.926C519.241 229.196 523.407 216.985 523.407 201.373ZM668.982 225.355H692.975C689.781 237.762 683.248 248.502 673.408 257.575C663.551 266.664 649.448 271.192 631.081 271.192C617.269 271.192 605.111 268.348 594.59 262.659C584.069 256.97 575.947 248.878 570.208 238.334C564.47 227.807 561.617 215.415 561.617 201.144C561.617 186.873 564.405 174.482 569.949 163.954C575.493 153.427 583.291 145.318 593.309 139.63C603.328 133.941 615.064 131.096 628.536 131.096C642.007 131.096 653.176 133.908 662.514 139.499C671.868 145.106 678.838 152.544 683.475 161.78C688.111 171.049 690.413 181.184 690.413 192.219V207.503H585.593C586.419 220.745 590.861 231.289 598.853 239.086C606.845 246.9 617.593 250.807 631.065 250.807C642.007 250.807 650.404 248.568 656.208 244.056C662.011 239.544 666.259 233.316 668.966 225.322L668.982 225.355ZM585.123 188.426H664.443C664.443 176.885 661.493 167.829 655.592 161.29C649.691 154.767 640.435 151.481 627.806 151.481C616.021 151.481 606.375 154.669 598.886 161.045C591.396 167.404 586.809 176.542 585.123 188.426ZM947.244 267.4H969.988V84H947.244V267.416V267.4ZM1266.89 120.487H1293.45V91.814H1266.89V120.487ZM1364.95 247.669C1360.82 248.094 1358.32 248.306 1357.5 248.306C1356.33 248.306 1355.37 247.963 1354.71 247.276C1354.04 246.606 1353.69 245.674 1353.69 244.464C1353.69 243.631 1353.91 241.113 1354.34 236.945C1354.74 232.793 1354.97 226.368 1354.97 217.72V154.342H1387.39L1381 134.938H1354.98V99.2683H1332.26V134.922H1307.53V154.326H1332.26V224.063C1332.26 238.678 1335.81 249.548 1342.87 256.676C1349.94 263.803 1360.72 267.383 1375.22 267.383H1392.9V247.015H1384.05C1375.46 247.015 1369.1 247.227 1364.97 247.652L1364.95 247.669ZM1502.34 134.938L1464.7 246.083C1464.2 247.456 1463.4 249.238 1460.83 249.238C1458.25 249.238 1457.44 247.456 1456.93 246.083L1419.29 134.938H1396.11L1439.7 267.4H1455.12C1456.12 267.4 1456.89 267.498 1457.41 267.661C1457.91 267.825 1458.33 268.25 1458.67 268.936C1459.33 269.95 1459.25 271.486 1458.41 273.514L1451.34 292.869C1450.32 295.419 1448.39 296.694 1445.54 296.694C1444.52 296.694 1442.17 296.481 1438.47 296.056C1434.76 295.631 1429.98 295.419 1424.08 295.419H1405.63V315.787H1429.88C1444.03 315.787 1452.54 313.368 1459.87 308.529C1467.2 303.691 1472.87 295.157 1476.92 282.93L1524 140.022V134.938H1502.37H1502.34ZM1189.96 184.356L1154.34 134.938H1129.33V140.022L1172.01 197.335L1119.97 262.299V267.383H1145.49L1186.92 214.14L1225.57 267.383H1250.08V262.299L1204.85 201.161L1253.88 140.284V134.938H1228.36L1189.97 184.356H1189.96ZM1269.31 267.4H1292.05V134.954H1269.31V267.416V267.4ZM1119.66 225.355C1116.45 237.762 1109.94 248.502 1100.1 257.575C1090.24 266.664 1076.14 271.192 1057.77 271.192C1043.96 271.192 1031.8 268.348 1021.28 262.659C1010.74 256.97 1002.64 248.878 996.899 238.334C991.176 227.807 988.323 215.415 988.323 201.144C988.323 186.873 991.111 174.482 996.656 163.954C1002.22 153.427 1010 145.318 1020.02 139.63C1030.03 133.941 1041.77 131.096 1055.26 131.096C1068.75 131.096 1079.9 133.908 1089.25 139.499C1098.59 145.106 1105.58 152.544 1110.21 161.78C1114.85 171.049 1117.15 181.184 1117.15 192.219V207.503H1012.32C1013.16 220.745 1017.58 231.289 1025.58 239.086C1033.57 246.9 1044.32 250.807 1057.79 250.807C1068.73 250.807 1077.13 248.568 1082.93 244.056C1088.73 239.544 1092.98 233.316 1095.67 225.322H1119.68L1119.66 225.355ZM1012.38 188.426H1091.7C1091.7 176.885 1088.75 167.829 1082.87 161.29C1076.96 154.767 1067.71 151.481 1055.08 151.481C1043.29 151.481 1033.65 154.669 1026.16 161.045C1018.67 167.404 1014.07 176.542 1012.4 188.426H1012.38Z" fill="black"/> +</g> +<defs> +<clipPath id="clip0_313_533"> +<rect width="1113" height="232" fill="white" transform="translate(411 84)"/> +</clipPath> +</defs> +</svg> diff --git a/apps/marketing/public/logos/runway-wordmark.svg b/apps/marketing/public/logos/runway-wordmark.svg new file mode 100644 index 00000000000..b34f5185611 --- /dev/null +++ b/apps/marketing/public/logos/runway-wordmark.svg @@ -0,0 +1,8 @@ +<svg width="334" height="75" viewBox="0 0 334 75" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M26.8298 18.0923C20.6417 18.0923 17.3605 21.7538 17.3605 28.2583V56.3685H0.361328V1.27002H9.90621L15.8713 7.62677C18.7065 3.51405 22.9553 1.27002 28.6973 1.27002H33.8421V18.0923H26.8337H26.8298Z" fill="white"/> +<path d="M37.9355 1.27002H54.9348V32.2232C54.9348 37.9811 58.6619 41.942 64.181 41.942C69.7001 41.942 73.3516 37.9811 73.3516 32.2232V1.27002H90.3508V56.3725H80.8059L74.9922 50.1675C71.042 54.8792 65.4473 57.6423 58.8889 57.6423C46.5128 57.6423 37.9395 48.5943 37.9395 35.3617V1.27002H37.9355Z" fill="white"/> +<path d="M148.21 56.3722H131.211V25.6426C131.211 19.7371 127.408 15.7002 121.742 15.7002C116.075 15.7002 112.273 19.7371 112.273 25.6426V56.3722H95.2734V1.26975H104.818L110.708 7.55064C114.658 2.7671 120.324 0 127.038 0C139.565 0 148.214 9.19573 148.214 22.58V56.3722H148.21Z" fill="white"/> +<path d="M146.349 1.27002H164.092L172.443 32.5227L182.509 1.27002H194.885L204.952 32.6705L213.302 1.27002H229.632L212.335 56.3725H197.498L187.73 26.0941L177.886 56.3725H163.945L146.349 1.27002Z" fill="white"/> +<path d="M252.82 0C267.283 0 276.008 8.00184 276.008 21.3063V56.3722H266.391L261.398 50.9898C257.67 55.3261 252.303 57.642 245.816 57.642C234.26 57.642 226.133 50.4667 226.133 40.2967C226.133 29.4559 234.037 23.175 247.755 23.175H259.462V21.3822C259.462 16.6705 256.854 14.131 251.932 14.131C247.532 14.131 244.701 16.375 244.402 20.1124H227.777C228.745 7.92597 238.589 0 252.828 0L252.82 0ZM259.454 36.3357V33.9439H249.539C245.29 33.9439 242.829 35.8885 242.829 39.2505C242.829 42.8402 245.736 45.0084 250.582 45.0084C256.324 45.0084 259.454 42.0177 259.454 36.3357Z" fill="white"/> +<path d="M303.894 74.6881H285.927L295.543 51.585L273.248 1.27002H293.082L304.638 31.249L315.525 1.27002H333.492L303.894 74.6881Z" fill="white"/> +</svg> diff --git a/apps/marketing/public/logos/toss-wordmark.svg b/apps/marketing/public/logos/toss-wordmark.svg new file mode 100644 index 00000000000..2605e5dd1c2 --- /dev/null +++ b/apps/marketing/public/logos/toss-wordmark.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="240 22 418 148" fill="none"><g fill="#202632"><path d="m265.2 126v-43.5h-22.7v-23h22.7v-27.5l27.8-7.4v34.9h34.5v23h-34.5v43.1c0 11.5 6.2 18.2 16.9 18.2 6.1 0 10.5-1.2 15.2-4.6l9.9 20.7c-7.3 5.7-17.1 8.4-27.6 8.4-27.1-.2-42.2-15.4-42.2-42.3z"/><path d="m336.3 112.3c0-31.6 24.6-55.4 57.7-55.4s57.7 23.8 57.7 55.4c0 31.7-24.6 55.4-57.7 55.4s-57.7-23.6-57.7-55.4zm57.7 30.8c17 0 29.6-13.1 29.6-30.6s-12.7-30.7-29.6-30.7c-17 0-29.6 13.1-29.6 30.6s12.7 30.7 29.6 30.7z"/><path d="m458.8 140.2 27.6-7.4c1.8 8.6 9.7 13.4 22.6 13.4 10.5 0 18.2-4.8 18.2-11.1 0-4.6-5.4-8-13.6-9.7l-20.6-4.4c-19.5-4.1-31.4-14.6-31.4-29.4 0-20.8 18-34.6 44.4-34.6 23.6 0 41.5 10.8 46.2 28.1l-26 7c-1.7-8.3-9.8-13.8-21.2-13.8-9.5 0-15.9 4.5-15.9 10.8 0 4.9 4.3 7.4 11.5 9.1l24.1 5.5c18.4 4.1 29.5 14.5 29.5 29.3 0 21.8-18.2 34.8-47.7 34.8-25.9 0-43.9-10.5-47.7-27.6z"/><path d="m560.9 140.2 27.6-7.4c1.8 8.6 9.7 13.4 22.6 13.4 10.5 0 18.2-4.8 18.2-11.1 0-4.6-5.4-8-13.6-9.7l-20.6-4.3c-19.5-4.1-31.4-14.6-31.4-29.4 0-20.8 18-34.6 44.4-34.6 23.6 0 41.5 10.8 46.2 28.1l-26 7c-1.7-8.3-9.8-13.8-21.2-13.8-9.5 0-15.9 4.5-15.9 10.8 0 4.9 4.3 7.4 11.5 9.1l24.1 5.5c18.4 4.1 29.5 14.5 29.5 29.3 0 21.8-18.2 34.8-47.7 34.8-25.9-.1-43.8-10.6-47.7-27.7z"/></g></svg> diff --git a/apps/marketing/public/logos/wordware-wordmark.svg b/apps/marketing/public/logos/wordware-wordmark.svg new file mode 100644 index 00000000000..b41ba14d1a7 --- /dev/null +++ b/apps/marketing/public/logos/wordware-wordmark.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="218" height="34" viewBox="0 0 218 34" fill="none"><path d="M209.107 3.72613V8.76784H207.84V3.72613H205.917V2.64448H211.036V3.72613H209.107Z" fill="#0F391C"/><path d="M214.358 8.76784L212.881 4.28499V8.76784H211.693V2.64448H213.448L214.871 7.00114L216.282 2.64448H218V8.76784H216.8V4.28499L215.317 8.76784H214.358Z" fill="#0F391C"/><path d="M22.8334 0L29.9482 26.813L37.1051 0H41.5488L32.3298 33.4832H27.9343L20.7774 7.41534L13.6627 33.4832H9.26723L0 0H4.6306L11.7875 26.813L18.86 0H22.8395H22.8334Z" fill="#0F391C"/><path d="M52.5947 34C45.902 34 41.2232 28.8682 41.2232 21.3146C41.2232 13.761 45.902 8.62919 52.5947 8.62919C59.2873 8.62919 63.9662 13.761 63.9662 21.3146C63.9662 28.8682 59.2873 34 52.5947 34ZM52.5947 30.4065C56.8997 30.4065 59.7094 26.8611 59.7094 21.3146C59.7094 15.7681 56.8997 12.1746 52.5947 12.1746C48.2897 12.1746 45.48 15.72 45.48 21.3146C45.48 26.9092 48.2897 30.4065 52.5947 30.4065Z" fill="#0F391C"/><path d="M81.0415 13.2443C80.3421 13.1061 79.7754 13.058 78.9855 13.058C75.4281 13.058 72.5762 15.8583 72.5762 20.1488V33.4892H68.3677V9.09191H72.5762V13.2443C73.6494 10.7745 76.0371 8.90563 79.3593 8.90563C80.0165 8.90563 80.6255 8.9537 81.0415 9.00177V13.2443Z" fill="#0F391C"/><path d="M83.1458 21.3146C83.1458 14.368 87.0288 8.6292 93.8119 8.6292C97.2305 8.6292 99.8955 10.2637 101.391 12.6373V0H105.599V33.4832H101.391V29.9378C99.8955 32.3655 97.2245 33.994 93.8119 33.994C87.0288 33.994 83.1458 28.2132 83.1458 21.3086V21.3146ZM94.5655 12.3128C89.9771 12.3128 87.4086 16.0445 87.4086 21.3146C87.4086 26.5847 89.9832 30.3164 94.5655 30.3164C98.0264 30.3164 101.493 27.7985 101.493 22.204V20.4793C101.493 14.7886 98.0325 12.3188 94.5655 12.3188V12.3128Z" fill="#0F391C"/><path d="M127.788 9.09191L132.84 27.2817L137.941 9.09191H142.15L134.709 33.4832H131.013L125.913 15.3895L120.812 33.4832H117.116L109.675 9.09191H114.029L119.178 27.2337L124.23 9.09191H127.788Z" fill="#0F391C"/><path d="M143.832 26.9092C143.832 22.0597 147.624 20.0527 153.099 18.935L159.044 17.7211V16.6935C159.044 13.8932 157.501 12.3128 154.227 12.3128C151.14 12.3128 149.313 13.761 148.565 16.4652L144.586 15.4376C145.755 11.4715 149.361 8.62919 154.365 8.62919C159.84 8.62919 163.211 11.2853 163.211 16.5133V28.4054C163.211 29.9919 164.193 30.5026 165.833 30.1301V33.4892C162.089 33.9579 159.937 33.0686 159.424 30.737C157.929 32.6479 155.216 33.9099 151.893 33.9099C147.45 33.9099 143.844 31.2959 143.844 26.9152L143.832 26.9092ZM159.038 20.9841L154.263 22.0117C150.519 22.7568 147.992 23.7844 147.992 26.7229C147.992 29.1025 149.723 30.5026 152.388 30.5026C155.993 30.5026 159.032 28.3093 159.032 25.1845V20.9841H159.038Z" fill="#0F391C"/><path d="M183.041 13.2443C182.342 13.1061 181.775 13.058 180.985 13.058C177.428 13.058 174.576 15.8583 174.576 20.1488V33.4892H170.368V9.09191H174.576V13.2443C175.649 10.7745 178.037 8.90563 181.359 8.90563C182.016 8.90563 182.625 8.9537 183.041 9.00177V13.2443Z" fill="#0F391C"/><path d="M184.072 21.3146C184.072 14.0375 188.703 8.62919 195.347 8.62919C201.992 8.62919 205.784 13.8031 205.784 20.5695V21.9696H188.094C188.329 27.1014 191.278 30.3644 195.534 30.3644C198.808 30.3644 201.148 28.5917 201.895 25.7013L205.591 27.0053C204.096 31.3439 200.4 34 195.528 34C188.745 34 184.066 28.8261 184.066 21.3146H184.072ZM188.329 18.7487H201.522C201.425 15.2994 199.556 12.2167 195.299 12.2167C191.742 12.2167 189.077 14.5963 188.329 18.7487Z" fill="#0F391C"/></svg> diff --git a/apps/marketing/public/marketplace/themes/catppuccin-frappe.json b/apps/marketing/public/marketplace/themes/catppuccin-frappe.json new file mode 100644 index 00000000000..175974d0905 --- /dev/null +++ b/apps/marketing/public/marketplace/themes/catppuccin-frappe.json @@ -0,0 +1,68 @@ +{ + "id": "catppuccin-frappe", + "name": "Catppuccin Frappé", + "type": "dark", + "author": "Catppuccin", + "description": "Catppuccin Frappé theme for Superset", + "ui": { + "background": "#303446", + "foreground": "#c6d0f5", + "card": "#292c3c", + "cardForeground": "#c6d0f5", + "popover": "#292c3c", + "popoverForeground": "#c6d0f5", + "primary": "#ca9ee6", + "primaryForeground": "#232634", + "secondary": "#414559", + "secondaryForeground": "#c6d0f5", + "muted": "#414559", + "mutedForeground": "#a5adce", + "accent": "#51576d", + "accentForeground": "#c6d0f5", + "tertiary": "#232634", + "tertiaryActive": "#414559", + "destructive": "#e78284", + "destructiveForeground": "#232634", + "border": "#51576d", + "input": "#51576d", + "ring": "#ca9ee6", + "sidebar": "#292c3c", + "sidebarForeground": "#c6d0f5", + "sidebarPrimary": "#ca9ee6", + "sidebarPrimaryForeground": "#232634", + "sidebarAccent": "#414559", + "sidebarAccentForeground": "#c6d0f5", + "sidebarBorder": "#414559", + "sidebarRing": "#ca9ee6", + "chart1": "#ca9ee6", + "chart2": "#a6d189", + "chart3": "#e5c890", + "chart4": "#8caaee", + "chart5": "#e78284", + "highlightMatch": "rgba(202, 158, 230, 0.2)", + "highlightActive": "rgba(202, 158, 230, 0.5)" + }, + "terminal": { + "background": "#303446", + "foreground": "#c6d0f5", + "cursor": "#f2d5cf", + "cursorAccent": "#303446", + "selectionBackground": "#626880", + "black": "#51576d", + "red": "#e78284", + "green": "#a6d189", + "yellow": "#e5c890", + "blue": "#8caaee", + "magenta": "#f4b8e4", + "cyan": "#99d1db", + "white": "#b5bfe2", + "brightBlack": "#626880", + "brightRed": "#e78284", + "brightGreen": "#a6d189", + "brightYellow": "#e5c890", + "brightBlue": "#8caaee", + "brightMagenta": "#f4b8e4", + "brightCyan": "#99d1db", + "brightWhite": "#c6d0f5" + } +} diff --git a/apps/marketing/public/marketplace/themes/catppuccin-latte.json b/apps/marketing/public/marketplace/themes/catppuccin-latte.json new file mode 100644 index 00000000000..234a4b29f8a --- /dev/null +++ b/apps/marketing/public/marketplace/themes/catppuccin-latte.json @@ -0,0 +1,68 @@ +{ + "id": "catppuccin-latte", + "name": "Catppuccin Latte", + "type": "light", + "author": "Catppuccin", + "description": "Catppuccin Latte theme for Superset", + "ui": { + "background": "#eff1f5", + "foreground": "#4c4f69", + "card": "#e6e9ef", + "cardForeground": "#4c4f69", + "popover": "#e6e9ef", + "popoverForeground": "#4c4f69", + "primary": "#8839ef", + "primaryForeground": "#dce0e8", + "secondary": "#ccd0da", + "secondaryForeground": "#4c4f69", + "muted": "#ccd0da", + "mutedForeground": "#6c6f85", + "accent": "#bcc0cc", + "accentForeground": "#4c4f69", + "tertiary": "#dce0e8", + "tertiaryActive": "#ccd0da", + "destructive": "#d20f39", + "destructiveForeground": "#dce0e8", + "border": "#bcc0cc", + "input": "#bcc0cc", + "ring": "#8839ef", + "sidebar": "#e6e9ef", + "sidebarForeground": "#4c4f69", + "sidebarPrimary": "#8839ef", + "sidebarPrimaryForeground": "#dce0e8", + "sidebarAccent": "#ccd0da", + "sidebarAccentForeground": "#4c4f69", + "sidebarBorder": "#ccd0da", + "sidebarRing": "#8839ef", + "chart1": "#8839ef", + "chart2": "#40a02b", + "chart3": "#df8e1d", + "chart4": "#1e66f5", + "chart5": "#d20f39", + "highlightMatch": "rgba(136, 57, 239, 0.2)", + "highlightActive": "rgba(136, 57, 239, 0.5)" + }, + "terminal": { + "background": "#eff1f5", + "foreground": "#4c4f69", + "cursor": "#dc8a78", + "cursorAccent": "#eff1f5", + "selectionBackground": "#acb0be", + "black": "#bcc0cc", + "red": "#d20f39", + "green": "#40a02b", + "yellow": "#df8e1d", + "blue": "#1e66f5", + "magenta": "#ea76cb", + "cyan": "#04a5e5", + "white": "#5c5f77", + "brightBlack": "#acb0be", + "brightRed": "#d20f39", + "brightGreen": "#40a02b", + "brightYellow": "#df8e1d", + "brightBlue": "#1e66f5", + "brightMagenta": "#ea76cb", + "brightCyan": "#04a5e5", + "brightWhite": "#4c4f69" + } +} diff --git a/apps/marketing/public/marketplace/themes/catppuccin-macchiato.json b/apps/marketing/public/marketplace/themes/catppuccin-macchiato.json new file mode 100644 index 00000000000..66950d17f00 --- /dev/null +++ b/apps/marketing/public/marketplace/themes/catppuccin-macchiato.json @@ -0,0 +1,68 @@ +{ + "id": "catppuccin-macchiato", + "name": "Catppuccin Macchiato", + "type": "dark", + "author": "Catppuccin", + "description": "Catppuccin Macchiato theme for Superset", + "ui": { + "background": "#24273a", + "foreground": "#cad3f5", + "card": "#1e2030", + "cardForeground": "#cad3f5", + "popover": "#1e2030", + "popoverForeground": "#cad3f5", + "primary": "#c6a0f6", + "primaryForeground": "#181825", + "secondary": "#363a4f", + "secondaryForeground": "#cad3f5", + "muted": "#363a4f", + "mutedForeground": "#a5adcb", + "accent": "#494d64", + "accentForeground": "#cad3f5", + "tertiary": "#181825", + "tertiaryActive": "#363a4f", + "destructive": "#ed8796", + "destructiveForeground": "#181825", + "border": "#494d64", + "input": "#494d64", + "ring": "#c6a0f6", + "sidebar": "#1e2030", + "sidebarForeground": "#cad3f5", + "sidebarPrimary": "#c6a0f6", + "sidebarPrimaryForeground": "#181825", + "sidebarAccent": "#363a4f", + "sidebarAccentForeground": "#cad3f5", + "sidebarBorder": "#363a4f", + "sidebarRing": "#c6a0f6", + "chart1": "#c6a0f6", + "chart2": "#a6da95", + "chart3": "#eed49f", + "chart4": "#8aadf4", + "chart5": "#ed8796", + "highlightMatch": "rgba(198, 160, 246, 0.2)", + "highlightActive": "rgba(198, 160, 246, 0.5)" + }, + "terminal": { + "background": "#24273a", + "foreground": "#cad3f5", + "cursor": "#f4dbd6", + "cursorAccent": "#24273a", + "selectionBackground": "#5b6078", + "black": "#494d64", + "red": "#ed8796", + "green": "#a6da95", + "yellow": "#eed49f", + "blue": "#8aadf4", + "magenta": "#f5bde6", + "cyan": "#91d7e3", + "white": "#b8c0e0", + "brightBlack": "#5b6078", + "brightRed": "#ed8796", + "brightGreen": "#a6da95", + "brightYellow": "#eed49f", + "brightBlue": "#8aadf4", + "brightMagenta": "#f5bde6", + "brightCyan": "#91d7e3", + "brightWhite": "#cad3f5" + } +} diff --git a/apps/marketing/public/marketplace/themes/catppuccin-mocha.json b/apps/marketing/public/marketplace/themes/catppuccin-mocha.json new file mode 100644 index 00000000000..c1d86502fea --- /dev/null +++ b/apps/marketing/public/marketplace/themes/catppuccin-mocha.json @@ -0,0 +1,68 @@ +{ + "id": "catppuccin-mocha", + "name": "Catppuccin Mocha", + "type": "dark", + "author": "Catppuccin", + "description": "Catppuccin Mocha theme for Superset", + "ui": { + "background": "#1e1e2e", + "foreground": "#cdd6f4", + "card": "#181825", + "cardForeground": "#cdd6f4", + "popover": "#181825", + "popoverForeground": "#cdd6f4", + "primary": "#cba6f7", + "primaryForeground": "#11111b", + "secondary": "#313244", + "secondaryForeground": "#cdd6f4", + "muted": "#313244", + "mutedForeground": "#a6adc8", + "accent": "#45475a", + "accentForeground": "#cdd6f4", + "tertiary": "#11111b", + "tertiaryActive": "#313244", + "destructive": "#f38ba8", + "destructiveForeground": "#11111b", + "border": "#45475a", + "input": "#45475a", + "ring": "#cba6f7", + "sidebar": "#181825", + "sidebarForeground": "#cdd6f4", + "sidebarPrimary": "#cba6f7", + "sidebarPrimaryForeground": "#11111b", + "sidebarAccent": "#313244", + "sidebarAccentForeground": "#cdd6f4", + "sidebarBorder": "#313244", + "sidebarRing": "#cba6f7", + "chart1": "#cba6f7", + "chart2": "#a6e3a1", + "chart3": "#f9e2af", + "chart4": "#89b4fa", + "chart5": "#f38ba8", + "highlightMatch": "rgba(203, 166, 247, 0.2)", + "highlightActive": "rgba(203, 166, 247, 0.5)" + }, + "terminal": { + "background": "#1e1e2e", + "foreground": "#cdd6f4", + "cursor": "#f5e0dc", + "cursorAccent": "#1e1e2e", + "selectionBackground": "#585b70", + "black": "#45475a", + "red": "#f38ba8", + "green": "#a6e3a1", + "yellow": "#f9e2af", + "blue": "#89b4fa", + "magenta": "#f5c2e7", + "cyan": "#89dceb", + "white": "#bac2de", + "brightBlack": "#585b70", + "brightRed": "#f38ba8", + "brightGreen": "#a6e3a1", + "brightYellow": "#f9e2af", + "brightBlue": "#89b4fa", + "brightMagenta": "#f5c2e7", + "brightCyan": "#89dceb", + "brightWhite": "#cdd6f4" + } +} diff --git a/apps/marketing/public/marketplace/themes/ember.json b/apps/marketing/public/marketplace/themes/ember.json new file mode 100644 index 00000000000..f4a4eec52f3 --- /dev/null +++ b/apps/marketing/public/marketplace/themes/ember.json @@ -0,0 +1,68 @@ +{ + "id": "ember", + "name": "Ember", + "type": "dark", + "author": "Superset", + "description": "Warm dark theme inspired by the Figma start screen design", + "ui": { + "background": "#151110", + "foreground": "#eae8e6", + "card": "#201E1C", + "cardForeground": "#eae8e6", + "popover": "#201E1C", + "popoverForeground": "#eae8e6", + "primary": "#eae8e6", + "primaryForeground": "#151110", + "secondary": "#2a2827", + "secondaryForeground": "#eae8e6", + "muted": "#2a2827", + "mutedForeground": "#a8a5a3", + "accent": "#2a2827", + "accentForeground": "#eae8e6", + "tertiary": "#1a1716", + "tertiaryActive": "#252220", + "destructive": "#cc4444", + "destructiveForeground": "#ffcccc", + "border": "#2a2827", + "input": "#2a2827", + "ring": "#3a3837", + "sidebar": "#1a1716", + "sidebarForeground": "#eae8e6", + "sidebarPrimary": "#e07850", + "sidebarPrimaryForeground": "#151110", + "sidebarAccent": "#252220", + "sidebarAccentForeground": "#eae8e6", + "sidebarBorder": "#2a2827", + "sidebarRing": "#3a3837", + "chart1": "#e07850", + "chart2": "#50a878", + "chart3": "#d4a84b", + "chart4": "#7b68ee", + "chart5": "#dc6b6b", + "highlightMatch": "rgba(224, 120, 80, 0.2)", + "highlightActive": "rgba(224, 120, 80, 0.5)" + }, + "terminal": { + "background": "#151110", + "foreground": "#eae8e6", + "cursor": "#e07850", + "cursorAccent": "#151110", + "selectionBackground": "rgba(224, 120, 80, 0.25)", + "black": "#151110", + "red": "#dc6b6b", + "green": "#7ec699", + "yellow": "#e5c07b", + "blue": "#61afef", + "magenta": "#c678dd", + "cyan": "#56b6c2", + "white": "#eae8e6", + "brightBlack": "#5c5856", + "brightRed": "#e88888", + "brightGreen": "#98d1a8", + "brightYellow": "#ecd08f", + "brightBlue": "#7ec0f5", + "brightMagenta": "#d494e6", + "brightCyan": "#73c7d3", + "brightWhite": "#ffffff" + } +} diff --git a/apps/marketing/public/marketplace/themes/github-dark-colorblind.json b/apps/marketing/public/marketplace/themes/github-dark-colorblind.json new file mode 100644 index 00000000000..d9d22e03a86 --- /dev/null +++ b/apps/marketing/public/marketplace/themes/github-dark-colorblind.json @@ -0,0 +1,68 @@ +{ + "id": "github-dark-colorblind", + "name": "GitHub Dark Colorblind", + "type": "dark", + "author": "GitHub (primer/github-vscode-theme)", + "description": "GitHub Dark Default theme for Superset", + "ui": { + "background": "#0d1117", + "foreground": "#e6edf3", + "card": "#161b22", + "cardForeground": "#e6edf3", + "popover": "#161b22", + "popoverForeground": "#e6edf3", + "primary": "#2f81f7", + "primaryForeground": "#ffffff", + "secondary": "#282e33", + "secondaryForeground": "#c9d1d9", + "muted": "#21262d", + "mutedForeground": "#7d8590", + "accent": "#1f6feb", + "accentForeground": "#ffffff", + "tertiary": "#010409", + "tertiaryActive": "#161b22", + "destructive": "#da3633", + "destructiveForeground": "#ffa198", + "border": "#30363d", + "input": "#30363d", + "ring": "#1f6feb", + "sidebar": "#010409", + "sidebarForeground": "#e6edf3", + "sidebarPrimary": "#f78166", + "sidebarPrimaryForeground": "#ffffff", + "sidebarAccent": "#6e768166", + "sidebarAccentForeground": "#e6edf3", + "sidebarBorder": "#30363d", + "sidebarRing": "#1f6feb", + "chart1": "#2f81f7", + "chart2": "#3fb950", + "chart3": "#d29922", + "chart4": "#bc8cff", + "chart5": "#f85149", + "highlightMatch": "#9e6a03", + "highlightActive": "#f2cc6080" + }, + "terminal": { + "background": "#0d1117", + "foreground": "#e6edf3", + "cursor": "#2f81f7", + "cursorAccent": "#0d1117", + "selectionBackground": "#388bfd40", + "black": "#484f58", + "red": "#ff7b72", + "green": "#3fb950", + "yellow": "#d29922", + "blue": "#58a6ff", + "magenta": "#bc8cff", + "cyan": "#39c5cf", + "white": "#b1bac4", + "brightBlack": "#6e7681", + "brightRed": "#ffa198", + "brightGreen": "#56d364", + "brightYellow": "#e3b341", + "brightBlue": "#79c0ff", + "brightMagenta": "#d2a8ff", + "brightCyan": "#56d4dd", + "brightWhite": "#ffffff" + } +} diff --git a/apps/marketing/public/marketplace/themes/monokai-classic.json b/apps/marketing/public/marketplace/themes/monokai-classic.json new file mode 100644 index 00000000000..91f1a888a62 --- /dev/null +++ b/apps/marketing/public/marketplace/themes/monokai-classic.json @@ -0,0 +1,68 @@ +{ + "id": "monokai-classic", + "name": "Monokai Classic", + "type": "dark", + "author": "Wimer Hazenberg", + "description": "Sublime Text's iconic dark theme", + "ui": { + "background": "#272822", + "foreground": "#f8f8f2", + "card": "#3e3d32", + "cardForeground": "#f8f8f2", + "popover": "#3e3d32", + "popoverForeground": "#f8f8f2", + "primary": "#a6e22e", + "primaryForeground": "#272822", + "secondary": "#3e3d32", + "secondaryForeground": "#f8f8f2", + "muted": "#3e3d32", + "mutedForeground": "#75715e", + "accent": "#49483e", + "accentForeground": "#f8f8f2", + "tertiary": "#1e1f1c", + "tertiaryActive": "#3e3d32", + "destructive": "#f92672", + "destructiveForeground": "#f8f8f2", + "border": "#49483e", + "input": "#49483e", + "ring": "#a6e22e", + "sidebar": "#1e1f1c", + "sidebarForeground": "#f8f8f2", + "sidebarPrimary": "#a6e22e", + "sidebarPrimaryForeground": "#272822", + "sidebarAccent": "#3e3d32", + "sidebarAccentForeground": "#f8f8f2", + "sidebarBorder": "#49483e", + "sidebarRing": "#a6e22e", + "chart1": "#f92672", + "chart2": "#a6e22e", + "chart3": "#66d9ef", + "chart4": "#f4bf75", + "chart5": "#ae81ff", + "highlightMatch": "rgba(244, 191, 117, 0.25)", + "highlightActive": "rgba(244, 191, 117, 0.55)" + }, + "terminal": { + "background": "#272822", + "foreground": "#f8f8f2", + "cursor": "#f8f8f2", + "cursorAccent": "#272822", + "selectionBackground": "rgba(73, 72, 62, 0.6)", + "black": "#272822", + "red": "#f92672", + "green": "#a6e22e", + "yellow": "#f4bf75", + "blue": "#66d9ef", + "magenta": "#ae81ff", + "cyan": "#a1efe4", + "white": "#f8f8f2", + "brightBlack": "#75715e", + "brightRed": "#f92672", + "brightGreen": "#a6e22e", + "brightYellow": "#f4bf75", + "brightBlue": "#66d9ef", + "brightMagenta": "#ae81ff", + "brightCyan": "#a1efe4", + "brightWhite": "#f9f8f5" + } +} diff --git a/apps/marketing/public/marketplace/themes/one-dark-pro.json b/apps/marketing/public/marketplace/themes/one-dark-pro.json new file mode 100644 index 00000000000..6af16480463 --- /dev/null +++ b/apps/marketing/public/marketplace/themes/one-dark-pro.json @@ -0,0 +1,68 @@ +{ + "id": "one-dark-pro", + "name": "One Dark Pro", + "type": "dark", + "author": "Atom", + "description": "Atom's iconic One Dark theme", + "ui": { + "background": "#282c34", + "foreground": "#abb2bf", + "card": "#2c313c", + "cardForeground": "#abb2bf", + "popover": "#2c313c", + "popoverForeground": "#abb2bf", + "primary": "#61afef", + "primaryForeground": "#282c34", + "secondary": "#3e4451", + "secondaryForeground": "#abb2bf", + "muted": "#3e4451", + "mutedForeground": "#5c6370", + "accent": "#3e4451", + "accentForeground": "#abb2bf", + "tertiary": "#21252b", + "tertiaryActive": "#2c313c", + "destructive": "#e06c75", + "destructiveForeground": "#282c34", + "border": "#3e4451", + "input": "#3e4451", + "ring": "#61afef", + "sidebar": "#21252b", + "sidebarForeground": "#abb2bf", + "sidebarPrimary": "#61afef", + "sidebarPrimaryForeground": "#282c34", + "sidebarAccent": "#2c313c", + "sidebarAccentForeground": "#abb2bf", + "sidebarBorder": "#3e4451", + "sidebarRing": "#61afef", + "chart1": "#61afef", + "chart2": "#98c379", + "chart3": "#e5c07b", + "chart4": "#c678dd", + "chart5": "#e06c75", + "highlightMatch": "rgba(97, 175, 239, 0.2)", + "highlightActive": "rgba(97, 175, 239, 0.5)" + }, + "terminal": { + "background": "#282c34", + "foreground": "#abb2bf", + "cursor": "#528bff", + "cursorAccent": "#282c34", + "selectionBackground": "rgba(62, 68, 81, 0.6)", + "black": "#282c34", + "red": "#e06c75", + "green": "#98c379", + "yellow": "#e5c07b", + "blue": "#61afef", + "magenta": "#c678dd", + "cyan": "#56b6c2", + "white": "#abb2bf", + "brightBlack": "#5c6370", + "brightRed": "#e06c75", + "brightGreen": "#98c379", + "brightYellow": "#e5c07b", + "brightBlue": "#61afef", + "brightMagenta": "#c678dd", + "brightCyan": "#56b6c2", + "brightWhite": "#ffffff" + } +} diff --git a/apps/marketing/public/title.svg b/apps/marketing/public/title.svg index 726a91b632e..6a5eb8e3ed2 100644 --- a/apps/marketing/public/title.svg +++ b/apps/marketing/public/title.svg @@ -1,51 +1,106 @@ <svg width="702" height="214" viewBox="0 0 702 214" fill="none" xmlns="http://www.w3.org/2000/svg"> -<g filter="url(#filter0_di_134_16)"> -<path d="M61.5874 79.3636H72.3146V90.0909H61.5874V79.3636ZM50.8601 79.3636H61.5874V90.0909H50.8601V79.3636ZM50.8601 90.0909H61.5874V100.818H50.8601V90.0909ZM40.1328 100.818H50.8601V111.545H40.1328V100.818ZM40.1328 111.545H50.8601V122.273H40.1328V111.545ZM50.8601 122.273H61.5874V133H50.8601V122.273ZM50.8601 133H61.5874V143.727H50.8601V133ZM61.5874 133H72.3146V143.727H61.5874V133ZM104.455 79.3636H115.182V90.0909H104.455V79.3636ZM93.7273 79.3636H104.455V90.0909H93.7273V79.3636ZM93.7273 90.0909H104.455V100.818H93.7273V90.0909ZM83 100.818H93.7273V111.545H83V100.818ZM83 111.545H93.7273V122.273H83V111.545ZM93.7273 122.273H104.455V133H93.7273V122.273ZM93.7273 133H104.455V143.727H93.7273V133ZM104.455 133H115.182V143.727H104.455V133Z" fill="url(#paint0_linear_134_16)" fill-opacity="0.8" shape-rendering="crispEdges"/> +<g clip-path="url(#clip0_43_58)"> +<path d="M174.599 75H187.4V87.7999H174.599V75ZM161.8 75H174.599V87.7999H161.8V75ZM149 87.7999H161.8V100.6H149V87.7999ZM149 100.6H161.8V113.4H149V100.6ZM161.8 100.6H174.599V113.4H161.8V100.6ZM174.599 100.6H187.4V113.4H174.599V100.6ZM174.599 113.4H187.4V126.2H174.599V113.4ZM174.599 126.2H187.4V139H174.599V126.2ZM161.8 126.2H174.599V139H161.8V126.2ZM149 126.2H161.8V139H149V126.2ZM149 75H161.8V87.7999H149V75ZM200.15 75H212.95V87.7999H200.15V75ZM200.15 87.7999H212.95V100.6H200.15V87.7999ZM200.15 100.6H212.95V113.4H200.15V100.6ZM200.15 113.4H212.95V126.2H200.15V113.4ZM200.15 126.2H212.95V139H200.15V126.2ZM212.95 126.2H225.749V139H212.95V126.2ZM225.749 126.2H238.55V139H225.749V126.2ZM225.749 113.4H238.55V126.2H225.749V113.4ZM225.749 100.6H238.55V113.4H225.749V100.6ZM225.749 87.7999H238.55V100.6H225.749V87.7999ZM225.749 75H238.55V87.7999H225.749V75ZM251.3 75H264.099V87.7999H251.3V75ZM251.3 87.7999H264.099V100.6H251.3V87.7999ZM251.3 100.6H264.099V113.4H251.3V100.6ZM251.3 113.4H264.099V126.2H251.3V113.4ZM251.3 126.2H264.099V139H251.3V126.2ZM264.099 75H276.9V87.7999H264.099V75ZM276.9 75H289.7V87.7999H276.9V75ZM276.9 87.7999H289.7V100.6H276.9V87.7999ZM276.9 100.6H289.7V113.4H276.9V100.6ZM264.099 100.6H276.9V113.4H264.099V100.6ZM302.45 75H315.249V87.7999H302.45V75ZM302.45 87.7999H315.249V100.6H302.45V87.7999ZM302.45 100.6H315.249V113.4H302.45V100.6ZM302.45 113.4H315.249V126.2H302.45V113.4ZM302.45 126.2H315.249V139H302.45V126.2ZM315.249 75H328.05V87.7999H315.249V75ZM315.249 126.2H328.05V139H315.249V126.2ZM315.249 100.6H328.05V113.4H315.249V100.6ZM328.05 75H340.85V87.7999H328.05V75ZM328.05 126.2H340.85V139H328.05V126.2ZM353.6 126.2H366.399V139H353.6V126.2ZM353.6 113.4H366.399V126.2H353.6V113.4ZM353.6 100.6H366.399V113.4H353.6V100.6ZM353.6 87.7999H366.399V100.6H353.6V87.7999ZM353.6 75H366.399V87.7999H353.6V75ZM366.399 75H379.2V87.7999H366.399V75ZM379.2 75H392V87.7999H379.2V75ZM379.2 87.7999H392V100.6H379.2V87.7999ZM366.399 100.6H379.2V113.4H366.399V100.6ZM379.2 113.4H392V126.2H379.2V113.4ZM379.2 126.2H392V139H379.2V126.2ZM430.35 75H443.15V87.7999H430.35V75ZM417.549 75H430.35V87.7999H417.549V75ZM404.75 87.7999H417.549V100.6H404.75V87.7999ZM404.75 100.6H417.549V113.4H404.75V100.6ZM417.549 100.6H430.35V113.4H417.549V100.6ZM430.35 100.6H443.15V113.4H430.35V100.6ZM430.35 113.4H443.15V126.2H430.35V113.4ZM430.35 126.2H443.15V139H430.35V126.2ZM417.549 126.2H430.35V139H417.549V126.2ZM404.75 126.2H417.549V139H404.75V126.2ZM404.75 75H417.549V87.7999H404.75V75ZM455.9 75H468.7V87.7999H455.9V75ZM455.9 87.7999H468.7V100.6H455.9V87.7999ZM455.9 100.6H468.7V113.4H455.9V100.6ZM455.9 113.4H468.7V126.2H455.9V113.4ZM455.9 126.2H468.7V139H455.9V126.2ZM468.7 75H481.5V87.7999H468.7V75ZM468.7 126.2H481.5V139H468.7V126.2ZM468.7 100.6H481.5V113.4H468.7V100.6ZM481.5 75H494.299V87.7999H481.5V75ZM481.5 126.2H494.299V139H481.5V126.2ZM507.049 75H519.85V87.7999H507.049V75ZM519.85 75H532.65V87.7999H519.85V75ZM532.65 75H545.449V87.7999H532.65V75ZM519.85 87.7999H532.65V100.6H519.85V87.7999ZM519.85 100.6H532.65V113.4H519.85V100.6ZM519.85 113.4H532.65V126.2H519.85V113.4ZM519.85 126.2H532.65V139H519.85V126.2Z" fill="#EAE8E6"/> +<g filter="url(#filter0_di_43_58)"> +<path d="M617.944 75H607.278V85.6667H617.944V96.3333H628.611V107V117.667H617.944V128.333H607.278V139H617.944H628.611V128.333V117.667H639.278V107V96.3333H628.611V85.6667V75H617.944Z" fill="white"/> +<path d="M617.944 75H607.278V85.6667H617.944V96.3333H628.611V107V117.667H617.944V128.333H607.278V139H617.944H628.611V128.333V117.667H639.278V107V96.3333H628.611V85.6667V75H617.944Z" fill="url(#paint0_linear_43_58)" fill-opacity="0.6"/> +</g> +<g filter="url(#filter1_di_43_58)"> +<path d="M585.944 75H575.278V85.6667H585.944V96.3333H596.611V107V117.667H585.944V128.333H575.278V139H585.944H596.611V128.333V117.667H607.278V107V96.3333H596.611V85.6667V75H585.944Z" fill="white"/> +<path d="M585.944 75H575.278V85.6667H585.944V96.3333H596.611V107V117.667H585.944V128.333H575.278V139H585.944H596.611V128.333V117.667H607.278V107V96.3333H596.611V85.6667V75H585.944Z" fill="url(#paint1_linear_43_58)" fill-opacity="0.6"/> +</g> +<g filter="url(#filter2_di_43_58)"> +<path d="M76.0556 75H86.7222V85.6667H76.0556V96.3333H65.3889V107V117.667H76.0556V128.333H86.7222V139H76.0556H65.3889V128.333V117.667H54.7222V107V96.3333H65.3889V85.6667V75H76.0556Z" fill="white"/> +<path d="M76.0556 75H86.7222V85.6667H76.0556V96.3333H65.3889V107V117.667H76.0556V128.333H86.7222V139H76.0556H65.3889V128.333V117.667H54.7222V107V96.3333H65.3889V85.6667V75H76.0556Z" fill="url(#paint2_linear_43_58)" fill-opacity="0.6"/> +</g> +<g filter="url(#filter3_di_43_58)"> +<path d="M108.055 75H118.722V85.6667H108.055V96.3333H97.3887V107V117.667H108.055V128.333H118.722V139H108.055H97.3887V128.333V117.667H86.7221V107V96.3333H97.3887V85.6667V75H108.055Z" fill="white"/> +<path d="M108.055 75H118.722V85.6667H108.055V96.3333H97.3887V107V117.667H108.055V128.333H118.722V139H108.055H97.3887V128.333V117.667H86.7221V107V96.3333H97.3887V85.6667V75H108.055Z" fill="url(#paint3_linear_43_58)" fill-opacity="0.6"/> </g> -<path d="M174.288 79.8182H186.925V92.4545H174.288V79.8182ZM161.652 79.8182H174.288V92.4545H161.652V79.8182ZM149.016 92.4545H161.652V105.091H149.016V92.4545ZM149.016 105.091H161.652V117.727H149.016V105.091ZM161.652 105.091H174.288V117.727H161.652V105.091ZM174.288 105.091H186.925V117.727H174.288V105.091ZM174.288 117.727H186.925V130.364H174.288V117.727ZM174.288 130.364H186.925V143H174.288V130.364ZM161.652 130.364H174.288V143H161.652V130.364ZM149.016 130.364H161.652V143H149.016V130.364ZM149.016 79.8182H161.652V92.4545H149.016V79.8182ZM199.512 79.8182H212.148V92.4545H199.512V79.8182ZM199.512 92.4545H212.148V105.091H199.512V92.4545ZM199.512 105.091H212.148V117.727H199.512V105.091ZM199.512 117.727H212.148V130.364H199.512V117.727ZM199.512 130.364H212.148V143H199.512V130.364ZM212.148 130.364H224.784V143H212.148V130.364ZM224.784 130.364H237.421V143H224.784V130.364ZM224.784 117.727H237.421V130.364H224.784V117.727ZM224.784 105.091H237.421V117.727H224.784V105.091ZM224.784 92.4545H237.421V105.091H224.784V92.4545ZM224.784 79.8182H237.421V92.4545H224.784V79.8182ZM250.008 79.8182H262.644V92.4545H250.008V79.8182ZM250.008 92.4545H262.644V105.091H250.008V92.4545ZM250.008 105.091H262.644V117.727H250.008V105.091ZM250.008 117.727H262.644V130.364H250.008V117.727ZM250.008 130.364H262.644V143H250.008V130.364ZM262.644 79.8182H275.281V92.4545H262.644V79.8182ZM275.281 79.8182H287.917V92.4545H275.281V79.8182ZM275.281 92.4545H287.917V105.091H275.281V92.4545ZM275.281 105.091H287.917V117.727H275.281V105.091ZM262.644 105.091H275.281V117.727H262.644V105.091ZM300.504 79.8182H313.14V92.4545H300.504V79.8182ZM300.504 92.4545H313.14V105.091H300.504V92.4545ZM300.504 105.091H313.14V117.727H300.504V105.091ZM300.504 117.727H313.14V130.364H300.504V117.727ZM300.504 130.364H313.14V143H300.504V130.364ZM313.14 79.8182H325.777V92.4545H313.14V79.8182ZM313.14 130.364H325.777V143H313.14V130.364ZM313.14 105.091H325.777V117.727H313.14V105.091ZM325.777 79.8182H338.413V92.4545H325.777V79.8182ZM325.777 130.364H338.413V143H325.777V130.364ZM351 130.364H363.636V143H351V130.364ZM351 117.727H363.636V130.364H351V117.727ZM351 105.091H363.636V117.727H351V105.091ZM351 92.4545H363.636V105.091H351V92.4545ZM351 79.8182H363.636V92.4545H351V79.8182ZM363.636 79.8182H376.273V92.4545H363.636V79.8182ZM376.273 79.8182H388.909V92.4545H376.273V79.8182ZM376.273 92.4545H388.909V105.091H376.273V92.4545ZM363.636 105.091H376.273V117.727H363.636V105.091ZM376.273 117.727H388.909V130.364H376.273V117.727ZM376.273 130.364H388.909V143H376.273V130.364ZM426.769 79.8182H439.405V92.4545H426.769V79.8182ZM414.132 79.8182H426.769V92.4545H414.132V79.8182ZM401.496 92.4545H414.132V105.091H401.496V92.4545ZM401.496 105.091H414.132V117.727H401.496V105.091ZM414.132 105.091H426.769V117.727H414.132V105.091ZM426.769 105.091H439.405V117.727H426.769V105.091ZM426.769 117.727H439.405V130.364H426.769V117.727ZM426.769 130.364H439.405V143H426.769V130.364ZM414.132 130.364H426.769V143H414.132V130.364ZM401.496 130.364H414.132V143H401.496V130.364ZM401.496 79.8182H414.132V92.4545H401.496V79.8182ZM451.992 79.8182H464.629V92.4545H451.992V79.8182ZM451.992 92.4545H464.629V105.091H451.992V92.4545ZM451.992 105.091H464.629V117.727H451.992V105.091ZM451.992 117.727H464.629V130.364H451.992V117.727ZM451.992 130.364H464.629V143H451.992V130.364ZM464.629 79.8182H477.265V92.4545H464.629V79.8182ZM464.629 130.364H477.265V143H464.629V130.364ZM464.629 105.091H477.265V117.727H464.629V105.091ZM477.265 79.8182H489.901V92.4545H477.265V79.8182ZM477.265 130.364H489.901V143H477.265V130.364ZM502.488 79.8182H515.125V92.4545H502.488V79.8182ZM515.125 79.8182H527.761V92.4545H515.125V79.8182ZM527.761 79.8182H540.397V92.4545H527.761V79.8182ZM515.125 92.4545H527.761V105.091H515.125V92.4545ZM515.125 105.091H527.761V117.727H515.125V105.091ZM515.125 117.727H527.761V130.364H515.125V117.727ZM515.125 130.364H527.761V143H515.125V130.364Z" fill="#EAE8E6"/> -<g filter="url(#filter1_di_134_16)"> -<path d="M576.133 79.3636H586.86V90.0909H576.133V79.3636ZM586.86 90.0909H597.587V100.818H586.86V90.0909ZM597.587 100.818H608.315V111.545H597.587V100.818ZM586.86 122.273H597.587V133H586.86V122.273ZM576.133 133H586.86V143.727H576.133V133ZM597.587 111.545H608.315V122.273H597.587V111.545ZM586.86 133H597.587V143.727H586.86V133ZM586.86 79.3636H597.587V90.0909H586.86V79.3636ZM619 79.3636H629.727V90.0909H619V79.3636ZM629.727 90.0909H640.455V100.818H629.727V90.0909ZM640.455 100.818H651.182V111.545H640.455V100.818ZM629.727 122.273H640.455V133H629.727V122.273ZM619 133H629.727V143.727H619V133ZM640.455 111.545H651.182V122.273H640.455V111.545ZM629.727 133H640.455V143.727H629.727V133ZM629.727 79.3636H640.455V90.0909H629.727V79.3636Z" fill="url(#paint1_linear_134_16)" fill-opacity="0.8" shape-rendering="crispEdges"/> </g> <defs> -<filter id="filter0_di_134_16" x="36.1328" y="79.3638" width="83.0488" height="72.3633" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<filter id="filter0_di_43_58" x="602.355" y="75" width="41.8462" height="73.8462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> <feFlood flood-opacity="0" result="BackgroundImageFix"/> <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> -<feOffset dy="4"/> -<feGaussianBlur stdDeviation="2"/> +<feOffset dy="4.92308"/> +<feGaussianBlur stdDeviation="2.46154"/> <feComposite in2="hardAlpha" operator="out"/> -<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> -<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_134_16"/> -<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_134_16" result="shape"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_43_58"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_43_58" result="shape"/> <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> -<feOffset dy="3"/> -<feGaussianBlur stdDeviation="1.05"/> +<feOffset dy="1.33333"/> +<feGaussianBlur stdDeviation="0.666667"/> <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> -<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.57 0"/> -<feBlend mode="normal" in2="shape" result="effect2_innerShadow_134_16"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect2_innerShadow_43_58"/> </filter> -<filter id="filter1_di_134_16" x="572.133" y="79.3638" width="83.0488" height="72.3633" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<filter id="filter1_di_43_58" x="570.355" y="75" width="41.8462" height="73.8462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> <feFlood flood-opacity="0" result="BackgroundImageFix"/> <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> -<feOffset dy="4"/> -<feGaussianBlur stdDeviation="2"/> +<feOffset dy="4.92308"/> +<feGaussianBlur stdDeviation="2.46154"/> <feComposite in2="hardAlpha" operator="out"/> -<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> -<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_134_16"/> -<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_134_16" result="shape"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_43_58"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_43_58" result="shape"/> <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> -<feOffset dy="3"/> -<feGaussianBlur stdDeviation="1.05"/> +<feOffset dy="1.33333"/> +<feGaussianBlur stdDeviation="0.666667"/> <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> -<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.57 0"/> -<feBlend mode="normal" in2="shape" result="effect2_innerShadow_134_16"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect2_innerShadow_43_58"/> </filter> -<linearGradient id="paint0_linear_134_16" x1="83" y1="86.6" x2="82.9215" y2="131.8" gradientUnits="userSpaceOnUse"> +<filter id="filter2_di_43_58" x="49.7991" y="75" width="41.8462" height="73.8462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="4.92308"/> +<feGaussianBlur stdDeviation="2.46154"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_43_58"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_43_58" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1.33333"/> +<feGaussianBlur stdDeviation="0.666667"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect2_innerShadow_43_58"/> +</filter> +<filter id="filter3_di_43_58" x="81.799" y="75" width="41.8462" height="73.8462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="4.92308"/> +<feGaussianBlur stdDeviation="2.46154"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_43_58"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_43_58" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="1.33333"/> +<feGaussianBlur stdDeviation="0.666667"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/> +<feBlend mode="normal" in2="shape" result="effect2_innerShadow_43_58"/> +</filter> +<linearGradient id="paint0_linear_43_58" x1="596.653" y1="72.6" x2="596.737" y2="138.893" gradientUnits="userSpaceOnUse"> +<stop stop-color="#BBB9B7"/> +<stop offset="1" stop-color="#848382"/> +</linearGradient> +<linearGradient id="paint1_linear_43_58" x1="564.653" y1="72.6" x2="564.737" y2="138.893" gradientUnits="userSpaceOnUse"> +<stop stop-color="#BBB9B7"/> +<stop offset="1" stop-color="#848382"/> +</linearGradient> +<linearGradient id="paint2_linear_43_58" x1="97.3472" y1="72.6" x2="97.263" y2="138.893" gradientUnits="userSpaceOnUse"> <stop stop-color="#BBB9B7"/> <stop offset="1" stop-color="#848382"/> </linearGradient> -<linearGradient id="paint1_linear_134_16" x1="619" y1="86.6" x2="618.921" y2="131.8" gradientUnits="userSpaceOnUse"> +<linearGradient id="paint3_linear_43_58" x1="129.347" y1="72.6" x2="129.263" y2="138.893" gradientUnits="userSpaceOnUse"> <stop stop-color="#BBB9B7"/> <stop offset="1" stop-color="#848382"/> </linearGradient> +<clipPath id="clip0_43_58"> +<rect width="702" height="214" fill="white"/> +</clipPath> </defs> </svg> diff --git a/apps/marketing/src/app/components/CTAButtons/HeaderCTA.tsx b/apps/marketing/src/app/components/CTAButtons/HeaderCTA.tsx index 95876019dad..c5556274903 100644 --- a/apps/marketing/src/app/components/CTAButtons/HeaderCTA.tsx +++ b/apps/marketing/src/app/components/CTAButtons/HeaderCTA.tsx @@ -1,12 +1,7 @@ "use client"; -import { DOWNLOAD_URL_MAC_ARM64 } from "@superset/shared/constants"; -import { Download } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { HiMiniClock } from "react-icons/hi2"; -import { track } from "@/lib/analytics"; -import { usePlatform } from "../../hooks/useOS"; import { DownloadButton } from "../DownloadButton"; import { WaitlistModal } from "../WaitlistModal"; @@ -16,7 +11,6 @@ interface HeaderCTAProps { } export function HeaderCTA({ isLoggedIn, dashboardUrl }: HeaderCTAProps) { - const { os, isMobile } = usePlatform(); const [isWaitlistOpen, setIsWaitlistOpen] = useState(false); const portalRef = useRef<HTMLElement | null>(null); @@ -24,8 +18,6 @@ export function HeaderCTA({ isLoggedIn, dashboardUrl }: HeaderCTAProps) { portalRef.current = document.body; }, []); - const showDownload = !isMobile && (os === "macos" || os === "unknown"); - const dashboardLink = isLoggedIn && ( <a href={dashboardUrl} @@ -45,44 +37,13 @@ export function HeaderCTA({ isLoggedIn, dashboardUrl }: HeaderCTAProps) { ) : null; - if (isMobile) { - return ( - <> - {dashboardLink} - <DownloadButton - size="sm" - onJoinWaitlist={() => setIsWaitlistOpen(true)} - /> - {waitlistModal} - </> - ); - } - return ( <> {dashboardLink} - {showDownload ? ( - <a - href={DOWNLOAD_URL_MAC_ARM64} - className="px-4 py-2 text-sm font-normal bg-foreground text-background hover:bg-foreground/90 transition-colors flex items-center justify-center gap-2" - onClick={() => track("download_clicked")} - > - Download for macOS - <Download className="size-4" aria-hidden="true" /> - </a> - ) : ( - <button - type="button" - className="px-4 py-2 text-sm font-normal bg-foreground text-background hover:bg-foreground/90 transition-colors flex items-center justify-center gap-2" - onClick={() => { - track("waitlist_clicked"); - setIsWaitlistOpen(true); - }} - > - Join Waitlist - <HiMiniClock className="size-4" aria-hidden="true" /> - </button> - )} + <DownloadButton + size="sm" + onJoinWaitlist={() => setIsWaitlistOpen(true)} + /> {waitlistModal} </> ); diff --git a/apps/marketing/src/app/components/CTASection/CTASection.tsx b/apps/marketing/src/app/components/CTASection/CTASection.tsx index 9b4ca00e2ed..508cf4b1784 100644 --- a/apps/marketing/src/app/components/CTASection/CTASection.tsx +++ b/apps/marketing/src/app/components/CTASection/CTASection.tsx @@ -11,11 +11,8 @@ export function CTASection() { <> <section className="relative py-32 px-8 lg:px-[30px]"> <div className="max-w-7xl mx-auto flex flex-col items-center text-center"> - <h2 - className="text-[32px] lg:text-[40px] font-normal tracking-normal leading-[1.3em] text-foreground mb-8" - style={{ fontFamily: "var(--font-ibm-plex-mono)" }} - > - Get Superset Today + <h2 className="text-3xl sm:text-4xl xl:text-5xl font-medium tracking-tight leading-[1.1] text-foreground mb-8"> + Try Superset now. </h2> <div> <DownloadButton onJoinWaitlist={() => setIsWaitlistOpen(true)} /> diff --git a/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx b/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx index ab70b0621d0..526ed6e0404 100644 --- a/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx +++ b/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx @@ -24,7 +24,7 @@ export function DownloadButton({ ? "px-2 sm:px-4 py-2 text-sm" : "px-3 sm:px-6 py-2 sm:py-3 text-sm sm:text-base"; - const buttonClasses = `bg-foreground text-background ${sizeClasses} font-normal hover:bg-foreground/80 transition-colors flex items-center gap-2 ${className}`; + const buttonClasses = `bg-brand/10 text-[#ff8c3a] border border-brand/20 ${sizeClasses} font-normal hover:bg-brand/15 hover:border-brand/35 transition-colors flex items-center gap-2 ${className}`; if (isMobile) { const appleIcon = ( diff --git a/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/FeatureDemo.tsx b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/FeatureDemo.tsx index c5179d1b0fa..749577a64b2 100644 --- a/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/FeatureDemo.tsx +++ b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/FeatureDemo.tsx @@ -1,7 +1,5 @@ -"use client"; - -import { MeshGradient } from "@superset/ui/mesh-gradient"; import type { ReactNode } from "react"; +import { DitheredBackground } from "./components/DitheredBackground"; interface FeatureDemoProps { children: ReactNode; @@ -16,12 +14,12 @@ export function FeatureDemo({ }: FeatureDemoProps) { return ( <div - className={`relative w-full min-h-[300px] lg:aspect-4/3 rounded overflow-hidden ${className}`} + className={`relative w-full min-h-[300px] lg:aspect-4/3 overflow-hidden ${className}`} > {/* Background gradient */} - <MeshGradient + <DitheredBackground colors={colors} - className="absolute inset-0 w-full h-full rounded" + className="absolute inset-0 w-full h-full" /> {/* Content overlay */} diff --git a/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/DitheredBackground.tsx b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/DitheredBackground.tsx new file mode 100644 index 00000000000..5d9958577c9 --- /dev/null +++ b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/DitheredBackground.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { lazy, Suspense } from "react"; + +const Dithering = lazy(() => + import("@paper-design/shaders-react").then((mod) => ({ + default: mod.Dithering, + })), +); + +interface DitheredBackgroundProps { + colors: readonly [string, string, string, string]; + className?: string; +} + +export function DitheredBackground({ + colors, + className = "", +}: DitheredBackgroundProps) { + return ( + <div + className={`${className} pointer-events-none opacity-30 mix-blend-screen`} + > + <Suspense fallback={null}> + <Dithering + colorBack="#00000000" + colorFront={colors[0]} + shape="warp" + type="4x4" + speed={0.15} + className="size-full" + minPixelRatio={1} + /> + </Suspense> + </div> + ); +} diff --git a/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/index.ts b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/index.ts new file mode 100644 index 00000000000..cfaafce3c76 --- /dev/null +++ b/apps/marketing/src/app/components/FeaturesSection/components/FeatureDemo/components/DitheredBackground/index.ts @@ -0,0 +1 @@ +export { DitheredBackground } from "./DitheredBackground"; diff --git a/apps/marketing/src/app/components/Footer/Footer.tsx b/apps/marketing/src/app/components/Footer/Footer.tsx index f4d47fbfd88..1c17d82cdc8 100644 --- a/apps/marketing/src/app/components/Footer/Footer.tsx +++ b/apps/marketing/src/app/components/Footer/Footer.tsx @@ -9,8 +9,8 @@ import { SocialLinks } from "../SocialLinks"; function SupersetLogo() { return ( <svg - width="98" - height="16" + width="156" + height="26" viewBox="0 0 392 64" fill="none" xmlns="http://www.w3.org/2000/svg" @@ -25,6 +25,33 @@ function SupersetLogo() { ); } +interface FooterLink { + href: string; + label: string; + external?: boolean; +} + +const COMPANY_LINKS: FooterLink[] = [ + { href: "/team", label: "About" }, + { href: COMPANY.CAREERS_URL, label: "Careers", external: true }, + { href: COMPANY.STATUS_URL, label: "Status", external: true }, +]; + +const RESOURCE_LINKS: FooterLink[] = [ + { href: COMPANY.DOCS_URL, label: "Documentation", external: true }, + { href: "/pricing", label: "Pricing" }, + { href: "/blog", label: "Blog" }, + { href: "/community", label: "Community" }, + { href: "/enterprise", label: "Enterprise" }, + { href: "/changelog", label: "Changelog" }, +]; + +const LEGAL_LINKS: FooterLink[] = [ + { href: COMPANY.TRUST_URL, label: "Security", external: true }, + { href: "/terms", label: "Terms" }, + { href: "/privacy", label: "Privacy" }, +]; + export function Footer() { return ( <footer className="border-t border-border bg-background"> @@ -33,66 +60,71 @@ export function Footer() { whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ duration: 0.5 }} - className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-14" + className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-20" > - {/* Main footer content */} - <div className="flex flex-col sm:flex-row justify-between items-start gap-8"> - {/* Left side - Logo and legal links */} - <div className="space-y-5"> + <div className="grid grid-cols-2 gap-10 md:grid-cols-[minmax(0,1fr)_auto_auto_auto] md:gap-x-20"> + <div className="col-span-2 flex flex-col gap-6 md:col-span-1"> <Link href="/" - className="text-muted-foreground hover:text-foreground transition-colors inline-block" + className="inline-block text-foreground transition-colors hover:text-foreground/80" > <SupersetLogo /> </Link> - <nav className="flex items-center gap-6 text-sm"> - <a - href={COMPANY.DOCS_URL} - className="text-muted-foreground hover:text-foreground transition-colors" - > - Docs - </a> - <Link - href="/team" - className="text-muted-foreground hover:text-foreground transition-colors" - > - About - </Link> - <Link - href="/privacy" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Privacy - </Link> - <Link - href="/terms" - className="text-muted-foreground hover:text-foreground transition-colors" - > - Terms - </Link> - <a - href="https://statuspage.incident.io/superset" - target="_blank" - rel="noopener noreferrer" - className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 group" - > - Status - <ArrowUpRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" /> - </a> - </nav> + <SocialLinks className="-ml-2" /> + <p className="text-sm text-muted-foreground"> + © {new Date().getFullYear()} Superset Inc. + </p> </div> - {/* Right side - Social links */} - <SocialLinks /> - </div> - - {/* Bottom - Copyright */} - <div className="mt-10 pt-6 border-t border-border/60"> - <p className="text-muted-foreground text-sm"> - © {new Date().getFullYear()} Superset Inc. All rights reserved. - </p> + <FooterColumn title="Company" links={COMPANY_LINKS} /> + <FooterColumn title="Resources" links={RESOURCE_LINKS} /> + <FooterColumn title="Legal" links={LEGAL_LINKS} /> </div> </motion.div> </footer> ); } + +function FooterColumn({ + title, + links, +}: { + title: string; + links: FooterLink[]; +}) { + return ( + <div className="flex flex-col gap-4"> + <p className="text-sm font-medium text-foreground">{title}</p> + <ul className="flex flex-col gap-3"> + {links.map((link) => ( + <li key={link.href}> + <FooterLinkItem link={link} /> + </li> + ))} + </ul> + </div> + ); +} + +function FooterLinkItem({ link }: { link: FooterLink }) { + const className = + "group inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"; + if (link.external) { + return ( + <a + href={link.href} + target="_blank" + rel="noopener noreferrer" + className={className} + > + {link.label} + <ArrowUpRight className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-100" /> + </a> + ); + } + return ( + <Link href={link.href} className={className}> + {link.label} + </Link> + ); +} diff --git a/apps/marketing/src/app/components/Header/Header.tsx b/apps/marketing/src/app/components/Header/Header.tsx index f6d150465ba..bf08bc2c5ec 100644 --- a/apps/marketing/src/app/components/Header/Header.tsx +++ b/apps/marketing/src/app/components/Header/Header.tsx @@ -1,37 +1,10 @@ "use client"; -import { COMPANY } from "@superset/shared/constants"; -import { AnimatePresence, motion } from "framer-motion"; -import { Menu, X } from "lucide-react"; +import { motion } from "framer-motion"; import Link from "next/link"; -import { useState } from "react"; - -function SupersetLogo() { - return ( - <svg - viewBox="0 0 392 64" - fill="none" - xmlns="http://www.w3.org/2000/svg" - aria-label="Superset" - className="h-5 w-auto" - > - <title>Superset - - - ); -} - -const NAV_LINKS = [ - { href: COMPANY.DOCS_URL, label: "Docs", external: true }, - { href: "/changelog", label: "Changelog", external: false }, - { href: "/blog", label: "Blog", external: false }, - { href: "/team", label: "About", external: false }, - { href: "/community", label: "Community", external: false }, - { href: "/enterprise", label: "Enterprise", external: false }, -]; +import { DesktopNav } from "./components/DesktopNav"; +import { MobileNav } from "./components/MobileNav"; +import { SupersetLogo } from "./components/SupersetLogo"; interface HeaderProps { ctaButtons: React.ReactNode; @@ -39,115 +12,38 @@ interface HeaderProps { } export function Header({ ctaButtons, starCounter }: HeaderProps) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - return (
- {/* Logo */} - - - + + + + - {/* Desktop Navigation */} - -
{ctaButtons}
+ +
+ {starCounter} +
{ctaButtons}
- {/* Mobile: Hamburger button */} - setIsMenuOpen(!isMenuOpen)} - aria-label={isMenuOpen ? "Close menu" : "Open menu"} - aria-expanded={isMenuOpen} - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ duration: 0.3, delay: 0.1 }} - > - {isMenuOpen ? ( - - ) : ( - - )} - +
- - {/* Mobile menu */} - - {isMenuOpen && ( - -
- {NAV_LINKS.map((link) => - link.external ? ( - - {link.label} - - ) : ( - setIsMenuOpen(false)} - > - {link.label} - - ), - )} -
{starCounter}
-
- {ctaButtons} -
-
-
- )} -
); diff --git a/apps/marketing/src/app/components/Header/components/DesktopNav/DesktopNav.tsx b/apps/marketing/src/app/components/Header/components/DesktopNav/DesktopNav.tsx new file mode 100644 index 00000000000..71998df78eb --- /dev/null +++ b/apps/marketing/src/app/components/Header/components/DesktopNav/DesktopNav.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "@superset/ui/navigation-menu"; +import { cn } from "@superset/ui/utils"; +import Link from "next/link"; +import { + type NavLink, + PRODUCT_LINKS, + RESOURCE_LINKS, + TOP_LEVEL_LINKS, +} from "../../constants"; + +const triggerClass = cn( + navigationMenuTriggerStyle(), + "h-8 bg-transparent px-3 text-sm font-normal text-muted-foreground hover:bg-accent/40 hover:text-foreground focus:bg-accent/40 focus:text-foreground data-[state=open]:bg-accent/40 data-[state=open]:text-foreground", +); + +export function DesktopNav() { + return ( + + + + + Product + + +
    + {PRODUCT_LINKS.map((link) => ( + + ))} +
+
+
+ + + + Resources + + +
    + {RESOURCE_LINKS.map((link) => ( + + ))} +
+
+
+ + {TOP_LEVEL_LINKS.map((link) => ( + + + {link.label} + + + ))} +
+
+ ); +} + +function NavListItem({ link }: { link: NavLink }) { + const content = ( + <> +
+ {link.label} +
+ {link.description && ( +

+ {link.description} +

+ )} + + ); + + return ( +
  • + + {link.external ? ( + + {content} + + ) : ( + {content} + )} + +
  • + ); +} diff --git a/apps/marketing/src/app/components/Header/components/DesktopNav/index.ts b/apps/marketing/src/app/components/Header/components/DesktopNav/index.ts new file mode 100644 index 00000000000..dfe57058671 --- /dev/null +++ b/apps/marketing/src/app/components/Header/components/DesktopNav/index.ts @@ -0,0 +1 @@ +export { DesktopNav } from "./DesktopNav"; diff --git a/apps/marketing/src/app/components/Header/components/MobileNav/MobileNav.tsx b/apps/marketing/src/app/components/Header/components/MobileNav/MobileNav.tsx new file mode 100644 index 00000000000..5e1006e778b --- /dev/null +++ b/apps/marketing/src/app/components/Header/components/MobileNav/MobileNav.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { AnimatePresence, motion } from "framer-motion"; +import { Menu, X } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { + type NavLink, + PRODUCT_LINKS, + RESOURCE_LINKS, + TOP_LEVEL_LINKS, +} from "../../constants"; + +interface MobileNavProps { + ctaButtons: React.ReactNode; + starCounter?: React.ReactNode; +} + +export function MobileNav({ ctaButtons, starCounter }: MobileNavProps) { + const [isOpen, setIsOpen] = useState(false); + const close = () => setIsOpen(false); + + return ( +
    + + + + {isOpen && ( + +
    + + + +
    + {starCounter} + {ctaButtons} +
    +
    +
    + )} +
    +
    + ); +} + +function MobileSection({ + title, + links, + onNavigate, +}: { + title?: string; + links: NavLink[]; + onNavigate: () => void; +}) { + return ( +
    + {title && ( +

    + {title} +

    + )} + {links.map((link) => + link.external ? ( + + {link.label} + + ) : ( + + {link.label} + + ), + )} +
    + ); +} diff --git a/apps/marketing/src/app/components/Header/components/MobileNav/index.ts b/apps/marketing/src/app/components/Header/components/MobileNav/index.ts new file mode 100644 index 00000000000..0e0055fab18 --- /dev/null +++ b/apps/marketing/src/app/components/Header/components/MobileNav/index.ts @@ -0,0 +1 @@ +export { MobileNav } from "./MobileNav"; diff --git a/apps/marketing/src/app/components/Header/components/SupersetLogo/SupersetLogo.tsx b/apps/marketing/src/app/components/Header/components/SupersetLogo/SupersetLogo.tsx new file mode 100644 index 00000000000..a8728055ef8 --- /dev/null +++ b/apps/marketing/src/app/components/Header/components/SupersetLogo/SupersetLogo.tsx @@ -0,0 +1,17 @@ +export function SupersetLogo() { + return ( + + Superset + + + ); +} diff --git a/apps/marketing/src/app/components/Header/components/SupersetLogo/index.ts b/apps/marketing/src/app/components/Header/components/SupersetLogo/index.ts new file mode 100644 index 00000000000..f0b39d9214e --- /dev/null +++ b/apps/marketing/src/app/components/Header/components/SupersetLogo/index.ts @@ -0,0 +1 @@ +export { SupersetLogo } from "./SupersetLogo"; diff --git a/apps/marketing/src/app/components/Header/constants.ts b/apps/marketing/src/app/components/Header/constants.ts new file mode 100644 index 00000000000..e80941d9227 --- /dev/null +++ b/apps/marketing/src/app/components/Header/constants.ts @@ -0,0 +1,50 @@ +import { COMPANY } from "@superset/shared/constants"; + +export interface NavLink { + href: string; + label: string; + description?: string; + external?: boolean; +} + +export const PRODUCT_LINKS: NavLink[] = [ + { + href: "/", + label: "Overview", + description: "The terminal for coding agents.", + }, + { + href: "/changelog", + label: "Changelog", + description: "New releases and product updates.", + }, +]; + +export const RESOURCE_LINKS: NavLink[] = [ + { + href: COMPANY.DOCS_URL, + label: "Documentation", + description: "Guides, references, and integrations.", + external: true, + }, + { + href: "/blog", + label: "Blog", + description: "Engineering deep-dives and launches.", + }, + { + href: "/community", + label: "Community", + description: "Discord, GitHub, and office hours.", + }, + { + href: "/team", + label: "About", + description: "The people behind Superset.", + }, +]; + +export const TOP_LEVEL_LINKS: NavLink[] = [ + { href: "/pricing", label: "Pricing" }, + { href: "/enterprise", label: "Enterprise" }, +]; diff --git a/apps/marketing/src/app/components/HeroSection/HeroSection.tsx b/apps/marketing/src/app/components/HeroSection/HeroSection.tsx index 01976e7fe12..e5df199d90c 100644 --- a/apps/marketing/src/app/components/HeroSection/HeroSection.tsx +++ b/apps/marketing/src/app/components/HeroSection/HeroSection.tsx @@ -26,7 +26,9 @@ export function HeroSection() {

    - Orchestrate swarms of Claude Code, Codex, etc. in parallel. - Works for any agents. Built for the AI era. + Orchestrate 100+ coding agents in parallel. Works for any + agents. Built for the AI era.

    diff --git a/apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx b/apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx index 59f1608ede5..61a5c62b79c 100644 --- a/apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx +++ b/apps/marketing/src/app/components/HeroSection/components/ProductDemo/ProductDemo.tsx @@ -56,12 +56,6 @@ export function ProductDemo({ scrollYProgress }: ProductDemoProps) { [containerWidth || 1, constrainedWidth || 1], ); - // Pills shift up to follow the shrinking mockup (minimal on mobile since scale is subtle) - const pillsY = useTransform( - scrollYProgress, - [0, 1], - [0, isMobile ? -6 : -40], - ); return (
    {/* Mockup with scroll-driven scale */} @@ -82,11 +76,8 @@ export function ProductDemo({ scrollYProgress }: ProductDemoProps) {
    - {/* Selector pills - below mockup, shift up as mockup scales */} - + {/* Selector pills - directly below mockup */} +
    {DEMO_OPTIONS.map((option) => ( setActiveOption(option.label as ActiveDemo)} /> ))} - +
    ); } diff --git a/apps/marketing/src/app/components/HeroSection/components/TypewriterText/TypewriterText.tsx b/apps/marketing/src/app/components/HeroSection/components/TypewriterText/TypewriterText.tsx index e925789b219..ec59a8961e1 100644 --- a/apps/marketing/src/app/components/HeroSection/components/TypewriterText/TypewriterText.tsx +++ b/apps/marketing/src/app/components/HeroSection/components/TypewriterText/TypewriterText.tsx @@ -7,6 +7,7 @@ interface TextSegment { text: string; className?: string; style?: React.CSSProperties; + render?: (visibleText: string) => React.ReactNode; } interface TypewriterTextProps { @@ -71,6 +72,18 @@ export function TypewriterText({ Math.min(segment.text.length, displayedText.length - segStart), ); + if (segment.render) { + return ( + + {segment.render(visibleText)} + + ); + } + return ( + + + LinkedIn + + + + + + YouTube + + + ); } diff --git a/apps/marketing/src/app/components/TrustedBySection/TrustedBySection.tsx b/apps/marketing/src/app/components/TrustedBySection/TrustedBySection.tsx index 58ee674ac97..b0346c59ed7 100644 --- a/apps/marketing/src/app/components/TrustedBySection/TrustedBySection.tsx +++ b/apps/marketing/src/app/components/TrustedBySection/TrustedBySection.tsx @@ -22,10 +22,10 @@ const CLIENT_LOGOS = [ height: 20, }, { - name: "doordash", - label: "DoorDash", - logo: "/logos/doordash-wordmark.svg", - height: 14, + name: "wordware", + label: "Wordware", + logo: "/logos/wordware-wordmark.svg", + height: 16, }, { name: "salesforce", @@ -59,10 +59,10 @@ const CLIENT_LOGOS = [ height: 18, }, { - name: "ramp", - label: "Ramp", - logo: "/logos/ramp-wordmark.svg", - height: 22, + name: "toss", + label: "Toss", + logo: "/logos/toss-wordmark.svg", + height: 18, }, { name: "google", @@ -110,7 +110,7 @@ export function TrustedBySection() {
    -

    +

    Trusted by builders from

    @@ -120,7 +120,7 @@ export function TrustedBySection() { {CLIENT_LOGOS.map((client) => (
    (
    -
    -
    -
    -

    - Code 10x faster with no switching cost -

    -

    - Superset works with your existing tools. We provide - parallelization and better UX to enhance your Claude Code, - OpenCode, Cursor, etc. -

    -
    -
    - -
    -
    - {isPlaying ? ( -