Skip to content

Commit 03ac540

Browse files
authored
Reorganize conflict intake UI and expandable summaries (#96)
- Extract shared merge-conflict logic into reusable helpers - Add expandable summary preview sheets for long guidance text - Refresh intake layout with tooltip help and improved workspace display
1 parent 4156c90 commit 03ac540

7 files changed

Lines changed: 502 additions & 79 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { ExpandableSummary } from "./ExpandableSummary";
5+
6+
describe("ExpandableSummary", () => {
7+
it("renders inline text by default when no children are provided", () => {
8+
const html = renderToStaticMarkup(
9+
<ExpandableSummary text="This is an AI summary that is long enough to be expandable and interesting." />,
10+
);
11+
expect(html).toContain(
12+
"This is an AI summary that is long enough to be expandable and interesting.",
13+
);
14+
});
15+
16+
it("renders custom children when provided", () => {
17+
const html = renderToStaticMarkup(
18+
<ExpandableSummary text="Raw text here">
19+
<span className="custom">Custom inline render</span>
20+
</ExpandableSummary>,
21+
);
22+
expect(html).toContain("Custom inline render");
23+
expect(html).toContain("custom");
24+
});
25+
26+
it("shows the expand button for text longer than 40 characters", () => {
27+
const html = renderToStaticMarkup(
28+
<ExpandableSummary text="This is definitely long enough to warrant an expand button for the user." />,
29+
);
30+
expect(html).toContain("Expand summary");
31+
});
32+
33+
it("hides the expand button for very short text", () => {
34+
const html = renderToStaticMarkup(<ExpandableSummary text="Short." />);
35+
expect(html).not.toContain("Expand summary");
36+
});
37+
38+
it("hides the expand button for whitespace-only text", () => {
39+
const html = renderToStaticMarkup(<ExpandableSummary text=" " />);
40+
expect(html).not.toContain("Expand summary");
41+
});
42+
43+
it("applies the group/expand class for hover interaction", () => {
44+
const html = renderToStaticMarkup(
45+
<ExpandableSummary text="A sufficiently long expandable AI response summary text." />,
46+
);
47+
expect(html).toContain("group/expand");
48+
});
49+
50+
it("passes className to the wrapper div", () => {
51+
const html = renderToStaticMarkup(
52+
<ExpandableSummary
53+
className="mt-3"
54+
text="Another long expandable text for testing className."
55+
/>,
56+
);
57+
expect(html).toContain("mt-3");
58+
});
59+
});
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { MaximizeIcon } from "lucide-react";
2+
import { memo, useState } from "react";
3+
import ReactMarkdown from "react-markdown";
4+
import remarkGfm from "remark-gfm";
5+
6+
import { cn } from "~/lib/utils";
7+
import { Sheet, SheetPopup, SheetPanel } from "~/components/ui/sheet";
8+
9+
/**
10+
* Wraps plain-text or markdown AI summaries with an expand button
11+
* that opens a clean, Notion-like full-height preview sheet.
12+
*
13+
* Usage:
14+
* <ExpandableSummary text={recommendation.detail} title="Resolution guidance" />
15+
*
16+
* The inline render is plain text (default children or the `text` prop).
17+
* The expanded view renders the text as GitHub-flavored Markdown in a
18+
* minimal, readable layout.
19+
*/
20+
21+
interface ExpandableSummaryProps {
22+
/** The raw text / markdown content to render */
23+
text: string;
24+
/** Title shown at the top of the expanded sheet */
25+
title?: string;
26+
/** Optional subtitle / context line below the title */
27+
subtitle?: string;
28+
/** Additional className for the inline wrapper */
29+
className?: string;
30+
/** Inline text element — defaults to rendering `text` in a <p> */
31+
children?: React.ReactNode;
32+
}
33+
34+
export const ExpandableSummary = memo(function ExpandableSummary({
35+
text,
36+
title,
37+
subtitle,
38+
className,
39+
children,
40+
}: ExpandableSummaryProps) {
41+
const [open, setOpen] = useState(false);
42+
43+
// Don't render the expand affordance for very short text
44+
const isExpandable = text.trim().length > 40;
45+
46+
return (
47+
<>
48+
<div className={cn("group/expand relative", className)}>
49+
{children ?? <p className="text-sm opacity-85">{text}</p>}
50+
51+
{isExpandable ? (
52+
<button
53+
aria-label="Expand summary"
54+
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"
55+
onClick={() => setOpen(true)}
56+
type="button"
57+
>
58+
<MaximizeIcon className="size-3" />
59+
</button>
60+
) : null}
61+
</div>
62+
63+
{isExpandable ? (
64+
<Sheet onOpenChange={setOpen} open={open}>
65+
<SheetPopup className="max-w-2xl" showCloseButton side="right" variant="inset">
66+
<SheetPanel scrollFade>
67+
<article className="summary-preview mx-auto max-w-prose px-2 py-6">
68+
{title ? (
69+
<header className="mb-8 border-b border-border/50 pb-6">
70+
<h1 className="font-heading text-xl font-semibold leading-tight text-foreground">
71+
{title}
72+
</h1>
73+
{subtitle ? (
74+
<p className="mt-2 text-sm text-muted-foreground">{subtitle}</p>
75+
) : null}
76+
</header>
77+
) : null}
78+
79+
<div className="summary-preview-body text-[15px] leading-relaxed text-foreground/88">
80+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
81+
</div>
82+
</article>
83+
</SheetPanel>
84+
</SheetPopup>
85+
</Sheet>
86+
) : null}
87+
</>
88+
);
89+
});

apps/web/src/components/merge-conflicts/MergeConflictShell.logic.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
groupConflictCandidatesByFile,
88
humanizeConflictError,
99
pickRecommendedConflictCandidate,
10+
pullRequestStateBadgeClassName,
11+
workspaceModeLabel,
1012
} from "./MergeConflictShell.logic";
1113

