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
59 changes: 59 additions & 0 deletions apps/web/src/components/merge-conflicts/ExpandableSummary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { ExpandableSummary } from "./ExpandableSummary";

describe("ExpandableSummary", () => {
it("renders inline text by default when no children are provided", () => {
const html = renderToStaticMarkup(
<ExpandableSummary text="This is an AI summary that is long enough to be expandable and interesting." />,
);
expect(html).toContain(
"This is an AI summary that is long enough to be expandable and interesting.",
);
});

it("renders custom children when provided", () => {
const html = renderToStaticMarkup(
<ExpandableSummary text="Raw text here">
<span className="custom">Custom inline render</span>
</ExpandableSummary>,
);
expect(html).toContain("Custom inline render");
expect(html).toContain("custom");
});

it("shows the expand button for text longer than 40 characters", () => {
const html = renderToStaticMarkup(
<ExpandableSummary text="This is definitely long enough to warrant an expand button for the user." />,
);
expect(html).toContain("Expand summary");
});

it("hides the expand button for very short text", () => {
const html = renderToStaticMarkup(<ExpandableSummary text="Short." />);
expect(html).not.toContain("Expand summary");
});

it("hides the expand button for whitespace-only text", () => {
const html = renderToStaticMarkup(<ExpandableSummary text=" " />);
expect(html).not.toContain("Expand summary");
});

it("applies the group/expand class for hover interaction", () => {
const html = renderToStaticMarkup(
<ExpandableSummary text="A sufficiently long expandable AI response summary text." />,
);
expect(html).toContain("group/expand");
});

it("passes className to the wrapper div", () => {
const html = renderToStaticMarkup(
<ExpandableSummary
className="mt-3"
text="Another long expandable text for testing className."
/>,
);
expect(html).toContain("mt-3");
});
});
89 changes: 89 additions & 0 deletions apps/web/src/components/merge-conflicts/ExpandableSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { MaximizeIcon } from "lucide-react";
import { memo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

import { cn } from "~/lib/utils";
import { Sheet, SheetPopup, SheetPanel } from "~/components/ui/sheet";

/**
* Wraps plain-text or markdown AI summaries with an expand button
* that opens a clean, Notion-like full-height preview sheet.
*
* Usage:
* <ExpandableSummary text={recommendation.detail} title="Resolution guidance" />
*
* The inline render is plain text (default children or the `text` prop).
* The expanded view renders the text as GitHub-flavored Markdown in a
* minimal, readable layout.
*/

interface ExpandableSummaryProps {
/** The raw text / markdown content to render */
text: string;
/** Title shown at the top of the expanded sheet */
title?: string;
/** Optional subtitle / context line below the title */
subtitle?: string;
/** Additional className for the inline wrapper */
className?: string;
/** Inline text element — defaults to rendering `text` in a <p> */
children?: React.ReactNode;
}

export const ExpandableSummary = memo(function ExpandableSummary({
text,
title,
subtitle,
className,
children,
}: ExpandableSummaryProps) {
const [open, setOpen] = useState(false);

// Don't render the expand affordance for very short text
const isExpandable = text.trim().length > 40;

return (
<>
<div className={cn("group/expand relative", className)}>
{children ?? <p className="text-sm opacity-85">{text}</p>}

{isExpandable ? (
<button
aria-label="Expand summary"
className="absolute -top-1 -end-1 flex size-6 items-center justify-center rounded-md bg-background/80 text-muted-foreground/50 opacity-0 backdrop-blur-sm transition-all duration-150 hover:bg-muted hover:text-foreground group-hover/expand:opacity-100"
onClick={() => setOpen(true)}
type="button"
>
<MaximizeIcon className="size-3" />
</button>
) : null}
</div>

{isExpandable ? (
<Sheet onOpenChange={setOpen} open={open}>
<SheetPopup className="max-w-2xl" showCloseButton side="right" variant="inset">
<SheetPanel scrollFade>
<article className="summary-preview mx-auto max-w-prose px-2 py-6">
{title ? (
<header className="mb-8 border-b border-border/50 pb-6">
<h1 className="font-heading text-xl font-semibold leading-tight text-foreground">
{title}
</h1>
{subtitle ? (
<p className="mt-2 text-sm text-muted-foreground">{subtitle}</p>
) : null}
</header>
) : null}

<div className="summary-preview-body text-[15px] leading-relaxed text-foreground/88">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
</div>
</article>
</SheetPanel>
</SheetPopup>
</Sheet>
) : null}
</>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
groupConflictCandidatesByFile,
humanizeConflictError,
pickRecommendedConflictCandidate,
pullRequestStateBadgeClassName,
workspaceModeLabel,
} from "./MergeConflictShell.logic";

describe("pickRecommendedConflictCandidate", () => {
Expand Down Expand Up @@ -211,3 +213,54 @@ describe("buildConflictFeedbackPreview", () => {
).toContain("Operator note: Keep the API signature from the incoming branch.");
});
});

describe("workspaceModeLabel", () => {
it('returns "Repo scan" when no workspace is prepared', () => {
expect(workspaceModeLabel(null)).toBe("Repo scan");
});

it('returns "Prepared in repo" for local mode', () => {
expect(
workspaceModeLabel({
branch: "feature/auth",
cwd: "/Users/val/project",
mode: "local",
worktreePath: null,
}),
).toBe("Prepared in repo");
});

it('returns "Dedicated worktree" for worktree mode', () => {
expect(
workspaceModeLabel({
branch: "feature/auth",
cwd: "/Users/val/.git/worktrees/auth",
mode: "worktree",
worktreePath: "/Users/val/.git/worktrees/auth",
}),
).toBe("Dedicated worktree");
});
});

describe("pullRequestStateBadgeClassName", () => {
it("returns emerald styling for open PRs", () => {
const result = pullRequestStateBadgeClassName("open");
expect(result).toContain("emerald");
expect(result).not.toContain("muted");
});

it("returns muted styling for merged PRs", () => {
const result = pullRequestStateBadgeClassName("merged");
expect(result).toContain("muted");
expect(result).not.toContain("emerald");
});

it("returns muted styling for closed PRs", () => {
const result = pullRequestStateBadgeClassName("closed");
expect(result).toContain("muted");
});

it("returns identical styling for merged and closed states", () => {
expect(pullRequestStateBadgeClassName("merged")).toBe(pullRequestStateBadgeClassName("closed"));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,25 @@ export function computeActiveStepIndex(
const index = steps.findIndex((step) => step.status !== "done");
return index === -1 ? steps.length : index;
}

export interface PreparedWorkspace {
branch: string;
cwd: string;
mode: "local" | "worktree";
worktreePath: string | null;
}

export function workspaceModeLabel(workspace: PreparedWorkspace | null): string {
if (!workspace) return "Repo scan";
return workspace.mode === "worktree" ? "Dedicated worktree" : "Prepared in repo";
}

export function pullRequestStateBadgeClassName(state: GitResolvedPullRequest["state"]): string {
switch (state) {
case "open":
return "border-emerald-500/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300";
case "merged":
case "closed":
return "border-border bg-muted/70 text-foreground";
}
}
Loading
Loading