From 6dc5b1bf58bad0632d344c60ce0d26bf6a440ec1 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 16 Oct 2026 18:49:09 -0400 Subject: [PATCH 1/3] feat(app): offer the chat's git and PR actions in the Diff header, and Close for a PR that can't merge yet The Diff header now shows the same actions as the details sidebar, from the same useGit list, rendered by one shared GitActionButton. An open PR that Merge isn't offered for gets a standalone Close PR. Why: apps/app/src/git.ts#function useGit | the sidebar and Diff header take their git actions from this one list, so neither offers an action the other wouldn't --- apps/app/src/git.ts | 9 +- apps/app/src/panels/diff.tsx | 1 + packages/ui/src/components/diff/diff-view.tsx | 7 ++ packages/ui/src/components/diff/types.ts | 4 + packages/ui/src/components/git-action.tsx | 91 +++++++++++++++++++ .../session-details/session-details.tsx | 89 +++--------------- packages/ui/src/index.ts | 2 +- 7 files changed, 121 insertions(+), 82 deletions(-) create mode 100644 packages/ui/src/components/git-action.tsx diff --git a/apps/app/src/git.ts b/apps/app/src/git.ts index 1d506067..c43ebe05 100644 --- a/apps/app/src/git.ts +++ b/apps/app/src/git.ts @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import type { Changes, GithubCheck, GithubPull } from "@ace/channel/protocol"; import type { Listing } from "@ace/host/protocol"; -import { type DetailsAction, toast } from "@ace/ui"; +import { type GitAction, toast } from "@ace/ui"; import { IconBranch, IconCommit, @@ -44,7 +44,7 @@ const ASKS: Record, (pull: number) => string> = { close: (pull) => `Close #${pull} with \`gh pr close\`, keeping its branch.`, }; -const PENDING: Record, Pick> = { +const PENDING: Record, Pick> = { commit: { label: "Committing", icon: IconCommit }, update: { label: "Updating PR", icon: IconPush }, rebase: { label: "Rebasing", icon: IconBranch }, @@ -84,7 +84,7 @@ export function useGit(channel: Listing, chat: number, user: string) { ask(text).catch(() => publish(channel.id, { asked: null })); } - const actions: DetailsAction[] = []; + const actions: GitAction[] = []; if (asked && asked !== "create") actions.push({ ...PENDING[asked], pending: true }); else if (ready && changes) { const number = pull?.number || 0; @@ -137,6 +137,9 @@ export function useGit(channel: Listing, chat: number, user: string) { onClick: go("merge"), menu: [close], }); + } else if (pull?.state === "open") { + // Until it can merge, Close has no Merge menu to sit in. + actions.push(close); } } diff --git a/apps/app/src/panels/diff.tsx b/apps/app/src/panels/diff.tsx index a7eb224e..7b00f701 100644 --- a/apps/app/src/panels/diff.tsx +++ b/apps/app/src/panels/diff.tsx @@ -207,6 +207,7 @@ export function Diff({ channel: listing, chat, user, active }: Props) { creatingPr={git.creatingPr} onCreatePr={git.createPr} onCreateDraftPr={git.createDraftPr} + actions={git.actions} onFileOpen={open} className="rounded-none border-0" /> diff --git a/packages/ui/src/components/diff/diff-view.tsx b/packages/ui/src/components/diff/diff-view.tsx index cc422806..3e0b9f23 100644 --- a/packages/ui/src/components/diff/diff-view.tsx +++ b/packages/ui/src/components/diff/diff-view.tsx @@ -17,6 +17,7 @@ import { Button } from "../../ui/button"; import { TooltipProvider } from "../../ui/tooltip"; import { FileTree } from "../file-tree/file-tree"; +import { GitActionButton } from "../git-action"; import { PrAction, ToolButton } from "./diff-actions"; import { FileTreeShell } from "./diff-file-tree"; import { same, selection } from "./selection"; @@ -355,6 +356,7 @@ function useDiffView({ onCreatePr, onCreateDraftPr, onManualCreatePr, + actions, onFileOpen, className, }: DiffViewProps) { @@ -524,6 +526,7 @@ function useDiffView({ onCreatePr, onCreateDraftPr, onManualCreatePr, + actions, className, summary, view, @@ -556,6 +559,7 @@ function DiffViewContent(props: DiffViewProps) { onCreatePr, onCreateDraftPr, onManualCreatePr, + actions, className, summary, view, @@ -615,6 +619,9 @@ function DiffViewContent(props: DiffViewProps) {
+ {actions?.map((action) => ( + + ))} void; onCreateDraftPr?: () => void; onManualCreatePr?: () => void; + /** Git and pull request actions for the branch, each offered only while it can be taken. */ + actions?: GitAction[]; onFileOpen?: (file: DiffViewFile) => void; className?: string; }; diff --git a/packages/ui/src/components/git-action.tsx b/packages/ui/src/components/git-action.tsx new file mode 100644 index 00000000..4f9c226d --- /dev/null +++ b/packages/ui/src/components/git-action.tsx @@ -0,0 +1,91 @@ +import type { ComponentType } from "react"; + +import { cn } from "../lib/utils"; +import { Button } from "../ui/button"; +import { DropdownMenuItem } from "../ui/dropdown-menu"; +import { SplitButton } from "../ui/split-button"; +import { LoadingSpinner } from "./session-item/session-item-loading"; + +type Icon = ComponentType<{ className?: string }>; + +/** Something a branch or its pull request could have done next, such as merging. */ +export type GitAction = { + label: string; + icon: Icon; + /** The icon's color class. */ + tone?: string; + /** Shown after the label, such as how many commits a rebase brings in. */ + count?: string; + title?: string; + /** Underway: the button shows a spinner and can't be clicked again. */ + pending?: boolean; + onClick?: () => void; + /** Less common actions, in the button's menu. */ + menu?: { label: string; icon: Icon; onClick: () => void }[]; +}; + +/** One action as a button, split when it has a menu; callers style it for where it sits. */ +export function GitActionButton({ action, variant, className, primaryClassName }: { + action: GitAction; + variant: "ghost" | "outline"; + className?: string; + primaryClassName?: string; +}) { + const { label, icon: Glyph, tone, count, title, pending, onClick, menu } = action; + const content = ( + <> + {pending + ? ( + + + + ) + : } + {label} + {count && !pending && ( + + {count} + + )} + + ); + const primary = cn("disabled:opacity-100", primaryClassName); + if (!menu?.length) { + return ( + + ); + } + return ( + ( + + + {item.label} + + ))} + menuLabel={`${label} options`} + > + {content} + + ); +} diff --git a/packages/ui/src/components/session-details/session-details.tsx b/packages/ui/src/components/session-details/session-details.tsx index 150c7669..9d155cb5 100644 --- a/packages/ui/src/components/session-details/session-details.tsx +++ b/packages/ui/src/components/session-details/session-details.tsx @@ -18,10 +18,10 @@ import { } from "../../icons"; import { cn } from "../../lib/utils"; import { Button } from "../../ui/button"; -import { DropdownMenuItem } from "../../ui/dropdown-menu"; import { SplitButton } from "../../ui/split-button"; import { type Check, CHECK_ICONS, checkSummary, sortChecks } from "../checks/checks"; import { CreatePrMenu } from "../create-pr-menu"; +import { type GitAction, GitActionButton } from "../git-action"; import { LoadingSpinner } from "../session-item/session-item-loading"; export type DetailsPullState = "open" | "draft" | "closed" | "merged"; @@ -42,21 +42,6 @@ export type DetailsSubagent = { export type DetailsLink = { url: string; label: string; kind: "issue" | "pull" | "web" }; export type DetailsFact = { label: string; value: string; title?: string }; type Icon = ComponentType<{ className?: string }>; -/** Something the branch or its pull request could have done next, such as merging. */ -export type DetailsAction = { - label: string; - icon: Icon; - /** The icon's color class. */ - tone?: string; - /** Shown at the row's end, such as how many commits a rebase brings in. */ - count?: string; - title?: string; - /** Underway: the row shows a spinner and can't be clicked again. */ - pending?: boolean; - onClick?: () => void; - /** Less common actions, in the row's menu. */ - menu?: { label: string; icon: Icon; onClick: () => void }[]; -}; /** One group's totals, such as committed or untracked files; ungrouped totals omit `group`. */ export type DetailsChanges = { group?: string; adds: number; dels: number; files: number }; @@ -79,7 +64,7 @@ export type SessionDetailsViewProps = { onCreatePr?: () => void; onCreateDraftPr?: () => void; /** Rows after the pull request, each offered only while it can be taken. */ - actions?: DetailsAction[]; + actions?: GitAction[]; onClose: () => void; }; @@ -246,66 +231,6 @@ function CreatePr({ creating, onCreate, onDraft }: { ); } -function Action({ action }: { action: DetailsAction }) { - const { label, icon: Glyph, tone, count, title, pending, onClick, menu } = action; - const content = ( - <> - {pending - ? ( - - - - ) - : } - {label} - {count && !pending && ( - - {count} - - )} - - ); - const primary = "min-w-0 flex-1 justify-start gap-2 px-2 font-normal disabled:opacity-100"; - if (!menu?.length) { - return ( - - ); - } - return ( - ( - - - {item.label} - - ))} - menuLabel={`${label} options`} - > - {content} - - ); -} - function Pull({ pull }: { pull: DetailsPull }) { const [Icon, color] = PULL_ICONS[pull.state]; const failed = pull.checks.some((check) => check.state === "failed"); @@ -402,7 +327,15 @@ export function SessionDetailsView( No pull request
)} - {actions?.map((action) => )} + {actions?.map((action) => ( + + ))} )} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 6b1995e3..653745e0 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -36,6 +36,7 @@ export { } from "./components/diff/diff-view"; export { Facepile, type FacepileAvatar, type FacepileProps } from "./components/facepile/facepile"; export { FileView, type FileViewProps } from "./components/file/file-view"; +export { type GitAction } from "./components/git-action"; export { Layout, Main, @@ -90,7 +91,6 @@ export { type ScrollViewRange, } from "./components/scroll-view/scroll-view"; export { - type DetailsAction, type DetailsChanges, type DetailsFact, type DetailsLink, From 00595351cca27530543408119cc9508c88597e9e Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 16 Oct 2026 18:49:13 -0400 Subject: [PATCH 2/3] lane: sync git-actions-3 memory --- ...3CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md | 6 +++--- ...3DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md | 5 +++-- ...53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md | 8 ++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 .lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md diff --git a/.lane/memory/apps/app/src/git.ts/01M53CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md b/.lane/memory/apps/app/src/git.ts/01M53CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md index deb86e81..23913e55 100644 --- a/.lane/memory/apps/app/src/git.ts/01M53CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md +++ b/.lane/memory/apps/app/src/git.ts/01M53CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md @@ -4,9 +4,9 @@ anchor: function useGit created: 2026-10-16T22:18:47Z norm: '1' sig: 6c6d8173da981fc6 -body_hash: a96a97fd9a2a567f -raw_hash: 7b665c098e0225cc -vouched: 2026-10-16T22:31:01Z +body_hash: d9f3d17ba0fba378 +raw_hash: 730462f8973a08c0 +vouched: 2026-10-16T22:49:12Z lines: 28-57 --- diff --git a/.lane/memory/apps/app/src/git.ts/01M53DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md b/.lane/memory/apps/app/src/git.ts/01M53DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md index f6b4238a..cd9679a0 100644 --- a/.lane/memory/apps/app/src/git.ts/01M53DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md +++ b/.lane/memory/apps/app/src/git.ts/01M53DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md @@ -4,8 +4,9 @@ anchor: function useGit created: 2026-10-16T22:30:58Z norm: '1' sig: 6c6d8173da981fc6 -body_hash: a96a97fd9a2a567f -raw_hash: 7b665c098e0225cc +body_hash: d9f3d17ba0fba378 +raw_hash: 730462f8973a08c0 +vouched: 2026-10-16T22:49:12Z lines: 67-153 --- diff --git a/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md b/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md new file mode 100644 index 00000000..b63374a4 --- /dev/null +++ b/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md @@ -0,0 +1,8 @@ +--- +id: 01M53EFAEEC0X721792QGVQXJB +anchor: function useGit +created: 2026-10-16T22:49:09Z +norm: '1' +--- + +the sidebar and Diff header take their git actions from this one list, so neither offers an action the other wouldn't From 120fd07a7923b6ec0d5e2013cb53383c7f61ec20 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 16 Oct 2026 18:49:14 -0400 Subject: [PATCH 3/3] lane: sync git-actions-3 memory --- .../01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md b/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md index b63374a4..f2555a79 100644 --- a/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md +++ b/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md @@ -3,6 +3,10 @@ id: 01M53EFAEEC0X721792QGVQXJB anchor: function useGit created: 2026-10-16T22:49:09Z norm: '1' +sig: 6c6d8173da981fc6 +body_hash: d9f3d17ba0fba378 +raw_hash: 730462f8973a08c0 +lines: 67-156 --- the sidebar and Diff header take their git actions from this one list, so neither offers an action the other wouldn't