FN-5925: polish task review tab layout

Refine the task review tab layout for clearer hierarchy and better mobile behavior.

- group the review summary and decision badge into a stacked header layout
- reorganize review items with dedicated selection, status, and metadata regions
- restyle review cards, status badges, auto-merge controls, and body containers for improved spacing and overflow handling
- extend TaskReviewTab coverage for populated layout hooks and mobile CSS regressions

Files changed:
 packages/dashboard/app/components/TaskReviewTab.css     | 220 +++++++++++++++------
 packages/dashboard/app/components/TaskReviewTab.tsx     |  24 ++-
 packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx    |  32 ++-
 3 files changed, 206 insertions(+), 70 deletions(-)

Fusion-Task-Id: FN-5925

Fusion-Task-Lineage: fdb9d882-abda-48b0-aed0-a1ff4262af8f
This commit is contained in:
gsxdsm
2026-06-02 23:26:59 -07:00
parent a1b7556c7b
commit 8b450506fc
3 changed files with 207 additions and 71 deletions

View File

@@ -1,110 +1,188 @@
.task-review-tab { .task-review-tab {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-md); gap: var(--space-lg);
min-width: 0;
} }
.task-review-tab__header { .task-review-tab__header {
display: flex; display: flex;
gap: var(--space-md); align-items: flex-start;
justify-content: space-between; justify-content: space-between;
align-items: center; gap: var(--space-lg);
flex-wrap: wrap; flex-wrap: wrap;
} }
.task-review-tab__summary-wrap { .task-review-tab__summary-wrap {
flex: 1 1 20rem;
min-width: 0;
}
.task-review-tab__summary-group {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: var(--space-sm); align-items: flex-start;
flex-wrap: wrap; gap: var(--space-xs);
min-width: 0;
} }
.task-review-tab__summary { .task-review-tab__summary {
margin: 0; margin: 0;
color: var(--text-muted); color: var(--text);
font-family: var(--font-primary);
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
} }
.task-review-tab__decision { .task-review-tab__decision,
.task-review-tab__status {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: fit-content;
min-width: 0;
padding: 0 var(--space-sm); padding: 0 var(--space-sm);
min-height: calc(var(--space-lg) + var(--space-xs));
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
border: var(--btn-border-width) solid transparent;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.2;
text-transform: capitalize; text-transform: capitalize;
} }
.task-review-tab__decision--APPROVED { .task-review-tab__decision--APPROVED,
.task-review-tab__status--addressed {
color: var(--color-success); color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 14%, transparent);
border-color: color-mix(in srgb, var(--color-success) 28%, transparent);
} }
.task-review-tab__decision--CHANGES_REQUESTED { .task-review-tab__decision--CHANGES_REQUESTED,
.task-review-tab__status--failed {
color: var(--color-error); color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 14%, transparent);
border-color: color-mix(in srgb, var(--color-error) 28%, transparent);
} }
.task-review-tab__decision--COMMENTED, .task-review-tab__decision--COMMENTED,
.task-review-tab__decision--PENDING, .task-review-tab__decision--PENDING,
.task-review-tab__decision--REVIEW_REQUIRED { .task-review-tab__decision--REVIEW_REQUIRED,
.task-review-tab__status--queued,
.task-review-tab__status--in-progress {
color: var(--color-warning); color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
} }
.task-review-tab__actions { .task-review-tab__actions {
display: flex; display: flex;
gap: var(--space-sm); flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
justify-content: flex-end;
gap: var(--space-sm);
flex: 0 1 auto;
}
.task-review-tab__actions .btn {
flex-shrink: 0;
} }
.task-review-tab__auto-merge-control { .task-review-tab__auto-merge-control {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2xs); gap: var(--space-xs);
min-inline-size: max-content; min-inline-size: 14rem;
padding: var(--space-sm);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface) 72%, var(--card));
} }
.task-review-tab__list { .task-review-tab__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-md);
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
.task-review-tab__item { .task-review-tab__item {
padding: var(--space-md); padding: var(--card-padding);
} }
.task-review-tab__item-inner { .task-review-tab__item-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-md);
min-width: 0;
} }
.task-review-tab__row { .task-review-tab__direct-item {
display: grid; display: block;
grid-template-columns: auto 1fr auto; min-width: 0;
}
.task-review-tab__direct-item--selectable {
cursor: pointer;
}
.task-review-tab__item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-sm); gap: var(--space-sm);
align-items: start; min-width: 0;
}
.task-review-tab__item-selection {
display: flex;
align-items: flex-start;
gap: var(--space-sm);
min-width: 0;
flex: 1 1 auto;
}
.task-review-tab__item-selection input {
margin: 0;
flex-shrink: 0;
} }
.task-review-tab__item-summary { .task-review-tab__item-summary {
color: var(--text); color: var(--text);
font-family: var(--font-primary);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
} }
.task-review-tab__direct-item { .task-review-tab__item-meta-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-xs);
} }
.task-review-tab__body { .task-review-tab__body {
margin: 0; margin: 0;
padding: var(--space-sm); min-width: 0;
max-width: 100%;
padding: var(--space-md);
border: var(--btn-border-width) solid var(--border); border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
font-family: var(--font-primary);
font-size: 0.875rem;
line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
max-width: 100%; overflow-wrap: anywhere;
font-family: var(--font-primary); overflow-x: auto;
} }
.task-review-tab__body.markdown-body { .task-review-tab__body.markdown-body {
@@ -124,22 +202,12 @@
max-width: 100%; max-width: 100%;
} }
.task-review-tab__status { .task-review-tab__meta,
color: var(--text-muted); .task-review-tab__empty,
text-transform: capitalize; .task-review-tab__error {
} font-family: var(--font-primary);
font-size: 0.75rem;
.task-review-tab__status--failed { line-height: 1.5;
color: var(--color-error);
}
.task-review-tab__status--queued,
.task-review-tab__status--in-progress {
color: var(--color-warning);
}
.task-review-tab__status--addressed {
color: var(--color-success);
} }
.task-review-tab__meta, .task-review-tab__meta,
@@ -147,18 +215,35 @@
color: var(--text-muted); color: var(--text-muted);
} }
.task-review-tab__refresh-meta { .task-review-tab__refresh-meta,
display: inline-flex; .task-review-tab__empty,
align-items: center; .task-review-tab__error {
display: flex;
align-items: flex-start;
gap: var(--space-sm); gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface) 72%, var(--card));
}
.task-review-tab__refresh-meta {
min-width: 0;
} }
.task-review-tab__refresh-meta .status-dot { .task-review-tab__refresh-meta .status-dot {
flex-shrink: 0; flex-shrink: 0;
margin-top: var(--space-xs);
}
.task-review-tab__empty {
overflow-wrap: anywhere;
} }
.task-review-tab__error { .task-review-tab__error {
color: var(--color-error); color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 26%, var(--border));
background: color-mix(in srgb, var(--color-error) 12%, var(--surface));
} }
.task-review-tab__reviewers, .task-review-tab__reviewers,
@@ -173,27 +258,52 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.task-review-tab__row { .task-review-tab {
grid-template-columns: auto 1fr; gap: var(--space-md);
} }
.task-review-tab__status { .task-review-tab__header {
grid-column: 2; flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.task-review-tab__summary-wrap,
.task-review-tab__actions,
.task-review-tab__auto-merge-control {
width: 100%;
} }
.task-review-tab__actions { .task-review-tab__actions {
width: 100%; justify-content: flex-start;
gap: var(--space-sm); gap: var(--space-sm);
flex-wrap: wrap;
} }
.task-review-tab__auto-merge-control { .task-review-tab__actions .btn {
width: 100%; width: 100%;
min-inline-size: 0; justify-content: center;
} }
.task-review-tab__auto-merge-control .select { .task-review-tab__item,
width: 100%; .task-review-tab__body,
.task-review-tab__refresh-meta,
.task-review-tab__empty,
.task-review-tab__error {
min-width: 0;
}
.task-review-tab__item-header {
flex-direction: column;
align-items: stretch;
}
.task-review-tab__status,
.task-review-tab__decision {
max-width: 100%;
}
.task-review-tab__body {
padding: var(--space-sm);
} }
.task-review-tab__refresh-meta { .task-review-tab__refresh-meta {

View File

@@ -314,8 +314,10 @@ export function TaskReviewTab({
<div className="task-review-tab"> <div className="task-review-tab">
<div className="task-review-tab__header"> <div className="task-review-tab__header">
<div className="task-review-tab__summary-wrap"> <div className="task-review-tab__summary-wrap">
<p className="task-review-tab__summary">{summaryText}</p> <div className="task-review-tab__summary-group">
{decisionLabel ? <span className={`task-review-tab__decision task-review-tab__decision--${decisionLabel}`}>{decisionLabel}</span> : null} <p className="task-review-tab__summary">{summaryText}</p>
{decisionLabel ? <span className={`task-review-tab__decision task-review-tab__decision--${decisionLabel}`}>{decisionLabel}</span> : null}
</div>
</div> </div>
<div className="task-review-tab__actions"> <div className="task-review-tab__actions">
<div className="task-review-tab__auto-merge-control"> <div className="task-review-tab__auto-merge-control">
@@ -373,16 +375,20 @@ export function TaskReviewTab({
<li key={item.id} className="task-review-tab__item card"> <li key={item.id} className="task-review-tab__item card">
<div className="task-review-tab__item-inner"> <div className="task-review-tab__item-inner">
<label htmlFor={checkboxId} className="task-review-tab__direct-item task-review-tab__direct-item--selectable"> <label htmlFor={checkboxId} className="task-review-tab__direct-item task-review-tab__direct-item--selectable">
<div className="task-review-tab__summary-wrap"> <div className="task-review-tab__item-header">
<input id={checkboxId} type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} /> <div className="task-review-tab__item-selection">
<span className="task-review-tab__item-summary">{item.path ? `${item.path}: ` : ""}{item.summary}</span> <input id={checkboxId} type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
<span className="task-review-tab__item-summary">{item.path ? `${item.path}: ` : ""}{item.summary}</span>
</div>
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span> <span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
</div> </div>
</label> </label>
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt)}</div> <div className="task-review-tab__item-meta-list">
{item.addressing ? ( <div className="task-review-tab__meta">{formatTimestamp(item.createdAt)}</div>
<div className="task-review-tab__meta">Selected: {formatTimestamp(item.addressing.selectedAt)}{item.addressing.startedAt ? ` · Started: ${formatTimestamp(item.addressing.startedAt)}` : ""}{item.addressing.completedAt ? ` · Completed: ${formatTimestamp(item.addressing.completedAt)}` : ""}{item.addressing.error ? ` · Error: ${item.addressing.error}` : ""}</div> {item.addressing ? (
) : null} <div className="task-review-tab__meta">Selected: {formatTimestamp(item.addressing.selectedAt)}{item.addressing.startedAt ? ` · Started: ${formatTimestamp(item.addressing.startedAt)}` : ""}{item.addressing.completedAt ? ` · Completed: ${formatTimestamp(item.addressing.completedAt)}` : ""}{item.addressing.error ? ` · Error: ${item.addressing.error}` : ""}</div>
) : null}
</div>
{renderMarkdown ? ( {renderMarkdown ? (
<div className="task-review-tab__body markdown-body"> <div className="task-review-tab__body markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>

View File

@@ -125,7 +125,7 @@ describe("TaskReviewTab", () => {
expect(await screen.findByText("Failed to load review data.")).toBeInTheDocument(); expect(await screen.findByText("Failed to load review data.")).toBeInTheDocument();
}); });
it("renders PR decision and status modifiers", async () => { it("renders PR decision, status modifiers, and populated layout hooks", async () => {
const task = makeTask({ const task = makeTask({
reviewState: { reviewState: {
source: "pull-request", source: "pull-request",
@@ -136,6 +136,8 @@ describe("TaskReviewTab", () => {
body: "Fix null handling", body: "Fix null handling",
author: { login: "reviewer" }, author: { login: "reviewer" },
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
path: "src/parser.ts",
summary: "Parser guard is missing",
}, },
], ],
addressing: [{ itemId: "ri-1", status: "failed", selectedAt: new Date().toISOString() }], addressing: [{ itemId: "ri-1", status: "failed", selectedAt: new Date().toISOString() }],
@@ -143,9 +145,19 @@ describe("TaskReviewTab", () => {
}); });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null }); apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
render(<TaskReviewTab task={task} addToast={vi.fn()} />); const { container } = render(<TaskReviewTab task={task} addToast={vi.fn()} />);
await screen.findByText("CHANGES_REQUESTED"); await screen.findByText("CHANGES_REQUESTED");
expect(screen.getByText("failed").className).toContain("task-review-tab__status--failed"); expect(screen.getByText("failed").className).toContain("task-review-tab__status--failed");
expect(container.querySelector(".task-review-tab__header")).not.toBeNull();
expect(container.querySelector(".task-review-tab__summary-group")).not.toBeNull();
expect(container.querySelector(".task-review-tab__actions")).not.toBeNull();
expect(container.querySelector(".task-review-tab__refresh-meta")).not.toBeNull();
expect(container.querySelector(".task-review-tab__list")).not.toBeNull();
expect(container.querySelector(".task-review-tab__item-header")).not.toBeNull();
expect(container.querySelector(".task-review-tab__item-selection")).not.toBeNull();
expect(container.querySelector(".task-review-tab__item-meta-list")).not.toBeNull();
expect(container.querySelector(".task-review-tab__body")).not.toBeNull();
}); });
it("keeps review body outside the checkbox label and preserves selection on body clicks", async () => { it("keeps review body outside the checkbox label and preserves selection on body clicks", async () => {
@@ -369,7 +381,7 @@ describe("TaskReviewTab", () => {
expect(addToast).toHaveBeenCalledWith("Review refreshed", "success"); expect(addToast).toHaveBeenCalledWith("Review refreshed", "success");
}); });
it("renders reviewer-agent entries in direct mode", async () => { it("renders reviewer-agent entries in direct mode with populated layout hooks", async () => {
const task = makeTask(); const task = makeTask();
apiMocks.fetchTaskReview.mockResolvedValue({ apiMocks.fetchTaskReview.mockResolvedValue({
reviewState: { reviewState: {
@@ -393,9 +405,11 @@ describe("TaskReviewTab", () => {
emptyMessage: null, emptyMessage: null,
}); });
render(<TaskReviewTab task={task} addToast={vi.fn()} />); const { container } = render(<TaskReviewTab task={task} addToast={vi.fn()} />);
expect(await screen.findByText("code review Step 2: REVISE")).toBeInTheDocument(); expect(await screen.findByText("code review Step 2: REVISE")).toBeInTheDocument();
expect(screen.getAllByText("REVISE").length).toBeGreaterThan(0); expect(screen.getAllByText("REVISE").length).toBeGreaterThan(0);
expect(container.querySelector(".task-review-tab__item-header")).not.toBeNull();
expect(container.querySelector(".task-review-tab__item-meta-list")).not.toBeNull();
}); });
it("renders all persisted addressing progress states from snapshots", async () => { it("renders all persisted addressing progress states from snapshots", async () => {
@@ -485,14 +499,20 @@ describe("TaskReviewTab", () => {
expect(screen.getByText(/Error: Patch failed/)).toBeInTheDocument(); expect(screen.getByText(/Error: Patch failed/)).toBeInTheDocument();
}); });
it("keeps mobile actions wrapping contract and removes equal-width flex buttons", async () => { it("keeps mobile actions wrapping contract, stacks header groups, and prevents body overflow regressions", async () => {
const css = await loadAllAppCss(); const css = await loadAllAppCss();
const mobileMediaStart = css.indexOf("@media (max-width: 768px)"); const mobileMediaStart = css.indexOf("@media (max-width: 768px)");
expect(mobileMediaStart).toBeGreaterThanOrEqual(0); expect(mobileMediaStart).toBeGreaterThanOrEqual(0);
const mobileCss = css.slice(mobileMediaStart); const mobileCss = css.slice(mobileMediaStart);
expect(mobileCss).toMatch(/\.task-review-tab__actions\s*\{[^}]*flex-wrap\s*:\s*wrap\s*;[^}]*\}/); expect(mobileCss).toMatch(/\.task-review-tab__header\s*\{[^}]*flex-direction\s*:\s*column\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__actions\s*\{[^}]*justify-content\s*:\s*flex-start\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*width\s*:\s*100%\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__body\s*\{[^}]*padding\s*:\s*var\(--space-sm\)\s*;[^}]*\}/);
expect(mobileCss).not.toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*flex\s*:\s*1\s*;[^}]*\}/); expect(mobileCss).not.toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*flex\s*:\s*1\s*;[^}]*\}/);
expect(css).toMatch(/\.task-review-tab__body\s*\{[^}]*overflow-x\s*:\s*auto\s*;[^}]*overflow-wrap\s*:\s*anywhere\s*;[^}]*\}/);
expect(css).toMatch(/\.task-review-tab__item\s*\{[^}]*padding\s*:\s*var\(--card-padding\)\s*;[^}]*\}/);
}); });
it("shows create PR action when in-review without prInfo and auth is available", async () => { it("shows create PR action when in-review without prInfo and auth is available", async () => {