1214
describe("pickRecommendedConflictCandidate", () => {
@@ -211,3 +213,54 @@ describe("buildConflictFeedbackPreview", () => {
211213
).toContain("Operator note: Keep the API signature from the incoming branch.");
212214
});
213215
});
216+
217+
describe("workspaceModeLabel", () => {
218+
it('returns "Repo scan" when no workspace is prepared', () => {
219+
expect(workspaceModeLabel(null)).toBe("Repo scan");
220+
});
221+
222+
it('returns "Prepared in repo" for local mode', () => {
223+
expect(
224+
workspaceModeLabel({
225+
branch: "feature/auth",
226+
cwd: "/Users/val/project",
227+
mode: "local",
228+
worktreePath: null,
229+
}),
230+
).toBe("Prepared in repo");
231+
});
232+
233+
it('returns "Dedicated worktree" for worktree mode', () => {
234+
expect(
235+
workspaceModeLabel({
236+
branch: "feature/auth",
237+
cwd: "/Users/val/.git/worktrees/auth",
238+
mode: "worktree",
239+
worktreePath: "/Users/val/.git/worktrees/auth",
240+
}),
241+
).toBe("Dedicated worktree");
242+
});
243+
});
244+
245+
describe("pullRequestStateBadgeClassName", () => {
246+
it("returns emerald styling for open PRs", () => {
247+
const result = pullRequestStateBadgeClassName("open");
248+
expect(result).toContain("emerald");
249+
expect(result).not.toContain("muted");
250+
});
251+
252+
it("returns muted styling for merged PRs", () => {
253+
const result = pullRequestStateBadgeClassName("merged");
254+
expect(result).toContain("muted");
255+
expect(result).not.toContain("emerald");
256+
});
257+
258+
it("returns muted styling for closed PRs", () => {
259+
const result = pullRequestStateBadgeClassName("closed");
260+
expect(result).toContain("muted");
261+
});
262+
263+
it("returns identical styling for merged and closed states", () => {
264+
expect(pullRequestStateBadgeClassName("merged")).toBe(pullRequestStateBadgeClassName("closed"));
265+
});
266+
});

apps/web/src/components/merge-conflicts/MergeConflictShell.logic.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,25 @@ export function computeActiveStepIndex(
224224
const index = steps.findIndex((step) => step.status !== "done");
225225
return index === -1 ? steps.length : index;
226226
}
227+
228+
export interface PreparedWorkspace {
229+
branch: string;
230+
cwd: string;
231+
mode: "local" | "worktree";
232+
worktreePath: string | null;
233+
}
234+
235+
export function workspaceModeLabel(workspace: PreparedWorkspace | null): string {
236+
if (!workspace) return "Repo scan";
237+
return workspace.mode === "worktree" ? "Dedicated worktree" : "Prepared in repo";
238+
}
239+
240+
export function pullRequestStateBadgeClassName(state: GitResolvedPullRequest["state"]): string {
241+
switch (state) {
242+
case "open":
243+
return "border-emerald-500/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300";
244+
case "merged":
245+
case "closed":
246+
return "border-border bg-muted/70 text-foreground";
247+
}
248+
}

0 commit comments

Comments
 (0)