diff --git a/apps/desktop/plans/20260528-1500-pr-button-agent-select.md b/apps/desktop/plans/20260528-1500-pr-button-agent-select.md new file mode 100644 index 00000000000..3fd613318b1 --- /dev/null +++ b/apps/desktop/plans/20260528-1500-pr-button-agent-select.md @@ -0,0 +1,276 @@ +# PR action button — agent select + +Status: phase 1 shipped (split-button shell), phase 2 starting (agent picker) +Owner: desktop +Related: PR #4966 (inline agent-comment composer on v2 DiffPane), v2 `PRActionHeader` + +Mirror the agent-pick affordance we shipped on the DiffPane comment +composer into the top-right PR action slot, so the user can hand PR +authoring off to either a running agent session or a freshly launched +one — without losing the one-click default. + +The PR flow already runs through an agent (the current Create PR opens +a new chat tab with a slash command + `pr-context.md`). This work +gives the user control over **which** agent runs it, plus extends the +same affordance to Update PR (pr-exists state) and lets the project +ship per-repo prompt customizations. + +## Current state + +`apps/desktop/.../WorkspaceSidebar/components/PRActionHeader/PRActionHeader.tsx` +renders `CreatePRIconButton` (lines 155–177): an icon-only button +(`VscGitPullRequest`) wrapped in a tooltip. Clicking it dispatches +`{ state, draft: false }` into the `PRFlowDispatch` reducer, which +runs the existing programmatic create-PR flow. No label, no chevron, +no agent handoff. + +The DiffPane comment composer (`AgentCommentComposer`, shipped in +#4966) gives us the pattern we want to reuse: +- `AgentPickerSelect` — Radix select grouped into **Active sessions** + (`existing:`) + **Start new** (`new:`). +- `useDiffCommentTarget` — selection state, localStorage persistence + for both existing/new picks, validation + fallbacks (dead session, + deleted config). +- `AgentPlacementToggle` — split-pane vs new-tab, only when `kind === "new"`. +- Submit routing: `existing` → `sendToTerminalAgent`; `new` → + `onCreateNewAgentSession({ configId, placement, prompt })`. + +## UI interactions (locked) + +### Split-button shape + +``` +┌──────────────────┬───┐ +│ 📥 Create PR │ ▾ │ +└──────────────────┴───┘ +``` + +The button is a **bordered pill** mirroring the v1 PRButton / v2 +`PRStatusGroup` styling — `rounded border border-border bg-muted/40` +container, primary region with icon + label, vertical divider, then a +chevron region. Sits to the right of `PRStatusGroup` so the action +header reads as one visual family. + +- Primary region → runs the *default* agent (today: a new chat tab + with the `/pr/{create,update}-pr` slash command + the + `pr-context.md` attachment; later, the last-picked agent from the + dropdown). +- Chevron region → opens a `DropdownMenu` anchored bottom-end. The + dropdown is **purely** the agent picker — there is no separate + "direct vs agent" distinction. Every click runs an agent. + +One component (`PRActionSplitButton`) covers both verbs via a `kind` +prop. Labels swap to **"Update PR"** with a `VscEdit` icon when a PR +already exists. + +### All-states behaviour + +| `PRFlowState` | Slot rendering | +|---|---| +| `loading` | empty (no anchor) | +| `unavailable` | muted `VscGitPullRequest` icon + tooltip with reason | +| `no-pr` | **Create PR** pill | +| `pr-exists` | **Update PR** pill + `PRStatusGroup` (`#N` + merge dropdown) | +| `busy` (no PR yet) | **Create PR** pill, primary disabled, icon → spinner, label "Creating…" | +| `busy` (PR exists) | **Update PR** pill (busy) + `PRStatusGroup` | +| `error` | retry icon | + +### Dropdown contents + +``` +ACTIVE SESSIONS + 🟢 claude pane 1 + 🟢 codex pane 2 +───────────────────── +START NEW + + claude + + codex + + cursor +───────────────────── +✎ Edit PR prompt… +``` + +Two groups grouped exactly like `AgentPickerSelect`, plus a tail item +that opens the project-prompt Dialog. + +- **Active sessions** — every running terminal agent for this + workspace, rendered with preset icon and pane label. Source: the + same hook the comment composer uses. Click → send the + PR-flow payload to that terminal via `sendToTerminalAgent`, and + bring focus to its pane. +- **Start new** — every available `HostAgentConfig` for this + workspace, prefixed with `+`. Click → launch a new agent session + (split-pane placement) and seed it with the PR-flow payload. +- **Edit PR prompt…** — opens a Dialog (see below). + +Empty states: +- No active sessions and no presets configured → both groups read + "No agents available — open Settings to add a preset" as disabled + items. The button itself stays clickable (primary still works via + the legacy chat path until the last-picked agent is established). +- No active sessions only → "Active sessions" group header reads + "No active sessions" (disabled item), "Start new" lists configs. + +### Placement & persistence (Start new) + +Reuse the comment-composer hook pattern. New hook +`usePRActionAgentTarget` lives next to the split button and wraps the +same primitives as `useDiffCommentTarget`: + +- Remembers last picked existing terminalId and last picked new + configId in localStorage, **keyed separately** from the comment + composer so PR picks and comment picks don't trample each other. +- New sessions default to **split-pane** placement (matches the + comment composer). No inline placement toggle in the menu — power + users can move the pane afterwards. +- Validation + fallbacks mirror `useDiffCommentTarget`: if a + remembered terminal is gone or a config was deleted, fall back to + "most recent active session" → "first config" → "open new chat tab" + (the legacy default). Never silently send to the wrong target. + +The remembered pick **is** the "default agent" used by the primary +button. Click an item in the dropdown to switch defaults; the next +primary click goes there. + +### Agent payload + +Each invocation sends the same payload — the chosen agent only varies +the *transport*. The payload is: + +- The **invocation string**: `/pr/create-pr` (or `--draft`) for + no-pr, `/pr/update-pr` for pr-exists. +- The **`pr-context.md` attachment**: branch + sync snapshot, PR + metadata when one exists, and the "## Project guidelines" section + (see Custom prompt below). + +Transports per target kind: +- **Chat tab (legacy default + new chat fallback)** — existing + `onOpenChat({ initialPrompt, initialFiles })` path. +- **Existing terminal agent** — `sendToTerminalAgent` posts the + invocation string as a single user message. The pr-context content + is inlined into the message (terminal agents can't carry separate + file attachments through the xterm channel). +- **New terminal preset launch** — host launches the preset with the + same inlined invocation + context as the seed prompt. + +The agent runs the actual `gh pr create`/`gh pr edit` itself. The +desktop side does not create or edit the PR in parallel; clicking is +a handoff. + +### Custom prompt (per-project) + +Storage: `.superset/pr-prompt.md`, checked into the project repo. +Optional — when absent or empty, behaviour is unchanged. + +Edit surface: a **Dialog** opened from the "Edit PR prompt…" item at +the bottom of the chevron dropdown. The dialog shows the file path, +a multi-line textarea seeded from the file's current contents, a +short explainer ("Will be applied to both Create and Update"), and +Save/Cancel. A secondary "Open in editor" link deep-links the file +into a v2 file editor tab for power editing. + +Composition: **appended, not replaced**. `buildPRContext` reads the +file at dispatch time (via the file system tRPC the renderer already +has) and, if non-empty, appends it as a `## Project guidelines` +section at the end of `pr-context.md`. The canonical slash command in +`.agents/commands/pr/*.md` keeps owning mechanics (preconditions, gh +syntax, formatting); the project file just carries opinions +("title format: `feat(scope): …`", "always include a Test Plan +section", "default to draft"). One file covers both verbs. + +The slash command body needs a one-line addition telling the agent +to honor any `## Project guidelines` section in `pr-context.md`. + +### Edge cases + +- `createPREnabled` gate is gone; the kill-switch served its purpose + during phase 1 and the always-true state is the new default. +- Submenu opened while a launch is mid-flight → the selected item + shows a spinner; ignore additional clicks until the dispatch + resolves. +- Keyboard: ⌘⇧P stays bound to the global `OPEN_PR` hotkey (opens + the PR on GitHub). No new shortcut binding. +- The project prompt file is read every dispatch (no caching) so the + user sees updates without a reload. + +## Component plan + +``` +PRActionHeader/ + components/ + PRActionSplitButton/ # shipped (phase 1) + PRActionSplitButton.tsx + index.ts + components/ + PRAgentPickerMenu/ # phase 2 — dropdown content + PRAgentPickerMenu.tsx + index.ts + PRPromptEditDialog/ # phase 3 — Edit prompt dialog + PRPromptEditDialog.tsx + index.ts + hooks/ + usePRActionAgentTarget/ # phase 2 — persistence + validation + usePRActionAgentTarget.ts + usePRActionAgentTarget.test.ts + index.ts + usePRActionDispatch/ # phase 2 — routes target → transport + usePRActionDispatch.ts + usePRActionDispatch.test.ts + index.ts +``` + +`PRFlowDispatch` keeps the chat-tab transport for the legacy default; +the new `usePRActionDispatch` wraps it and adds terminal + new-pane +transports, branching on `target.kind`. + +### Reuse strategy (vs. comment composer) + +The comment composer code under `DiffPane/components/AgentCommentComposer` +ships three reusable concerns we want to share, plus one we don't: + +| Piece | Decision | +|---|---| +| Data source: list active terminal agents + available `HostAgentConfig`s for a workspace | **Refactor & lift.** Today this is co-located inside `AgentPickerSelect.tsx`. Extract to a shared hook under a non-DiffPane path (`apps/desktop/src/renderer/hooks/agents/useWorkspaceAgentTargets/`) so both surfaces consume it. | +| Selection model + localStorage persistence (`useDiffCommentTarget`) | **Refactor.** Generalise into `createAgentTargetStore({ storageKey, defaultPlacement })` and have both `useDiffCommentTarget` and `usePRActionAgentTarget` wrap it. Keys stay distinct so picks don't bleed across surfaces. | +| Submit routing (`useDiffCommentComposer`) | **Re-implement, don't share.** The comment flow sends a freeform user message; the PR flow sends a slash command + attachment. Different payloads, similar shape — copy the routing skeleton. | +| `AgentPickerSelect` Radix Select widget | **Do not reuse.** PR menu uses `DropdownMenu` items, not a select. Same data, different shell. | +| `AgentPlacementToggle` (split-pane / new-tab) | **Not surfaced in v1.** PR flow defaults to split-pane silently. The toggle lives in the comment composer only. | + +This gives one canonical answer per concern and keeps the comment +composer's shape intact — the DiffPane work stays a thin wrapper +around the same shared primitives. + +### Transports + +The shared dispatch hook picks transport from `target.kind`: + +- `chat-tab` (default fallback) — existing `onOpenChat({ initialPrompt, initialFiles })`. +- `existing` — `sendToTerminalAgent(terminalId, message)`, where + `message` is the slash command followed by the `pr-context.md` + contents fenced inline (terminal agents can't take separate file + attachments through xterm). Focus the target pane. +- `new` — launch the preset via the host with a seed prompt built the + same way as `existing`. + +## Phasing + +1. ✅ **Split button shell** — bordered pill, all-states routing, + Create + Update + busy + spinner. Shipped in `29a20e127`. +2. **Agent picker (this phase)** — extract shared data hook + target + store from the comment composer, render the dropdown, wire + existing + new transports, persist last pick. +3. **Custom prompt** — `.superset/pr-prompt.md` read on dispatch, + appended to `pr-context.md`; "Edit PR prompt…" Dialog at the tail + of the dropdown; slash command body learns to honor the section. +4. **Polish** — mid-launch spinner per item, empty states, telemetry + on which transport users actually pick. + +## Open questions + +- Should `sendToTerminalAgent` get a small "PR handoff" toast so the + user knows the agent has been pinged in a (possibly off-screen) + pane? Leaning yes. +- For the prompt Dialog's "Open in editor" link, do we open a v2 + file tab (`addTab({ kind: "file", path })`) or just shell out + through the existing PathActions menu? Pick whichever has a + one-call surface. diff --git a/apps/desktop/src/renderer/hooks/agents/useAgentTarget/index.ts b/apps/desktop/src/renderer/hooks/agents/useAgentTarget/index.ts new file mode 100644 index 00000000000..99ae50aea98 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/agents/useAgentTarget/index.ts @@ -0,0 +1,13 @@ +export type { + AgentSessionPlacement, + AgentTarget, + AgentTargetStorageKeys, + DecodedAgentSelection, + UseAgentTargetResult, +} from "./useAgentTarget"; +export { + decodeAgentSelection, + EXISTING_PREFIX, + NEW_PREFIX, + useAgentTarget, +} from "./useAgentTarget"; diff --git a/apps/desktop/src/renderer/hooks/agents/useAgentTarget/useAgentTarget.ts b/apps/desktop/src/renderer/hooks/agents/useAgentTarget/useAgentTarget.ts new file mode 100644 index 00000000000..f3ccc47601d --- /dev/null +++ b/apps/desktop/src/renderer/hooks/agents/useAgentTarget/useAgentTarget.ts @@ -0,0 +1,165 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; +import { useCallback, useMemo, useState } from "react"; +import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; + +export type AgentSessionPlacement = "split-pane" | "new-tab"; + +export type AgentTarget = + | { kind: "existing"; terminalId: string } + | { kind: "new"; configId: string; placement: AgentSessionPlacement }; + +export interface DecodedAgentSelection { + kind: "existing" | "new"; + id: string; +} + +export const EXISTING_PREFIX = "existing:"; +export const NEW_PREFIX = "new:"; + +export function decodeAgentSelection( + value: string, +): DecodedAgentSelection | null { + if (value.startsWith(EXISTING_PREFIX)) { + return { kind: "existing", id: value.slice(EXISTING_PREFIX.length) }; + } + if (value.startsWith(NEW_PREFIX)) { + return { kind: "new", id: value.slice(NEW_PREFIX.length) }; + } + return null; +} + +export interface AgentTargetStorageKeys { + /** Last picked terminal session id. */ + terminalId: string; + /** Last picked new-session config id. */ + configId: string; + /** Last picked placement for new sessions. */ + placement: string; +} + +interface UseAgentTargetArgs { + sessions: TerminalAgentBinding[]; + configs: HostAgentConfig[]; + storageKeys: AgentTargetStorageKeys; + defaultPlacement?: AgentSessionPlacement; +} + +export interface UseAgentTargetResult { + /** Encoded selection (`existing:` | `new:`) or null while data + * is still loading. */ + value: string | null; + placement: AgentSessionPlacement; + resolved: AgentTarget | null; + onValueChange: (next: string) => void; + onPlacementChange: (next: string) => void; +} + +function readStorage(key: string): string | null { + if (typeof window === "undefined") return null; + return window.localStorage.getItem(key); +} + +function writeStorage(key: string, value: string) { + if (typeof window === "undefined") return; + window.localStorage.setItem(key, value); +} + +/** + * Selection state + localStorage persistence for an agent picker. Default + * value is *derived* from sessions + configs + storage on every render, so a + * freshly mounted surface reflects the last-picked target as soon as data + * loads (no useEffect flicker). Picks for existing-session vs new-session are + * persisted independently so they don't clobber each other. + * + * Storage keys are passed in so the same hook can back multiple surfaces + * (DiffPane comment composer, top-right PR action button, etc.) without + * picks bleeding across them. + * + * Priority for the default selection: + * 1. last picked terminal session, if still alive + * 2. most recent active session + * 3. last picked new-agent config, if still listed + * 4. first config + */ +export function useAgentTarget({ + sessions, + configs, + storageKeys, + defaultPlacement = "split-pane", +}: UseAgentTargetArgs): UseAgentTargetResult { + const [override, setOverride] = useState(null); + const [placement, setPlacement] = useState(() => { + const stored = readStorage(storageKeys.placement); + return stored === "new-tab" || stored === "split-pane" + ? stored + : defaultPlacement; + }); + + const computedDefault = useMemo(() => { + if (sessions.length > 0) { + const stored = readStorage(storageKeys.terminalId); + const alive = + stored && sessions.some((s) => s.terminalId === stored) + ? stored + : sessions[0]?.terminalId; + if (alive) return `${EXISTING_PREFIX}${alive}`; + } + if (configs.length === 0) return null; + const storedConfigId = readStorage(storageKeys.configId); + const fromStorage = + storedConfigId && configs.some((c) => c.id === storedConfigId) + ? storedConfigId + : configs[0]?.id; + return fromStorage ? `${NEW_PREFIX}${fromStorage}` : null; + }, [sessions, configs, storageKeys.terminalId, storageKeys.configId]); + + // Validate the override against current data — if their pick is now gone + // (terminal died, config deleted), fall back to the default. + const overrideIsValid = useMemo(() => { + if (!override) return false; + const decoded = decodeAgentSelection(override); + if (!decoded) return false; + if (decoded.kind === "existing") { + return sessions.some((s) => s.terminalId === decoded.id); + } + return configs.some((c) => c.id === decoded.id); + }, [override, sessions, configs]); + + const value = overrideIsValid ? override : computedDefault; + + const resolved = useMemo(() => { + if (!value) return null; + const decoded = decodeAgentSelection(value); + if (!decoded) return null; + if (decoded.kind === "existing") { + return { kind: "existing", terminalId: decoded.id }; + } + return { kind: "new", configId: decoded.id, placement }; + }, [value, placement]); + + const onValueChange = useCallback( + (next: string) => { + setOverride(next); + const decoded = decodeAgentSelection(next); + if (!decoded) return; + writeStorage( + decoded.kind === "existing" + ? storageKeys.terminalId + : storageKeys.configId, + decoded.id, + ); + }, + [storageKeys.terminalId, storageKeys.configId], + ); + + const onPlacementChange = useCallback( + (next: string) => { + if (next !== "split-pane" && next !== "new-tab") return; + setPlacement(next); + writeStorage(storageKeys.placement, next); + }, + [storageKeys.placement], + ); + + return { value, placement, resolved, onValueChange, onPlacementChange }; +} 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 5008e0cd13a..44e4459927c 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 @@ -11,6 +11,7 @@ import { useSettings } from "renderer/stores/settings"; import type { CommentPaneData, DiffFocusSide } from "../../types"; import { FilesTab } from "./components/FilesTab"; import { PRActionHeader } from "./components/PRActionHeader"; +import type { PRActionCreateNewAgentSession } from "./components/PRActionHeader/components/PRActionSplitButton"; import { SidebarHeader } from "./components/SidebarHeader"; import { useChangesTab } from "./hooks/useChangesTab"; import { type OpenChatFn, usePRFlowDispatch } from "./hooks/usePRFlowDispatch"; @@ -18,11 +19,6 @@ 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"]; @@ -46,6 +42,9 @@ interface WorkspaceSidebarProps { ) => void; onOpenComment?: (comment: CommentPaneData) => void; onOpenChat?: OpenChatFn; + /** Plumbed from usePaneRegistry's createNewAgentSession — used by the PR + * action header when the user picks a "Start new" agent. */ + onCreateNewAgentSession?: PRActionCreateNewAgentSession; onSearch?: () => void; selectedFilePath?: string; pendingReveal?: PendingReveal | null; @@ -83,6 +82,7 @@ export function WorkspaceSidebar({ onSelectDiffFile, onOpenComment, onOpenChat, + onCreateNewAgentSession, onSearch, selectedFilePath, pendingReveal, @@ -183,7 +183,7 @@ export function WorkspaceSidebar({ state={flowState} dispatch={dispatch} onRetry={onRetry} - createPREnabled={CREATE_PR_BUTTON_ENABLED} + onCreateNewAgentSession={onCreateNewAgentSession} /> 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; + /** Host-side terminal-agent launcher. When omitted, "Start new" picks + * surface an error toast. */ + onCreateNewAgentSession?: PRActionCreateNewAgentSession; } export function PRActionHeader({ @@ -26,49 +35,90 @@ export function PRActionHeader({ state, dispatch, onRetry, - createPREnabled = true, + onCreateNewAgentSession, }: PRActionHeaderProps) { const action = selectActionButton(state); + // Agent picker data — same assembly as the DiffPane comment composer, + // just with PR-action-scoped storage keys via usePRActionAgentTarget. + const bindings = useTerminalAgentBindings(workspaceId); + const sessions = useMemo( + () => + Array.from(bindings.values()).sort( + (a, b) => b.lastEventAt - a.lastEventAt, + ), + [bindings], + ); + const hostUrl = useWorkspaceHostUrl(workspaceId); + const { data: configs = [] } = useV2AgentConfigs(hostUrl); + const { + value: selectedValue, + resolved: resolvedTarget, + onValueChange, + } = usePRActionAgentTarget({ sessions, configs }); + + const submit = usePRActionDispatch({ + workspaceId, + flowDispatch: dispatch, + onCreateNewAgentSession, + }); + + const onPickTarget = ( + target: import("renderer/hooks/agents/useAgentTarget").AgentTarget, + ) => { + onValueChange( + target.kind === "existing" + ? `existing:${target.terminalId}` + : `new:${target.configId}`, + ); + }; + + const splitButtonProps = { + sessions, + configs, + selectedValue, + resolvedTarget, + onPickTarget, + onSubmit: ( + target: import("renderer/hooks/agents/useAgentTarget").AgentTarget | null, + ) => submit({ state, target }), + }; + return (
); } -/** - * 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. - */ +type SplitButtonProps = Omit< + React.ComponentProps, + "kind" | "busy" +>; + function ActionSlot({ variant, state, - dispatch, onRetry, - createPREnabled, workspaceId, + splitButtonProps, }: { variant: ReturnType; state: PRFlowState; - dispatch: PRFlowDispatch; onRetry?: () => void; - createPREnabled: boolean; workspaceId: string; + splitButtonProps: SplitButtonProps; }) { switch (variant.kind) { case "hidden": - // `pr-exists` lands here — render the link + indicators + dropdown. return ( ; case "create-pr-dropdown": - if (!createPREnabled) { - return ( - - ); - } - return ; + return ; - case "cancel-busy": + case "update-pr-dropdown": return ( <> - +
+ +
+ + ); + + case "cancel-busy": { + // `busy` covers two cases: agent creating a PR (no pr yet) or agent + // editing an existing one. Mirror the resting layout — pill + PR + // status group — with the pill in a disabled+spinner state so the + // header doesn't lose its anchor while the agent runs. + const hasPR = state.kind === "busy" && state.pr !== null; + return ( + <> + - + {hasPR && ( +
+ +
+ )} ); + } case "retry": return ( @@ -117,14 +187,7 @@ function ActionSlot({ } } -function UnavailableIcon({ - reason, - tooltip, -}: { - reason: UnavailableReason | "create-disabled"; - tooltip?: string; -}) { - const tooltipText = tooltip ?? unavailableTooltip(reason); +function UnavailableIcon({ reason }: { reason: UnavailableReason }) { return ( @@ -132,14 +195,14 @@ function UnavailableIcon({ - {tooltipText} + + {unavailableTooltip(reason)} + ); } -function unavailableTooltip( - reason: UnavailableReason | "create-disabled", -): string { +function unavailableTooltip(reason: UnavailableReason): string { switch (reason) { case "no-repo": return "No GitHub repository connected"; @@ -147,31 +210,5 @@ function unavailableTooltip( 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 ( - - - - - Create Pull Request - - ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/PRActionSplitButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/PRActionSplitButton.tsx new file mode 100644 index 00000000000..3b33d1a2158 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/PRActionSplitButton.tsx @@ -0,0 +1,133 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { + VscChevronDown, + VscEdit, + VscGitPullRequest, + VscLoading, +} from "react-icons/vsc"; +import type { AgentTarget } from "renderer/hooks/agents/useAgentTarget"; +import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; +import { PRAgentPickerMenu } from "./components/PRAgentPickerMenu"; + +type SplitButtonKind = "create" | "update"; + +interface PRActionSplitButtonProps { + kind: SplitButtonKind; + sessions: TerminalAgentBinding[]; + configs: HostAgentConfig[]; + /** Currently-selected encoded value (`existing:` | `new:`) so the + * active item can be marked in the menu. */ + selectedValue: string | null; + resolvedTarget: AgentTarget | null; + onPickTarget: (target: AgentTarget) => void; + /** Fires the action with the currently-resolved target (or null fallback + * → chat tab). The dispatch hook owns transport routing. */ + onSubmit: (target: AgentTarget | null) => void | Promise; + /** Disables the primary + swaps the action icon for a spinner. */ + busy?: boolean; +} + +/** + * Bordered icon+label group with a chevron, mirroring the v1 PRButton and + * the v2 PRStatusGroup pill so the action slot reads as a single family. + * + * Every invocation runs through an agent — the primary region fires the + * default agent (last-picked existing terminal or new preset; chat tab as + * a fallback), and the chevron exposes the picker so the user can switch + * the default. + * + * One component covers both no-pr ("Create PR") and pr-exists + * ("Update PR") via the `kind` discriminant. + */ +export function PRActionSplitButton({ + kind, + sessions, + configs, + selectedValue, + resolvedTarget, + onPickTarget, + onSubmit, + busy = false, +}: PRActionSplitButtonProps) { + const copy = labels(kind, busy); + const primaryHandler = () => void onSubmit(resolvedTarget); + const handlePick = (target: AgentTarget) => { + onPickTarget(target); + void onSubmit(target); + }; + + const ActionIcon = kind === "create" ? VscGitPullRequest : VscEdit; + + return ( +
+ + + + + {copy.primaryTooltip} + +
+ + + + + + + + +
+ ); +} + +function labels(kind: SplitButtonKind, busy: boolean) { + if (kind === "create") { + return { + primaryLabel: busy ? "Creating…" : "Create PR", + primaryAriaLabel: "Create pull request with agent", + primaryTooltip: busy + ? "Agent is creating the PR" + : "Create PR with agent", + chevronAriaLabel: "Choose which agent creates the PR", + }; + } + return { + primaryLabel: busy ? "Updating…" : "Update PR", + primaryAriaLabel: "Update pull request with agent", + primaryTooltip: busy ? "Agent is updating the PR" : "Update PR with agent", + chevronAriaLabel: "Choose which agent updates the PR", + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/components/PRAgentPickerMenu/PRAgentPickerMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/components/PRAgentPickerMenu/PRAgentPickerMenu.tsx new file mode 100644 index 00000000000..cd2807149ab --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/components/PRAgentPickerMenu/PRAgentPickerMenu.tsx @@ -0,0 +1,162 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; +import { + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "@superset/ui/dropdown-menu"; +import { LuPlus } from "react-icons/lu"; +import { usePresetIcon } from "renderer/assets/app-icons/preset-icons"; +import { + type AgentTarget, + EXISTING_PREFIX, + NEW_PREFIX, +} from "renderer/hooks/agents/useAgentTarget"; +import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; + +interface PRAgentPickerMenuProps { + sessions: TerminalAgentBinding[]; + configs: HostAgentConfig[]; + /** Currently-selected encoded value, used to mark the active item. */ + value: string | null; + /** Fired when the user picks an item — receives the resolved target so + * the parent can both persist the pick and submit through it. */ + onPickTarget: (target: AgentTarget) => void; +} + +const groupLabelClass = + "text-[10px] font-normal uppercase tracking-wide text-muted-foreground"; + +/** + * DropdownMenu rendition of the agent picker — mirrors `AgentPickerSelect`'s + * "Active sessions" + "Start new" grouping for visual continuity with the + * DiffPane comment composer. New sessions use the persisted placement + * default (split-pane) since the PR menu doesn't surface the placement + * toggle. + */ +export function PRAgentPickerMenu({ + sessions, + configs, + value, + onPickTarget, +}: PRAgentPickerMenuProps) { + const hasSessions = sessions.length > 0; + const hasConfigs = configs.length > 0; + + if (!hasSessions && !hasConfigs) { + return ( + + No agents configured — add a preset in Settings + + ); + } + + return ( + <> + + Active sessions + + {hasSessions ? ( + sessions.map((session) => { + const encoded = `${EXISTING_PREFIX}${session.terminalId}`; + return ( + + onPickTarget({ + kind: "existing", + terminalId: session.terminalId, + }) + } + className="text-xs" + data-active={encoded === value ? "true" : undefined} + > + + + ); + }) + ) : ( + + No active sessions + + )} + {hasConfigs ? ( + <> + + + Start new + + {configs.map((config) => { + const encoded = `${NEW_PREFIX}${config.id}`; + return ( + + onPickTarget({ + kind: "new", + configId: config.id, + // Placement is the persisted default — picked up by + // the parent's usePRActionAgentTarget. The menu + // itself doesn't surface a toggle (one-click flow). + placement: "split-pane", + }) + } + className="text-xs" + data-active={encoded === value ? "true" : undefined} + > + + + ); + })} + + ) : null} + + ); +} + +function ExistingSessionItem({ binding }: { binding: TerminalAgentBinding }) { + const iconSrc = usePresetIcon(binding.agentId); + return ( + + {iconSrc ? ( + + ) : null} + {binding.agentId} + + · {binding.terminalId.slice(0, 6)} + + + ); +} + +function NewSessionItem({ + label, + presetId, +}: { + label: string; + presetId: string; +}) { + const iconSrc = usePresetIcon(presetId); + return ( + + {iconSrc ? ( + + ) : ( + + )} + {label} + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/components/PRAgentPickerMenu/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/components/PRAgentPickerMenu/index.ts new file mode 100644 index 00000000000..db4422851cc --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/components/PRAgentPickerMenu/index.ts @@ -0,0 +1 @@ +export { PRAgentPickerMenu } from "./PRAgentPickerMenu"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionAgentTarget/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionAgentTarget/index.ts new file mode 100644 index 00000000000..b9b43a12c8c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionAgentTarget/index.ts @@ -0,0 +1 @@ +export { usePRActionAgentTarget } from "./usePRActionAgentTarget"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionAgentTarget/usePRActionAgentTarget.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionAgentTarget/usePRActionAgentTarget.ts new file mode 100644 index 00000000000..c254da75ec5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionAgentTarget/usePRActionAgentTarget.ts @@ -0,0 +1,30 @@ +import type { HostAgentConfig } from "@superset/host-service/settings"; +import { + type AgentTargetStorageKeys, + type UseAgentTargetResult, + useAgentTarget, +} from "renderer/hooks/agents/useAgentTarget"; +import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; + +const PR_ACTION_STORAGE_KEYS: AgentTargetStorageKeys = { + configId: "lastSelectedPRActionNewAgentConfigId", + terminalId: "lastSelectedPRActionTerminalId", + placement: "lastSelectedPRActionPlacement", +}; + +interface UsePRActionAgentTargetArgs { + sessions: TerminalAgentBinding[]; + configs: HostAgentConfig[]; +} + +/** PR action button's agent target — thin wrapper around the shared + * `useAgentTarget` hook with PR-action-scoped storage keys, so picks here + * don't trample the DiffPane comment composer's last-picked agent. */ +export function usePRActionAgentTarget( + args: UsePRActionAgentTargetArgs, +): UseAgentTargetResult { + return useAgentTarget({ + ...args, + storageKeys: PR_ACTION_STORAGE_KEYS, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionDispatch/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionDispatch/index.ts new file mode 100644 index 00000000000..a76c6221215 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionDispatch/index.ts @@ -0,0 +1,2 @@ +export type { PRActionCreateNewAgentSession } from "./usePRActionDispatch"; +export { usePRActionDispatch } from "./usePRActionDispatch"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionDispatch/usePRActionDispatch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionDispatch/usePRActionDispatch.ts new file mode 100644 index 00000000000..8ad50af82ac --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/hooks/usePRActionDispatch/usePRActionDispatch.ts @@ -0,0 +1,92 @@ +import { toast } from "@superset/ui/sonner"; +import { useCallback } from "react"; +import type { AgentTarget } from "renderer/hooks/agents/useAgentTarget"; +import { useSendToTerminalAgent } from "renderer/hooks/host-service/useSendToTerminalAgent"; +import { + type PRFlowDispatch, + planDispatch, +} from "../../../../../../hooks/usePRFlowDispatch/usePRFlowDispatch"; +import { buildPRContext } from "../../../../utils/buildPRContext"; +import type { PRFlowState } from "../../../../utils/getPRFlowState"; + +export type PRActionCreateNewAgentSession = (input: { + configId: string; + placement: "split-pane" | "new-tab"; + prompt: string; +}) => Promise<{ terminalId: string } | null>; + +interface UsePRActionDispatchArgs { + workspaceId: string; + /** Legacy chat-tab path used when no agent target is selected. */ + flowDispatch: PRFlowDispatch; + onCreateNewAgentSession?: PRActionCreateNewAgentSession; +} + +interface SubmitArgs { + state: PRFlowState; + target: AgentTarget | null; +} + +/** + * Routes a PR-action submit to the right transport based on the chosen + * agent target. + * + * - `null` target → legacy `flowDispatch` (opens a chat tab with the slash + * command + `pr-context.md` attachment). Used when no agent has been + * picked yet. + * - `existing` target → sends the slash command + inlined pr-context to + * the terminal agent via xterm. Terminals can't carry separate file + * attachments through the channel, so the context is fenced inline. + * - `new` target → launches the preset with the same inlined seed + * prompt; the host bakes the prompt into the agent's argv/stdin. + */ +export function usePRActionDispatch({ + workspaceId, + flowDispatch, + onCreateNewAgentSession, +}: UsePRActionDispatchArgs) { + const { send: sendToTerminalAgent } = useSendToTerminalAgent(); + + return useCallback( + async ({ state, target }: SubmitArgs) => { + if (!target) { + flowDispatch({ state, draft: false }); + return; + } + + const plan = planDispatch(state, { draft: false }); + if (!plan) return; // state isn't actionable + + const inlined = formatInlinedPrompt(plan.prompt, state); + + if (target.kind === "existing") { + try { + await sendToTerminalAgent({ + workspaceId, + terminalId: target.terminalId, + text: inlined, + }); + } catch { + // useSendToTerminalAgent surfaces its own toast. + } + return; + } + + if (!onCreateNewAgentSession) { + toast.error("Couldn't start a new agent session"); + return; + } + await onCreateNewAgentSession({ + configId: target.configId, + placement: target.placement, + prompt: inlined, + }); + }, + [workspaceId, flowDispatch, sendToTerminalAgent, onCreateNewAgentSession], + ); +} + +function formatInlinedPrompt(prompt: string, state: PRFlowState): string { + const context = buildPRContext(state); + return `${prompt}\n\n--- pr-context.md ---\n${context}`; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/index.ts new file mode 100644 index 00000000000..241f447635d --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/components/PRActionSplitButton/index.ts @@ -0,0 +1,6 @@ +export { usePRActionAgentTarget } from "./hooks/usePRActionAgentTarget"; +export { + type PRActionCreateNewAgentSession, + usePRActionDispatch, +} from "./hooks/usePRActionDispatch"; +export { PRActionSplitButton } from "./PRActionSplitButton"; 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 index 13e68a7343d..497a0a11391 100644 --- 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 @@ -1,4 +1,8 @@ -import type { BranchSyncStatus, PRFlowState } from "../getPRFlowState"; +import type { + BranchSyncStatus, + PRFlowState, + PullRequest, +} from "../getPRFlowState"; /** * Builds the markdown attachment that is passed to the agent when the @@ -9,6 +13,8 @@ export function buildPRContext(state: PRFlowState): string { switch (state.kind) { case "no-pr": return renderNoPR(state.sync); + case "pr-exists": + return renderPrExists(state.pr, state.sync); default: return renderStub(state.kind); } @@ -77,6 +83,75 @@ function renderNoPR(sync: BranchSyncStatus): string { return lines.join("\n"); } +function renderPrExists( + pr: PullRequest, + sync: BranchSyncStatus | null, +): string { + const lines: string[] = []; + lines.push("# PR context"); + lines.push(""); + lines.push( + "You are about to update an existing pull request. Use this snapshot", + "to decide whether to push pending commits and refresh the PR title", + "or body before reporting back.", + ); + lines.push(""); + + lines.push("## Pull request"); + lines.push(`- Number: #${pr.number}`); + lines.push(`- URL: ${pr.url}`); + lines.push(`- State: ${pr.isDraft ? "draft" : pr.state}`); + lines.push(`- Repo: \`${pr.repoOwner}/${pr.repoName}\``); + lines.push(""); + + if (sync) { + 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(""); + + const preconditions: string[] = []; + if (sync.hasUncommitted) { + preconditions.push("- Commit or stash uncommitted changes."); + } + if (sync.hasUpstream && sync.pushCount > 0) { + preconditions.push("- Push unpushed commits."); + } + if (sync.hasUpstream && sync.pullCount > 0) { + preconditions.push( + "- Branch is behind upstream; pull/rebase before updating the PR,", + " or stop and ask the user to resolve.", + ); + } + if (preconditions.length > 0) { + lines.push("## Required preconditions"); + for (const line of preconditions) lines.push(line); + lines.push(""); + } + } + + lines.push("## Updating the PR"); + lines.push( + "- Refresh the PR title/body from latest commits if they have drifted", + ' (`gh pr edit --title "..." --body "..."`).', + "- After pushing, print the PR URL on its own line.", + ); + 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/getPRFlowState/getPRFlowState.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/PRActionHeader/utils/getPRFlowState/getPRFlowState.test.ts index 94913a80075..8c425e13c94 100644 --- 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 @@ -150,10 +150,10 @@ describe("selectActionButton", () => { test("loading → hidden", () => { expect(selectActionButton({ kind: "loading" })).toEqual({ kind: "hidden" }); }); - test("pr-exists → hidden (post-PR actions land later)", () => { + test("pr-exists → update-pr-dropdown", () => { expect( selectActionButton({ kind: "pr-exists", pr: pr(), sync: sync() }), - ).toEqual({ kind: "hidden" }); + ).toEqual({ kind: "update-pr-dropdown" }); }); test("unavailable → disabled-tooltip with reason", () => { expect( 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 index d73628e664d..a687cfcef05 100644 --- 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 @@ -65,6 +65,7 @@ export type ActionButtonVariant = | { kind: "hidden" } | { kind: "disabled-tooltip"; reasonKind: UnavailableReason } | { kind: "create-pr-dropdown" } + | { kind: "update-pr-dropdown" } | { kind: "cancel-busy" } | { kind: "retry" }; @@ -77,9 +78,7 @@ export function selectActionButton(state: PRFlowState): ActionButtonVariant { 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" }; + return { kind: "update-pr-dropdown" }; case "busy": return { kind: "cancel-busy" }; case "error": 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 index cd78a1a8996..98aa05dd850 100644 --- 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 @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { BranchSyncStatus, PRFlowState, + PullRequest, } from "../../components/PRActionHeader/utils/getPRFlowState"; import { planDispatch } from "./usePRFlowDispatch"; @@ -19,6 +20,28 @@ const sync: BranchSyncStatus = { const noPrState: PRFlowState = { kind: "no-pr", sync }; +const prFixture: 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", +}; + +const prExistsState: PRFlowState = { + kind: "pr-exists", + pr: prFixture, + sync, +}; + describe("planDispatch", () => { test("no-pr without draft → /pr/create-pr prompt", () => { const plan = planDispatch(noPrState, { draft: false }); @@ -60,4 +83,23 @@ describe("planDispatch", () => { ), ).toBeNull(); }); + + test("pr-exists → /pr/update-pr prompt", () => { + const plan = planDispatch(prExistsState, { draft: false }); + expect(plan).not.toBeNull(); + expect(plan?.prompt).toBe("/pr/update-pr"); + }); + + test("pr-exists attachment carries PR number + branch", () => { + const plan = planDispatch(prExistsState, { draft: false }); + expect(plan?.attachment.filename).toBe("pr-context.md"); + 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("#42"); + expect(decoded).toContain("Current: `feature-x`"); + }); }); 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 index 7af0bf2e67f..48533b4d182 100644 --- 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 @@ -66,6 +66,17 @@ export function planDispatch( }, }; } + case "pr-exists": { + const markdown = buildPRContext(state); + return { + prompt: "/pr/update-pr", + attachment: { + data: encodeAsDataUrl(markdown, "text/markdown"), + mediaType: "text/markdown", + filename: "pr-context.md", + }, + }; + } // MVP scope: other states don't dispatch yet. default: return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useCreateNewAgentSession/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useCreateNewAgentSession/index.ts new file mode 100644 index 00000000000..babe8748dcf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useCreateNewAgentSession/index.ts @@ -0,0 +1,5 @@ +export type { + CreateNewAgentSession, + CreateNewAgentSessionInput, +} from "./useCreateNewAgentSession"; +export { useCreateNewAgentSession } from "./useCreateNewAgentSession"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useCreateNewAgentSession/useCreateNewAgentSession.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useCreateNewAgentSession/useCreateNewAgentSession.ts new file mode 100644 index 00000000000..26009e356bf --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useCreateNewAgentSession/useCreateNewAgentSession.ts @@ -0,0 +1,74 @@ +import type { WorkspaceStore } from "@superset/panes"; +import { toast } from "@superset/ui/sonner"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { useCallback } from "react"; +import { useWorkspace } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceProvider"; +import type { StoreApi } from "zustand/vanilla"; +import type { PaneViewerData, TerminalPaneData } from "../../types"; + +export interface CreateNewAgentSessionInput { + configId: string; + placement: "split-pane" | "new-tab"; + prompt: string; +} + +export type CreateNewAgentSession = ( + input: CreateNewAgentSessionInput, +) => Promise<{ terminalId: string } | null>; + +interface UseCreateNewAgentSessionArgs { + store: StoreApi>; +} + +/** + * Launches a terminal-agent preset for the current workspace and seats the + * resulting pane either next to the active tab (`split-pane`) or in a fresh + * tab. Shared by: + * - `usePaneRegistry` (DiffPane comment composer "new session" handoff) + * - the v2 top-right PR action button (agent picker "Start new" entries) + * + * The host bakes the seed prompt into the agent's argv/stdin transport, so + * no follow-up writeInput is needed and there's no bind-wait race. + */ +export function useCreateNewAgentSession({ + store, +}: UseCreateNewAgentSessionArgs): CreateNewAgentSession { + const { workspace } = useWorkspace(); + const workspaceId = workspace.id; + const runAgent = workspaceTrpc.agents.run.useMutation(); + + return useCallback( + async (input) => { + try { + const result = await runAgent.mutateAsync({ + workspaceId, + agent: input.configId, + prompt: input.prompt, + }); + if (result.kind !== "terminal") { + toast.error("Selected agent isn't a terminal agent"); + return null; + } + const terminalId = result.sessionId; + const state = store.getState(); + const pane = { + kind: "terminal" as const, + titleOverride: result.label, + data: { terminalId } as TerminalPaneData, + }; + if (input.placement === "split-pane" && state.activeTabId) { + state.addPane({ tabId: state.activeTabId, pane }); + } else { + state.addTab({ panes: [pane] }); + } + return { terminalId }; + } catch (error) { + const description = + error instanceof Error ? error.message : "Unknown error"; + toast.error("Couldn't start agent session", { description }); + return null; + } + }, + [runAgent, store, workspaceId], + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/AgentCommentComposer/hooks/useDiffCommentTarget/useDiffCommentTarget.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/AgentCommentComposer/hooks/useDiffCommentTarget/useDiffCommentTarget.ts index 5912e7cd706..37264a8d3ef 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/AgentCommentComposer/hooks/useDiffCommentTarget/useDiffCommentTarget.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/AgentCommentComposer/hooks/useDiffCommentTarget/useDiffCommentTarget.ts @@ -1,145 +1,40 @@ import type { HostAgentConfig } from "@superset/host-service/settings"; -import { useCallback, useMemo, useState } from "react"; +import { + type AgentTargetStorageKeys, + type UseAgentTargetResult, + useAgentTarget, +} from "renderer/hooks/agents/useAgentTarget"; import type { TerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; -export type AgentSessionPlacement = "split-pane" | "new-tab"; - -export type AgentTarget = - | { kind: "existing"; terminalId: string } - | { kind: "new"; configId: string; placement: AgentSessionPlacement }; - -export interface DecodedSelection { - kind: "existing" | "new"; - id: string; -} - -export const EXISTING_PREFIX = "existing:"; -export const NEW_PREFIX = "new:"; - -const LAST_NEW_AGENT_CONFIG_ID_KEY = "lastSelectedDiffCommentNewAgentConfigId"; -const LAST_TERMINAL_ID_KEY = "lastSelectedDiffCommentTerminalId"; -const LAST_PLACEMENT_KEY = "lastSelectedDiffCommentPlacement"; -const DEFAULT_PLACEMENT: AgentSessionPlacement = "split-pane"; - -function readStorage(key: string): string | null { - if (typeof window === "undefined") return null; - return window.localStorage.getItem(key); -} - -function writeStorage(key: string, value: string) { - if (typeof window === "undefined") return; - window.localStorage.setItem(key, value); -} - -export function decodeSelection(value: string): DecodedSelection | null { - if (value.startsWith(EXISTING_PREFIX)) { - return { kind: "existing", id: value.slice(EXISTING_PREFIX.length) }; - } - if (value.startsWith(NEW_PREFIX)) { - return { kind: "new", id: value.slice(NEW_PREFIX.length) }; - } - return null; -} +export type { + AgentSessionPlacement, + AgentTarget, + DecodedAgentSelection as DecodedSelection, +} from "renderer/hooks/agents/useAgentTarget"; +export { + decodeAgentSelection as decodeSelection, + EXISTING_PREFIX, + NEW_PREFIX, +} from "renderer/hooks/agents/useAgentTarget"; + +const COMMENT_STORAGE_KEYS: AgentTargetStorageKeys = { + configId: "lastSelectedDiffCommentNewAgentConfigId", + terminalId: "lastSelectedDiffCommentTerminalId", + placement: "lastSelectedDiffCommentPlacement", +}; interface UseDiffCommentTargetArgs { sessions: TerminalAgentBinding[]; configs: HostAgentConfig[]; } -interface UseDiffCommentTargetResult { - /** Encoded selection (`existing:` | `new:`) or null while data - * is still loading. */ - value: string | null; - placement: AgentSessionPlacement; - resolved: AgentTarget | null; - onValueChange: (next: string) => void; - onPlacementChange: (next: string) => void; -} - -/** - * Resolves the composer's current agent target and placement. The default - * value is *derived* from sessions + configs + localStorage on every render, - * so a freshly mounted composer reflects the last-picked target as soon as - * data loads (no useEffect flicker). Picks are persisted independently so - * existing-session and new-session preferences don't clobber each other. - * - * Priority for the default selection: - * 1. last picked terminal session, if still alive - * 2. most recent active session - * 3. last picked new-agent config, if still listed - * 4. first config - */ -export function useDiffCommentTarget({ - sessions, - configs, -}: UseDiffCommentTargetArgs): UseDiffCommentTargetResult { - const [override, setOverride] = useState(null); - const [placement, setPlacement] = useState(() => { - const stored = readStorage(LAST_PLACEMENT_KEY); - return stored === "new-tab" || stored === "split-pane" - ? stored - : DEFAULT_PLACEMENT; +/** DiffPane comment composer's agent target — thin wrapper around the + * shared `useAgentTarget` hook with comment-scoped storage keys. */ +export function useDiffCommentTarget( + args: UseDiffCommentTargetArgs, +): UseAgentTargetResult { + return useAgentTarget({ + ...args, + storageKeys: COMMENT_STORAGE_KEYS, }); - - const computedDefault = useMemo(() => { - if (sessions.length > 0) { - const stored = readStorage(LAST_TERMINAL_ID_KEY); - const alive = - stored && sessions.some((s) => s.terminalId === stored) - ? stored - : sessions[0]?.terminalId; - if (alive) return `${EXISTING_PREFIX}${alive}`; - } - if (configs.length === 0) return null; - const storedConfigId = readStorage(LAST_NEW_AGENT_CONFIG_ID_KEY); - const fromStorage = - storedConfigId && configs.some((c) => c.id === storedConfigId) - ? storedConfigId - : configs[0]?.id; - return fromStorage ? `${NEW_PREFIX}${fromStorage}` : null; - }, [sessions, configs]); - - // Validate the user's override against current data — if their pick is - // now gone (terminal died, config deleted), fall back to the default. - const overrideIsValid = useMemo(() => { - if (!override) return false; - const decoded = decodeSelection(override); - if (!decoded) return false; - if (decoded.kind === "existing") { - return sessions.some((s) => s.terminalId === decoded.id); - } - return configs.some((c) => c.id === decoded.id); - }, [override, sessions, configs]); - - const value = overrideIsValid ? override : computedDefault; - - const resolved = useMemo(() => { - if (!value) return null; - const decoded = decodeSelection(value); - if (!decoded) return null; - if (decoded.kind === "existing") { - return { kind: "existing", terminalId: decoded.id }; - } - return { kind: "new", configId: decoded.id, placement }; - }, [value, placement]); - - const onValueChange = useCallback((next: string) => { - setOverride(next); - const decoded = decodeSelection(next); - if (!decoded) return; - writeStorage( - decoded.kind === "existing" - ? LAST_TERMINAL_ID_KEY - : LAST_NEW_AGENT_CONFIG_ID_KEY, - decoded.id, - ); - }, []); - - const onPlacementChange = useCallback((next: string) => { - if (next !== "split-pane" && next !== "new-tab") return; - setPlacement(next); - writeStorage(LAST_PLACEMENT_KEY, next); - }, []); - - return { value, placement, resolved, onValueChange, onPlacementChange }; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 5b5fcd59f21..adb6c4833a9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -9,7 +9,7 @@ import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; import { workspaceTrpc } from "@superset/workspace-client"; import { Circle, GitCompareArrows, Globe, MessageSquare } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { LuArrowDownToLine, LuClipboard, @@ -43,6 +43,7 @@ import type { PaneViewerData, TerminalPaneData, } from "../../types"; +import { useCreateNewAgentSession } from "../useCreateNewAgentSession"; import type { TerminalLauncher } from "../useV2TerminalLauncher"; import { BrowserPane, BrowserPaneToolbar } from "./components/BrowserPane"; import { ChatPane } from "./components/ChatPane"; @@ -117,7 +118,6 @@ export function usePaneRegistry({ }: UsePaneRegistryOptions): PaneRegistry { const { workspace } = useWorkspace(); const workspaceId = workspace.id; - const runAgent = workspaceTrpc.agents.run.useMutation(); const collections = useCollections(); const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; @@ -163,47 +163,7 @@ export function usePaneRegistry({ [collections.v2WorkspaceLocalState, workspaceId], ); - const createNewAgentSession = useCallback( - async (input: { - configId: string; - placement: "split-pane" | "new-tab"; - prompt: string; - }): Promise<{ terminalId: string } | null> => { - try { - // Host pipeline bakes the prompt into the initialCommand using the - // agent's argv/stdin transport — no follow-up writeInput needed, - // no bind-wait race vs. the launching shell. - const result = await runAgent.mutateAsync({ - workspaceId, - agent: input.configId, - prompt: input.prompt, - }); - if (result.kind !== "terminal") { - toast.error("Selected agent isn't a terminal agent"); - return null; - } - const terminalId = result.sessionId; - const state = store.getState(); - const pane = { - kind: "terminal" as const, - titleOverride: result.label, - data: { terminalId } as TerminalPaneData, - }; - if (input.placement === "split-pane" && state.activeTabId) { - state.addPane({ tabId: state.activeTabId, pane }); - } else { - state.addTab({ panes: [pane] }); - } - return { terminalId }; - } catch (error) { - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error("Couldn't start agent session", { description }); - return null; - } - }, - [runAgent, store, workspaceId], - ); + const createNewAgentSession = useCreateNewAgentSession({ store }); return useMemo>( () => ({ 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 42364f1b4b0..b24bc9aa5ca 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 @@ -22,6 +22,7 @@ import { useBrowserShellInteractionPassthrough } from "./hooks/useBrowserShellIn import { useClearActivePaneAttention } from "./hooks/useClearActivePaneAttention"; import { useConsumeAutomationRunLink } from "./hooks/useConsumeAutomationRunLink"; import { useConsumeOpenUrlRequest } from "./hooks/useConsumeOpenUrlRequest"; +import { useCreateNewAgentSession } from "./hooks/useCreateNewAgentSession"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { useDefaultPaneActions } from "./hooks/useDefaultPaneActions"; import { useDirtyTabCloseGuard } from "./hooks/useDirtyTabCloseGuard"; @@ -174,6 +175,7 @@ function V2WorkspaceContent() { launcher, store, }); + const createNewAgentSession = useCreateNewAgentSession({ store }); const defaultContextMenuActions = useDefaultContextMenuActions({ paneRegistry, launcher, @@ -360,6 +362,7 @@ function V2WorkspaceContent() { onSelectFile={openFilePaneFromTreeClick} onSelectDiffFile={openDiffPane} onOpenComment={openCommentPane} + onCreateNewAgentSession={createNewAgentSession} onSearch={handleQuickOpen} selectedFilePath={selectedFilePath} pendingReveal={pendingReveal}