polish(desktop): detail-page layout cleanup for readme and commit views

README: wider reading inset, soft-wrap reflow, scrollable code blocks
(new Markdown blockCode option), header row removed. Commit detail:
full-width files pane that fills the remaining viewport via a flex
chain instead of a fixed min-height, author shown in header and meta,
tighter title spacing, link icon inline. Assign-to-me renders as a
left-aligned link in the assignees row.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-19 13:11:49 -04:00
parent 04ca3eb2cf
commit 3823617089
10 changed files with 163 additions and 90 deletions

View File

@@ -267,7 +267,9 @@ export function IssueAssigneesRow({
{canSelfAssign && viewer ? (
<Button
className={cn(
"h-5 px-1 text-xs text-muted-foreground hover:text-foreground",
contextActions
? "h-5 px-1 text-xs text-muted-foreground hover:text-foreground"
: "h-5 px-0 text-xs text-primary hover:bg-transparent hover:text-primary hover:underline",
contextActions && PROJECT_CONTEXT_ACTION_BUTTON_CLASS,
)}
data-testid={`${testIdPrefix}-self-assign`}

View File

@@ -1,4 +1,4 @@
import { Calendar, GitCommitHorizontal, Hash } from "lucide-react";
import { Calendar, GitCommitHorizontal, Hash, UserRound } from "lucide-react";
import type { Repository } from "@/features/projects/hooks";
import { commitDiscussionQuery } from "@/features/projects/lib/discussionChannels";
@@ -48,86 +48,113 @@ export function ProjectCommitDetailPanel({
project: Repository;
}) {
const shortHash = commit?.shortHash ?? commitHash.slice(0, 7);
const fileCount = diff?.files.length;
return (
<div
className={PROJECT_DETAIL_READING_COLUMN_CLASS}
className="flex w-full flex-1 flex-col overflow-hidden"
data-project-detail-panel
data-testid="project-commit-detail"
>
<header className="space-y-2 px-6 pb-3 pt-5">
<h3 className="line-clamp-2 text-lg font-semibold leading-6 text-foreground">
{commit?.subject ?? shortHash}{" "}
<ShareLinkButton
className="ml-1 inline-flex h-7 w-7 align-text-bottom"
label="Copy commit link"
link={commitShareLink(project, commit?.hash ?? commitHash)}
testId="project-commit-copy-link"
/>
</h3>
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
<span>Committed</span>
{commit ? (
<span
className="shrink-0 whitespace-nowrap"
title={commitDateLabel(commit.timestamp)}
>
{commitDateLabel(commit.timestamp)}
</span>
) : null}
<ProjectOriginReference
agentName={originAgentName}
channelId={originChannelId}
/>
</p>
</header>
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={Hash} label="Commit">
<span className="inline-flex min-w-0 items-center gap-1 font-mono text-xs">
{shortHash}
<CopyCommitHashButton
className="h-6 w-6"
hash={commit?.hash ?? commitHash}
<div className={PROJECT_DETAIL_READING_COLUMN_CLASS}>
<header className="space-y-1 px-6 pb-3 pt-5">
<h3 className="line-clamp-2 text-lg font-semibold leading-6 text-foreground">
{commit?.subject ?? shortHash}{" "}
<ShareLinkButton
className="ml-0.5 inline-flex h-auto w-auto align-middle hover:bg-transparent"
label="Copy commit link"
link={commitShareLink(project, commit?.hash ?? commitHash)}
testId="project-commit-copy-link"
/>
</span>
</ProjectDetailMetaRow>
{commit ? (
<ProjectDetailMetaRow icon={Calendar} label="Date">
<span className="text-muted-foreground">
{commitDateLabel(commit.timestamp)}
</h3>
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted-foreground">
<span>Committed</span>
{commit ? (
<span
className="shrink-0 whitespace-nowrap"
title={commitDateLabel(commit.timestamp)}
>
{commitDateLabel(commit.timestamp)}
</span>
) : null}
{commit?.authorName ? (
<span className="min-w-0 truncate" title={commit.authorEmail}>
by <span className="text-foreground">{commit.authorName}</span>
</span>
) : null}
<ProjectOriginReference
agentName={originAgentName}
channelId={originChannelId}
/>
</p>
</header>
<ProjectDetailMetaList>
<ProjectDetailMetaRow icon={Hash} label="Commit">
<span className="inline-flex min-w-0 items-center gap-1 font-mono text-xs">
{shortHash}
<CopyCommitHashButton
className="h-6 w-6"
hash={commit?.hash ?? commitHash}
/>
</span>
</ProjectDetailMetaRow>
{commit?.authorName ? (
<ProjectDetailMetaRow icon={UserRound} label="Author">
<span
className="min-w-0 truncate text-muted-foreground"
title={commit.authorEmail}
>
{commit.authorName}
</span>
</ProjectDetailMetaRow>
) : null}
{commit ? (
<ProjectDetailMetaRow icon={Calendar} label="Date">
<span className="text-muted-foreground">
{commitDateLabel(commit.timestamp)}
</span>
</ProjectDetailMetaRow>
) : null}
{diff ? (
<ProjectDetailMetaRow icon={GitCommitHorizontal} label="Changes">
<span className="flex items-center gap-1.5">
<span className="text-green-500">+{diff.additions}</span>
<span className="text-destructive">-{diff.deletions}</span>
</span>
</ProjectDetailMetaRow>
) : null}
</ProjectDetailMetaList>
{diff?.commitBody ? (
<ProjectDetailSection defaultOpen title="Description">
<ProjectRichContent
content={diff.commitBody}
hardLineBreaks={false}
/>
</ProjectDetailSection>
) : null}
{diff ? (
<ProjectDetailMetaRow icon={GitCommitHorizontal} label="Changes">
<span className="flex items-center gap-1.5">
<span className="text-green-500">+{diff.additions}</span>
<span className="text-destructive">-{diff.deletions}</span>
</span>
</ProjectDetailMetaRow>
) : null}
</ProjectDetailMetaList>
{diff?.commitBody ? (
<ProjectDetailSection defaultOpen title="Description">
<ProjectRichContent
content={diff.commitBody}
hardLineBreaks={false}
/>
</ProjectDetailSection>
) : null}
<DiscussedInChannels
className="mx-6 mb-4"
entityLabel="this commit"
query={commitDiscussionQuery({
hash: commit?.hash ?? commitHash,
shortHash: commit?.shortHash,
})}
testId="commit-discussed-in"
/>
<ProjectDetailSection count={fileCount} defaultOpen title="Files changed">
<div className="-mx-6">
<DiscussedInChannels
className="mx-6 mb-4"
entityLabel="this commit"
query={commitDiscussionQuery({
hash: commit?.hash ?? commitHash,
shortHash: commit?.shortHash,
})}
testId="commit-discussed-in"
/>
</div>
<ProjectDetailSection
className="flex min-h-0 flex-1 flex-col"
contentClassName="flex min-h-0 flex-1 flex-col"
defaultOpen
headerClassName="mx-auto max-w-3xl"
title="Files changed"
>
{/* Full-bleed: cancel the section's inner padding so the file
tree + diff grid spans the whole content column, and let it
grow to the bottom of the scrollport when the page is short. */}
<div className="-mx-6 -mb-6 flex min-h-0 flex-1 flex-col">
<ProjectDiffFilesPanel
className="min-h-0 flex-1"
fileTreeClassName="max-h-none"
diff={diff}
embedded
error={diffError}

View File

@@ -889,7 +889,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto overscroll-y-none px-4 pb-4"
data-testid="project-detail-scroll"
>
<div className="w-full space-y-3">
{/* min-h-full + flex chain lets the commit detail's diff pane
grow to the bottom of the scrollport without forcing a
taller page when content already overflows. */}
<div className="flex min-h-full w-full flex-col space-y-3">
<WorkspaceTabs
key={`${project.id}:${repository.id}:${tabsResetKey}`}
initialTab={

View File

@@ -10,16 +10,25 @@ import { cn } from "@/shared/lib/cn";
*/
export function ProjectDetailSection({
children,
className,
contentClassName,
count,
defaultOpen = true,
headerClassName,
onOpenChange,
open: openProp,
testId,
title,
}: {
children: React.ReactNode;
/** Extra classes for the outer section, e.g. flex growth in fill layouts. */
className?: string;
/** Extra classes for the expanded content wrapper. */
contentClassName?: string;
count?: number;
defaultOpen?: boolean;
/** Extra classes for the header row, e.g. to align it with a reading column while the content is full-bleed. */
headerClassName?: string;
onOpenChange?: (open: boolean) => void;
open?: boolean;
testId?: string;
@@ -35,12 +44,16 @@ export function ProjectDetailSection({
return (
<section
className={className}
data-open={open ? "true" : "false"}
data-testid={testId ?? "project-detail-section"}
>
<button
aria-expanded={open}
className="flex min-h-10 w-full min-w-0 items-center gap-2 px-6 py-2 text-left text-sm font-medium leading-5 text-foreground transition-colors hover:bg-muted/20"
className={cn(
"flex min-h-10 w-full min-w-0 items-center gap-2 px-6 py-2 text-left text-sm font-medium leading-5 text-foreground transition-colors hover:bg-muted/20",
headerClassName,
)}
onClick={() => setOpen(!open)}
type="button"
>
@@ -59,7 +72,9 @@ export function ProjectDetailSection({
) : null}
</span>
</button>
{open ? <div className="px-6 pb-6 pt-1">{children}</div> : null}
{open ? (
<div className={cn("px-6 pb-6 pt-1", contentClassName)}>{children}</div>
) : null}
</section>
);
}

View File

@@ -755,8 +755,10 @@ export function ProjectPullRequestFilesChangedPanel({
}
export function ProjectDiffFilesPanel({
className,
error,
diff,
fileTreeClassName,
isLoading,
embedded = false,
focusedAnchor,
@@ -764,6 +766,10 @@ export function ProjectDiffFilesPanel({
inlineComments,
subjectLabel,
}: {
/** Extra classes for the file-tree/diff grid container. */
className?: string;
/** Overrides the file tree's default `max-h-96` cap, e.g. for full-height layouts. */
fileTreeClassName?: string;
error: unknown;
diff: ProjectRepoDiff | null | undefined;
isLoading: boolean;
@@ -857,6 +863,7 @@ export function ProjectDiffFilesPanel({
className={cn(
"grid min-h-0 overflow-hidden lg:grid-cols-[17rem_minmax(0,1fr)]",
outerBorderClass,
className,
)}
data-project-detail-panel={embedded ? undefined : true}
>
@@ -876,7 +883,12 @@ export function ProjectDiffFilesPanel({
/>
</label>
</div>
<nav className="max-h-96 overflow-auto border-border/50 border-t py-1">
<nav
className={cn(
"max-h-96 overflow-auto border-border/50 border-t py-1",
fileTreeClassName,
)}
>
<FileTreeItems
node={fileTree}
onSelect={setSelectedPath}

View File

@@ -263,7 +263,7 @@ export function ReadmePanel({
return (
<section className="overflow-hidden">
{header}
<div className="p-6 text-sm text-muted-foreground">
<div className="px-8 py-6 text-sm text-muted-foreground">
{fileContent.error
? "Could not load this README. Try again after refreshing the repository."
: gitDataState === "empty"
@@ -283,11 +283,13 @@ export function ReadmePanel({
return (
<section className="overflow-hidden">
{header}
<div className="p-4">
<div className="min-w-0 px-8 py-6">
{isMarkdown ? (
<Markdown
blockCode
className="text-sm"
content={readmeContent}
hardLineBreaks={false}
interactive={false}
/>
) : language ? (

View File

@@ -1,5 +1,4 @@
import {
BookOpen,
CircleDot,
Files as FilesIcon,
GitCommitHorizontal,
@@ -350,9 +349,7 @@ export function WorkspaceTabs({
[selectedPullRequestId],
);
const sectionHeader =
selectedTab === "overview" && readmeFile ? (
<ProjectSectionHeader icon={BookOpen} title="README" />
) : selectedTab === "files" && files.length > 0 ? (
selectedTab === "files" && files.length > 0 ? (
<ProjectSectionHeader icon={FilesIcon} title="Files" />
) : selectedTab === "activity" && !selectedCommitHash ? (
<ProjectSectionHeader icon={GitCommitHorizontal} title="Commits" />
@@ -387,7 +384,7 @@ export function WorkspaceTabs({
return (
<Tabs
className="min-w-0 space-y-3"
className="flex min-w-0 flex-1 flex-col space-y-3"
onValueChange={handleTabChange}
value={selectedTab}
>
@@ -422,7 +419,7 @@ export function WorkspaceTabs({
{/* Project content follows the same borderless flow as work-item details.
Inner panels retain standalone chrome, neutralized here. */}
<div
className="-mx-4 [&_[data-project-detail-panel]]:rounded-none [&_[data-project-detail-panel]]:border-0"
className="-mx-4 flex flex-1 flex-col [&_[data-project-detail-panel]]:rounded-none [&_[data-project-detail-panel]]:border-0"
data-testid="project-workspace-panel"
>
{sectionHeader}
@@ -444,7 +441,10 @@ export function WorkspaceTabs({
/>
</TabsContent>
<TabsContent className="m-0" value="activity">
<TabsContent
className="m-0 min-h-0 flex-1 flex-col data-[state=active]:flex"
value="activity"
>
{repositoryUnavailableState ??
(selectedCommitHash ? (
<ProjectCommitDetailPanel

View File

@@ -1259,6 +1259,7 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) {
export function createMarkdownComponents(
interactive = true,
mediaInset = false,
blockCode = false,
): Components {
const listItemClassName = "[&_p]:inline";
const listClassName = "space-y-1 pl-6 marker:text-muted-foreground/80";
@@ -1566,7 +1567,7 @@ export function createMarkdownComponents(
return <p>{children}</p>;
},
pre: ({ children }) => {
if (!interactive) return <span>{children}</span>;
if (!interactive && !blockCode) return <span>{children}</span>;
let language = "";
React.Children.forEach(children, (child) => {
if (
@@ -1701,9 +1702,9 @@ export function createMarkdownComponents(
}
/**
* The component map only varies by the three boolean render flags, so at most
* eight instances ever exist. Module-stable maps mean cached markdown element
* trees (see ./markdown/nodeCache.ts) never embed per-mount closures.
* The component map only varies by the four boolean render flags, so at most
* sixteen instances ever exist. Module-stable maps mean cached markdown
* element trees (see ./markdown/nodeCache.ts) never embed per-mount closures.
*/
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "8";
const markdownComponentsByVariant = new Map<string, MarkdownComponentSet>();
@@ -1721,12 +1722,13 @@ function getMarkdownComponents(
interactive: boolean,
leadingInlineContent: boolean,
mediaInset: boolean,
blockCode: boolean,
): MarkdownComponentSet {
const variant = `${MARKDOWN_COMPONENT_SCHEMA_VERSION}:${interactive ? "i" : ""}${leadingInlineContent ? "l" : ""}${mediaInset ? "m" : ""}`;
const variant = `${MARKDOWN_COMPONENT_SCHEMA_VERSION}:${interactive ? "i" : ""}${leadingInlineContent ? "l" : ""}${mediaInset ? "m" : ""}${blockCode ? "c" : ""}`;
let entry = markdownComponentsByVariant.get(variant);
if (!entry) {
entry = {
components: createMarkdownComponents(interactive, mediaInset),
components: createMarkdownComponents(interactive, mediaInset, blockCode),
variant,
};
markdownComponentsByVariant.set(variant, entry);
@@ -1743,6 +1745,7 @@ function MarkdownInner({
hardLineBreaks = true,
imetaByUrl,
interactive = true,
blockCode = false,
agentMentionPubkeysByName,
leadingInlineContent,
mediaInset = false,
@@ -1857,6 +1860,7 @@ function MarkdownInner({
interactive,
hasLeadingInlineContent,
mediaInset,
blockCode,
);
const markdownNode =
configNudge === null

View File

@@ -77,6 +77,13 @@ export type MarkdownProps = {
hardLineBreaks?: boolean;
imetaByUrl?: ImetaLookup;
interactive?: boolean;
/**
* Render fenced code as scrollable code blocks even when `interactive` is
* false. Non-interactive surfaces default to inlining code (compact
* previews); document surfaces like repository READMEs pass true so long
* lines scroll inside the block instead of stretching the layout.
*/
blockCode?: boolean;
agentMentionPubkeysByName?: Record<string, string>;
mentionNames?: string[];
mentionPubkeysByName?: Record<string, string>;

View File

@@ -111,6 +111,7 @@ export function markdownPropsAreEqual(
prev.customEmoji === next.customEmoji &&
prev.hardLineBreaks === next.hardLineBreaks &&
prev.interactive === next.interactive &&
prev.blockCode === next.blockCode &&
prev.mediaInset === next.mediaInset &&
shallowRecordEqual(
prev.agentMentionPubkeysByName,