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/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/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts b/apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts index 9d7b20bd21b..eabd986be95 100644 --- a/apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts +++ b/apps/desktop/src/renderer/hooks/useV2UserPreferences/useV2UserPreferences.ts @@ -17,6 +17,7 @@ export interface V2UserPreferencesApi { setUrlLinks: (next: LinkTierMap) => void; setRightSidebarOpen: (next: boolean | ((prev: boolean) => boolean)) => void; setRightSidebarTab: (next: RightSidebarTab) => void; + setRightSidebarWidth: (next: number) => void; setDeleteLocalBranch: (next: boolean) => void; } @@ -104,6 +105,25 @@ export function useV2UserPreferences(): V2UserPreferencesApi { [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( @@ -129,6 +149,7 @@ export function useV2UserPreferences(): V2UserPreferencesApi { setUrlLinks, setRightSidebarOpen, setRightSidebarTab, + setRightSidebarWidth, setDeleteLocalBranch, }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index f3482eb06f7..59f13cd622a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -94,50 +94,53 @@ function DashboardLayout() { ); 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> - <AddRepositoryModals /> - {deleteTarget && ( - <DeleteWorkspaceDialog - workspaceId={deleteTarget.workspaceId} - workspaceName={deleteTarget.workspaceName} - workspaceType={deleteTarget.workspaceType} - open={true} - onOpenChange={(open) => { - if (!open) setDeleteTarget(null); - }} - /> - )} </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/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 5f9f027668d..d1d585c9ca9 100644 --- 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 @@ -1,21 +1,33 @@ import { Button } from "@superset/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@superset/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; 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 { useV2UserPreferences } from "renderer/hooks/useV2UserPreferences"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import { sidebarHeaderTabTriggerClassName } from "renderer/screens/main/components/WorkspaceView/RightSidebar/headerTabStyles"; 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; @@ -25,6 +37,7 @@ 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; @@ -62,6 +75,7 @@ export function WorkspaceSidebar({ onSelectFile, onSelectDiffFile, onOpenComment, + onOpenChat, onSearch, selectedFilePath, pendingReveal, @@ -69,20 +83,16 @@ export function WorkspaceSidebar({ workspaceName, }: WorkspaceSidebarProps) { const collections = useCollections(); - const { preferences, setRightSidebarTab } = useV2UserPreferences(); - const activeTab = preferences.rightSidebarTab; const localState = collections.v2WorkspaceLocalState.get(workspaceId); - const changesSubtab = localState?.sidebarState?.changesSubtab ?? "diffs"; + const activeTab: SidebarTabId = + (localState?.sidebarState?.activeTab as SidebarTabId | undefined) ?? + "changes"; function setActiveTab(tab: string) { - if (tab !== "changes" && tab !== "files") return; - setRightSidebarTab(tab); - } - - function setChangesSubtab(subtab: "diffs" | "review") { + if (!isSidebarTabId(tab)) return; if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { - draft.sidebarState.changesSubtab = subtab; + draft.sidebarState.activeTab = tab; }); } @@ -92,7 +102,11 @@ export function WorkspaceSidebar({ const el = containerRef.current; if (!el) return; const ro = new ResizeObserver(([entry]) => { - if (entry) setCompact(entry.contentRect.width < 200); + 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(); @@ -100,14 +114,23 @@ export function WorkspaceSidebar({ const gitStatus = useGitStatus(workspaceId); - const changesTab = useChangesTab({ + const changesTabDef = useChangesTab({ workspaceId, gitStatus, onSelectFile: onSelectDiffFile, }); + 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", @@ -125,81 +148,28 @@ export function WorkspaceSidebar({ ), }; - const combinedChangesTab: SidebarTabDefinition = { - id: "changes", - label: "Changes", - icon: LuGitCompareArrows, - badge: changesTab.badge, - actions: changesSubtab === "diffs" ? changesTab.actions : reviewTab.actions, - content: ( - <Tabs - value={changesSubtab} - onValueChange={(v) => setChangesSubtab(v as "diffs" | "review")} - className="flex min-h-0 flex-1 flex-col gap-0" - > - <div className="h-8 shrink-0 border-b bg-background"> - <TabsList className="grid h-full w-full grid-cols-2 items-stretch gap-0 rounded-none bg-transparent p-0"> - <TabsTrigger - value="diffs" - className={cn( - sidebarHeaderTabTriggerClassName, - "min-w-0 w-full justify-center", - )} - > - <span>Diffs</span> - {changesTab.badge != null && ( - <span className="text-[11px] text-muted-foreground/60 tabular-nums"> - {changesTab.badge} - </span> - )} - </TabsTrigger> - <TabsTrigger - value="review" - className={cn( - sidebarHeaderTabTriggerClassName, - "min-w-0 w-full justify-center", - )} - > - <span>Review</span> - {reviewTab.badge != null && reviewTab.badge > 0 && ( - <span className="text-[11px] text-muted-foreground/60 tabular-nums"> - {reviewTab.badge} - </span> - )} - </TabsTrigger> - </TabsList> - </div> - <TabsContent - value="diffs" - className="mt-0 flex min-h-0 flex-1 flex-col outline-none" - > - {changesTab.content} - </TabsContent> - <TabsContent - value="review" - className="mt-0 flex min-h-0 flex-1 flex-col outline-none" - > - {reviewTab.content} - </TabsContent> - </Tabs> - ), - }; - - const tabs = [combinedChangesTab, filesTab]; + const tabs: SidebarTabDefinition[] = [filesTab, changesTab, reviewTab]; const activeTabDef = tabs.find((t) => t.id === activeTab); return ( <div ref={containerRef} - className="flex h-full min-h-0 flex-col overflow-hidden border-l border-border bg-background" + 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 flex-1 flex-col"> + <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/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 index a30d54783a6..b73ce99b654 100644 --- 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 @@ -19,7 +19,7 @@ export function SidebarHeader({ return ( <div className="flex h-10 shrink-0 items-stretch border-b border-border"> - <div className="flex items-center h-full"> + <div className="flex min-w-0 items-center h-full overflow-hidden"> {tabs.map((tab) => { const isActive = activeTab === tab.id; const btn = ( @@ -31,7 +31,7 @@ export function SidebarHeader({ compact, })} > - {tab.icon && <tab.icon className="size-3.5" />} + {tab.icon && <tab.icon className="size-3" />} {!compact && tab.label} </button> ); @@ -54,7 +54,7 @@ export function SidebarHeader({ onClick={() => onTabChange(tab.id)} className={getSidebarHeaderTabButtonClassName({ isActive })} > - {tab.icon && <tab.icon className="size-3.5" />} + {tab.icon && <tab.icon className="size-3" />} {tab.label} </button> ); @@ -62,7 +62,9 @@ export function SidebarHeader({ </div> <div className="flex-1" /> {actions && ( - <div className="flex items-center h-10 pr-2 gap-0.5">{actions}</div> + <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/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 index 69addd6fe26..ae7dc18e485 100644 --- 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 @@ -9,7 +9,6 @@ interface ChangesHeaderProps { currentBranch: { name: string; aheadCount: number; behindCount: number }; defaultBranchName: string; baseBranch: string | null; - commitCount: number; totalFiles: number; totalAdditions: number; totalDeletions: number; @@ -27,7 +26,6 @@ export function ChangesHeader({ currentBranch, defaultBranchName, baseBranch, - commitCount, totalFiles, totalAdditions, totalDeletions, @@ -61,9 +59,9 @@ export function ChangesHeader({ }; return ( - <div className="border-b border-border bg-muted/30 px-3 py-2.5 space-y-1.5"> + <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.5 shrink-0 text-muted-foreground" /> + <GitBranch className="size-3 shrink-0 text-muted-foreground" /> {isEditing ? ( <input ref={inputRef} @@ -83,78 +81,43 @@ export function ChangesHeader({ if (skipBlurRef.current) return; handleSubmit(); }} - className="min-w-0 flex-1 truncate bg-transparent font-medium outline-none ring-1 ring-ring rounded-sm px-1" + className="min-w-0 flex-1 truncate rounded-sm bg-transparent px-1 font-medium outline-none ring-1 ring-ring" /> ) : ( <> - <span className="truncate font-medium">{currentBranch.name}</span> + <span className="min-w-0 truncate font-medium"> + {currentBranch.name} + </span> {canRename && ( <button type="button" onClick={startEditing} - className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground" + 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="text-[11px] text-muted-foreground"> - {commitCount} {commitCount === 1 ? "commit" : "commits"} from{" "} - <BaseBranchSelector - branches={branches} - currentValue={baseBranch ?? defaultBranchName} - onChange={onBaseBranchChange} - /> - </div> - - {currentBranch.aheadCount > 0 && currentBranch.behindCount > 0 && ( - <div className="text-[11px] text-muted-foreground"> - <div>Your branch and</div> - <div className="font-medium text-foreground"> - origin/{currentBranch.name} - </div> - <div>have diverged</div> - <div> - {currentBranch.aheadCount} local not pushed,{" "} - {currentBranch.behindCount} remote to pull - </div> - </div> - )} - {currentBranch.aheadCount > 0 && currentBranch.behindCount === 0 && ( - <div className="text-[11px] text-muted-foreground"> - <div> - {currentBranch.aheadCount}{" "} - {currentBranch.aheadCount === 1 ? "commit" : "commits"} ahead of - </div> - <div className="font-medium text-foreground"> - origin/{currentBranch.name} - </div> - </div> - )} - {currentBranch.behindCount > 0 && currentBranch.aheadCount === 0 && ( - <div className="text-[11px] text-muted-foreground"> - <div> - {currentBranch.behindCount}{" "} - {currentBranch.behindCount === 1 ? "commit" : "commits"} behind - </div> - <div className="font-medium text-foreground"> - origin/{currentBranch.name} - </div> - </div> - )} - - <div className="flex items-center justify-between pt-0.5"> + <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 items-center gap-1.5 text-xs text-muted-foreground"> - <span>{totalFiles} files changed</span> + <div className="flex shrink-0 items-center gap-1.5"> + <span> + {totalFiles} {totalFiles === 1 ? "file" : "files"} + </span> {(totalAdditions > 0 || totalDeletions > 0) && ( <span> {totalAdditions > 0 && ( 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 index e9d77751217..33442519c15 100644 --- 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 @@ -72,7 +72,6 @@ export const ChangesTabContent = memo(function ChangesTabContent({ currentBranch={status.data.currentBranch} defaultBranchName={status.data.defaultBranch.name} baseBranch={baseBranch} - commitCount={commits.data?.commits.length ?? 0} totalFiles={totalChanges} totalAdditions={totalAdditions} totalDeletions={totalDeletions} 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 index ba92c8bb940..cfa3afc380b 100644 --- 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 @@ -1,6 +1,10 @@ +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 { useCallback } from "react"; +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"; @@ -119,6 +123,47 @@ export function useChangesTab({ 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} @@ -145,6 +190,7 @@ export function useChangesTab({ 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/ReviewTabContent/ReviewTabContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useReviewTab/components/ReviewTabContent/ReviewTabContent.tsx index 55aa0e989c8..70beca5832e 100644 --- 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 @@ -47,7 +47,7 @@ export const ReviewTabContent = memo(function ReviewTabContent({ } return ( - <div className="flex h-full min-h-0 flex-col overflow-y-auto"> + <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" /> 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 index db6a016233b..deb38f7bc92 100644 --- 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 @@ -2,14 +2,17 @@ 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 V2PullRequest = NonNullable<RouterOutputs["git"]["getPullRequest"]>; -type V2CheckRun = V2PullRequest["checks"][number]; type V2ThreadsData = RouterOutputs["git"]["getPullRequestThreads"]; interface UseReviewTabParams { @@ -50,7 +53,7 @@ export function useReviewTab({ title: raw.title, state: raw.isDraft ? "draft" : raw.state, reviewDecision: normalizeReviewDecision(raw.reviewDecision), - checksStatus: computeChecksStatus(raw.checks), + checksStatus: computeChecksRollup(raw.checks).overall, checks: raw.checks.map((c) => ({ name: c.name, // The DB stores the already-resolved effective status (success/failure/ @@ -85,6 +88,7 @@ export function useReviewTab({ return { id: "review", label: "Review", + icon: LuMessageSquare, badge: openCommentCount, content, }; @@ -102,62 +106,6 @@ function normalizeReviewDecision( return "pending"; } -type EffectiveCheckStatus = - | "success" - | "failure" - | "pending" - | "skipped" - | "cancelled"; - -const KNOWN_CHECK_STATUSES = new Set<string>([ - "success", - "failure", - "pending", - "skipped", - "cancelled", -]); - -/** - * The DB stores the already-resolved effective status in `checksJson[].status` - * (e.g. "success", "failure"). But the tRPC router re-parses it into a - * CheckRun whose `status` field is typed as CheckStatusState ("completed" etc.) - * and whose `conclusion` is always null. So we first check whether the status - * value is already one of the effective statuses; if not, fall back to the - * status+conclusion logic for raw GitHub data. - */ -function coerceCheckStatus( - status: string, - conclusion: string | null, -): EffectiveCheckStatus { - if (KNOWN_CHECK_STATUSES.has(status)) return status as EffectiveCheckStatus; - // Raw GitHub data path: status is "completed"/"in_progress"/etc. - 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"; -} - -function computeChecksStatus( - checks: V2CheckRun[], -): "success" | "failure" | "pending" | "none" { - let hasFailure = false; - let hasPending = false; - let relevantCount = 0; - for (const c of checks) { - const s = coerceCheckStatus(c.status, c.conclusion); - if (s === "skipped" || s === "cancelled") continue; - relevantCount++; - if (s === "failure") hasFailure = true; - else if (s === "pending") hasPending = true; - } - if (relevantCount === 0) return "none"; - if (hasFailure) return "failure"; - if (hasPending) return "pending"; - return "success"; -} - function computeDurationText( startedAt: string | null, completedAt: string | null, 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 cbe20ee23dc..73a0b01d2e1 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,17 +1,14 @@ import { Workspace } from "@superset/panes"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@superset/ui/resizable"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute } from "@tanstack/react-router"; -import { useCallback, useState } from "react"; +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 { 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"; @@ -141,6 +138,7 @@ function WorkspaceContent({ preferences: v2UserPreferences, setRightSidebarOpen, setRightSidebarTab, + setRightSidebarWidth, } = useV2UserPreferences(); const { store } = useV2WorkspacePaneLayout({ projectId, @@ -199,8 +197,35 @@ function WorkspaceContent({ 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, @@ -212,76 +237,75 @@ function WorkspaceContent({ return ( <FileDocumentStoreProvider workspaceId={workspaceId}> - <ResizablePanelGroup - direction="horizontal" - className="min-h-0 min-w-0 flex-1 overflow-auto" - > - <ResizablePanel className="min-w-[320px]" defaultSize={80} minSize={30}> - <div - className="flex min-h-0 min-w-0 h-full 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> - </ResizablePanel> - {sidebarOpen && ( - <> - <ResizableHandle onDragging={onSidebarResizeDragging} /> - <ResizablePanel - className="min-w-[220px]" - defaultSize={20} - minSize={15} - maxSize={40} - > - <WorkspaceSidebar + <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} - workspaceName={workspaceName} - onSelectFile={openFilePane} - onSelectDiffFile={openDiffPane} - onOpenComment={openCommentPane} - onSearch={handleQuickOpen} - selectedFilePath={selectedFilePath} - pendingReveal={pendingReveal} + sources={getV2NotificationSourcesForTab(tab)} /> - </ResizablePanel> - </> + )} + 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} + workspaceName={workspaceName} + onSelectFile={openFilePane} + onSelectDiffFile={openDiffPane} + onOpenComment={openCommentPane} + onSearch={handleQuickOpen} + selectedFilePath={selectedFilePath} + pendingReveal={pendingReveal} + /> + </ResizablePanel>, + sidebarSlotEl, )} - </ResizablePanelGroup> <CommandPalette workspaceId={workspaceId} open={quickOpenOpen} 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 index 1dbcb5dcf54..deaa996eb65 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.test.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.test.ts @@ -51,7 +51,6 @@ function makeCollections() { tabOrder: number; sectionId: string | null; changesFilter: { kind: string }; - changesSubtab: string; }; paneLayout: unknown; viewedFiles: string[]; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts index d5a235bc6b6..0084fa02784 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/writeSidebarState.ts @@ -135,7 +135,6 @@ export function writeV2SidebarState( tabOrder: workspaceTabOrder.get(v1WorkspaceId) ?? v1Workspace.tabOrder, sectionId: v2SectionId, changesFilter: { kind: "all" }, - changesSubtab: "diffs", }, paneLayout: EMPTY_PANE_LAYOUT, viewedFiles: [], 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 ef260659947..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 @@ -36,7 +36,7 @@ export const workspaceLocalStateSchema = z.object({ tabOrder: z.number().int().default(0), sectionId: z.string().uuid().nullable().default(null), changesFilter: changesFilterSchema.default({ kind: "all" }), - changesSubtab: z.enum(["diffs", "review"]).default("diffs"), + activeTab: z.enum(["changes", "files", "review"]).default("changes"), isHidden: z.boolean().default(false), }), paneLayout: paneWorkspaceStateSchema, @@ -248,6 +248,7 @@ export const v2UserPreferencesSchema = z.object({ 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), }); @@ -261,5 +262,6 @@ export const DEFAULT_V2_USER_PREFERENCES: V2UserPreferencesRow = { urlLinks: DEFAULT_LINK_TIER_MAP, rightSidebarOpen: true, rightSidebarTab: "changes", + rightSidebarWidth: 340, deleteLocalBranch: false, }; 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/bun.lock b/bun.lock index ed538233849..f68abeb726f 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.6.1", + "version": "1.6.2", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36", diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 4080cf84c0d..40402ef3d1d 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -21,6 +21,7 @@ import { countUntrackedFileLines, detectUnstagedRenames, getChangedFilesForDiff, + getDefaultBranchName, mapGitStatus, parseNumstat, resolveBaseComparison, @@ -435,6 +436,73 @@ export const gitRouter = router({ }; }), + getBranchSyncStatus: protectedProcedure + .input(z.object({ workspaceId: z.string() })) + .query(async ({ ctx, input }) => { + const worktreePath = resolveWorktreePath(ctx, input.workspaceId); + const git = await ctx.git(worktreePath); + + const currentBranch = ( + await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "") + ).trim(); + const isDetached = !currentBranch || currentBranch === "HEAD"; + + const defaultBranch = await getDefaultBranchName(git); + const isDefaultBranch = + !isDetached && !!defaultBranch && currentBranch === defaultBranch; + + const remotes = await git.getRemotes(false).catch(() => []); + const hasRepo = remotes.length > 0; + + let hasUpstream = false; + let pushCount = 0; + let pullCount = 0; + try { + await git.raw(["rev-parse", "--abbrev-ref", "@{upstream}"]); + hasUpstream = true; + const tracking = await git.raw([ + "rev-list", + "--left-right", + "--count", + "@{upstream}...HEAD", + ]); + const [pullStr, pushStr] = tracking.trim().split(/\s+/); + pullCount = Number.parseInt(pullStr || "0", 10); + pushCount = Number.parseInt(pushStr || "0", 10); + } catch { + // no upstream — counts stay zero + } + + // Read working-tree status separately from branch info so a transient + // `git status` failure (e.g. lock contention during a concurrent + // operation) doesn't poison the whole sync read. Log on failure so it + // isn't silent — `hasUncommitted` defaults to false in that case + // because over-reporting "uncommitted" on every blip is more annoying + // than under-reporting briefly until the next refetch. + let hasUncommitted = false; + try { + const status = await git.status(); + hasUncommitted = status.files.length > 0; + } catch (error) { + console.warn( + "[git/getBranchSyncStatus] git.status() failed; treating working tree as clean for this read", + error, + ); + } + + return { + hasRepo, + hasUpstream, + pushCount, + pullCount, + isDefaultBranch, + isDetached, + hasUncommitted, + currentBranch: isDetached ? null : currentBranch, + defaultBranch, + }; + }), + getPullRequest: protectedProcedure .input(z.object({ workspaceId: z.string() })) .query(({ ctx, input }) => { @@ -489,6 +557,8 @@ export const gitRouter = router({ headRefName: pr.headBranch ?? "", updatedAt: pr.updatedAt ? new Date(pr.updatedAt).toISOString() : "", checks, + repoOwner: pr.repoOwner, + repoName: pr.repoName, }; }),