diff --git a/docs/architecture/contracts.md b/docs/architecture/contracts.md
index 4d388f0..b796f37 100644
--- a/docs/architecture/contracts.md
+++ b/docs/architecture/contracts.md
@@ -69,7 +69,7 @@ When changing PR status fetching, derivation, or UI rendering:
- `src/types/window-api.d.ts` — type definitions for the 4 methods
- `src/store/app.store.ts` — `workspacePrInfoById`, `fetchWorkspacePrStatus`, `fetchAllWorkspacePrStatuses`
- `src/components/layout/PrStatusIcon.tsx` — icon lookup and color mapping
-- `src/components/layout/TopBarOpenPR.tsx` — PR hub trigger, dropdown, creation dialog
+- `src/components/layout/TopBarOpenPR.tsx` — PR hub trigger, ship-skill dispatch, dropdown actions
- `src/components/layout/ProjectWorkspaceSidebar.tsx` — sidebar icon rendering
See `docs/features/workspace-pr-status.md` for the full architecture reference.
diff --git a/docs/features/workspace-pr-status.md b/docs/features/workspace-pr-status.md
index d597c00..dd2b596 100644
--- a/docs/features/workspace-pr-status.md
+++ b/docs/features/workspace-pr-status.md
@@ -120,24 +120,24 @@ gh pr view --json ...
│ │
Sidebar icon TopBar PR Hub
(PrStatusIcon) (DropdownMenu +
- creation dialog)
+ ship workflow)
```
### File Map
-| Layer | File | Role |
-| ------------------ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
-| Types & derivation | `src/lib/pr-status.ts` | `WorkspacePrStatus`, `GitHubPrPayload`, `derivePrStatus()`, visual config, action config |
-| IPC handlers | `electron/main/ipc/scm.ts` | `scm:get-pr-status`, `scm:get-pr-status-for-url`, `scm:set-pr-ready`, `scm:merge-pr`, `scm:update-pr-branch` |
-| Preload bridge | `electron/preload.ts` | `getPrStatus`, `getPrStatusForUrl`, `setPrReady`, `mergePr`, `updatePrBranch` |
-| Window API types | `src/types/window-api.d.ts` | Type definitions for the 5 PR-related methods |
+| Layer | File | Role |
+| ------------------ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
+| Types & derivation | `src/lib/pr-status.ts` | `WorkspacePrStatus`, `GitHubPrPayload`, `derivePrStatus()`, visual config, action config |
+| IPC handlers | `electron/main/ipc/scm.ts` | `scm:get-pr-status`, `scm:get-pr-status-for-url`, `scm:set-pr-ready`, `scm:merge-pr`, `scm:update-pr-branch` |
+| Preload bridge | `electron/preload.ts` | `getPrStatus`, `getPrStatusForUrl`, `setPrReady`, `mergePr`, `updatePrBranch` |
+| Window API types | `src/types/window-api.d.ts` | Type definitions for the 5 PR-related methods |
| Store | `src/store/app.store.ts` | `workspacePrInfoById`, `fetchWorkspacePrStatus`, `fetchAllWorkspacePrStatuses`, `continueWorkspaceFromSummary` |
-| Icon component | `src/components/layout/PrStatusIcon.tsx` | Reusable icon renderer: status → Lucide icon + color |
-| Sidebar | `src/components/layout/ProjectWorkspaceSidebar.tsx` | Renders `PrStatusIcon` for non-default workspaces |
-| TopBar hub | `src/components/layout/TopBarOpenPR.tsx` | PR status badge, dropdown actions, creation dialog, and continue entry for completed workspaces |
-| Continue dialog | `src/components/layout/ContinueWorkspaceDialog.tsx` | New workspace handoff dialog for completed PR workspaces |
-| Information panel | `src/components/layout/WorkspaceInformationPanel.tsx` | Shows the current branch PR plus related manual PR URLs resolved through GitHub metadata when available |
-| Continue helper | `src/lib/workspace-continue.ts` | Builds the continuation markdown brief and `.stave/context/...` file path |
+| Icon component | `src/components/layout/PrStatusIcon.tsx` | Reusable icon renderer: status → Lucide icon + color |
+| Sidebar | `src/components/layout/ProjectWorkspaceSidebar.tsx` | Renders `PrStatusIcon` for non-default workspaces |
+| TopBar hub | `src/components/layout/TopBarOpenPR.tsx` | PR status badge, dropdown actions, ship-skill dispatch, and continue entry for completed workspaces |
+| Continue dialog | `src/components/layout/ContinueWorkspaceDialog.tsx` | New workspace handoff dialog for completed PR workspaces |
+| Information panel | `src/components/layout/WorkspaceInformationPanel.tsx` | Shows the current branch PR plus related manual PR URLs resolved through GitHub metadata when available |
+| Continue helper | `src/lib/workspace-continue.ts` | Builds the continuation markdown brief and `.stave/context/...` file path |
### IPC Handlers
@@ -153,12 +153,12 @@ All handlers check `gh auth status` before executing.
## Polling Strategy
-| Context | Interval | Trigger |
-| ------------------------- | ---------- | ---------------------------- |
-| Active workspace (TopBar) | 60 seconds | `useEffect` interval |
-| All workspaces (Sidebar) | 5 minutes | `useEffect` interval |
-| Manual | On demand | Dropdown "Refresh" item |
-| After action | Immediate | Post-create/merge/mark-ready |
+| Context | Interval | Trigger |
+| ------------------------- | ---------- | ----------------------- |
+| Active workspace (TopBar) | 60 seconds | `useEffect` interval |
+| All workspaces (Sidebar) | 5 minutes | `useEffect` interval |
+| Manual | On demand | Dropdown "Refresh" item |
+| After action | Immediate | Post-merge/mark-ready |
PR info is **transient state** — not persisted in `RecentProjectState` or SQLite.
Each session fetches fresh from GitHub on mount.
@@ -167,10 +167,10 @@ Each session fetches fresh from GitHub on mount.
The top bar button changes based on status:
-| Status | Button Appearance | Click Behavior |
-| --------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------ |
-| `no_pr` | `[GitPullRequest] Create PR` (primary badge) | Opens PR creation dialog _(disabled while any task in the active workspace is responding)_ |
-| Any other | `[StatusIcon] Status Label` (status-colored badge) | Opens dropdown menu |
+| Status | Button Appearance | Click Behavior |
+| --------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| `no_pr` | `[GitPullRequest] Create PR` (primary badge) | Starts the `ship` skill in the active task _(disabled while a task is responding or the active workspace skill catalog is not ready)_ |
+| Any other | `[StatusIcon] Status Label` (status-colored badge) | Opens dropdown menu |
### Dropdown Actions by Status
@@ -207,17 +207,14 @@ This is intentionally a **summary brief** flow, not a full task-history clone. I
The dialog keeps the base branch simple by default. It shows the current remote base as a badge and reveals a searchable remote branch picker only after the user clicks **Change**.
-### PR Creation Dialog
+### Ship Workflow
-- The dialog opens in a loading splash state until the suggested PR title and description are ready. Stave no longer shows a provisional fallback draft first and then replaces it in place.
-- Draft generation now includes the active workspace task, prompt-draft context, attached continuation brief snippets, notes, and open todos so the first AI draft stays focused on the current workspace instead of echoing older workspace PR summaries.
-- The dialog includes a **Target Branch** picker so users can choose which base branch the PR should merge into before running `gh pr create`.
-- Suggested PR titles are normalized against the branch's latest conventional commit subject so the type and scope stay aligned with the workspace PR flow guidance.
-- The title field validates Conventional Commits format and expects a lowercase subject, for example `fix(topbar): add create pr loading splash`.
-- If the user switches to another workspace while the dialog is open, Stave closes the dialog and discards the in-flight draft request so a previous workspace response cannot bleed into the newly active workspace.
-- Right before submission, Stave refreshes git status again and auto-commits only the files that are still currently uncommitted before pushing and opening the PR.
-- When uncommitted files are auto-committed during PR creation, progress, success, and failure messages are shown inline inside the dialog instead of as transient toast notifications.
-- While commit, push, or PR creation is running, the dialog no longer accepts dismiss attempts that would clear the prepared title and description mid-flight.
+- Create PR activates the installed `ship` skill in the active task instead of maintaining a second PR orchestration path in the renderer.
+- The skill owns repository-rule discovery, scoped validation, intentional staging and commit, push, ready PR creation, and auto-merge setup.
+- Dispatch uses the task-to-workspace ownership map, so git and provider work resolve from the task's workspace rather than whichever workspace becomes active later.
+- Stave requires the skill catalog to be loaded for the exact active workspace before enabling Create PR. A catalog from a previously selected workspace is never reused for this action.
+- Progress and approval requests appear in the active task conversation. There is no PR draft dialog or cross-workspace draft state to hydrate, replace, or leak during workspace switches.
+- Draft PR creation is intentionally not offered by this flow because the `ship` workflow creates a ready PR and queues it for automatic merge.
## Store Shape
@@ -244,7 +241,7 @@ interface WorkspacePrInfo {
## Future Work (Phase 2+)
-- **PR creation → auto-refresh**: After `scm:create-pr` succeeds, immediately fetch status _(done in Phase 1)_
+- **Ship completion → auto-refresh**: Refresh PR status immediately when the ship task reports that PR creation completed
- **Merge queue**: Add `queued_for_merge` status for repos using GitHub merge queues
- **Edit PR**: `gh pr edit` for title/body changes from within Stave
- **Close/Reopen PR**: `gh pr close` / `gh pr reopen`
diff --git a/src/components/layout/TopBarOpenPR.tsx b/src/components/layout/TopBarOpenPR.tsx
index 1059b74..66a82aa 100644
--- a/src/components/layout/TopBarOpenPR.tsx
+++ b/src/components/layout/TopBarOpenPR.tsx
@@ -1,1268 +1,465 @@
-import { useCallback, useEffect, useRef, useState, type CSSProperties, type FormEvent } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type CSSProperties,
+} from "react";
import { useShallow } from "zustand/react/shallow";
import {
- CheckCircle2,
- ChevronDown,
- ChevronRight,
ExternalLink,
GitBranch,
GitPullRequest,
- Info,
LoaderCircle,
RefreshCw,
- TriangleAlert,
} from "lucide-react";
import { ContinueWorkspaceDialog } from "@/components/layout/ContinueWorkspaceDialog";
-import { CreateWorkspaceBranchPicker } from "@/components/layout/CreateWorkspaceBranchPicker";
import {
- Button,
+ buildCreatePrShipPrompt,
+ isCreatePrWorkspaceStateActive,
+ resolveCreatePrShipAvailability,
+} from "@/components/layout/TopBarOpenPR.utils";
+import { PrStatusIcon } from "@/components/layout/PrStatusIcon";
+import {
+ TOP_BAR_PR_ACTION_EVENT,
+ type TopBarPrActionDetail,
+} from "@/components/layout/top-bar-pr-events";
+import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
- Input,
- Textarea,
Tooltip,
TooltipContent,
TooltipTrigger,
toast,
} from "@/components/ui";
import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import {
- buildPullRequestWorkspaceContext,
- generateFallbackPullRequestDraft,
-} from "@/lib/source-control-pr";
-import {
- canApplyCreatePrDialogOpenChange,
- canSubmitCreatePr,
- type CreatePrDialogStep,
- type CreatePrSubmitAction,
- buildCreatePrTargetBranchOptions,
- shouldShowCreatePrSubmitSpinner,
-} from "@/components/layout/TopBarOpenPR.utils";
-import { TOP_BAR_PR_ACTION_EVENT, type TopBarPrActionDetail } from "@/components/layout/top-bar-pr-events";
-import { PrStatusIcon } from "@/components/layout/PrStatusIcon";
-import { useAppStore } from "@/store/app.store";
-import {
- type WorkspacePrStatus,
- PR_STATUS_VISUAL,
- PR_STATUS_ACTIONS,
PR_CREATE_BUTTON_CLASS,
+ PR_STATUS_ACTIONS,
+ PR_STATUS_VISUAL,
PR_TONE_BADGE_CLASS,
+ type PrAction,
+ type WorkspacePrStatus,
} from "@/lib/pr-status";
+import { normalizeComparablePath } from "@/lib/source-control-worktrees";
import { isTaskArchived } from "@/lib/tasks";
-import {
- collectIntentContext,
- type PrePrReviewFinding,
-} from "@/lib/source-control-review";
-import { buildIntentGuardContextInput } from "@/lib/workspace-information";
-import { deriveTurnVerificationStatus } from "@/lib/workspace-scripts";
-import { getProviderLabel } from "@/lib/providers/model-catalog";
import { cn } from "@/lib/utils";
+import { useAppStore } from "@/store/app.store";
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-const PRE_COMMIT_HOOK_PATTERNS = [
- /pre-commit/i,
- /husky/i,
- /lint-staged/i,
- /hook failed/i,
- /eslint.*error/i,
- /prettier.*error/i,
-];
-
-/**
- * Heuristic: does the stderr from a failed `git commit` look like a
- * pre-commit hook (husky, lint-staged, eslint, prettier) rejection?
- */
-function looksLikePreCommitHookFailure(stderr: string | undefined): boolean {
- if (!stderr) return false;
- return PRE_COMMIT_HOOK_PATTERNS.some((re) => re.test(stderr));
-}
-
-// ---------------------------------------------------------------------------
-// Types
-// ---------------------------------------------------------------------------
-
-interface ScmStatusItem {
- path: string;
- code: string;
-}
-
-type InlineNoticeTone = "info" | "success" | "warning" | "error";
-
-interface InlineNotice {
- tone: InlineNoticeTone;
- title: string;
- description?: string;
-}
-
-type Step = CreatePrDialogStep;
-
-function InlineNoticeBanner(props: { notice: InlineNotice }) {
- const toneClassName =
- props.notice.tone === "success"
- ? "border-success/30 bg-success/10 text-success dark:bg-success/15"
- : props.notice.tone === "warning"
- ? "border-warning/40 bg-warning/10 text-warning dark:bg-warning/15"
- : props.notice.tone === "error"
- ? "border-destructive/40 bg-destructive/10 text-destructive"
- : "border-border/70 bg-muted/30 text-foreground";
-
- const Icon =
- props.notice.tone === "success"
- ? CheckCircle2
- : props.notice.tone === "warning" || props.notice.tone === "error"
- ? TriangleAlert
- : Info;
-
- return (
-
-
-
-
{props.notice.title}
- {props.notice.description ? (
-
{props.notice.description}
- ) : null}
-
-
- );
-}
-
-function CreatePrLoadingSplash(props: { currentBranch?: string; baseBranch: string }) {
- return (
-
-
-
-
-
-
-
-
Preparing a PR draft
-
- Reviewing {props.currentBranch ?? "HEAD"} against {props.baseBranch}, recent commits, and workspace PR guidance.
-
-
-
-
-
-
-
- );
-}
-
-function formatReviewFindingLocation(finding: PrePrReviewFinding) {
- return finding.line ? `${finding.file}:${finding.line}` : finding.file;
-}
-
-function formatReviewFindingKind(kind: PrePrReviewFinding["kind"]) {
- return kind.replace(/_/g, " ");
-}
-
-function getReviewSeverityClassName(severity: PrePrReviewFinding["severity"]) {
- if (severity === "critical" || severity === "high") {
- return "border-destructive/40 bg-destructive/10 text-destructive";
- }
- if (severity === "medium") {
- return "border-warning/40 bg-warning/10 text-warning";
- }
- return "border-border/70 bg-muted text-muted-foreground";
-}
-
-function PrePrReviewFindingsPanel(props: {
- findings: PrePrReviewFinding[];
- truncated?: boolean;
-}) {
- if (props.findings.length === 0) {
- return null;
- }
-
- return (
-
-
-
-
-
- AI review found {props.findings.length} issue
- {props.findings.length === 1 ? "" : "s"}
-
-
- Stop to fix these before opening the PR, or proceed if they are not
- relevant.
- {props.truncated ? " The review used a truncated diff." : ""}
-
-
-
-
-
- {props.findings.map((finding, index) => (
-
-
-
- {finding.severity}
-
-
- {formatReviewFindingKind(finding.kind)}
-
-
- {formatReviewFindingLocation(finding)}
-
-
-
- {finding.message}
-
-
- ))}
-
-
- );
-}
-
-function PrePrVerificationPanel(props: {
- failures: Array<{ scriptId: string; message: string; blocking: boolean }>;
- blocking: boolean;
-}) {
- if (props.failures.length === 0) {
- return null;
- }
-
- const containerClass = props.blocking
- ? "border-destructive/40 bg-destructive/5"
- : "border-warning/40 bg-warning/5";
- const iconClass = props.blocking ? "text-destructive" : "text-warning";
-
- return (
-
-
-
-
-
- Verification {props.blocking ? "failed" : "reported warnings"} —{" "}
- {props.failures.length} check
- {props.failures.length === 1 ? "" : "s"}
-
-
- {props.blocking
- ? "Blocking pr.beforeOpen checks failed. Fix them before opening the PR."
- : "These pr.beforeOpen checks are non-blocking — proceed anyway, or stop to fix them first."}
-
-
-
-
-
- {props.failures.map((failure, index) => (
-
-
-
- {failure.scriptId}
-
-
- {failure.blocking ? "blocking" : "non-blocking"}
-
-
-
- {failure.message}
-
-
- ))}
-
-
- );
-}
-
-function PullRequestBranchFields(props: {
- currentBranch?: string;
- defaultBranch: string;
- disabled?: boolean;
- loading?: boolean;
- onTargetBranchChange: (branch: string) => void;
- targetBranch: string;
- targetBranchOptions: string[];
-}) {
- const headBranch = props.currentBranch?.trim() || "HEAD";
-
- return (
-
-
-
- From Branch
-
-
-
- {headBranch}
-
-
-
-
-
- );
+interface WorkspaceOperation {
+ id: number;
+ workspaceId: string;
+ label: string;
}
-// ---------------------------------------------------------------------------
-// Component
-// ---------------------------------------------------------------------------
-
export function TopBarOpenPR(props: { noDragStyle: CSSProperties }) {
- const [step, setStep] = useState("idle");
- const [activeSubmitAction, setActiveSubmitAction] = useState(null);
- const [dialogOpen, setDialogOpen] = useState(false);
- const [continueDialogOpen, setContinueDialogOpen] = useState(false);
- const [continuingWorkspace, setContinuingWorkspace] = useState(false);
- const [targetBranch, setTargetBranch] = useState("");
- const [targetBranchOptions, setTargetBranchOptions] = useState([]);
- const [loadingTargetBranches, setLoadingTargetBranches] = useState(false);
-
- // PR fields
- const [prTitle, setPrTitle] = useState("");
- const [prBody, setPrBody] = useState("");
- const [inlineNotice, setInlineNotice] = useState(null);
- const [reviewFindings, setReviewFindings] = useState(
- [],
- );
- const [reviewDiffTruncated, setReviewDiffTruncated] = useState(false);
- const [verificationFailures, setVerificationFailures] = useState<
- Array<{ scriptId: string; message: string; blocking: boolean }>
- >([]);
- const [verificationBlocking, setVerificationBlocking] = useState(false);
-
- // Uncommitted changes section
- const [changedFiles, setChangedFiles] = useState([]);
- const [commitMessage, setCommitMessage] = useState("");
- const [changesExpanded, setChangesExpanded] = useState(true);
- const suggestionRequestIdRef = useRef(0);
+ const [activeOperation, setActiveOperation] =
+ useState(null);
+ const [continueDialogWorkspaceId, setContinueDialogWorkspaceId] = useState<
+ string | null
+ >(null);
+ const [continuingWorkspaceId, setContinuingWorkspaceId] = useState<
+ string | null
+ >(null);
+ const operationSequenceRef = useRef(0);
const [
activeWorkspaceId,
- workspaceDefaultById,
workspaceBranchById,
workspacePathById,
projectPath,
defaultBranch,
activeTaskId,
- promptDraftByTask,
- workspaceInformation,
tasks,
activeTurnIdsByTask,
workspacePrInfoById,
- prePrReviewEnabled,
- prePrReviewProvider,
- prePrReviewClaudeModel,
- prePrReviewCodexModel,
- prePrReviewCodexBinaryPath,
- prePrReviewCodexReasoningEffort,
+ skillCatalog,
+ draftProvider,
+ refreshSkillCatalog,
fetchWorkspacePrStatus,
continueWorkspaceFromSummary,
- ] = useAppStore(useShallow((state) => [
- state.activeWorkspaceId,
- state.workspaceDefaultById,
- state.workspaceBranchById,
- state.workspacePathById,
- state.projectPath,
- state.defaultBranch,
- state.activeTaskId,
- state.promptDraftByTask,
- state.workspaceInformation,
- state.tasks,
- state.activeTurnIdsByTask,
- state.workspacePrInfoById,
- state.settings.prePrReviewEnabled,
- state.settings.prePrReviewProvider,
- state.settings.modelClaude,
- state.settings.modelCodex,
- state.settings.codexBinaryPath,
- state.settings.codexReasoningEffort,
- state.fetchWorkspacePrStatus,
- state.continueWorkspaceFromSummary,
- ] as const));
+ sendUserMessage,
+ ] = useAppStore(
+ useShallow(
+ (state) =>
+ [
+ state.activeWorkspaceId,
+ state.workspaceBranchById,
+ state.workspacePathById,
+ state.projectPath,
+ state.defaultBranch,
+ state.activeTaskId,
+ state.tasks,
+ state.activeTurnIdsByTask,
+ state.workspacePrInfoById,
+ state.skillCatalog,
+ state.draftProvider,
+ state.refreshSkillCatalog,
+ state.fetchWorkspacePrStatus,
+ state.continueWorkspaceFromSummary,
+ state.sendUserMessage,
+ ] as const,
+ ),
+ );
- const isDefaultWorkspace = Boolean(workspaceDefaultById[activeWorkspaceId]);
- const workspaceCwd = workspacePathById[activeWorkspaceId] ?? projectPath ?? "";
- const hasWorkspaceContext = Boolean(activeWorkspaceId && workspaceCwd);
+ const workspaceCwd = workspacePathById[activeWorkspaceId] ?? "";
const currentBranch = workspaceBranchById[activeWorkspaceId];
const defaultBaseBranch = defaultBranch.trim() || "main";
const continueBaseBranch = `origin/${defaultBaseBranch}`;
- const activeTask = tasks.find((task) => task.id === activeTaskId && !isTaskArchived(task)) ?? null;
- const activeTaskDraft = activeTask?.id ? (promptDraftByTask[activeTask.id] ?? null) : null;
+ const hasWorkspaceContext = Boolean(activeWorkspaceId && workspaceCwd);
+ const isDefaultWorkspace =
+ Boolean(projectPath) &&
+ normalizeComparablePath(workspaceCwd) ===
+ normalizeComparablePath(projectPath);
+ const activeTask =
+ tasks.find((task) => task.id === activeTaskId && !isTaskArchived(task)) ??
+ null;
+ const activeProvider = activeTask?.provider ?? draftProvider;
const prInfo = workspacePrInfoById[activeWorkspaceId];
const prStatus: WorkspacePrStatus = prInfo?.derived ?? "no_pr";
const visual = PR_STATUS_VISUAL[prStatus];
const actions = PR_STATUS_ACTIONS[prStatus];
+ const badgeColorClass = PR_TONE_BADGE_CLASS[visual.tone];
+
+ const createPrShipAvailability = useMemo(
+ () =>
+ resolveCreatePrShipAvailability({
+ catalogStatus: skillCatalog.status,
+ catalogWorkspacePath: skillCatalog.workspacePath,
+ workspacePath: workspaceCwd,
+ skills: skillCatalog.skills,
+ providerId: activeProvider,
+ }),
+ [
+ activeProvider,
+ skillCatalog.skills,
+ skillCatalog.status,
+ skillCatalog.workspacePath,
+ workspaceCwd,
+ ],
+ );
+
+ const currentOperation = isCreatePrWorkspaceStateActive({
+ activeWorkspaceId,
+ stateWorkspaceId: activeOperation?.workspaceId,
+ })
+ ? activeOperation
+ : null;
+ const isBusy = currentOperation !== null;
+ const isContinuingWorkspace = isCreatePrWorkspaceStateActive({
+ activeWorkspaceId,
+ stateWorkspaceId: continuingWorkspaceId,
+ });
+ const isContinueDialogOpen = isCreatePrWorkspaceStateActive({
+ activeWorkspaceId,
+ stateWorkspaceId: continueDialogWorkspaceId,
+ });
+ const hasRespondingTask = tasks.some((task) =>
+ Boolean(activeTurnIdsByTask[task.id]),
+ );
+ const canContinueWorkspace =
+ prStatus === "merged" || prStatus === "closed_unmerged";
- // -------------------------------------------------------------------------
- // Polling – fetch PR status for active workspace
- // -------------------------------------------------------------------------
+ const beginWorkspaceOperation = useCallback(
+ (label: string) => {
+ const operation = {
+ id: ++operationSequenceRef.current,
+ workspaceId: activeWorkspaceId,
+ label,
+ };
+ setActiveOperation(operation);
+ return operation;
+ },
+ [activeWorkspaceId],
+ );
+
+ const finishWorkspaceOperation = useCallback(
+ (operation: WorkspaceOperation) => {
+ setActiveOperation((current) =>
+ current?.id === operation.id ? null : current,
+ );
+ },
+ [],
+ );
const fetchStatus = useCallback(() => {
if (activeWorkspaceId && !isDefaultWorkspace) {
void fetchWorkspacePrStatus({ workspaceId: activeWorkspaceId });
}
- }, [activeWorkspaceId, isDefaultWorkspace, fetchWorkspacePrStatus]);
+ }, [activeWorkspaceId, fetchWorkspacePrStatus, isDefaultWorkspace]);
useEffect(() => {
fetchStatus();
- const interval = setInterval(fetchStatus, 60_000);
- return () => clearInterval(interval);
+ const interval = window.setInterval(fetchStatus, 60_000);
+ return () => window.clearInterval(interval);
}, [fetchStatus]);
- // -------------------------------------------------------------------------
- // Helpers
- // -------------------------------------------------------------------------
-
- function generateFallbackCommitMessage(files: ScmStatusItem[]) {
- const added = files.filter((f) => f.code === "?" || f.code === "A").length;
- const modified = files.filter((f) => f.code === "M").length;
- const deleted = files.filter((f) => f.code === "D").length;
- const parts: string[] = [];
- if (added > 0) parts.push(`${added} added`);
- if (modified > 0) parts.push(`${modified} modified`);
- if (deleted > 0) parts.push(`${deleted} deleted`);
- return `chore: update ${parts.join(", ") || `${files.length} changes`}`;
- }
-
- function generateFallbackPRDraft(files: ScmStatusItem[]) {
- const baseBranch = targetBranch.trim() || defaultBaseBranch;
- return generateFallbackPullRequestDraft({
- baseBranch,
- headBranch: currentBranch,
- fileList: files.map((file) => `${file.code} ${file.path}`).join("\n"),
- });
- }
-
- const resetCreatePrDialogState = useCallback((args?: { closeDialog?: boolean }) => {
- suggestionRequestIdRef.current += 1;
- if (args?.closeDialog) {
- setDialogOpen(false);
- }
- setStep("idle");
- setActiveSubmitAction(null);
- setTargetBranch(defaultBaseBranch);
- setTargetBranchOptions([]);
- setLoadingTargetBranches(false);
- setPrTitle("");
- setPrBody("");
- setInlineNotice(null);
- setReviewFindings([]);
- setReviewDiffTruncated(false);
- setVerificationFailures([]);
- setVerificationBlocking(false);
- setChangedFiles([]);
- setCommitMessage("");
- setChangesExpanded(true);
- }, [defaultBaseBranch]);
-
- async function buildWorkspaceContextForPrDraft() {
- const readFile = window.api?.fs?.readFile;
- const attachedContextSnippets: Array<{ label: string; content: string }> = [];
-
- if (readFile && workspaceCwd && activeTaskDraft?.attachedFilePaths.length) {
- const snippetResults = await Promise.all(
- activeTaskDraft.attachedFilePaths.slice(0, 2).map(async (filePath) => {
- try {
- const result = await readFile({ rootPath: workspaceCwd, filePath });
- if (!result.ok || !result.content.trim()) {
- return null;
- }
- return { label: filePath, content: result.content };
- } catch {
- return null;
- }
- }),
- );
-
- for (const snippet of snippetResults) {
- if (snippet) {
- attachedContextSnippets.push(snippet);
- }
- }
- }
-
- return buildPullRequestWorkspaceContext({
- activeTaskTitle: activeTask?.title,
- taskPrompt: activeTaskDraft?.text,
- attachedContextSnippets,
- notes: workspaceInformation.notes,
- openTodos: workspaceInformation.todos
- .filter((todo) => !todo.completed && todo.text.trim().length > 0)
- .map((todo) => todo.text.trim()),
- });
- }
-
- // -------------------------------------------------------------------------
- // PR Creation flow
- // -------------------------------------------------------------------------
-
- const previousWorkspaceIdRef = useRef(activeWorkspaceId);
-
useEffect(() => {
- if (previousWorkspaceIdRef.current === activeWorkspaceId) {
- return;
- }
- previousWorkspaceIdRef.current = activeWorkspaceId;
- resetCreatePrDialogState({ closeDialog: true });
- }, [activeWorkspaceId, resetCreatePrDialogState]);
-
- async function handleCreateClick() {
- const getStatus = window.api?.sourceControl?.getStatus;
- const listBranches = window.api?.sourceControl?.listBranches;
- const suggestPRDescription = window.api?.provider?.suggestPRDescription;
- if (!getStatus) {
- toast.error("Unable to create PR", { description: "Source Control bridge unavailable." });
+ if (
+ !workspaceCwd ||
+ normalizeComparablePath(skillCatalog.workspacePath) ===
+ normalizeComparablePath(workspaceCwd)
+ ) {
return;
}
+ void refreshSkillCatalog({ workspacePath: workspaceCwd });
+ }, [refreshSkillCatalog, skillCatalog.workspacePath, workspaceCwd]);
- // Guard: ensure the cwd comes from the workspace's own worktree path,
- // not a fallback to the project root. Using the project root for a
- // non-default workspace would cause git commands to return data from
- // the wrong branch, producing stale or cross-workspace PR drafts.
- if (!workspacePathById[activeWorkspaceId]) {
+ const handleCreateClick = useCallback(async () => {
+ if (!workspaceCwd) {
toast.error("Unable to create PR", {
- description: "Workspace path is not available yet. Try switching away and back.",
+ description: "The active workspace path is not available yet.",
});
return;
}
- const requestId = suggestionRequestIdRef.current + 1;
- suggestionRequestIdRef.current = requestId;
-
- setStep("loading");
- setDialogOpen(true);
- setPrTitle("");
- setPrBody("");
- setTargetBranch(defaultBaseBranch);
- setTargetBranchOptions([defaultBaseBranch]);
- setLoadingTargetBranches(Boolean(listBranches));
- setActiveSubmitAction(null);
- setCommitMessage("");
- setChangedFiles([]);
- setChangesExpanded(true);
- setReviewFindings([]);
- setReviewDiffTruncated(false);
- setInlineNotice({
- tone: "info",
- title: "Preparing PR draft",
- description: "Reviewing the branch diff, recent commits, and workspace PR guidance.",
- });
-
- const statusPromise = getStatus({ cwd: workspaceCwd });
- const branchPromise = listBranches
- ? listBranches({ cwd: workspaceCwd }).catch(() => undefined)
- : Promise.resolve(undefined);
- const workspaceContextPromise = buildWorkspaceContextForPrDraft();
- const promptPrDescription = useAppStore.getState().settings.promptPrDescription || undefined;
- const descPromise = suggestPRDescription
- ? workspaceContextPromise
- .then((workspaceContext) => suggestPRDescription({
- cwd: workspaceCwd,
- baseBranch: defaultBaseBranch,
- headBranch: currentBranch || undefined,
- promptTemplate: promptPrDescription,
- workspaceContext: workspaceContext || undefined,
- }))
- .catch(() => undefined)
- : undefined;
-
- const [status, descResult, branchResult] = await Promise.all([
- statusPromise,
- descPromise ?? Promise.resolve(undefined),
- branchPromise,
- ]);
- if (suggestionRequestIdRef.current !== requestId) {
- return;
- }
-
- if (!status.ok) {
- toast.error("Unable to check status", { description: status.stderr || "git status failed." });
- resetCreatePrDialogState({ closeDialog: true });
+ if (createPrShipAvailability.status !== "ready") {
+ const description =
+ createPrShipAvailability.status === "loading"
+ ? "The ship workflow is still loading for this workspace."
+ : createPrShipAvailability.status === "missing"
+ ? "Install or enable the `ship` skill for the active provider."
+ : "The skill catalog could not be loaded for this workspace.";
+ toast.warning("Create PR is unavailable", { description });
return;
}
- const nextTargetBranchOptions = branchResult?.ok
- ? buildCreatePrTargetBranchOptions({
- defaultBranch: defaultBaseBranch,
- headBranch: currentBranch,
- remoteBranches: branchResult.remoteBranches ?? [],
- })
- : [defaultBaseBranch];
- const nextTargetBranch = nextTargetBranchOptions.includes(defaultBaseBranch)
- ? defaultBaseBranch
- : nextTargetBranchOptions[0] ?? defaultBaseBranch;
- setTargetBranchOptions(nextTargetBranchOptions);
- setTargetBranch(nextTargetBranch);
- setLoadingTargetBranches(false);
-
- setChangedFiles(status.items);
- const fallbackDraft = generateFallbackPullRequestDraft({
- baseBranch: nextTargetBranch,
- headBranch: currentBranch,
- fileList: status.items.map((file) => `${file.code} ${file.path}`).join("\n"),
- });
- const nextTitle = descResult?.ok && descResult.title?.trim()
- ? descResult.title.trim()
- : fallbackDraft.title;
- const nextBody = descResult?.ok && descResult.body?.trim()
- ? descResult.body.trim()
- : fallbackDraft.body;
-
- setPrTitle(nextTitle);
- setPrBody(nextBody);
- setStep("ready");
- setInlineNotice(
- suggestPRDescription && !descResult?.ok
- ? {
- tone: "warning",
- title: "Using fallback PR draft",
- description: "Could not generate a tailored title and description. Review the suggested draft before creating the PR.",
- }
- : null,
- );
- }
-
- async function handleSubmit(options: { draft: boolean; skipReview?: boolean; skipVerification?: boolean }) {
- const submitAction: CreatePrSubmitAction = options.draft ? "draft" : "pr";
- const getStatus = window.api?.sourceControl?.getStatus;
- const runCommand = window.api?.terminal?.runCommand;
- const createPR = window.api?.sourceControl?.createPR;
- const reviewDiff = window.api?.provider?.reviewDiff;
- const openExternal = window.api?.shell?.openExternal;
- const runScriptHook = window.api?.scripts?.runHook;
- const selectedTargetBranch = targetBranch.trim() || defaultBaseBranch;
- setActiveSubmitAction(submitAction);
- if (!options.skipReview) {
- setReviewFindings([]);
- setReviewDiffTruncated(false);
- }
- if (!options.skipVerification) {
- setVerificationFailures([]);
- setVerificationBlocking(false);
- }
-
- if (!runCommand || !createPR) {
- setInlineNotice({
- tone: "error",
- title: "Unable to create PR",
- description: "The source control bridge is unavailable in this workspace.",
+ const operation = beginWorkspaceOperation("Starting ship...");
+ try {
+ const result = await sendUserMessage({
+ taskId: activeTask?.id ?? "",
+ content: buildCreatePrShipPrompt(
+ createPrShipAvailability.invocationToken,
+ ),
});
- setStep("ready");
- return;
- }
- let pendingFiles = changedFiles;
- if (getStatus) {
- const statusResult = await getStatus({ cwd: workspaceCwd });
- if (!statusResult.ok) {
- setInlineNotice({
- tone: "error",
- title: "Unable to refresh workspace changes",
- description: statusResult.stderr || "git status failed.",
+ if (result.status === "blocked") {
+ toast.error("Unable to start ship workflow", {
+ description:
+ "Resolve the active task's pending approval or input, then try again.",
});
- setStep("ready");
- setActiveSubmitAction(null);
return;
}
- pendingFiles = statusResult.items;
- setChangedFiles(pendingFiles);
- setChangesExpanded(pendingFiles.length > 0);
- }
-
- const fallbackDraft = generateFallbackPullRequestDraft({
- baseBranch: selectedTargetBranch,
- headBranch: currentBranch,
- fileList: pendingFiles.map((file) => `${file.code} ${file.path}`).join("\n"),
- });
- let title = prTitle.trim() || fallbackDraft.title;
- // Step 1: Commit uncommitted changes if any
- if (pendingFiles.length > 0) {
- const stageAll = window.api?.sourceControl?.stageAll;
- const commit = window.api?.sourceControl?.commit;
- if (!stageAll || !commit) {
- setInlineNotice({
- tone: "error",
- title: "Automatic commit is unavailable",
- description: "The source control bridge cannot stage and commit the pending files.",
+ if (result.status === "queued") {
+ toast.info("Ship workflow queued", {
+ description: "It will start after the current task turn completes.",
});
- setStep("ready");
return;
}
- setStep("committing");
- setInlineNotice({
- tone: "info",
- title: "Preparing automatic commit",
- description: "Uncommitted workspace changes will be staged and committed before the PR is created.",
- });
-
- let message = commitMessage.trim();
- if (!message) {
- const suggestCommitMessage = window.api?.provider?.suggestCommitMessage;
- setInlineNotice({
- tone: "info",
- title: "Generating commit message",
- description: "Creating a Conventional Commit message from the current diff.",
+ if (result.status === "steer-unavailable") {
+ toast.error("Unable to start ship workflow", {
+ description: result.message,
});
- if (suggestCommitMessage) {
- try {
- const result = await suggestCommitMessage({ cwd: workspaceCwd });
- if (result.ok && result.message) {
- message = result.message;
- }
- } catch {
- // fall through
- }
- }
- if (!message) {
- message = generateFallbackCommitMessage(pendingFiles);
- }
- }
- setCommitMessage(message);
-
- setInlineNotice({
- tone: "info",
- title: "Staging changes",
- description: `Adding ${pendingFiles.length} pending file${pendingFiles.length !== 1 ? "s" : ""} to the automatic commit.`,
- });
- const stageResult = await stageAll({ cwd: workspaceCwd });
- if (!stageResult.ok) {
- setInlineNotice({
- tone: "error",
- title: "Staging failed",
- description: stageResult.stderr || "git add failed.",
- });
- setStep("ready");
return;
}
- setInlineNotice({
- tone: "info",
- title: "Creating commit",
- description: message,
+ toast.success("Ship workflow started", {
+ description:
+ "Follow the active task for validation, commit, push, PR, and auto-merge progress.",
});
- let commitResult = await commit({ message, cwd: workspaceCwd });
-
- // When a pre-commit hook (husky/lint-staged/eslint) fails, try to
- // auto-fix lint errors and retry the commit once before giving up.
- if (!commitResult.ok && looksLikePreCommitHookFailure(commitResult.stderr)) {
- const tryAutoFixLint = window.api?.sourceControl?.tryAutoFixLint;
- if (tryAutoFixLint) {
- setInlineNotice({
- tone: "info",
- title: "Pre-commit hook failed — attempting auto-fix",
- description: "Running eslint --fix and prettier --write on staged files…",
- });
- const fixResult = await tryAutoFixLint({ cwd: workspaceCwd });
- if (fixResult.fixAttempted) {
- // Re-stage and retry the commit
- await stageAll({ cwd: workspaceCwd });
- setInlineNotice({
- tone: "info",
- title: "Retrying commit after auto-fix",
- description: message,
- });
- commitResult = await commit({ message, cwd: workspaceCwd });
- }
- }
- }
-
- if (!commitResult.ok) {
- setInlineNotice({
- tone: "error",
- title: "Commit failed",
- description: commitResult.stderr || "git commit failed.",
- });
- setStep("ready");
- return;
- }
-
- setChangedFiles([]);
- setChangesExpanded(false);
- setInlineNotice({
- tone: "success",
- title: "Changes committed automatically",
- description: message,
+ } catch (error) {
+ toast.error("Unable to start ship workflow", {
+ description:
+ error instanceof Error
+ ? error.message
+ : "The task could not be started.",
});
+ } finally {
+ finishWorkspaceOperation(operation);
}
+ }, [
+ activeTask?.id,
+ beginWorkspaceOperation,
+ createPrShipAvailability,
+ finishWorkspaceOperation,
+ sendUserMessage,
+ workspaceCwd,
+ ]);
- if (prePrReviewEnabled && reviewDiff && !options.skipReview) {
- const reviewProviderLabel = getProviderLabel({
- providerId: prePrReviewProvider,
- });
- const reviewModel =
- prePrReviewProvider === "codex"
- ? prePrReviewCodexModel
- : prePrReviewClaudeModel;
- const reviewRuntimeOptions =
- prePrReviewProvider === "codex"
- ? {
- model: reviewModel,
- codexApprovalPolicy: "never" as const,
- codexBinaryPath:
- prePrReviewCodexBinaryPath.trim() || undefined,
- codexFileAccess: "read-only" as const,
- codexNetworkAccess: false,
- codexReasoningEffort: prePrReviewCodexReasoningEffort,
- codexWebSearch: "disabled" as const,
- }
- : { model: reviewModel };
- setStep("reviewing");
- setInlineNotice({
- tone: "info",
- title: "Running AI pre-PR review",
- description: `${reviewProviderLabel} is checking the branch diff for concrete bugs, races, and security issues before pushing.`,
- });
-
- try {
- const reviewArgs = {
- cwd: workspaceCwd,
- baseBranch: selectedTargetBranch,
- headBranch: currentBranch || undefined,
- providerId: prePrReviewProvider,
- model: reviewModel,
- runtimeOptions: reviewRuntimeOptions,
- };
- const reviewResult = await reviewDiff(reviewArgs);
-
- const findings: PrePrReviewFinding[] = reviewResult.ok
- ? [...reviewResult.findings]
- : [];
- let truncated = Boolean(reviewResult.truncated);
-
- // Intent guard: a second single-turn check that compares the diff
- // against the pinned product intent (PRD / spec / design). Only runs
- // when the workspace has intent pinned, so it is a no-op otherwise.
- const intentContext = collectIntentContext(
- buildIntentGuardContextInput(
- useAppStore.getState().workspaceInformation,
- ),
- );
- if (intentContext) {
- setInlineNotice({
- tone: "info",
- title: "Running AI intent guard",
- description: `${reviewProviderLabel} is checking the change against the pinned product intent (PRD / spec / design).`,
- });
- try {
- const intentResult = await reviewDiff({
- ...reviewArgs,
- mode: "intent",
- intentContext,
- });
- if (intentResult.ok) {
- findings.push(...intentResult.findings);
- truncated = truncated || Boolean(intentResult.truncated);
- }
- } catch {
- // Intent guard is best-effort; ignore failures.
- }
- }
-
- if (findings.length > 0) {
- const resultProviderLabel = getProviderLabel({
- providerId: reviewResult.providerId ?? prePrReviewProvider,
- });
- setReviewFindings(findings);
- setReviewDiffTruncated(truncated);
- setInlineNotice({
- tone: "warning",
- title: "Review findings need a decision",
- description: `${resultProviderLabel} found issues. Stop to fix them, or proceed anyway if they are acceptable for this PR.`,
- });
- return;
- }
-
- setReviewFindings([]);
- setReviewDiffTruncated(false);
- if (!reviewResult.ok) {
- setInlineNotice({
- tone: "warning",
- title: "AI pre-PR review was skipped",
- description: `${reviewProviderLabel} review failed, so Stave will continue creating the PR.`,
- });
- }
- } catch {
- setInlineNotice({
- tone: "warning",
- title: "AI pre-PR review was skipped",
- description: `${reviewProviderLabel} review failed, so Stave will continue creating the PR.`,
- });
- }
+ const handleMarkReady = useCallback(async () => {
+ const setPrReady = window.api?.sourceControl?.setPrReady;
+ if (!setPrReady) {
+ toast.error("Bridge unavailable");
+ return;
}
- if (runScriptHook && activeWorkspaceId && projectPath && !options.skipVerification) {
- setStep("action");
- setInlineNotice({
- tone: "info",
- title: "Running PR preflight",
- description: "Executing configured `pr.beforeOpen` verification before push and PR creation.",
- });
- const hookResult = await runScriptHook({
- workspaceId: activeWorkspaceId,
- trigger: "pr.beforeOpen",
- projectPath,
- workspacePath: workspaceCwd,
- workspaceName: currentBranch ?? "workspace",
- branch: currentBranch ?? selectedTargetBranch,
- });
- if (!hookResult.summary) {
- // Infra error (invalid config / spawn failure) — hard stop.
- if (hookResult.error) {
- setInlineNotice({
- tone: "error",
- title: "PR preflight failed",
- description: hookResult.error,
- });
- setStep("ready");
- setActiveSubmitAction(null);
- return;
- }
- } else if (hookResult.summary.failures.length > 0) {
- // Gate on verification: blocking failures stop hard, non-blocking
- // failures warn and allow an explicit "Proceed anyway".
- const blocking =
- deriveTurnVerificationStatus(hookResult.summary) === "fail";
- setVerificationFailures(hookResult.summary.failures);
- setVerificationBlocking(blocking);
- setInlineNotice({
- tone: blocking ? "error" : "warning",
- title: blocking
- ? "Verification failed"
- : "Verification reported warnings",
- description: blocking
- ? "Blocking pr.beforeOpen checks failed. Fix them before opening the PR."
- : "Non-blocking pr.beforeOpen checks failed. Review them, then proceed or fix.",
+ const operation = beginWorkspaceOperation("Working...");
+ try {
+ const result = await setPrReady({ cwd: workspaceCwd });
+ if (!result.ok) {
+ toast.error("Failed to mark PR as ready", {
+ description: result.stderr,
});
- setStep("ready");
return;
}
+ toast.success("PR marked as ready for review");
+ fetchStatus();
+ } finally {
+ finishWorkspaceOperation(operation);
}
+ }, [
+ beginWorkspaceOperation,
+ fetchStatus,
+ finishWorkspaceOperation,
+ workspaceCwd,
+ ]);
- // Step 2: Push
- setStep("pushing");
- setInlineNotice({
- tone: "info",
- title: "Pushing branch",
- description: `Updating ${currentBranch ?? "HEAD"} on origin before creating the pull request.`,
- });
- const pushResult = await runCommand({ command: "git push -u origin HEAD", cwd: workspaceCwd });
- if (!pushResult.ok) {
- setInlineNotice({
- tone: "error",
- title: "Push failed",
- description: pushResult.stderr || "git push failed.",
- });
- setStep("ready");
- return;
- }
-
- // Step 3: Create PR
- setStep("creating-pr");
- setInlineNotice({
- tone: "info",
- title: options.draft ? "Creating draft PR" : "Creating pull request",
- description: `Submitting the prepared title and description to GitHub (target: ${selectedTargetBranch}).`,
- });
- const prResult = await createPR({
- title,
- body: prBody.trim() || undefined,
- baseBranch: selectedTargetBranch,
- draft: options.draft,
- cwd: workspaceCwd,
- });
-
- if (!prResult.ok) {
- setInlineNotice({
- tone: "error",
- title: "PR creation failed",
- description: prResult.stderr || "gh pr create failed.",
- });
- setStep("ready");
+ const handleMerge = useCallback(async () => {
+ const mergePr = window.api?.sourceControl?.mergePr;
+ if (!mergePr) {
+ toast.error("Bridge unavailable");
return;
}
- // Success – close dialog, refresh status
- setDialogOpen(false);
- setStep("idle");
- setInlineNotice(null);
- setActiveSubmitAction(null);
-
- const label = options.draft ? "Draft PR created" : "PR created";
- toast.success(label, { description: prResult.prUrl ?? "Pull request created successfully." });
-
- // Refresh PR status to pick up the new PR
- fetchStatus();
-
- if (runScriptHook && activeWorkspaceId && projectPath) {
- const hookResult = await runScriptHook({
- workspaceId: activeWorkspaceId,
- trigger: "pr.afterOpen",
- projectPath,
- workspacePath: workspaceCwd,
- workspaceName: currentBranch ?? "workspace",
- branch: currentBranch ?? selectedTargetBranch,
- });
- if (!hookResult.ok) {
- toast.warning("Post-PR scripts reported failures", {
- description: hookResult.error
- ?? hookResult.summary?.failures.map((failure) => `${failure.scriptId}: ${failure.message}`).join(" ")
- ?? "Configured `pr.afterOpen` scripts failed.",
- });
- }
- }
-
- if (prResult.prUrl && openExternal) {
- try {
- await openExternal({ url: prResult.prUrl });
- } catch {
- // non-critical
+ const operation = beginWorkspaceOperation("Working...");
+ try {
+ const result = await mergePr({ method: "squash", cwd: workspaceCwd });
+ if (!result.ok) {
+ toast.error("Merge failed", { description: result.stderr });
+ return;
}
+ toast.success("PR merged successfully");
+ fetchStatus();
+ } finally {
+ finishWorkspaceOperation(operation);
}
- }
-
- function handleFormSubmit(event: FormEvent) {
- event.preventDefault();
- if (step !== "ready") return;
- void handleSubmit({ draft: false });
- }
-
- function handleProceedAfterReview() {
- const submitAction = activeSubmitAction ?? "pr";
- setReviewFindings([]);
- setReviewDiffTruncated(false);
- void handleSubmit({
- draft: submitAction === "draft",
- skipReview: true,
- });
- }
-
- function handleStopAfterReview() {
- setStep("ready");
- setActiveSubmitAction(null);
- setInlineNotice({
- tone: "warning",
- title: "PR creation paused",
- description: "Fix the review findings, then create the PR again when ready.",
- });
- }
-
- function handleProceedAfterVerification() {
- const submitAction = activeSubmitAction ?? "pr";
- setVerificationFailures([]);
- setVerificationBlocking(false);
- void handleSubmit({
- draft: submitAction === "draft",
- skipReview: true,
- skipVerification: true,
- });
- }
-
- function handleStopAfterVerification() {
- setVerificationFailures([]);
- setVerificationBlocking(false);
- setStep("ready");
- setActiveSubmitAction(null);
- setInlineNotice({
- tone: "warning",
- title: "PR creation paused",
- description: "Fix the verification failures, then create the PR again when ready.",
- });
- }
-
- // -------------------------------------------------------------------------
- // PR Action handlers
- // -------------------------------------------------------------------------
-
- async function handleMarkReady() {
- const setPrReady = window.api?.sourceControl?.setPrReady;
- if (!setPrReady) { toast.error("Bridge unavailable"); return; }
-
- setStep("action");
- const result = await setPrReady({ cwd: workspaceCwd });
- setStep("idle");
-
- if (!result.ok) {
- toast.error("Failed to mark PR as ready", { description: result.stderr });
- return;
- }
- toast.success("PR marked as ready for review");
- fetchStatus();
- }
-
- async function handleMerge() {
- const mergePr = window.api?.sourceControl?.mergePr;
- if (!mergePr) { toast.error("Bridge unavailable"); return; }
-
- setStep("action");
- const result = await mergePr({ method: "squash", cwd: workspaceCwd });
- setStep("idle");
-
- if (!result.ok) {
- toast.error("Merge failed", { description: result.stderr });
- return;
- }
- toast.success("PR merged successfully");
- fetchStatus();
- }
+ }, [
+ beginWorkspaceOperation,
+ fetchStatus,
+ finishWorkspaceOperation,
+ workspaceCwd,
+ ]);
- async function handleUpdateBranch() {
+ const handleUpdateBranch = useCallback(async () => {
const updatePrBranch = window.api?.sourceControl?.updatePrBranch;
- if (!updatePrBranch) { toast.error("Bridge unavailable"); return; }
-
- setStep("action");
- const result = await updatePrBranch({ cwd: workspaceCwd });
- setStep("idle");
-
- if (!result.ok) {
- toast.error("Branch update failed", { description: result.stderr });
+ if (!updatePrBranch) {
+ toast.error("Bridge unavailable");
return;
}
- toast.success("Branch updated");
- fetchStatus();
- }
- async function handleContinueWorkspace(args: { name: string; baseBranch?: string }) {
- setContinuingWorkspace(true);
+ const operation = beginWorkspaceOperation("Working...");
try {
- const result = await continueWorkspaceFromSummary({ name: args.name, baseBranch: args.baseBranch });
+ const result = await updatePrBranch({ cwd: workspaceCwd });
if (!result.ok) {
- toast.error("Unable to continue in a new workspace", {
- description: result.message ?? "The continuation brief could not be prepared.",
- });
- return result;
+ toast.error("Branch update failed", { description: result.stderr });
+ return;
}
+ toast.success("Branch updated");
+ fetchStatus();
+ } finally {
+ finishWorkspaceOperation(operation);
+ }
+ }, [
+ beginWorkspaceOperation,
+ fetchStatus,
+ finishWorkspaceOperation,
+ workspaceCwd,
+ ]);
- if (result.noticeLevel === "warning") {
- toast.warning("Workspace continued with warning", {
- description: result.message ?? "The workspace was created, but part of the continuation brief setup needs attention.",
- });
- } else {
- toast.success("Workspace continued", {
- description: result.message ?? "The new workspace is ready with a continuation brief attached.",
+ const handleContinueWorkspace = useCallback(
+ async (args: { name: string; baseBranch?: string }) => {
+ const sourceWorkspaceId = activeWorkspaceId;
+ setContinuingWorkspaceId(sourceWorkspaceId);
+ try {
+ const result = await continueWorkspaceFromSummary({
+ name: args.name,
+ baseBranch: args.baseBranch,
});
+ if (!result.ok) {
+ toast.error("Unable to continue in a new workspace", {
+ description:
+ result.message ?? "The continuation brief could not be prepared.",
+ });
+ return result;
+ }
+
+ if (result.noticeLevel === "warning") {
+ toast.warning("Workspace continued with warning", {
+ description:
+ result.message ??
+ "The workspace was created, but part of the continuation brief setup needs attention.",
+ });
+ } else {
+ toast.success("Workspace continued", {
+ description:
+ result.message ??
+ "The new workspace is ready with a continuation brief attached.",
+ });
+ }
+ return result;
+ } finally {
+ setContinuingWorkspaceId((current) =>
+ current === sourceWorkspaceId ? null : current,
+ );
}
- return result;
- } finally {
- setContinuingWorkspace(false);
- }
- }
+ },
+ [activeWorkspaceId, continueWorkspaceFromSummary],
+ );
- function handleOpenGitHub() {
+ const handleOpenGitHub = useCallback(() => {
const url = prInfo?.pr?.url;
if (url) {
void window.api?.shell?.openExternal?.({ url });
}
- }
-
- function handleAction(key: string) {
- switch (key) {
- case "create_pr": void handleCreateClick(); break;
- case "create_draft": void handleCreateClick(); break;
- case "mark_ready": void handleMarkReady(); break;
- case "merge": void handleMerge(); break;
- case "update_branch": void handleUpdateBranch(); break;
- case "open_github": handleOpenGitHub(); break;
- case "refresh": fetchStatus(); break;
- }
- }
-
- // -------------------------------------------------------------------------
- // Derived UI state
- // -------------------------------------------------------------------------
+ }, [prInfo?.pr?.url]);
+
+ const handleAction = useCallback(
+ (key: PrAction) => {
+ switch (key) {
+ case "create_pr":
+ void handleCreateClick();
+ break;
+ case "mark_ready":
+ void handleMarkReady();
+ break;
+ case "merge":
+ void handleMerge();
+ break;
+ case "update_branch":
+ void handleUpdateBranch();
+ break;
+ case "open_github":
+ handleOpenGitHub();
+ break;
+ case "refresh":
+ fetchStatus();
+ break;
+ }
+ },
+ [
+ fetchStatus,
+ handleCreateClick,
+ handleMarkReady,
+ handleMerge,
+ handleOpenGitHub,
+ handleUpdateBranch,
+ ],
+ );
- const isBusy = step !== "idle" && step !== "ready";
- const isDialogBusy = step === "committing" || step === "reviewing" || step === "pushing" || step === "creating-pr";
- const isCreateDraftSubmitting = shouldShowCreatePrSubmitSpinner({
- step,
- activeSubmitAction,
- buttonAction: "draft",
- });
- const isCreatePrSubmitting = shouldShowCreatePrSubmitSpinner({
- step,
- activeSubmitAction,
- buttonAction: "pr",
- });
- const effectiveTitle = prTitle.trim() || generateFallbackPRDraft(changedFiles).title;
- const canSubmitPr = canSubmitCreatePr({
- step,
- title: effectiveTitle,
- });
- const statusLabel =
- step === "loading" ? "Loading..." :
- step === "committing" ? "Committing..." :
- step === "reviewing" ? "Reviewing..." :
- step === "pushing" ? "Pushing..." :
- step === "creating-pr" ? "Creating..." :
- step === "action" ? "Working..." :
- null;
- const hasRespondingTask = tasks.some((task) => Boolean(activeTurnIdsByTask[task.id]));
- const isCreateDisabled = isBusy || hasRespondingTask;
- const canContinueWorkspace = prStatus === "merged" || prStatus === "closed_unmerged";
- const isContinueDisabled = isBusy || continuingWorkspace || hasRespondingTask;
- const effectiveTargetBranch = targetBranch.trim() || defaultBaseBranch;
+ const isCreateDisabled =
+ isBusy || hasRespondingTask || createPrShipAvailability.status !== "ready";
+ const isContinueDisabled =
+ isBusy || isContinuingWorkspace || hasRespondingTask;
const createPrTooltip = hasRespondingTask
- ? "Pause or finish the running task before creating a pull request"
- : "Create a pull request on GitHub";
+ ? "Pause or finish the running task before starting the ship workflow"
+ : createPrShipAvailability.status === "loading"
+ ? "Loading the ship workflow for this workspace"
+ : createPrShipAvailability.status === "missing"
+ ? "Install or enable the ship skill to create pull requests"
+ : createPrShipAvailability.status === "error"
+ ? "The skill catalog is unavailable for this workspace"
+ : "Run the ship skill to validate, commit, push, open a ready PR, and enable auto-merge";
const continueTooltip = hasRespondingTask
? "Pause or finish the running task before continuing into a new workspace"
: "Create a new workspace and attach a continuation brief from this completed branch";
- const badgeColorClass = PR_TONE_BADGE_CLASS[visual.tone];
-
useEffect(() => {
const onTopBarPrAction = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -1292,12 +489,14 @@ export function TopBarOpenPR(props: { noDragStyle: CSSProperties }) {
return;
}
- setContinueDialogOpen(true);
+ setContinueDialogWorkspaceId(activeWorkspaceId);
};
window.addEventListener(TOP_BAR_PR_ACTION_EVENT, onTopBarPrAction);
- return () => window.removeEventListener(TOP_BAR_PR_ACTION_EVENT, onTopBarPrAction);
+ return () =>
+ window.removeEventListener(TOP_BAR_PR_ACTION_EVENT, onTopBarPrAction);
}, [
+ activeWorkspaceId,
canContinueWorkspace,
continueTooltip,
createPrTooltip,
@@ -1308,21 +507,13 @@ export function TopBarOpenPR(props: { noDragStyle: CSSProperties }) {
isDefaultWorkspace,
]);
- // -------------------------------------------------------------------------
- // Hide on default workspace
- // -------------------------------------------------------------------------
-
- if (!hasWorkspaceContext || isDefaultWorkspace) return null;
-
- // -------------------------------------------------------------------------
- // Render
- // -------------------------------------------------------------------------
+ if (!hasWorkspaceContext || isDefaultWorkspace) {
+ return null;
+ }
return (
<>
- {/* --- Trigger button: "Create PR" or PR status dropdown --- */}
{prStatus === "no_pr" ? (
- /* No PR – show "Create PR" button */
)}
- {statusLabel ?? "Create PR"}
+ {currentOperation?.label ?? "Create PR"}
{createPrTooltip}
) : (
- /* Has PR – show status dropdown */
@@ -1360,7 +550,7 @@ export function TopBarOpenPR(props: { noDragStyle: CSSProperties }) {
badgeColorClass,
)}
style={props.noDragStyle}
- disabled={isBusy || continuingWorkspace}
+ disabled={isBusy || isContinuingWorkspace}
aria-label="open-pr-status-menu"
>
{isBusy ? (
@@ -1368,7 +558,7 @@ export function TopBarOpenPR(props: { noDragStyle: CSSProperties }) {
) : (
)}
- {statusLabel ?? visual.label}
+ {currentOperation?.label ?? visual.label}
@@ -1379,19 +569,18 @@ export function TopBarOpenPR(props: { noDragStyle: CSSProperties }) {
- {/* PR info header */}
#{prInfo?.pr?.number} {prInfo?.pr?.title}
- {currentBranch} → {prInfo?.pr?.baseRefName ?? defaultBaseBranch}
+ {currentBranch} →{" "}
+ {prInfo?.pr?.baseRefName ?? defaultBaseBranch}
- {/* Primary action */}
{actions.primary ? (
) : null}
- {/* Secondary actions */}
{actions.secondary.map((action) => (
setContinueDialogOpen(true)}
+ onClick={() =>
+ setContinueDialogWorkspaceId(activeWorkspaceId)
+ }
disabled={isContinueDisabled}
>
- {continuingWorkspace ? (
+ {isContinuingWorkspace ? (
) : (
@@ -1448,216 +638,17 @@ export function TopBarOpenPR(props: { noDragStyle: CSSProperties }) {
)}
- {/* --- PR Creation Dialog --- */}
- {
- if (!canApplyCreatePrDialogOpenChange({ open, isDialogBusy })) {
- return;
- }
- if (open) {
- setDialogOpen(true);
- return;
- }
- resetCreatePrDialogState({ closeDialog: true });
- }}
- >
- {
- if (isDialogBusy) {
- event.preventDefault();
- }
- }}
- onInteractOutside={(event) => {
- if (isDialogBusy) {
- event.preventDefault();
- }
- }}
- >
-
- Create Pull Request
-
- Create a pull request from {currentBranch ?? "HEAD"} into {effectiveTargetBranch}
-
-
-
- {step === "loading" ? (
-
-
-
- ) : (
-
- )}
-
-
-
+ setContinueDialogWorkspaceId(open ? activeWorkspaceId : null)
+ }
onContinue={handleContinueWorkspace}
/>
>
diff --git a/src/components/layout/TopBarOpenPR.utils.ts b/src/components/layout/TopBarOpenPR.utils.ts
index 9873679..0e1bf96 100644
--- a/src/components/layout/TopBarOpenPR.utils.ts
+++ b/src/components/layout/TopBarOpenPR.utils.ts
@@ -1,108 +1,66 @@
-export type CreatePrDialogStep =
- | "idle"
- | "loading"
- | "ready"
- | "committing"
- | "reviewing"
- | "pushing"
- | "creating-pr"
- | "action";
+import { getEffectiveSkillEntries } from "@/lib/skills/catalog";
+import type { ProviderId } from "@/lib/providers/provider.types";
+import type { SkillCatalogEntry } from "@/lib/skills/types";
-export type CreatePrSubmitAction = "pr" | "draft";
+const CREATE_PR_SHIP_SKILL_SLUG = "ship";
-function normalizeRemoteBranchName(branch: string) {
- const trimmed = branch.trim();
- if (!trimmed) {
- return "";
- }
- const slashIndex = trimmed.indexOf("/");
- if (slashIndex < 0 || slashIndex === trimmed.length - 1) {
- return trimmed;
- }
- return trimmed.slice(slashIndex + 1);
-}
-
-function uniqueBranches(branches: string[]) {
- const seen = new Set();
- const normalized: string[] = [];
+export type CreatePrShipAvailability =
+ | { status: "ready"; invocationToken: string }
+ | { status: "loading" }
+ | { status: "missing" }
+ | { status: "error" };
- for (const branch of branches) {
- const trimmed = branch.trim();
- if (!trimmed || seen.has(trimmed)) {
- continue;
- }
- seen.add(trimmed);
- normalized.push(trimmed);
- }
-
- return normalized;
+function normalizeWorkspacePath(value?: string | null) {
+ return (value ?? "").trim().replace(/[\\/]+$/, "");
}
-export function buildCreatePrTargetBranchOptions(args: {
- defaultBranch: string;
- headBranch?: string;
- remoteBranches: string[];
-}) {
- const headBranch = args.headBranch?.trim();
- const normalizedDefaultBranch = args.defaultBranch.trim() || "main";
- const preferredRemoteBranches = uniqueBranches(args.remoteBranches);
- const originRemoteBranches = preferredRemoteBranches.filter((branch) => branch.startsWith("origin/"));
- const candidateRemoteBranches = originRemoteBranches.length > 0 ? originRemoteBranches : preferredRemoteBranches;
-
- const seen = new Set();
- const branches: string[] = [];
-
- for (const branch of candidateRemoteBranches) {
- const normalizedBranch = normalizeRemoteBranchName(branch);
- if (!normalizedBranch || normalizedBranch === headBranch || seen.has(normalizedBranch)) {
- continue;
- }
- seen.add(normalizedBranch);
- branches.push(normalizedBranch);
+export function resolveCreatePrShipAvailability(args: {
+ catalogStatus: "idle" | "loading" | "ready" | "error";
+ catalogWorkspacePath?: string | null;
+ workspacePath?: string | null;
+ skills: readonly SkillCatalogEntry[];
+ providerId: ProviderId;
+}): CreatePrShipAvailability {
+ if (args.catalogStatus === "error") {
+ return { status: "error" };
}
- const priorityByBranch = new Map(
- [normalizedDefaultBranch, "main", "master"].map((branch, index) => [branch, index]),
- );
+ if (
+ args.catalogStatus !== "ready" ||
+ !normalizeWorkspacePath(args.workspacePath) ||
+ normalizeWorkspacePath(args.catalogWorkspacePath) !==
+ normalizeWorkspacePath(args.workspacePath)
+ ) {
+ return { status: "loading" };
+ }
- const prioritizedBranches = [...branches].sort((left, right) => {
- const leftPriority = priorityByBranch.get(left) ?? Number.MAX_SAFE_INTEGER;
- const rightPriority = priorityByBranch.get(right) ?? Number.MAX_SAFE_INTEGER;
- if (leftPriority !== rightPriority) {
- return leftPriority - rightPriority;
- }
- return left.localeCompare(right);
- });
+ const shipSkill = getEffectiveSkillEntries({
+ skills: args.skills,
+ providerId: args.providerId,
+ }).find((skill) => skill.slug.toLowerCase() === CREATE_PR_SHIP_SKILL_SLUG);
- if (prioritizedBranches.length > 0) {
- return prioritizedBranches;
+ if (!shipSkill) {
+ return { status: "missing" };
}
- return [normalizedDefaultBranch];
-}
-export function shouldShowCreatePrSubmitSpinner(args: {
- step: CreatePrDialogStep;
- activeSubmitAction: CreatePrSubmitAction | null;
- buttonAction: CreatePrSubmitAction;
-}) {
- const isSubmitStep =
- args.step === "committing"
- || args.step === "pushing"
- || args.step === "creating-pr";
-
- return isSubmitStep && args.activeSubmitAction === args.buttonAction;
+ return {
+ status: "ready",
+ invocationToken: shipSkill.invocationToken,
+ };
}
-export function canSubmitCreatePr(args: {
- step: CreatePrDialogStep;
- title?: string;
-}) {
- return args.step === "ready" && (args.title?.trim().length ?? 0) > 0;
+export function buildCreatePrShipPrompt(invocationToken: string) {
+ const normalizedToken = invocationToken.trim() || "$ship";
+ return `${normalizedToken} Ship the completed change in this workspace as a ready pull request and enable auto-merge.`;
}
-export function canApplyCreatePrDialogOpenChange(args: {
- open: boolean;
- isDialogBusy: boolean;
+export function isCreatePrWorkspaceStateActive(args: {
+ activeWorkspaceId: string;
+ stateWorkspaceId?: string | null;
}) {
- return args.open || !args.isDialogBusy;
+ return Boolean(
+ args.activeWorkspaceId &&
+ args.stateWorkspaceId &&
+ args.activeWorkspaceId === args.stateWorkspaceId,
+ );
}
diff --git a/src/components/layout/command-palette-registry.ts b/src/components/layout/command-palette-registry.ts
index 5715337..acdc7e4 100644
--- a/src/components/layout/command-palette-registry.ts
+++ b/src/components/layout/command-palette-registry.ts
@@ -100,12 +100,7 @@ export interface CommandPaletteProjectSummary {
export interface CommandPaletteLayoutState {
editorVisible: boolean;
sidebarOverlayTab:
- | "explorer"
- | "changes"
- | "information"
- | "skills"
- | "scripts"
- | "lens";
+ "explorer" | "changes" | "information" | "skills" | "scripts" | "lens";
sidebarOverlayVisible: boolean;
terminalDocked: boolean;
workspaceSidebarCollapsed: boolean;
@@ -370,7 +365,7 @@ const coreCommandDefinitions: CommandPaletteCoreCommandDefinition[] = [
{
id: "task.create-pr",
title: "Create Pull Request",
- description: "Open the pull request flow for the active workspace.",
+ description: "Run the ship workflow for the active workspace.",
group: "task",
icon: GitPullRequest,
keywords: ["create pr", "pull request", "github", "open pr"],
@@ -380,8 +375,8 @@ const coreCommandDefinitions: CommandPaletteCoreCommandDefinition[] = [
id: "task.create-pr",
title: "Create Pull Request",
subtitle: args.activeWorkspaceBranch
- ? `Open the PR flow for ${args.activeWorkspaceBranch}.`
- : "Open the PR flow for the active workspace.",
+ ? `Run the ship workflow for ${args.activeWorkspaceBranch}.`
+ : "Run the ship workflow for the active workspace.",
group: "task",
icon: GitPullRequest,
keywords: ["create pr", "pull request", "github", "open pr"],
diff --git a/src/components/layout/settings-dialog-sections.tsx b/src/components/layout/settings-dialog-sections.tsx
index bb07f12..f4b1cb1 100644
--- a/src/components/layout/settings-dialog-sections.tsx
+++ b/src/components/layout/settings-dialog-sections.tsx
@@ -130,11 +130,9 @@ import {
} from "@/store/project.utils";
import {
DEFAULT_PROMPT_RESPONSE_STYLE,
- DEFAULT_PROMPT_PR_DESCRIPTION,
DEFAULT_PROMPT_INLINE_COMPLETION,
DEFAULT_PROMPT_WORKSPACE_TURN_SUMMARY,
} from "@/lib/providers/prompt-defaults";
-import type { PrePrReviewProviderId } from "@/lib/source-control-review";
import {
THINKING_PHRASE_ANIMATION_OPTIONS,
normalizeThinkingPhraseAnimationStyle,
@@ -3744,9 +3742,6 @@ function useSettingsModelSelectorOptions(args: {
function PromptsSection() {
const [
promptResponseStyle,
- prePrReviewEnabled,
- prePrReviewProvider,
- promptPrDescription,
promptInlineCompletion,
workspaceTurnSummaryPrimaryModel,
workspaceTurnSummaryFallbackModel,
@@ -3756,9 +3751,6 @@ function PromptsSection() {
(state) =>
[
state.settings.promptResponseStyle,
- state.settings.prePrReviewEnabled,
- state.settings.prePrReviewProvider,
- state.settings.promptPrDescription,
state.settings.promptInlineCompletion,
state.settings.workspaceTurnSummaryPrimaryModel,
state.settings.workspaceTurnSummaryFallbackModel,
@@ -3776,45 +3768,6 @@ function PromptsSection() {
/>
-
-
- updateSettings({ patch: { prePrReviewEnabled: checked } })
- }
- />
-
-
- value={prePrReviewProvider}
- onChange={(providerId) =>
- updateSettings({
- patch: { prePrReviewProvider: providerId },
- })
- }
- options={[
- {
- value: "claude-code",
- label: "Claude",
- description: "Uses the configured Claude model.",
- },
- {
- value: "codex",
- label: "Codex",
- description: "Uses the configured Codex model.",
- },
- ]}
- />
-
-
-
-
-
- updateSettings({ patch: { promptPrDescription: v } })
- }
- />
-
= {
- no_pr: { icon: "GitPullRequestCreateArrow", tone: "neutral", label: "No PR" },
- draft: { icon: "GitPullRequestDraft", tone: "neutral", label: "Draft" },
- review_required: { icon: "GitPullRequest", tone: "open", label: "Review required" },
- changes_requested: { icon: "GitPullRequest", tone: "danger", label: "Changes requested" },
- checks_pending: { icon: "GitPullRequest", tone: "attention", label: "Checks running" },
- checks_failed: { icon: "GitPullRequest", tone: "danger", label: "Checks failed" },
- merge_conflict: { icon: "GitCompareArrows", tone: "danger", label: "Merge conflict" },
- behind_base: { icon: "GitBranch", tone: "attention", label: "Behind base" },
- ready_to_merge: { icon: "GitMerge", tone: "open", label: "Ready to merge" },
- merged: { icon: "GitMerge", tone: "done", label: "Merged" },
- closed_unmerged: { icon: "GitPullRequestClosed", tone: "closed", label: "Closed" },
+ no_pr: { icon: "GitPullRequestCreateArrow", tone: "neutral", label: "No PR" },
+ draft: { icon: "GitPullRequestDraft", tone: "neutral", label: "Draft" },
+ review_required: {
+ icon: "GitPullRequest",
+ tone: "open",
+ label: "Review required",
+ },
+ changes_requested: {
+ icon: "GitPullRequest",
+ tone: "danger",
+ label: "Changes requested",
+ },
+ checks_pending: {
+ icon: "GitPullRequest",
+ tone: "attention",
+ label: "Checks running",
+ },
+ checks_failed: {
+ icon: "GitPullRequest",
+ tone: "danger",
+ label: "Checks failed",
+ },
+ merge_conflict: {
+ icon: "GitCompareArrows",
+ tone: "danger",
+ label: "Merge conflict",
+ },
+ behind_base: { icon: "GitBranch", tone: "attention", label: "Behind base" },
+ ready_to_merge: { icon: "GitMerge", tone: "open", label: "Ready to merge" },
+ merged: { icon: "GitMerge", tone: "done", label: "Merged" },
+ closed_unmerged: {
+ icon: "GitPullRequestClosed",
+ tone: "closed",
+ label: "Closed",
+ },
};
/** GitHub-style foreground classes for status icons. */
export const PR_TONE_ICON_CLASS: Record = {
- neutral: "text-[#59636e] dark:text-[#8b949e]",
- open: "text-[#1a7f37] dark:text-[#3fb950]",
+ neutral: "text-[#59636e] dark:text-[#8b949e]",
+ open: "text-[#1a7f37] dark:text-[#3fb950]",
attention: "text-[#9a6700] dark:text-[#d29922]",
- danger: "text-[#d1242f] dark:text-[#f85149]",
- done: "text-[#8250df] dark:text-[#a371f7]",
- closed: "text-[#d1242f] dark:text-[#f85149]",
+ danger: "text-[#d1242f] dark:text-[#f85149]",
+ done: "text-[#8250df] dark:text-[#a371f7]",
+ closed: "text-[#d1242f] dark:text-[#f85149]",
};
/** GitHub-style muted label classes for status badges/buttons. */
export const PR_TONE_BADGE_CLASS: Record = {
- neutral: "border-[#d0d7de] bg-[#f6f8fa] text-[#57606a] hover:bg-[#eef1f4] dark:border-[#6e768166] dark:bg-[#6e76811a] dark:text-[#8b949e] dark:hover:bg-[#6e768126]",
- open: "border-[#1a7f37]/25 bg-[#dafbe1] text-[#1a7f37] hover:bg-[#c4f1cf] dark:border-[#2ea04366] dark:bg-[#2ea04326] dark:text-[#3fb950] dark:hover:bg-[#2ea04333]",
- attention: "border-[#d4a72c66] bg-[#fff8c5] text-[#9a6700] hover:bg-[#fff1a8] dark:border-[#bb800966] dark:bg-[#bb800926] dark:text-[#d29922] dark:hover:bg-[#bb800933]",
- danger: "border-[#ff818266] bg-[#ffebe9] text-[#cf222e] hover:bg-[#ffd8d3] dark:border-[#f8514966] dark:bg-[#f851491a] dark:text-[#f85149] dark:hover:bg-[#f8514926]",
- done: "border-[#c297ff66] bg-[#fbefff] text-[#8250df] hover:bg-[#f3e8ff] dark:border-[#a371f766] dark:bg-[#a371f726] dark:text-[#a371f7] dark:hover:bg-[#a371f733]",
- closed: "border-[#ff818266] bg-[#ffebe9] text-[#cf222e] hover:bg-[#ffd8d3] dark:border-[#f8514966] dark:bg-[#f851491a] dark:text-[#f85149] dark:hover:bg-[#f8514926]",
+ neutral:
+ "border-[#d0d7de] bg-[#f6f8fa] text-[#57606a] hover:bg-[#eef1f4] dark:border-[#6e768166] dark:bg-[#6e76811a] dark:text-[#8b949e] dark:hover:bg-[#6e768126]",
+ open: "border-[#1a7f37]/25 bg-[#dafbe1] text-[#1a7f37] hover:bg-[#c4f1cf] dark:border-[#2ea04366] dark:bg-[#2ea04326] dark:text-[#3fb950] dark:hover:bg-[#2ea04333]",
+ attention:
+ "border-[#d4a72c66] bg-[#fff8c5] text-[#9a6700] hover:bg-[#fff1a8] dark:border-[#bb800966] dark:bg-[#bb800926] dark:text-[#d29922] dark:hover:bg-[#bb800933]",
+ danger:
+ "border-[#ff818266] bg-[#ffebe9] text-[#cf222e] hover:bg-[#ffd8d3] dark:border-[#f8514966] dark:bg-[#f851491a] dark:text-[#f85149] dark:hover:bg-[#f8514926]",
+ done: "border-[#c297ff66] bg-[#fbefff] text-[#8250df] hover:bg-[#f3e8ff] dark:border-[#a371f766] dark:bg-[#a371f726] dark:text-[#a371f7] dark:hover:bg-[#a371f733]",
+ closed:
+ "border-[#ff818266] bg-[#ffebe9] text-[#cf222e] hover:bg-[#ffd8d3] dark:border-[#f8514966] dark:bg-[#f851491a] dark:text-[#f85149] dark:hover:bg-[#f8514926]",
};
/** Styling for the create-PR trigger when the branch has no linked PR yet. */
@@ -143,7 +173,6 @@ export const PR_CREATE_BUTTON_CLASS =
export type PrAction =
| "create_pr"
- | "create_draft"
| "mark_ready"
| "merge"
| "update_branch"
@@ -156,49 +185,81 @@ export interface PrActionConfig {
variant?: "default" | "outline" | "ghost" | "destructive";
}
-export const PR_STATUS_ACTIONS: Record = {
+export const PR_STATUS_ACTIONS: Record<
+ WorkspacePrStatus,
+ { primary: PrActionConfig | null; secondary: PrActionConfig[] }
+> = {
no_pr: {
primary: { key: "create_pr", label: "Create PR" },
- secondary: [{ key: "create_draft", label: "Create Draft", variant: "outline" }],
+ secondary: [],
},
draft: {
primary: { key: "mark_ready", label: "Mark Ready" },
- secondary: [{ key: "open_github", label: "Open on GitHub", variant: "ghost" }, { key: "refresh", label: "Refresh", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "Open on GitHub", variant: "ghost" },
+ { key: "refresh", label: "Refresh", variant: "ghost" },
+ ],
},
review_required: {
primary: null,
- secondary: [{ key: "open_github", label: "Open on GitHub", variant: "ghost" }, { key: "refresh", label: "Refresh", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "Open on GitHub", variant: "ghost" },
+ { key: "refresh", label: "Refresh", variant: "ghost" },
+ ],
},
changes_requested: {
primary: null,
- secondary: [{ key: "update_branch", label: "Update Branch", variant: "outline" }, { key: "open_github", label: "Open on GitHub", variant: "ghost" }, { key: "refresh", label: "Refresh", variant: "ghost" }],
+ secondary: [
+ { key: "update_branch", label: "Update Branch", variant: "outline" },
+ { key: "open_github", label: "Open on GitHub", variant: "ghost" },
+ { key: "refresh", label: "Refresh", variant: "ghost" },
+ ],
},
checks_pending: {
primary: null,
- secondary: [{ key: "open_github", label: "Open on GitHub", variant: "ghost" }, { key: "refresh", label: "Refresh", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "Open on GitHub", variant: "ghost" },
+ { key: "refresh", label: "Refresh", variant: "ghost" },
+ ],
},
checks_failed: {
primary: null,
- secondary: [{ key: "open_github", label: "Open on GitHub", variant: "ghost" }, { key: "refresh", label: "Refresh", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "Open on GitHub", variant: "ghost" },
+ { key: "refresh", label: "Refresh", variant: "ghost" },
+ ],
},
merge_conflict: {
primary: null,
- secondary: [{ key: "open_github", label: "Open on GitHub", variant: "ghost" }, { key: "refresh", label: "Refresh", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "Open on GitHub", variant: "ghost" },
+ { key: "refresh", label: "Refresh", variant: "ghost" },
+ ],
},
behind_base: {
primary: { key: "update_branch", label: "Update Branch" },
- secondary: [{ key: "open_github", label: "Open on GitHub", variant: "ghost" }, { key: "refresh", label: "Refresh", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "Open on GitHub", variant: "ghost" },
+ { key: "refresh", label: "Refresh", variant: "ghost" },
+ ],
},
ready_to_merge: {
primary: { key: "merge", label: "Merge PR" },
- secondary: [{ key: "open_github", label: "Open on GitHub", variant: "ghost" }, { key: "refresh", label: "Refresh", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "Open on GitHub", variant: "ghost" },
+ { key: "refresh", label: "Refresh", variant: "ghost" },
+ ],
},
merged: {
primary: null,
- secondary: [{ key: "open_github", label: "View on GitHub", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "View on GitHub", variant: "ghost" },
+ ],
},
closed_unmerged: {
primary: null,
- secondary: [{ key: "open_github", label: "View on GitHub", variant: "ghost" }],
+ secondary: [
+ { key: "open_github", label: "View on GitHub", variant: "ghost" },
+ ],
},
};
diff --git a/tests/topbar-open-pr.utils.test.ts b/tests/topbar-open-pr.utils.test.ts
index 2e5664d..5b1b9de 100644
--- a/tests/topbar-open-pr.utils.test.ts
+++ b/tests/topbar-open-pr.utils.test.ts
@@ -1,173 +1,85 @@
import { describe, expect, test } from "bun:test";
import {
- buildCreatePrTargetBranchOptions,
- canApplyCreatePrDialogOpenChange,
- canSubmitCreatePr,
- shouldShowCreatePrSubmitSpinner,
+ buildCreatePrShipPrompt,
+ isCreatePrWorkspaceStateActive,
+ resolveCreatePrShipAvailability,
} from "@/components/layout/TopBarOpenPR.utils";
-
-describe("buildCreatePrTargetBranchOptions", () => {
- test("uses normalized origin branches for PR targets and excludes the current branch", () => {
- expect(buildCreatePrTargetBranchOptions({
- defaultBranch: "main",
- headBranch: "feature/create-pr-layout",
- remoteBranches: [
- "origin/feature/create-pr-layout",
- "origin/main",
- "origin/release",
- "upstream/develop",
- ],
- })).toEqual(["main", "release"]);
- });
-
- test("falls back to non-origin remotes when origin branches are unavailable", () => {
- expect(buildCreatePrTargetBranchOptions({
- defaultBranch: "develop",
- headBranch: "feature/create-pr-layout",
- remoteBranches: ["upstream/release", "upstream/develop"],
- })).toEqual(["develop", "release"]);
+import type { SkillCatalogEntry } from "@/lib/skills/types";
+
+const shipSkill: SkillCatalogEntry = {
+ id: "user:shared:ship",
+ slug: "ship",
+ name: "Ship",
+ description: "Publish a completed change as a ready PR.",
+ scope: "user",
+ provider: "shared",
+ path: "/tmp/skills/ship/SKILL.md",
+ realPath: "/tmp/skills/ship/SKILL.md",
+ sourceRootPath: "/tmp/skills",
+ sourceRootRealPath: "/tmp/skills",
+ invocationToken: "$ship",
+ instructions: "Ship the current change.",
+};
+
+describe("resolveCreatePrShipAvailability", () => {
+ test("resolves the ship skill only from the active workspace catalog", () => {
+ expect(
+ resolveCreatePrShipAvailability({
+ catalogStatus: "ready",
+ catalogWorkspacePath: "/tmp/worktree-a/",
+ workspacePath: "/tmp/worktree-a",
+ skills: [shipSkill],
+ providerId: "codex",
+ }),
+ ).toEqual({ status: "ready", invocationToken: "$ship" });
});
- test("falls back to the default branch when no remote branches are available", () => {
- expect(buildCreatePrTargetBranchOptions({
- defaultBranch: "main",
- headBranch: "feature/create-pr-layout",
- remoteBranches: [],
- })).toEqual(["main"]);
- });
-
- test("shows the spinner only on the clicked create pr button while submitting", () => {
- expect(shouldShowCreatePrSubmitSpinner({
- step: "committing",
- activeSubmitAction: "pr",
- buttonAction: "pr",
- })).toBe(true);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "pushing",
- activeSubmitAction: "pr",
- buttonAction: "pr",
- })).toBe(true);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "creating-pr",
- activeSubmitAction: "pr",
- buttonAction: "pr",
- })).toBe(true);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "creating-pr",
- activeSubmitAction: "pr",
- buttonAction: "draft",
- })).toBe(false);
+ test("waits when the catalog still belongs to another workspace", () => {
+ expect(
+ resolveCreatePrShipAvailability({
+ catalogStatus: "ready",
+ catalogWorkspacePath: "/tmp/worktree-a",
+ workspacePath: "/tmp/worktree-b",
+ skills: [shipSkill],
+ providerId: "codex",
+ }),
+ ).toEqual({ status: "loading" });
});
- test("shows the spinner only on the clicked draft button while submitting", () => {
- expect(shouldShowCreatePrSubmitSpinner({
- step: "committing",
- activeSubmitAction: "draft",
- buttonAction: "draft",
- })).toBe(true);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "pushing",
- activeSubmitAction: "draft",
- buttonAction: "draft",
- })).toBe(true);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "creating-pr",
- activeSubmitAction: "draft",
- buttonAction: "draft",
- })).toBe(true);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "creating-pr",
- activeSubmitAction: "draft",
- buttonAction: "pr",
- })).toBe(false);
- });
-
- test("does not show a submit spinner outside of submission steps", () => {
- expect(shouldShowCreatePrSubmitSpinner({
- step: "ready",
- activeSubmitAction: "pr",
- buttonAction: "pr",
- })).toBe(false);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "loading",
- activeSubmitAction: "draft",
- buttonAction: "draft",
- })).toBe(false);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "reviewing",
- activeSubmitAction: "pr",
- buttonAction: "pr",
- })).toBe(false);
-
- expect(shouldShowCreatePrSubmitSpinner({
- step: "committing",
- activeSubmitAction: null,
- buttonAction: "pr",
- })).toBe(false);
+ test("reports a missing compatible ship skill", () => {
+ expect(
+ resolveCreatePrShipAvailability({
+ catalogStatus: "ready",
+ catalogWorkspacePath: "/tmp/worktree-a",
+ workspacePath: "/tmp/worktree-a",
+ skills: [{ ...shipSkill, provider: "claude-code" }],
+ providerId: "codex",
+ }),
+ ).toEqual({ status: "missing" });
});
});
-describe("canSubmitCreatePr", () => {
- test("allows non-conventional titles as long as the title is not empty", () => {
- expect(canSubmitCreatePr({
- step: "ready",
- title: "Improve PR title fallback behavior",
- })).toBe(true);
- });
-
- test("rejects empty titles", () => {
- expect(canSubmitCreatePr({
- step: "ready",
- title: " ",
- })).toBe(false);
- });
-
- test("rejects missing titles", () => {
- expect(canSubmitCreatePr({
- step: "ready",
- })).toBe(false);
- });
-
- test("rejects submission when the dialog is not ready", () => {
- expect(canSubmitCreatePr({
- step: "loading",
- title: "Improve PR title fallback behavior",
- })).toBe(false);
-
- expect(canSubmitCreatePr({
- step: "reviewing",
- title: "Improve PR title fallback behavior",
- })).toBe(false);
+describe("buildCreatePrShipPrompt", () => {
+ test("activates ship and makes ready PR plus auto-merge explicit", () => {
+ expect(buildCreatePrShipPrompt("$ship")).toBe(
+ "$ship Ship the completed change in this workspace as a ready pull request and enable auto-merge.",
+ );
});
});
-describe("canApplyCreatePrDialogOpenChange", () => {
- test("blocks dismiss attempts while the dialog is busy", () => {
- expect(canApplyCreatePrDialogOpenChange({
- open: false,
- isDialogBusy: true,
- })).toBe(false);
- });
-
- test("allows opening while the dialog is busy", () => {
- expect(canApplyCreatePrDialogOpenChange({
- open: true,
- isDialogBusy: true,
- })).toBe(true);
- });
-
- test("allows normal close events when the dialog is idle", () => {
- expect(canApplyCreatePrDialogOpenChange({
- open: false,
- isDialogBusy: false,
- })).toBe(true);
+describe("isCreatePrWorkspaceStateActive", () => {
+ test("hides in-flight UI state immediately after switching workspaces", () => {
+ expect(
+ isCreatePrWorkspaceStateActive({
+ activeWorkspaceId: "workspace-b",
+ stateWorkspaceId: "workspace-a",
+ }),
+ ).toBe(false);
+ expect(
+ isCreatePrWorkspaceStateActive({
+ activeWorkspaceId: "workspace-a",
+ stateWorkspaceId: "workspace-a",
+ }),
+ ).toBe(true);
});
});