Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
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
9 changes: 6 additions & 3 deletions apps/app/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -44,7 +44,7 @@ const ASKS: Record<Exclude<Ask, "create">, (pull: number) => string> = {
close: (pull) => `Close #${pull} with \`gh pr close\`, keeping its branch.`,
};

const PENDING: Record<Exclude<Ask, "create">, Pick<DetailsAction, "label" | "icon">> = {
const PENDING: Record<Exclude<Ask, "create">, Pick<GitAction, "label" | "icon">> = {
commit: { label: "Committing", icon: IconCommit },
update: { label: "Updating PR", icon: IconPush },
rebase: { label: "Rebasing", icon: IconBranch },
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/app/src/panels/diff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/components/diff/diff-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -355,6 +356,7 @@ function useDiffView({
onCreatePr,
onCreateDraftPr,
onManualCreatePr,
actions,
onFileOpen,
className,
}: DiffViewProps) {
Expand Down Expand Up @@ -524,6 +526,7 @@ function useDiffView({
onCreatePr,
onCreateDraftPr,
onManualCreatePr,
actions,
className,
summary,
view,
Expand Down Expand Up @@ -556,6 +559,7 @@ function DiffViewContent(props: DiffViewProps) {
onCreatePr,
onCreateDraftPr,
onManualCreatePr,
actions,
className,
summary,
view,
Expand Down Expand Up @@ -615,6 +619,9 @@ function DiffViewContent(props: DiffViewProps) {
</div>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1.5">
{actions?.map((action) => (
<GitActionButton key={action.label} action={action} variant="outline" />
))}
<PrAction
pr={pr}
creatingPr={creatingPr}
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/diff/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { GitAction } from "../git-action";

type DiffViewFile = {
file: string;
from?: string;
Expand Down Expand Up @@ -43,6 +45,8 @@ type DiffViewProps = {
onCreatePr?: () => 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;
};
Expand Down
91 changes: 91 additions & 0 deletions packages/ui/src/components/git-action.tsx
Original file line number Diff line number Diff line change
@@ -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
? (
<span className="grid size-4 shrink-0 place-items-center text-muted-foreground">
<LoadingSpinner size={14} />
</span>
)
: <Glyph className={cn("size-4 shrink-0", tone || "text-muted-foreground")} aria-hidden />}
<span className="min-w-0 truncate">{label}</span>
{count && !pending && (
<span className="ml-auto shrink-0 font-medium text-muted-foreground tabular-nums">
{count}
</span>
)}
</>
);
const primary = cn("disabled:opacity-100", primaryClassName);
if (!menu?.length) {
return (
<Button
type="button"
variant={variant}
size="sm"
title={title}
className={cn(className, primary)}
disabled={pending || !onClick}
onClick={onClick}
>
{content}
</Button>
);
}
return (
<SplitButton
type="button"
variant={variant}
size="sm"
title={title}
className={className}
primaryClassName={primary}
triggerClassName="text-muted-foreground disabled:opacity-100"
contentClassName="inline-[max-content]"
disabled={pending || !onClick}
onClick={onClick}
menu={menu.map((item) => (
<DropdownMenuItem key={item.label} onClick={item.onClick}>
<item.icon className="size-4 text-muted-foreground" aria-hidden />
<span>{item.label}</span>
</DropdownMenuItem>
))}
menuLabel={`${label} options`}
>
{content}
</SplitButton>
);
}
89 changes: 11 additions & 78 deletions packages/ui/src/components/session-details/session-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 };

Expand All @@ -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;
};

Expand Down Expand Up @@ -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
? (
<span className="grid size-4 shrink-0 place-items-center text-muted-foreground">
<LoadingSpinner size={14} />
</span>
)
: <Glyph className={cn(GLYPH, tone || "text-muted-foreground")} aria-hidden />}
<span className="min-w-0 truncate">{label}</span>
{count && !pending && (
<span className="ml-auto shrink-0 font-medium text-muted-foreground tabular-nums">
{count}
</span>
)}
</>
);
const primary = "min-w-0 flex-1 justify-start gap-2 px-2 font-normal disabled:opacity-100";
if (!menu?.length) {
return (
<Button
type="button"
variant="ghost"
size="sm"
title={title}
className={cn("-mx-2 w-[calc(100%+1rem)]", primary)}
disabled={pending || !onClick}
onClick={onClick}
>
{content}
</Button>
);
}
return (
<SplitButton
type="button"
variant="ghost"
size="sm"
title={title}
className="-mx-2 w-[calc(100%+1rem)]"
primaryClassName={primary}
triggerClassName="text-muted-foreground disabled:opacity-100"
contentClassName="inline-[max-content]"
disabled={pending || !onClick}
onClick={onClick}
menu={menu.map((item) => (
<DropdownMenuItem key={item.label} onClick={item.onClick}>
<item.icon className="size-4 text-muted-foreground" aria-hidden />
<span>{item.label}</span>
</DropdownMenuItem>
))}
menuLabel={`${label} options`}
>
{content}
</SplitButton>
);
}

function Pull({ pull }: { pull: DetailsPull }) {
const [Icon, color] = PULL_ICONS[pull.state];
const failed = pull.checks.some((check) => check.state === "failed");
Expand Down Expand Up @@ -402,7 +327,15 @@ export function SessionDetailsView(
<span className="min-w-0 truncate">No pull request</span>
</div>
)}
{actions?.map((action) => <Action key={action.label} action={action} />)}
{actions?.map((action) => (
<GitActionButton
key={action.label}
action={action}
variant="ghost"
className="-mx-2 w-[calc(100%+1rem)]"
primaryClassName="min-w-0 flex-1 justify-start gap-2 px-2 font-normal"
/>
))}
</section>
)}

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -90,7 +91,6 @@ export {
type ScrollViewRange,
} from "./components/scroll-view/scroll-view";
export {
type DetailsAction,
type DetailsChanges,
type DetailsFact,
type DetailsLink,
Expand Down