From edbcee675514ef1ad4cd9b0a4235cc4d91c7fa8b Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 18:11:10 +0800 Subject: [PATCH 1/4] feat(desktop): add state-aware git next action --- apps/desktop/src/App.tsx | 194 +++++++--- .../src/components/ui/split-button.tsx | 22 +- apps/desktop/src/git/GitDockContent.tsx | 52 ++- apps/desktop/src/git/nextAction.ts | 361 ++++++++++++++++++ apps/desktop/src/i18n/strings.ts | 66 ++++ .../src/session/SessionHeaderActions.tsx | 102 ++--- .../tests/gitDockContentRendered.test.tsx | 105 +++++ apps/desktop/tests/gitNextAction.test.ts | 306 +++++++++++++++ .../sessionHeaderActionsRendered.test.tsx | 73 +++- .../2026-09-01-git-next-action/change.md | 144 +++++++ 10 files changed, 1299 insertions(+), 126 deletions(-) create mode 100644 apps/desktop/src/git/nextAction.ts create mode 100644 apps/desktop/tests/gitDockContentRendered.test.tsx create mode 100644 apps/desktop/tests/gitNextAction.test.ts create mode 100644 docs/sdlc/changes/2026-09-01-git-next-action/change.md diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0dc952b4..5a456153 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -73,7 +73,9 @@ import { gitDiffStat, gitPush, gitRevert, + gitSourceControlInfo, gitStatus, + githubCurrentPullRequest, githubImportPlugin, installMarketplacePlugin, importPromptImage, @@ -158,6 +160,7 @@ import { type Annotation, type AppshotCapture, type GitStatus, + type GitHubPullRequest, type GoalSnapshot, type GitHubPullRequestDetail, type Issue, @@ -179,6 +182,7 @@ import { type Sandbox, type SessionActivity, type SessionInfo, + type SourceControlInfo, type SessionInteractionCapabilities, type SkillInfo, type WorktreeBaselineKind, @@ -400,6 +404,7 @@ import { } from "./dock/Dock"; import { BrowserPanel } from "./browser/Browser"; import { GitDockContent, PullRequestDockContent } from "./git/GitDockContent"; +import { resolveGitNextAction } from "./git/nextAction"; import { TerminalDockContent } from "./terminal/TerminalDockContent"; import { TrajectoryView } from "./session/TrajectoryView"; import { SessionRail } from "./sidebar/SessionRail"; @@ -653,12 +658,18 @@ function localCanvasScene( interface GitWorkspaceData { status: GitStatus | null; diffStat: { added: number; deleted: number; truncated: boolean }; + sourceControl: SourceControlInfo | null; + pullRequest: GitHubPullRequest | null; + forgeError: string | null; } const EMPTY_DIFF_STAT = { added: 0, deleted: 0, truncated: false } as const; const EMPTY_GIT_WORKSPACE: GitWorkspaceData = { status: null, diffStat: EMPTY_DIFF_STAT, + sourceControl: null, + pullRequest: null, + forgeError: null, }; const EMPTY_CHECKPOINTS: Checkpoint[] = []; @@ -1364,6 +1375,26 @@ export default function App() { ); const git = currentGitWorkspace.value.status; const diffStat = currentGitWorkspace.value.diffStat; + const activeSessionRecord = useMemo( + () => + sessions.find((session) => session.id === activeSession) + ?? archivedSessions.find((session) => session.id === activeSession) + ?? null, + [activeSession, archivedSessions, sessions], + ); + const gitNextAction = resolveGitNextAction({ + status: git, + loading: currentGitWorkspace.loading, + sourceControl: currentGitWorkspace.value.sourceControl, + pullRequest: currentGitWorkspace.value.pullRequest, + forgeError: currentGitWorkspace.value.forgeError, + taskWorktree: Boolean( + activeSessionRecord?.worktree_path && !activeSessionRecord.worktree_discarded, + ), + canCleanup: Boolean( + activeSessionRecord?.worktree_path && !activeSessionRecord.worktree_discarded, + ), + }); const currentCheckpointWorkspace = workspaceStateForCwd( checkpointWorkspace, workspaceCwd, @@ -2598,14 +2629,7 @@ export default function App() { } }, [refreshProjects, selectProject, toast]); - const activeSessionTitle = useMemo( - () => - ( - sessions.find((s) => s.id === activeSession) ?? - archivedSessions.find((s) => s.id === activeSession) - )?.title ?? null, - [sessions, archivedSessions, activeSession], - ); + const activeSessionTitle = activeSessionRecord?.title ?? null; const activeTitle = activeBoardTask?.title ?? activeSessionTitle ?? t(temporarySession ? "rail.newTemporarySession" : "rail.newTask"); @@ -2645,15 +2669,12 @@ export default function App() { const focusedConfigOptions = configOptions; const focusedSessionUsage = sessionUsage; const activeWorktreeState = useMemo(() => { - const stored = - sessions.find((session) => session.id === activeSession) ?? - archivedSessions.find((session) => session.id === activeSession); return activeSessionWorktreeState( activeSession, - stored, + activeSessionRecord ?? undefined, activeSessionReceipt, - ); - }, [sessions, archivedSessions, activeSession, activeSessionReceipt]); + ); + }, [activeSession, activeSessionReceipt, activeSessionRecord]); const activeWorktreeBaseline = activeWorktreeState.baseline; const activeWorktreeUnknown = activeWorktreeState.legacyUnknown; @@ -2671,6 +2692,7 @@ export default function App() { const focusedActiveProjectRecord = activeProjectRecord; const focusedActiveProjectName = activeProjectName; const focusedGit = git; + const focusedGitNextAction = gitNextAction; const taskBoardSessions = useMemo( () => @@ -5529,46 +5551,84 @@ export default function App() { const fresh = () => gitRefreshSeq.current === request && (cwdRef.current || ".") === target; setGitWorkspace({ cwd: target, loading: true, value: EMPTY_GIT_WORKSPACE }); - gitStatus(target) - .then((s) => { - if (!fresh()) return; + void (async () => { + const [statusResult, sourceControlResult] = await Promise.allSettled([ + gitStatus(target), + gitSourceControlInfo(target), + ]); + if (!fresh()) return; + if (statusResult.status === "rejected") { setGitWorkspace({ cwd: target, loading: false, - value: { status: s, diffStat: EMPTY_DIFF_STAT }, + value: EMPTY_GIT_WORKSPACE, }); - if (s.is_repo && s.files.length > 0) { - gitDiffStat(target) - .then((stat) => { - if (!fresh()) return; - setGitWorkspace((current) => - current.cwd === target - ? { - ...current, - value: { - ...current.value, - diffStat: { - added: stat.added, - deleted: stat.deleted, - truncated: stat.truncated, - }, - }, - } - : current, - ); - }) - .catch(() => {}); - } - }) - .catch(() => { - if (fresh()) { - setGitWorkspace({ - cwd: target, - loading: false, - value: EMPTY_GIT_WORKSPACE, - }); + return; + } + + const status = statusResult.value; + const sourceControl = sourceControlResult.status === "fulfilled" + ? sourceControlResult.value + : null; + let forgeError = sourceControlResult.status === "rejected" + ? String(sourceControlResult.reason) + : null; + let pullRequest: GitHubPullRequest | null = null; + if (status.is_repo && sourceControl?.provider === "github") { + if (sourceControl.required_cli && !sourceControl.required_cli_available) { + forgeError = `${sourceControl.required_cli} is unavailable`; + } else { + try { + pullRequest = await githubCurrentPullRequest(target); + } catch (error) { + forgeError = String(error); + } } + } + + if (!fresh()) return; + setGitWorkspace({ + cwd: target, + loading: false, + value: { + status, + diffStat: EMPTY_DIFF_STAT, + sourceControl, + pullRequest, + forgeError, + }, }); + if (status.is_repo && status.files.length > 0) { + gitDiffStat(target) + .then((stat) => { + if (!fresh()) return; + setGitWorkspace((current) => + current.cwd === target + ? { + ...current, + value: { + ...current.value, + diffStat: { + added: stat.added, + deleted: stat.deleted, + truncated: stat.truncated, + }, + }, + } + : current, + ); + }) + .catch(() => {}); + } + })().catch(() => { + if (fresh()) { + setGitWorkspace({ + cwd: target, + loading: false, + value: EMPTY_GIT_WORKSPACE, + }); + } + }); }, [cwd]); const refreshCheckpoints = useCallback(() => { @@ -8033,13 +8093,29 @@ export default function App() { const activeProjectName = paneFocused ? focusedActiveProjectName : activeProjectRecord?.name ?? null; - const git = paneFocused - ? focusedGit + const paneGitWorkspace = paneFocused + ? currentGitWorkspace : workspaceStateForCwd( gitWorkspace, cwd || ".", EMPTY_GIT_WORKSPACE, - ).value.status; + ); + const git = paneFocused ? focusedGit : paneGitWorkspace.value.status; + const gitAction = paneFocused + ? focusedGitNextAction + : resolveGitNextAction({ + status: paneGitWorkspace.value.status, + loading: paneGitWorkspace.loading, + sourceControl: paneGitWorkspace.value.sourceControl, + pullRequest: paneGitWorkspace.value.pullRequest, + forgeError: paneGitWorkspace.value.forgeError, + taskWorktree: Boolean( + paneStored?.worktree_path && !paneStored.worktree_discarded, + ), + canCleanup: Boolean( + paneStored?.worktree_path && !paneStored.worktree_discarded, + ), + }); // Per-session model/config/usage: the focused pane keeps the authoritative single // values; a background pane reads its own session's recorded snapshot. const models = paneFocused @@ -8215,7 +8291,7 @@ export default function App() { void openWorkingDirectory("antigravity")} onOpenFinder={() => void openWorkingDirectory("finder")} finderHint={hint("open_finder")} - onCommit={openSourceControl} + onOpenSourceControl={openSourceControl} + onOpenPullRequest={() => manualDockTab("pull-request")} + onCleanupWorktree={() => { + if (paneStored) void discardWorktreeForSession(paneStored); + }} onCheckpoint={() => void doCheckpoint()} onPush={() => void doPush().catch(() => {})} onMoveTask={() => activeSession && setShowTaskHandoff(true)} @@ -8690,7 +8770,15 @@ export default function App() { git: ( void doPush().catch(() => {})} + onOpenPullRequest={() => manualDockTab("pull-request")} + onCleanupWorktree={() => { + if (activeSessionRecord) { + void discardWorktreeForSession(activeSessionRecord); + } + }} /> ), "pull-request": ( diff --git a/apps/desktop/src/components/ui/split-button.tsx b/apps/desktop/src/components/ui/split-button.tsx index f0fa7617..f9448718 100644 --- a/apps/desktop/src/components/ui/split-button.tsx +++ b/apps/desktop/src/components/ui/split-button.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import { ChevronDown } from "./icons"; import { cn } from "@/lib/utils"; @@ -20,7 +22,9 @@ type SplitButtonSize = "default" | "sm" | "compact" | "field"; interface SplitButtonProps { /** Text shown on the primary (left) half. */ - label: string; + label: ReactNode; + /** Accessible name when the visible label is responsive or otherwise composite. */ + primaryLabel?: string; /** Handler for the primary button click. */ onClick: () => void; /** Alternative actions rendered inside the chevron dropdown. */ @@ -29,6 +33,9 @@ interface SplitButtonProps { size?: SplitButtonSize; disabled?: boolean; className?: string; + primaryClassName?: string; + menuButtonClassName?: string; + menuLabel?: string; /** Where the dropdown aligns relative to the trigger. */ menuAlign?: "start" | "center" | "end"; menuSide?: "top" | "bottom"; @@ -48,12 +55,16 @@ const separatorClass: Record = { */ function SplitButton({ label, + primaryLabel, onClick, actions, variant = "default", size = "default", disabled = false, className, + primaryClassName, + menuButtonClassName, + menuLabel = "More actions", menuAlign = "end", menuSide = "top", }: SplitButtonProps) { @@ -64,7 +75,8 @@ function SplitButton({ variant={variant} size={size} disabled={disabled} - className={className} + aria-label={primaryLabel} + className={cn(className, primaryClassName)} onClick={onClick} > {label} @@ -85,7 +97,8 @@ function SplitButton({ variant={variant} size={size} disabled={disabled} - className="rounded-r-none focus-visible:z-10" + aria-label={primaryLabel} + className={cn("rounded-r-none focus-visible:z-10", primaryClassName)} onClick={onClick} > {label} @@ -99,10 +112,11 @@ function SplitButton({ variant={variant} size={size} disabled={disabled} - aria-label="More actions" + aria-label={menuLabel} className={cn( "relative rounded-l-none px-1.5 focus-visible:z-10 before:absolute before:left-0 before:h-4 before:w-px", separatorClass[variant], + menuButtonClassName, )} > diff --git a/apps/desktop/src/git/GitDockContent.tsx b/apps/desktop/src/git/GitDockContent.tsx index 17163552..8630be9d 100644 --- a/apps/desktop/src/git/GitDockContent.tsx +++ b/apps/desktop/src/git/GitDockContent.tsx @@ -1,16 +1,27 @@ import { GitBranch } from "@/components/ui/icons"; import type { GitStatus } from "../bridge"; -import { Button } from "@/components/ui/button"; +import { SplitButton } from "@/components/ui/split-button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useT } from "../i18n"; import { cn } from "@/lib/utils"; import { GitHubPullRequestPanel } from "./GitHubPullRequestPanel"; import { GitSyncStatus } from "./GitSyncStatus"; +import { + gitNextActionLabel, + gitNextActionReason, + runGitNextAction, + type GitNextActionItem, + type GitNextActionProjection, +} from "./nextAction"; type GitDockContentProps = { status: GitStatus | null; + action: GitNextActionProjection; onOpenSourceControl: () => void; + onPush: () => void; + onOpenPullRequest: () => void; + onCleanupWorktree: () => void; }; type PullRequestDockContentProps = { @@ -22,9 +33,20 @@ type PullRequestDockContentProps = { /** Working-tree summary rendered inside the generic Dock container. */ export function GitDockContent({ status, + action, onOpenSourceControl, + onPush, + onOpenPullRequest, + onCleanupWorktree, }: GitDockContentProps) { const t = useT(); + const runAction = (item: GitNextActionItem) => runGitNextAction(item, { + openSourceControl: onOpenSourceControl, + push: onPush, + openPullRequest: onOpenPullRequest, + cleanupWorktree: onCleanupWorktree, + }); + const primaryLabel = gitNextActionLabel(t, action.primary, action.changeRequestLabel); return ( @@ -65,13 +87,29 @@ export function GitDockContent({ )} - - ) : ( -

{t("rail.notARepo")}

- )} + ) : null} +
+ runAction(action.primary)} + actions={action.alternatives.map((item) => ({ + label: gitNextActionLabel(t, item, action.changeRequestLabel), + onClick: () => runAction(item), + disabled: item.disabled, + }))} + disabled={action.primary.disabled} + variant={action.primary.disabled ? "secondary" : "default"} + size="sm" + className="w-full" + primaryClassName="flex-1" + menuLabel={t("git.next.moreActions")} + /> +

+ {gitNextActionReason(t, action)} +

+
); diff --git a/apps/desktop/src/git/nextAction.ts b/apps/desktop/src/git/nextAction.ts new file mode 100644 index 00000000..ed1a9dd6 --- /dev/null +++ b/apps/desktop/src/git/nextAction.ts @@ -0,0 +1,361 @@ +import type { + GitHubPullRequest, + GitStatus, + SourceControlInfo, +} from "../bridge"; +import type { Translate } from "../i18n"; + +export type GitNextActionId = + | "checking" + | "unavailable" + | "up_to_date" + | "source_control" + | "review_changes" + | "push" + | "create_change_request" + | "resolve_conflicts" + | "review_failed_checks" + | "address_review" + | "view_checks" + | "review_remote_updates" + | "review_draft" + | "view_pull_request" + | "merge_pull_request" + | "cleanup_worktree"; + +export type GitNextActionDestination = + | "none" + | "source_control" + | "push" + | "pull_request" + | "cleanup"; + +export interface GitNextActionItem { + id: GitNextActionId; + destination: GitNextActionDestination; + disabled?: boolean; +} + +export type GitNextActionReason = + | { id: "checking" } + | { id: "not_repository" } + | { id: "local_changes"; count: number } + | { id: "ahead"; count: number } + | { id: "create_change_request" } + | { id: "conflicts" } + | { id: "failed_checks"; count: number } + | { id: "requested_changes" } + | { id: "pending_checks"; count: number } + | { id: "behind"; count: number } + | { id: "draft" } + | { id: "awaiting_review" } + | { id: "merge_ready" } + | { id: "merged" } + | { id: "closed" } + | { id: "pull_request" } + | { id: "forge_degraded" } + | { id: "clean" }; + +export interface GitNextActionProjection { + primary: GitNextActionItem; + alternatives: GitNextActionItem[]; + reason: GitNextActionReason; + changeRequestLabel: SourceControlInfo["change_request_label"] | "change request"; +} + +export interface GitNextActionInput { + status: GitStatus | null; + loading: boolean; + sourceControl: SourceControlInfo | null; + pullRequest: GitHubPullRequest | null; + forgeError: string | null; + taskWorktree: boolean; + canCleanup: boolean; +} + +export interface GitNextActionHandlers { + openSourceControl: () => void; + push: () => void; + openPullRequest: () => void; + cleanupWorktree: () => void; +} + +const action = ( + id: GitNextActionId, + destination: GitNextActionDestination, + disabled = false, +): GitNextActionItem => ({ id, destination, disabled: disabled || undefined }); + +function checkTone(check: GitHubPullRequest["checks"][number]): "success" | "failure" | "pending" { + const conclusion = (check.conclusion ?? "").toLocaleUpperCase(); + const status = (check.status ?? "").toLocaleUpperCase(); + if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion)) return "success"; + if ( + [ + "FAILURE", + "ACTION_REQUIRED", + "TIMED_OUT", + "CANCELLED", + "STALE", + "STARTUP_FAILURE", + "ERROR", + ].includes(conclusion) + ) { + return "failure"; + } + if (status === "COMPLETED" && conclusion) return "failure"; + return "pending"; +} + +function canCreateChangeRequest(input: GitNextActionInput): boolean { + const info = input.sourceControl; + return Boolean( + input.taskWorktree + && !input.pullRequest + && info?.create_change_request_supported + && (!info.required_cli || info.required_cli_available), + ); +} + +function alternativesFor( + input: GitNextActionInput, + primary: GitNextActionItem, +): GitNextActionItem[] { + const candidates: GitNextActionItem[] = []; + if (input.status?.is_repo) candidates.push(action("source_control", "source_control")); + if ((input.status?.ahead ?? 0) > 0) candidates.push(action("push", "push")); + if (input.pullRequest) candidates.push(action("view_pull_request", "pull_request")); + if (canCreateChangeRequest(input)) { + candidates.push(action("create_change_request", "source_control")); + } + if ( + input.pullRequest?.state.toLocaleUpperCase() === "MERGED" + && input.canCleanup + ) { + candidates.push(action("cleanup_worktree", "cleanup")); + } + + const destinations = new Set([primary.destination]); + return candidates.filter((candidate) => { + if (destinations.has(candidate.destination)) return false; + destinations.add(candidate.destination); + return true; + }); +} + +/** + * Resolve one useful next step from workspace-owned Git and forge state. + * + * This function never mutates Git and never guesses a task branch from its name. A change request + * becomes a candidate only when the active session owns a real task worktree and the inspected + * adapter advertises creation support. + */ +export function resolveGitNextAction(input: GitNextActionInput): GitNextActionProjection { + const changeRequestLabel = input.sourceControl?.change_request_label ?? "change request"; + const finish = ( + primary: GitNextActionItem, + reason: GitNextActionReason, + ): GitNextActionProjection => ({ + primary, + alternatives: primary.disabled ? [] : alternativesFor(input, primary), + reason, + changeRequestLabel, + }); + + if (input.loading || input.status === null) { + return finish(action("checking", "none", true), { id: "checking" }); + } + if (!input.status.is_repo) { + return finish(action("unavailable", "none", true), { id: "not_repository" }); + } + + if (input.status.files.length > 0) { + return finish(action("review_changes", "source_control"), { + id: "local_changes", + count: input.status.files.length, + }); + } + if (input.status.ahead > 0) { + return finish(action("push", "push"), { id: "ahead", count: input.status.ahead }); + } + + const pullRequest = input.pullRequest; + if (!pullRequest) { + if (canCreateChangeRequest(input)) { + return finish(action("create_change_request", "source_control"), { + id: "create_change_request", + }); + } + if (input.forgeError) { + return finish(action("source_control", "source_control"), { id: "forge_degraded" }); + } + return finish(action("up_to_date", "none", true), { id: "clean" }); + } + + const state = pullRequest.state.toLocaleUpperCase(); + if (state === "MERGED") { + if (input.canCleanup) { + return finish(action("cleanup_worktree", "cleanup"), { id: "merged" }); + } + return finish(action("view_pull_request", "pull_request"), { id: "merged" }); + } + if (state !== "OPEN") { + return finish(action("view_pull_request", "pull_request"), { id: "closed" }); + } + + const mergeable = pullRequest.mergeable.toLocaleUpperCase(); + const mergeState = pullRequest.merge_state_status.toLocaleUpperCase(); + if (mergeable === "CONFLICTING" || mergeState === "DIRTY") { + return finish(action("resolve_conflicts", "source_control"), { id: "conflicts" }); + } + + let failedChecks = 0; + let pendingChecks = 0; + for (const check of pullRequest.checks) { + const tone = checkTone(check); + if (tone === "failure") failedChecks += 1; + else if (tone === "pending") pendingChecks += 1; + } + if (failedChecks > 0) { + return finish(action("review_failed_checks", "pull_request"), { + id: "failed_checks", + count: failedChecks, + }); + } + + if (pullRequest.review_decision?.toLocaleUpperCase() === "CHANGES_REQUESTED") { + return finish(action("address_review", "pull_request"), { id: "requested_changes" }); + } + if (pendingChecks > 0) { + return finish(action("view_checks", "pull_request"), { + id: "pending_checks", + count: pendingChecks, + }); + } + if (input.status.behind > 0) { + return finish(action("review_remote_updates", "source_control"), { + id: "behind", + count: input.status.behind, + }); + } + if (pullRequest.is_draft) { + return finish(action("review_draft", "pull_request"), { id: "draft" }); + } + if (pullRequest.review_decision?.toLocaleUpperCase() === "REVIEW_REQUIRED") { + return finish(action("view_pull_request", "pull_request"), { id: "awaiting_review" }); + } + if (mergeState === "CLEAN") { + return finish(action("merge_pull_request", "pull_request"), { id: "merge_ready" }); + } + return finish(action("view_pull_request", "pull_request"), { id: "pull_request" }); +} + +export function runGitNextAction( + item: GitNextActionItem, + handlers: GitNextActionHandlers, +): void { + switch (item.destination) { + case "source_control": + handlers.openSourceControl(); + break; + case "push": + handlers.push(); + break; + case "pull_request": + handlers.openPullRequest(); + break; + case "cleanup": + handlers.cleanupWorktree(); + break; + case "none": + break; + } +} + +export function gitNextActionLabel( + t: Translate, + item: GitNextActionItem, + changeRequestLabel: GitNextActionProjection["changeRequestLabel"], +): string { + switch (item.id) { + case "checking": + return t("git.next.checking"); + case "unavailable": + return t("git.next.unavailable"); + case "up_to_date": + return t("git.next.upToDate"); + case "source_control": + return t("action.open_source_control"); + case "review_changes": + return t("git.next.reviewChanges"); + case "push": + return t("header.push"); + case "create_change_request": + return t("git.next.createChangeRequest", { label: changeRequestLabel }); + case "resolve_conflicts": + return t("git.next.resolveConflicts"); + case "review_failed_checks": + return t("git.next.reviewFailedChecks"); + case "address_review": + return t("git.next.addressReview"); + case "view_checks": + return t("git.next.viewChecks"); + case "review_remote_updates": + return t("git.next.reviewRemoteUpdates"); + case "review_draft": + return t("git.next.reviewDraft"); + case "view_pull_request": + return t("git.next.viewChangeRequest", { label: changeRequestLabel }); + case "merge_pull_request": + return t("git.next.mergeChangeRequest", { label: changeRequestLabel }); + case "cleanup_worktree": + return t("git.next.cleanupWorktree"); + } +} + +export function gitNextActionReason( + t: Translate, + projection: GitNextActionProjection, +): string { + const reason = projection.reason; + switch (reason.id) { + case "checking": + return t("git.next.reason.checking"); + case "not_repository": + return t("git.next.reason.notRepository"); + case "local_changes": + return t("git.next.reason.localChanges", { count: reason.count }); + case "ahead": + return t("git.next.reason.ahead", { count: reason.count }); + case "create_change_request": + return t("git.next.reason.createChangeRequest", { + label: projection.changeRequestLabel, + }); + case "conflicts": + return t("git.next.reason.conflicts"); + case "failed_checks": + return t("git.next.reason.failedChecks", { count: reason.count }); + case "requested_changes": + return t("git.next.reason.requestedChanges"); + case "pending_checks": + return t("git.next.reason.pendingChecks", { count: reason.count }); + case "behind": + return t("git.next.reason.behind", { count: reason.count }); + case "draft": + return t("git.next.reason.draft"); + case "awaiting_review": + return t("git.next.reason.awaitingReview"); + case "merge_ready": + return t("git.next.reason.mergeReady"); + case "merged": + return t("git.next.reason.merged"); + case "closed": + return t("git.next.reason.closed"); + case "pull_request": + return t("git.next.reason.pullRequest"); + case "forge_degraded": + return t("git.next.reason.forgeDegraded"); + case "clean": + return t("git.next.reason.clean"); + } +} diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts index c3a3bdde..cb4e71dc 100644 --- a/apps/desktop/src/i18n/strings.ts +++ b/apps/desktop/src/i18n/strings.ts @@ -287,6 +287,39 @@ export const en = { "header.checkpoint": "Checkpoint now", "header.push": "Push", "header.environment": "Project environment", + "git.next.moreActions": "More Git actions", + "git.next.checking": "Checking Git…", + "git.next.unavailable": "Source control unavailable", + "git.next.upToDate": "Up to date", + "git.next.reviewChanges": "Review changes", + "git.next.createChangeRequest": "Create {label}", + "git.next.resolveConflicts": "Resolve conflicts", + "git.next.reviewFailedChecks": "Review failed checks", + "git.next.addressReview": "Address review", + "git.next.viewChecks": "View checks", + "git.next.reviewRemoteUpdates": "Review remote updates", + "git.next.reviewDraft": "Review draft", + "git.next.viewChangeRequest": "View {label}", + "git.next.mergeChangeRequest": "Review & merge {label}", + "git.next.cleanupWorktree": "Clean up worktree", + "git.next.reason.checking": "Refreshing local Git and forge state before suggesting an action.", + "git.next.reason.notRepository": "The current workspace is not a Git repository.", + "git.next.reason.localChanges": "{count} changed files need review before remote state.", + "git.next.reason.ahead": "{count} local commits have not been pushed.", + "git.next.reason.createChangeRequest": "This task worktree has no open {label}.", + "git.next.reason.conflicts": "The open change request reports merge conflicts.", + "git.next.reason.failedChecks": "{count} checks failed on the open change request.", + "git.next.reason.requestedChanges": "Reviewers requested changes.", + "git.next.reason.pendingChecks": "{count} checks are still running.", + "git.next.reason.behind": "The branch is {count} commits behind its upstream.", + "git.next.reason.draft": "The change request is still a draft.", + "git.next.reason.awaitingReview": "The change request is waiting for required review.", + "git.next.reason.mergeReady": "Local work is clean and the change request can advance to merge.", + "git.next.reason.merged": "The change request is merged; the task worktree can be removed after confirmation.", + "git.next.reason.closed": "The change request is closed without a merge.", + "git.next.reason.pullRequest": "Open the change request to inspect its current state.", + "git.next.reason.forgeDegraded": "Local Git is available, but forge state could not be refreshed.", + "git.next.reason.clean": "No local or remote Git action is currently required.", "sideChat.toggle": "Toggle side chat", "sideChat.title": "Side chat", "sideChat.new": "New side chat", @@ -2967,6 +3000,39 @@ export const zhCN: Record = { "header.checkpoint": "立即创建检查点", "header.push": "推送", "header.environment": "项目环境", + "git.next.moreActions": "更多 Git 操作", + "git.next.checking": "正在检查 Git…", + "git.next.unavailable": "源代码管理不可用", + "git.next.upToDate": "已是最新", + "git.next.reviewChanges": "审阅改动", + "git.next.createChangeRequest": "创建 {label}", + "git.next.resolveConflicts": "解决冲突", + "git.next.reviewFailedChecks": "查看失败检查", + "git.next.addressReview": "处理审阅意见", + "git.next.viewChecks": "查看检查", + "git.next.reviewRemoteUpdates": "查看远端更新", + "git.next.reviewDraft": "检查草稿", + "git.next.viewChangeRequest": "查看 {label}", + "git.next.mergeChangeRequest": "审阅并合并 {label}", + "git.next.cleanupWorktree": "清理工作树", + "git.next.reason.checking": "正在刷新本地 Git 与托管平台状态,再推荐下一步。", + "git.next.reason.notRepository": "当前工作区不是 Git 仓库。", + "git.next.reason.localChanges": "有 {count} 个改动文件,应先完成审阅。", + "git.next.reason.ahead": "有 {count} 个本地提交尚未推送。", + "git.next.reason.createChangeRequest": "这个任务工作树还没有开放的 {label}。", + "git.next.reason.conflicts": "当前变更请求存在合并冲突。", + "git.next.reason.failedChecks": "当前变更请求有 {count} 项检查失败。", + "git.next.reason.requestedChanges": "审阅者请求了修改。", + "git.next.reason.pendingChecks": "仍有 {count} 项检查在运行。", + "git.next.reason.behind": "当前分支比上游落后 {count} 个提交。", + "git.next.reason.draft": "当前变更请求仍是草稿。", + "git.next.reason.awaitingReview": "当前变更请求正在等待必要审阅。", + "git.next.reason.mergeReady": "本地工作树干净,当前变更请求可以进入合并。", + "git.next.reason.merged": "变更请求已合并,确认后可以移除任务工作树。", + "git.next.reason.closed": "变更请求已关闭但未合并。", + "git.next.reason.pullRequest": "打开变更请求查看最新状态。", + "git.next.reason.forgeDegraded": "本地 Git 可用,但无法刷新托管平台状态。", + "git.next.reason.clean": "当前没有需要执行的本地或远端 Git 操作。", "sideChat.toggle": "切换侧边对话", "sideChat.title": "侧边对话", "sideChat.new": "新建侧边对话", diff --git a/apps/desktop/src/session/SessionHeaderActions.tsx b/apps/desktop/src/session/SessionHeaderActions.tsx index c9902572..ae3a9e40 100644 --- a/apps/desktop/src/session/SessionHeaderActions.tsx +++ b/apps/desktop/src/session/SessionHeaderActions.tsx @@ -1,22 +1,26 @@ import { Box, Folder, - GitBranch, GitCommitHorizontal, - History, Ellipsis, MessageSquareText, Orbit, Plus, Play, Send, - Upload, } from "@/components/ui/icons"; import { useT } from "../i18n"; import { formatCombo } from "../keys"; import type { ProjectScript } from "../bridge"; +import { + gitNextActionLabel, + runGitNextAction, + type GitNextActionItem, + type GitNextActionProjection, +} from "../git/nextAction"; import { Button } from "@/components/ui/button"; +import { SplitButton } from "@/components/ui/split-button"; import { DropdownMenu, DropdownMenuContent, @@ -27,7 +31,7 @@ import { } from "@/components/ui/dropdown-menu"; export function SessionHeaderActions({ - canCommit, + gitAction, onAddAction, onOpenCursor, onOpenAntigravity, @@ -37,12 +41,14 @@ export function SessionHeaderActions({ finderHint, actions = [], onRunAction, - onCommit, + onOpenSourceControl, + onOpenPullRequest, + onCleanupWorktree, onCheckpoint, onPush, onMoveTask, }: { - canCommit: boolean; + gitAction: GitNextActionProjection; onAddAction: () => void; onOpenCursor: () => void; onOpenAntigravity: () => void; @@ -52,12 +58,37 @@ export function SessionHeaderActions({ finderHint: string; actions?: ProjectScript[]; onRunAction?: (action: ProjectScript) => void; - onCommit: () => void; + onOpenSourceControl: () => void; + onOpenPullRequest: () => void; + onCleanupWorktree: () => void; onCheckpoint: () => void; onPush: () => void; onMoveTask: () => void; }) { const t = useT(); + const runGitAction = (item: GitNextActionItem) => runGitNextAction(item, { + openSourceControl: onOpenSourceControl, + push: onPush, + openPullRequest: onOpenPullRequest, + cleanupWorktree: onCleanupWorktree, + }); + const primaryGitLabel = gitNextActionLabel( + t, + gitAction.primary, + gitAction.changeRequestLabel, + ); + const gitAlternatives = gitAction.alternatives.map((item) => ({ + label: gitNextActionLabel(t, item, gitAction.changeRequestLabel), + onClick: () => runGitAction(item), + disabled: item.disabled, + })); + if (!gitAction.primary.disabled) { + gitAlternatives.push({ + label: t("header.checkpoint"), + onClick: onCheckpoint, + disabled: false, + }); + } const renderOpenMenu = () => ( @@ -85,25 +116,6 @@ export function SessionHeaderActions({ ); - const renderCommitMenu = () => ( - - - - - {t("action.open_source_control")} - - - - {t("header.checkpoint")} - - - - {t("header.push")} - - - - ); - return (
- - - - {t("header.commit")} - - )} - /> - {renderCommitMenu()} - + + + {primaryGitLabel} + + )} + primaryLabel={primaryGitLabel} + onClick={() => runGitAction(gitAction.primary)} + actions={gitAlternatives} + disabled={gitAction.primary.disabled} + variant="ghost" + size="compact" + menuSide="bottom" + menuAlign="end" + menuLabel={t("git.next.moreActions")} + className="session-header-git-action" + primaryClassName="session-header-action-main bg-fill-rest text-foreground hover:bg-fill-hover hover:text-foreground disabled:opacity-60" + menuButtonClassName="bg-fill-rest text-muted-foreground hover:bg-fill-hover hover:text-muted-foreground disabled:opacity-60" + />
); } diff --git a/apps/desktop/tests/gitDockContentRendered.test.tsx b/apps/desktop/tests/gitDockContentRendered.test.tsx new file mode 100644 index 00000000..fcc18f1e --- /dev/null +++ b/apps/desktop/tests/gitDockContentRendered.test.tsx @@ -0,0 +1,105 @@ +// @ts-nocheck +import { act as reactAct } from "react"; +import { afterEach, describe, expect, test } from "bun:test"; + +import { activateDom, button, dom, flush, mount, restoreDom, text } from "./domTestHarness"; + +activateDom(); +const { I18nProvider } = await import("../src/i18n"); +const { GitDockContent } = await import("../src/git/GitDockContent"); + +afterEach(() => { + dom.document.body.replaceChildren(); + restoreDom(); +}); + +async function press(element: Element) { + await reactAct(async () => { + element.dispatchEvent(new dom.window.PointerEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + pointerId: 1, + })); + element.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true, cancelable: true })); + }); + await flush(); +} + +const status = { + is_repo: true, + branch: "codex/next-action", + ahead: 2, + behind: 0, + files: [], +}; + +const action = { + primary: { id: "push", destination: "push" }, + alternatives: [ + { id: "source_control", destination: "source_control" }, + { id: "view_pull_request", destination: "pull_request" }, + ], + reason: { id: "ahead", count: 2 }, + changeRequestLabel: "PR", +}; + +describe("GitDockContent", () => { + test("renders and dispatches the shared next-action projection", async () => { + activateDom(); + const calls: string[] = []; + const view = mount( + + calls.push("source-control")} + onPush={() => calls.push("push")} + onOpenPullRequest={() => calls.push("pull-request")} + onCleanupWorktree={() => calls.push("cleanup")} + /> + , + ); + + expect(text(view.container, "2 local commits have not been pushed.")).not.toBeNull(); + await press(button(view.container, "Push")); + expect(calls).toEqual(["push"]); + + await press(button(view.container, "More Git actions")); + const sourceControl = Array.from(dom.document.body.querySelectorAll('[role="menuitem"]')) + .find((item) => item.textContent?.includes("Source control")); + if (!sourceControl) throw new Error("Source control alternative not found"); + await press(sourceControl); + expect(calls).toEqual(["push", "source-control"]); + + view.unmount(); + }); + + test("keeps an unavailable projection explicit without an action menu", () => { + activateDom(); + const view = mount( + + {}} + onPush={() => {}} + onOpenPullRequest={() => {}} + onCleanupWorktree={() => {}} + /> + , + ); + + const unavailableButton = button(view.container, "Source control unavailable"); + expect(unavailableButton.disabled).toBe(true); + expect(unavailableButton.dataset.variant).toBe("secondary"); + expect(text(view.container, "The current workspace is not a Git repository.")).not.toBeNull(); + expect(view.container.querySelector('[aria-label="More Git actions"]')).toBeNull(); + view.unmount(); + }); +}); diff --git a/apps/desktop/tests/gitNextAction.test.ts b/apps/desktop/tests/gitNextAction.test.ts new file mode 100644 index 00000000..016144de --- /dev/null +++ b/apps/desktop/tests/gitNextAction.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, test } from "bun:test"; +import type { GitHubPullRequest, GitStatus, SourceControlInfo } from "../src/bridge"; +import { + resolveGitNextAction, + type GitNextActionInput, +} from "../src/git/nextAction"; + +const status = (overrides: Partial = {}): GitStatus => ({ + is_repo: true, + branch: "codex/next-action", + ahead: 0, + behind: 0, + files: [], + ...overrides, +}); + +const sourceControl = (overrides: Partial = {}): SourceControlInfo => ({ + remote_name: "origin", + provider: "github", + provider_name: "GitHub", + host: "github.com", + web_url: "https://github.com/acme/code-two", + change_request_label: "PR", + create_change_request_supported: true, + required_cli: "gh", + required_cli_available: true, + ...overrides, +}); + +const pullRequest = (overrides: Partial = {}): GitHubPullRequest => ({ + number: 42, + title: "feat: next action", + url: "https://github.com/acme/code-two/pull/42", + state: "OPEN", + is_draft: false, + head_ref: "codex/next-action", + base_ref: "main", + additions: 12, + deletions: 2, + changed_files: 2, + body: "", + review_decision: "APPROVED", + mergeable: "MERGEABLE", + merge_state_status: "CLEAN", + author: "octocat", + comments_count: 0, + reviews_count: 1, + checks: [{ + name: "validate", + status: "COMPLETED", + conclusion: "SUCCESS", + details_url: null, + workflow_name: "Desktop", + }], + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T01:00:00Z", + ...overrides, +}); + +const input = (overrides: Partial = {}): GitNextActionInput => ({ + status: status(), + loading: false, + sourceControl: sourceControl(), + pullRequest: null, + forgeError: null, + taskWorktree: true, + canCleanup: true, + ...overrides, +}); + +const ids = (value: ReturnType) => ({ + primary: value.primary.id, + alternatives: value.alternatives.map((candidate) => candidate.id), + reason: value.reason.id, +}); + +describe("Git next action", () => { + test("keeps loading and non-repository states explicit and disabled", () => { + const loading = resolveGitNextAction(input({ loading: true })); + expect(ids(loading)).toEqual({ primary: "checking", alternatives: [], reason: "checking" }); + expect(loading.primary.disabled).toBe(true); + + const unavailable = resolveGitNextAction(input({ status: status({ is_repo: false }) })); + expect(ids(unavailable)).toEqual({ + primary: "unavailable", + alternatives: [], + reason: "not_repository", + }); + expect(unavailable.primary.disabled).toBe(true); + }); + + test("prioritizes local files, then unpushed commits, over forge state", () => { + const dirty = resolveGitNextAction(input({ + status: status({ + ahead: 2, + files: [{ + path: "src/app.ts", + original_path: null, + staged: false, + unstaged: true, + state: "modified", + staged_state: null, + unstaged_state: "modified", + }], + }), + pullRequest: pullRequest({ + mergeable: "CONFLICTING", + merge_state_status: "DIRTY", + }), + })); + expect(ids(dirty)).toEqual({ + primary: "review_changes", + alternatives: ["push", "view_pull_request"], + reason: "local_changes", + }); + + const ahead = resolveGitNextAction(input({ + status: status({ ahead: 2 }), + pullRequest: pullRequest({ + checks: [{ + name: "validate", + status: "COMPLETED", + conclusion: "FAILURE", + details_url: null, + workflow_name: null, + }], + }), + })); + expect(ids(ahead)).toEqual({ + primary: "push", + alternatives: ["source_control", "view_pull_request"], + reason: "ahead", + }); + }); + + test("offers change-request creation only for a capable task worktree", () => { + expect(ids(resolveGitNextAction(input()))).toEqual({ + primary: "create_change_request", + alternatives: [], + reason: "create_change_request", + }); + + const checkout = resolveGitNextAction(input({ taskWorktree: false, canCleanup: false })); + expect(ids(checkout)).toEqual({ primary: "up_to_date", alternatives: [], reason: "clean" }); + + const missingCli = resolveGitNextAction(input({ + sourceControl: sourceControl({ required_cli_available: false }), + })); + expect(ids(missingCli)).toEqual({ primary: "up_to_date", alternatives: [], reason: "clean" }); + }); + + test("orders pull-request blockers before review and merge readiness", () => { + const conflicting = resolveGitNextAction(input({ + pullRequest: pullRequest({ mergeable: "CONFLICTING", merge_state_status: "DIRTY" }), + })); + expect(ids(conflicting)).toEqual({ + primary: "resolve_conflicts", + alternatives: ["view_pull_request"], + reason: "conflicts", + }); + + const failed = resolveGitNextAction(input({ + pullRequest: pullRequest({ + review_decision: "CHANGES_REQUESTED", + checks: [{ + name: "validate", + status: "COMPLETED", + conclusion: "FAILURE", + details_url: null, + workflow_name: null, + }], + }), + })); + expect(ids(failed)).toEqual({ + primary: "review_failed_checks", + alternatives: ["source_control"], + reason: "failed_checks", + }); + + const requested = resolveGitNextAction(input({ + pullRequest: pullRequest({ review_decision: "CHANGES_REQUESTED" }), + })); + expect(ids(requested)).toEqual({ + primary: "address_review", + alternatives: ["source_control"], + reason: "requested_changes", + }); + }); + + test("keeps pending checks, behind branches, and drafts ahead of merge", () => { + const pending = resolveGitNextAction(input({ + pullRequest: pullRequest({ + checks: [{ + name: "validate", + status: "IN_PROGRESS", + conclusion: null, + details_url: null, + workflow_name: null, + }], + }), + })); + expect(ids(pending)).toEqual({ + primary: "view_checks", + alternatives: ["source_control"], + reason: "pending_checks", + }); + + const behind = resolveGitNextAction(input({ + status: status({ behind: 3 }), + pullRequest: pullRequest(), + })); + expect(ids(behind)).toEqual({ + primary: "review_remote_updates", + alternatives: ["view_pull_request"], + reason: "behind", + }); + + const draft = resolveGitNextAction(input({ pullRequest: pullRequest({ is_draft: true }) })); + expect(ids(draft)).toEqual({ + primary: "review_draft", + alternatives: ["source_control"], + reason: "draft", + }); + }); + + test("routes merge-ready and merged worktrees through existing guarded surfaces", () => { + const merge = resolveGitNextAction(input({ pullRequest: pullRequest() })); + expect(ids(merge)).toEqual({ + primary: "merge_pull_request", + alternatives: ["source_control"], + reason: "merge_ready", + }); + + const cleanup = resolveGitNextAction(input({ + pullRequest: pullRequest({ state: "MERGED" }), + })); + expect(ids(cleanup)).toEqual({ + primary: "cleanup_worktree", + alternatives: ["source_control", "view_pull_request"], + reason: "merged", + }); + + const retained = resolveGitNextAction(input({ + pullRequest: pullRequest({ state: "MERGED" }), + canCleanup: false, + })); + expect(ids(retained)).toEqual({ + primary: "view_pull_request", + alternatives: ["source_control"], + reason: "merged", + }); + }); + + test("never advertises merge while required review or an unknown blocker remains", () => { + const awaitingReview = resolveGitNextAction(input({ + pullRequest: pullRequest({ review_decision: "REVIEW_REQUIRED" }), + })); + expect(ids(awaitingReview)).toEqual({ + primary: "view_pull_request", + alternatives: ["source_control"], + reason: "awaiting_review", + }); + + const blocked = resolveGitNextAction(input({ + pullRequest: pullRequest({ + review_decision: "APPROVED", + merge_state_status: "BLOCKED", + }), + })); + expect(ids(blocked)).toEqual({ + primary: "view_pull_request", + alternatives: ["source_control"], + reason: "pull_request", + }); + }); + + test("degrades forge inspection without hiding valid local Git", () => { + const degraded = resolveGitNextAction(input({ + sourceControl: null, + forgeError: "gh timed out", + })); + expect(ids(degraded)).toEqual({ + primary: "source_control", + alternatives: [], + reason: "forge_degraded", + }); + + const dirty = resolveGitNextAction(input({ + sourceControl: null, + forgeError: "gh timed out", + status: status({ + files: [{ + path: "README.md", + original_path: null, + staged: true, + unstaged: false, + state: "modified", + staged_state: "modified", + unstaged_state: null, + }], + }), + })); + expect(dirty.primary.id).toBe("review_changes"); + expect(dirty.reason.id).toBe("local_changes"); + }); +}); diff --git a/apps/desktop/tests/sessionHeaderActionsRendered.test.tsx b/apps/desktop/tests/sessionHeaderActionsRendered.test.tsx index 09d008b3..ed493f8f 100644 --- a/apps/desktop/tests/sessionHeaderActionsRendered.test.tsx +++ b/apps/desktop/tests/sessionHeaderActionsRendered.test.tsx @@ -21,7 +21,15 @@ function renderActions(overrides = {}) { const calls: string[] = []; const callback = (name: string) => () => calls.push(name); const props = { - canCommit: true, + gitAction: { + primary: { id: "review_changes", destination: "source_control" }, + alternatives: [ + { id: "push", destination: "push" }, + { id: "view_pull_request", destination: "pull_request" }, + ], + reason: { id: "local_changes", count: 2 }, + changeRequestLabel: "PR", + }, onAddAction: callback("add"), onOpenCursor: callback("cursor"), onOpenAntigravity: callback("antigravity"), @@ -29,7 +37,9 @@ function renderActions(overrides = {}) { editorLaunchersAvailable: true, fileManagerLabel: "Finder", finderHint: "⌘O", - onCommit: callback("commit"), + onOpenSourceControl: callback("source-control"), + onOpenPullRequest: callback("pull-request"), + onCleanupWorktree: callback("cleanup"), onCheckpoint: callback("checkpoint"), onPush: callback("push"), onMoveTask: callback("move"), @@ -70,7 +80,7 @@ describe("SessionHeaderActions", () => { const group = view.container.querySelector(".session-header-actions"); expect(group).not.toBeNull(); - for (const label of ["Add action", "Open", "Commit"]) { + for (const label of ["Add action", "Open", "Review changes"]) { const action = button(view.container, label); expect(action.classList.contains("session-header-action-main")).toBe(true); expect(action.querySelector(".session-header-action-label")?.textContent).toBe(label); @@ -90,7 +100,7 @@ describe("SessionHeaderActions", () => { const addAction = button(view.container, "Add action"); expect(addAction.dataset.variant).toBe("ghost"); - for (const label of ["Add action", "Open", "Commit"]) { + for (const label of ["Add action", "Open", "Review changes"]) { const action = button(view.container, label); expect(action.dataset.variant).toBe("ghost"); expect(action.classList.contains("bg-fill-rest")).toBe(true); @@ -101,9 +111,9 @@ describe("SessionHeaderActions", () => { expect(action.classList.contains("button-toolbar-outline")).toBe(false); } - expect(view.container.querySelectorAll("button")).toHaveLength(3); + expect(view.container.querySelectorAll("button")).toHaveLength(4); expect(view.container.querySelector('[data-slot="button-group"]')).toBeNull(); - expect(view.container.querySelector(".session-header-split-trigger")).toBeNull(); + expect(view.container.querySelector(".session-header-git-action")).not.toBeNull(); expect(view.container.querySelector(".session-header-compact-action")).toBeNull(); for (const action of Array.from(view.container.querySelectorAll("button"))) { @@ -143,26 +153,53 @@ describe("SessionHeaderActions", () => { await press(moveItem); expect(calls).toEqual(["add", "finder", "move"]); - await press(button(view.container, "Commit")); - expect(dom.document.body.textContent).toContain("Source control"); + await press(button(view.container, "Review changes")); + expect(calls).toEqual(["add", "finder", "move", "source-control"]); + + await press(button(view.container, "More Git actions")); expect(dom.document.body.textContent).toContain("Checkpoint now"); expect(dom.document.body.textContent).toContain("Push"); - const sourceControlItem = Array.from(dom.document.body.querySelectorAll('[role="menuitem"]')) - .find((item) => item.textContent?.includes("Source control")); - if (!sourceControlItem) throw new Error("Source control menu item not found"); - await press(sourceControlItem); - expect(calls).toEqual(["add", "finder", "move", "commit"]); + expect(dom.document.body.textContent).toContain("View PR"); + const pushItem = Array.from(dom.document.body.querySelectorAll('[role="menuitem"]')) + .find((item) => item.textContent?.includes("Push")); + if (!pushItem) throw new Error("Push menu item not found"); + await press(pushItem); + expect(calls).toEqual(["add", "finder", "move", "source-control", "push"]); view.unmount(); }); - test("disables the complete commit menu outside a repository", () => { + test("disables the complete Git action while state is unavailable", () => { activateDom(); - const { view } = renderActions({ canCommit: false }); + const { view } = renderActions({ + gitAction: { + primary: { id: "unavailable", destination: "none", disabled: true }, + alternatives: [], + reason: { id: "not_repository" }, + changeRequestLabel: "change request", + }, + }); + + expect(button(view.container, "Source control unavailable").disabled).toBe(true); + expect(button(view.container, "Source control unavailable").classList.contains("disabled:opacity-60")).toBe(true); + expect(view.container.querySelector('[aria-label="More Git actions"]')).toBeNull(); + + view.unmount(); + }); + + test("labels a merge-ready route as a guarded review step", async () => { + activateDom(); + const { calls, view } = renderActions({ + gitAction: { + primary: { id: "merge_pull_request", destination: "pull_request" }, + alternatives: [{ id: "source_control", destination: "source_control" }], + reason: { id: "merge_ready" }, + changeRequestLabel: "PR", + }, + }); - expect(button(view.container, "Commit").disabled).toBe(true); - expect(button(view.container, "Commit").classList.contains("disabled:opacity-60")).toBe(true); - expect(view.container.querySelectorAll('[aria-label="Commit"]')).toHaveLength(1); + await press(button(view.container, "Review & merge PR")); + expect(calls).toEqual(["pull-request"]); view.unmount(); }); diff --git a/docs/sdlc/changes/2026-09-01-git-next-action/change.md b/docs/sdlc/changes/2026-09-01-git-next-action/change.md new file mode 100644 index 00000000..c9a36838 --- /dev/null +++ b/docs/sdlc/changes/2026-09-01-git-next-action/change.md @@ -0,0 +1,144 @@ +--- +id: change-2026-09-01-git-next-action +kind: change +schema: 2 +status: verified +risk: medium +owner: Codex +approvers: [chenli] +approved_at: 2026-09-01 +created: 2026-09-01 +updated: 2026-09-01 +source: User request in the current Codex task to implement the Superdot interaction recommendations in order, beginning with the state-aware Git primary action +inputs: docs/archive/research/superdot-product-design-research-2026-09-01.md section 8 and the live origin/main Git surfaces +outputs: One workspace-owned Git next-action projection reused by the session header and Git dock +scope: apps/desktop/src/App.tsx, apps/desktop/src/components/ui/split-button.tsx, apps/desktop/src/git, apps/desktop/src/i18n/strings.ts, apps/desktop/src/session/SessionHeaderActions.tsx, apps/desktop/tests, docs/sdlc/changes/2026-09-01-git-next-action +next_trigger: human review, PR creation, merge, or release request +verification_mode: owner +verified_by: Codex +verified_at: 2026-09-01 +--- + +# State-aware Git primary action + +## Intent + +CodeTwo currently exposes Commit, Push, source control, pull-request checks, review, merge, and +worktree cleanup as separate controls. A user must interpret local Git state and forge state before +choosing the next step. The desired outcome is one honest primary action for the active worktree, +with only currently valid alternatives, while preserving the existing review and confirmation +surfaces. + +This change affects the desktop Git projection, session header, and Git dock. It must preserve +workspace ownership during asynchronous refreshes, must not add a second Git state store, and must +not make a new destructive or forge mutation bypassing the existing handlers. Sidebar hover cards, +new review-thread state, GitLab merge-request inspection, and worktree lifecycle redesign are +non-goals for this slice. + +## Spec + +The renderer derives one `GitNextAction` from the current workspace's `GitStatus`, source-control +capability, current GitHub pull request, and whether the active session owns a disposable worktree. +Local files take priority over remote review state, followed by unpushed commits, pull-request +blockers, checks/review state, merge readiness, and merged-worktree cleanup. Loading, unsupported, +and clean/no-action states remain explicit and disabled. + +The session header renders the primary action as the main half of the existing shared split-button +pattern. Its chevron lists only distinct valid alternatives. The Git dock renders the same resolved +primary action and explanation. Primary actions route to existing Source Control, Push, Pull +Request, and confirmed worktree-discard paths; this change does not introduce direct merge, review, +or deletion commands. + +Asynchronous source-control and pull-request reads are keyed to the current cwd. A prior workspace's +result must never appear after navigation. A provider or CLI failure may reduce forge-specific +actions, but must not erase successfully loaded local Git status. + +### Acceptance criteria + +- [x] AC-1: Unit tests prove the resolver's priority for loading, non-repository, local changes, ahead commits, missing PR, conflicts, failed/pending checks, requested changes, merge-ready, merged, and clean states. +- [x] AC-2: The session header presents exactly one state-aware primary Git action and only valid distinct alternatives, using existing Source Control, Push, Pull Request, and cleanup handlers. +- [x] AC-3: The Git dock displays the same resolved primary action and reason as the header rather than calculating its own lifecycle result. +- [x] AC-4: A cwd switch or forge-inspection failure cannot project stale forge state or hide valid local Git state; loading and degraded states have explicit disabled copy. +- [x] AC-5: Targeted renderer tests, desktop build/type checks, and light/dark/narrow rendered evidence show the action remains accessible and product-ready. + +## Decision and gates + +Chenli approved implementation by asking CodeTwo to optimize the researched interactions in order. +Codex owns implementation and verification. Human review remains required before merge. No release, +deployment, or production mutation is authorized. + +## Plan + +1. Add a pure Git next-action resolver and exhaustive state-priority tests for AC-1 and AC-4. +2. Extend the existing shared `SplitButton` only enough for the header's icon and accessible menu + label, then replace the fixed Commit menu with the resolved action for AC-2. +3. Load source-control and current-PR context alongside the existing workspace-owned Git refresh, + and pass one projection to the header and Git dock for AC-3 and AC-4. +4. Run targeted tests, build checks, rendered-window acceptance, and repository lifecycle Gates for + AC-5. Roll back by reverting this change; all mutations continue to use the previous handlers. + +## Build + +Implemented on `codex/git-next-action` from `origin/main` at `b7010aa4`. A pure resolver now maps +workspace-owned Git, source-control, pull-request, and task-worktree state to one primary action and +deduplicated alternatives. The active workspace refresh loads local Git and forge context without +borrowing an earlier cwd, and the session header and Git dock consume the same projection and +existing guarded handlers. Disabled checking and unavailable states use a neutral surface rather +than looking like an enabled primary command. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `apps/desktop/tests/gitNextAction.test.ts` covers the complete priority ladder, + including conservative review-required and unknown-blocker outcomes that never advertise merge. +- AC-2: PASS — `apps/desktop/tests/sessionHeaderActionsRendered.test.tsx` verifies one directly + dispatchable primary action, valid chevron alternatives, explicit unavailable state, and stable + accessible names when responsive labels hide. +- AC-3: PASS — `apps/desktop/tests/gitDockContentRendered.test.tsx` verifies the shared label, + explanation, primary dispatch, alternative dispatch, and neutral disabled styling; source + inspection confirms both surfaces receive the projection resolved by `App`. +- AC-4: PASS — `apps/desktop/tests/gitNextAction.test.ts` preserves local actions when forge + inspection degrades, `apps/desktop/tests/gitState.test.ts` proves a mismatched cwd projects an + empty loading state, and every async source-control/PR result is guarded by the current request + sequence and cwd before publication. +- AC-5: PASS — `bun test tests/gitNextAction.test.ts tests/sessionHeaderActionsRendered.test.tsx + tests/gitDockContentRendered.test.tsx` completed with 18 tests and 93 expectations; the full + `bun test` desktop suite completed with 850 tests, 5,039 expectations, and zero failures across + 147 files. + `bun run build:renderer` passed ESLint, Stylelint, TypeScript, and a 6,600-module Vite production + build. Browser inspection of the production renderer confirmed matching header/Git-dock copy in + light, dark, and 820x760 narrow layouts, responsive accessible names, neutral disabled styling, + and no console warnings or errors. + +- `bun test script/verify/checks.test.ts`: 5 tests and 23 expectations passed. +- `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`, and + `bun script/verify/sdlc.ts --worktree`: passed. +- `git diff --check`: passed. +- Process preflight found another CodeTwo Electrobun instance. Verification therefore served this + branch's built renderer on isolated port 4173 and did not start a second Core or touch the live + instance's data. + +Residual risk: enabled Git/forge states were exercised in component tests rather than a second +native desktop instance because the repository currently permits only one live Core owner. GitHub +pull-request inspection depends on the existing `gh` integration and its response time. GitLab MR +inspection remains outside this slice. Existing non-failing Base UI `act(...)` warnings remain in +the test suite and were not introduced by this change. + +## Review and release + +Approval: pending human review. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: production renderer Browser pass recorded above; native release smoke is not +applicable until released. +Rollback: revert the implementation commit; existing Git handlers remain independently usable. +No release: PR creation, merge, deployment, and release were not requested. + +Preparing this section does not authorize merge, deployment, release, or production mutation. + +## Feedback + +No feedback exists yet; the observation boundary is the completed rendered acceptance pass. From c027386760017e870354bf58efeeae965ea637fe Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 18:46:51 +0800 Subject: [PATCH 2/4] docs(sdlc): record git action acceptance --- docs/sdlc/changes/2026-09-01-git-next-action/change.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/sdlc/changes/2026-09-01-git-next-action/change.md b/docs/sdlc/changes/2026-09-01-git-next-action/change.md index c9a36838..80e7cd02 100644 --- a/docs/sdlc/changes/2026-09-01-git-next-action/change.md +++ b/docs/sdlc/changes/2026-09-01-git-next-action/change.md @@ -13,7 +13,7 @@ source: User request in the current Codex task to implement the Superdot interac inputs: docs/archive/research/superdot-product-design-research-2026-09-01.md section 8 and the live origin/main Git surfaces outputs: One workspace-owned Git next-action projection reused by the session header and Git dock scope: apps/desktop/src/App.tsx, apps/desktop/src/components/ui/split-button.tsx, apps/desktop/src/git, apps/desktop/src/i18n/strings.ts, apps/desktop/src/session/SessionHeaderActions.tsx, apps/desktop/tests, docs/sdlc/changes/2026-09-01-git-next-action -next_trigger: human review, PR creation, merge, or release request +next_trigger: PR creation, code review, merge, or release request verification_mode: owner verified_by: Codex verified_at: 2026-09-01 @@ -129,7 +129,8 @@ the test suite and were not introduced by this change. ## Review and release -Approval: pending human review. +Approval: Chenli confirmed the rendered interaction acceptance was complete on 2026-09-01 with +no changes requested. PR-level code review and merge approval remain separate Gates. Release target: none. Release identity: not applicable until released. Smoke evidence: production renderer Browser pass recorded above; native release smoke is not @@ -141,4 +142,6 @@ Preparing this section does not authorize merge, deployment, release, or product ## Feedback -No feedback exists yet; the observation boundary is the completed rendered acceptance pass. +Chenli confirmed “验收完了” on 2026-09-01. No corrective feedback was requested; the accepted +observation boundary is the completed light, dark, narrow-layout, interaction, and automated-test +pass recorded above. From 7b116cfc83a15f6314746cc805f186a3c53afb95 Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 18:54:30 +0800 Subject: [PATCH 3/4] docs(sdlc): link git action review --- docs/sdlc/changes/2026-09-01-git-next-action/change.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sdlc/changes/2026-09-01-git-next-action/change.md b/docs/sdlc/changes/2026-09-01-git-next-action/change.md index 80e7cd02..0635cdee 100644 --- a/docs/sdlc/changes/2026-09-01-git-next-action/change.md +++ b/docs/sdlc/changes/2026-09-01-git-next-action/change.md @@ -131,6 +131,7 @@ the test suite and were not introduced by this change. Approval: Chenli confirmed the rendered interaction acceptance was complete on 2026-09-01 with no changes requested. PR-level code review and merge approval remain separate Gates. +Review surface: [PR #217](https://github.com/IchenDEV/codeTwo/pull/217). Release target: none. Release identity: not applicable until released. Smoke evidence: production renderer Browser pass recorded above; native release smoke is not From 19d408bbded79c4c04a265063e1915464fab48d9 Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 19:48:52 +0800 Subject: [PATCH 4/4] test(desktop): restore taskboard mutation gate --- .../tests/taskBoardWorkspaceModel.test.ts | 9 ++ .../change.md | 97 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 docs/sdlc/changes/2026-09-01-taskboard-mutation-gate/change.md diff --git a/apps/desktop/tests/taskBoardWorkspaceModel.test.ts b/apps/desktop/tests/taskBoardWorkspaceModel.test.ts index 5a6f0552..6ada625a 100644 --- a/apps/desktop/tests/taskBoardWorkspaceModel.test.ts +++ b/apps/desktop/tests/taskBoardWorkspaceModel.test.ts @@ -7,6 +7,7 @@ import { checkoutLabel, formatUpdatedAt, LANE_TONES, + laneLabel, openPullRequestCount, projectTasks, PULL_REQUEST_TONES, @@ -78,6 +79,13 @@ describe("TaskBoard workspace model", () => { }) }) + test("labels every projected task lane", () => { + expect(laneLabel(t, "queue")).toBe("taskboard.lane.queue:") + expect(laneLabel(t, "running")).toBe("taskboard.lane.running:") + expect(laneLabel(t, "needs_you")).toBe("taskboard.lane.needsYou:") + expect(laneLabel(t, "done")).toBe("taskboard.lane.done:") + }) + test("formats each relative-time boundary and the dated fallback", () => { const now = Date.UTC(2026, 0, 10, 12) Date.now = () => now @@ -137,6 +145,7 @@ describe("TaskBoard workspace model", () => { expect(sessionStatusTone(awaiting)).toBe("warning") expect(sessionStatusTone(failed)).toBe("destructive") expect(sessionStatusTone(running)).toBe("success") + expect(sessionStatusTone({ ...running, archived: true })).toBe("success") expect(sessionStatusTone(session({ archived: true }))).toBe("neutral") expect(sessionStatusTone(session())).toBe("success") }) diff --git a/docs/sdlc/changes/2026-09-01-taskboard-mutation-gate/change.md b/docs/sdlc/changes/2026-09-01-taskboard-mutation-gate/change.md new file mode 100644 index 00000000..1a5a730a --- /dev/null +++ b/docs/sdlc/changes/2026-09-01-taskboard-mutation-gate/change.md @@ -0,0 +1,97 @@ +--- +id: change-2026-09-01-taskboard-mutation-gate +kind: change +schema: 2 +status: verified +risk: low +owner: Codex +approvers: [chenli] +approved_at: 2026-09-01 +created: 2026-09-01 +updated: 2026-09-01 +source: User request in the current Codex task to fix the failing mutation Gate linked from PR #217 +inputs: GitHub Actions job 99833531176 and the matching origin/main mutation failure +outputs: Regression assertions that kill every surviving TaskBoard workspace-model mutant +scope: apps/desktop/tests/taskBoardWorkspaceModel.test.ts, docs/sdlc/changes/2026-09-01-taskboard-mutation-gate +next_trigger: Human review of PR #217 and the refreshed GitHub Actions checks +verification_mode: owner +verified_by: Codex +verified_at: 2026-09-01 +--- + +# Restore the TaskBoard mutation Gate + +## Intent + +The desktop design-system workflow requires a 100% TaskBoard workspace-model mutation score, but +the current `origin/main` baseline and PR #217 both report the same seven surviving mutants. The +desired outcome is to restore the deterministic Gate with regression assertions that distinguish +every public lane label and the running-status precedence for an archived session. + +This is a tests-only correction. Production TaskBoard behavior, the mutation threshold, Git +next-action behavior, and unrelated test cleanup are non-goals. + +## Spec + +The existing workspace-model test suite must assert the translated label for all four projected +lanes. It must also prove that an actively running session uses the success tone even if its +persisted archived flag is set, so removing the explicit running branch changes an observed result. +The existing 100% mutation threshold remains unchanged. + +### Acceptance criteria + +- [x] AC-1: Workspace-model tests distinguish the queue, running, needs-you, and done lane labels. +- [x] AC-2: Workspace-model tests distinguish the running tone from the archived-session fallback. +- [x] AC-3: The narrowed reproduction and complete TaskBoard mutation Gate report zero surviving mutants, and the desktop regression suite remains green. + +## Decision and gates + +Chenli approved this correction by requesting “修复” after reviewing the failing PR #217 GitHub +Actions job. Codex owns implementation and verification. Human review and merge remain separate +Gates; this request does not authorize merge, release, deployment, or production mutation. + +## Plan + +1. Reuse `taskBoardWorkspaceModel.test.ts` and add only the missing public-output assertions for + AC-1 and AC-2. +2. Run the line-scoped mutation reproduction, targeted test, complete mutation Gate, desktop test + suite, renderer build, and repository lifecycle checks for AC-3. +3. Record actual evidence and return the Artifact to human review. Roll back by reverting the + tests and this Artifact; no production code or data changes are involved. + +## Build + +Implemented on `codex/git-next-action` as two regression additions to the existing workspace-model +test: explicit translated output for every lane and running-tone precedence for an archived +session. No production code, mutation configuration, threshold, dependency, or runtime behavior +changed. + +## Verification + +Verdict: verified. + +### Acceptance evidence + +- AC-1: PASS — `bun test tests/taskBoardWorkspaceModel.test.ts` completed with 11 tests and 57 expectations; the line-scoped Stryker reproduction completed at 100% with zero surviving mutants. +- AC-2: PASS — `bun test tests/taskBoardWorkspaceModel.test.ts` and `bunx stryker run stryker.taskboard.config.json --mutate 'src/taskboard/workspaceModel.ts:78:1-85:100' --reporters clear-text --concurrency 2` killed the conditional mutant that removed running-tone precedence for an archived session. +- AC-3: PASS — `bun run mutation:taskboard` completed at 100% with 89 killed and zero surviving executable mutants; `bun test` completed with 851 tests, 5,040 expectations, and zero failures; `bun run build:renderer` passed lint, typecheck, and the 6,600-module production build. `bun test script/verify/checks.test.ts`, `bun script/verify/docs.ts`, `bun script/verify/sdlc.ts`, `bun script/verify/sdlc.ts --worktree`, and `git diff --check` passed. + +Residual risk: GitHub Actions has not yet run against this local revision. Stryker reports 104 +type-checker errors for invalid generated mutants, excludes them from the executable denominator, +and reports the remaining 89 mutants killed at the unchanged 100% threshold. Existing Base UI +`act(...)` warnings remain outside this tests-only correction. + +## Review and release + +Approval: pending. +Release target: none. +Release identity: not applicable until released. +Smoke evidence: not applicable; this change only strengthens deterministic tests. +Rollback: revert the regression assertions and this change Artifact. +No release: pending human review of PR #217; no release action is authorized. + +Preparing this section does not authorize merge, deployment, release, or production mutation. + +## Feedback + +The triggering feedback is the failing GitHub Actions job linked by Chenli in the current task.