Skip to content

Commit f640ed3

Browse files
committed
Add fullscreen review display mode
1 parent df348e1 commit f640ed3

3 files changed

Lines changed: 237 additions & 4 deletions

File tree

src/ui/review-payload.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface PayloadRendererOptions {
1111
hostContext?: HostContext;
1212
errorMessage?: string | null;
1313
visibleFileCount?: number;
14+
presentation?: "inline" | "fullscreen";
1415
}
1516

1617
interface MountedPayload {

src/ui/workspace-app.css

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,63 @@ body {
198198
grid-template-columns: 54px minmax(0, 1fr) auto 24px;
199199
}
200200

201+
.review-header-row {
202+
display: grid;
203+
grid-template-columns: minmax(0, 1fr) auto;
204+
align-items: center;
205+
gap: 12px;
206+
padding-right: 16px;
207+
}
208+
201209
.review-title-group {
202210
display: grid;
203211
min-width: 0;
204212
gap: 3px;
205213
}
206214

215+
.review-header-actions,
216+
.review-fullscreen-actions,
217+
.review-fullscreen-title {
218+
display: flex;
219+
align-items: center;
220+
}
221+
222+
.review-header-actions,
223+
.review-fullscreen-actions {
224+
justify-content: flex-end;
225+
gap: 12px;
226+
}
227+
228+
.review-button {
229+
min-height: 34px;
230+
padding: 0 13px;
231+
border: 1px solid color-mix(in srgb, var(--color-border-primary, #3a3a40) 92%, transparent);
232+
border-radius: 9px;
233+
background: color-mix(in srgb, var(--color-background-tertiary, #333338) 88%, transparent);
234+
color: var(--color-text-primary, #f5f5f6);
235+
cursor: pointer;
236+
font: inherit;
237+
font-size: var(--font-text-sm-size, 13px);
238+
font-weight: 500;
239+
}
240+
241+
.review-button:hover:not(:disabled) {
242+
background: color-mix(in srgb, var(--color-background-tertiary, #414148) 96%, transparent);
243+
}
244+
245+
.review-button:disabled {
246+
cursor: wait;
247+
opacity: 0.7;
248+
}
249+
250+
.review-mode-error {
251+
padding: 8px 14px;
252+
border-top: 1px solid color-mix(in srgb, var(--color-border-danger, #7f3030) 56%, transparent);
253+
background: color-mix(in srgb, var(--color-background-danger, #421e1e) 24%, transparent);
254+
color: var(--color-danger-text, #ee7676);
255+
font-size: var(--font-text-sm-size, 12px);
256+
}
257+
207258
.review-summary {
208259
display: grid;
209260
border-top: 1px solid var(--tool-card-divider);
@@ -247,6 +298,48 @@ body {
247298
flex: none;
248299
}
249300

301+
.review-fullscreen-shell {
302+
height: 100vh;
303+
height: 100dvh;
304+
}
305+
306+
.review-fullscreen {
307+
display: grid;
308+
grid-template-rows: auto auto minmax(0, 1fr);
309+
width: 100%;
310+
height: 100%;
311+
overflow: hidden;
312+
background: var(--color-background-primary, #18191d);
313+
color: var(--color-text-primary, #f5f5f6);
314+
}
315+
316+
.review-fullscreen-header {
317+
display: flex;
318+
align-items: center;
319+
justify-content: space-between;
320+
gap: 20px;
321+
min-height: 68px;
322+
padding: 12px 18px;
323+
border-bottom: 1px solid var(--color-border-primary, #3a3a40);
324+
background: color-mix(in srgb, var(--color-background-secondary, #28282d) 94%, transparent);
325+
}
326+
327+
.review-fullscreen-title {
328+
min-width: 0;
329+
gap: 12px;
330+
}
331+
332+
.review-fullscreen-body {
333+
min-width: 0;
334+
min-height: 0;
335+
overflow: hidden;
336+
}
337+
338+
.review-fullscreen .review-diff {
339+
height: 100%;
340+
max-height: none;
341+
}
342+
250343
.review-diff {
251344
display: grid;
252345
max-height: 520px;
@@ -377,4 +470,9 @@ body {
377470
.review-header {
378471
grid-template-columns: 42px minmax(0, 1fr) auto 18px;
379472
}
473+
474+
.review-header-row {
475+
gap: 8px;
476+
padding-right: 12px;
477+
}
380478
}

src/ui/workspace-app.tsx

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface MountedPayload {
3333
hostContext?: HostContext;
3434
errorMessage?: string | null;
3535
visibleFileCount?: number;
36+
presentation?: "inline" | "fullscreen";
3637
}): void;
3738
unmount(): void;
3839
}
@@ -44,6 +45,8 @@ let hostContext: HostContext | undefined;
4445
let card: ToolResultCard | null = null;
4546
let expanded = false;
4647
let reviewFilesExpanded = false;
48+
let reviewDisplayModePending = false;
49+
let reviewDisplayModeError: string | null = null;
4750
let errorMessage: string | null = null;
4851
let currentPayload: MountedPayload | null = null;
4952
let currentPayloadContainer: HTMLElement | null = null;
@@ -78,6 +81,8 @@ async function boot(): Promise<void> {
7881
card = null;
7982
expanded = false;
8083
reviewFilesExpanded = false;
84+
reviewDisplayModePending = false;
85+
reviewDisplayModeError = null;
8186
errorMessage = "No result card is available for this tool result.";
8287
render();
8388
return;
@@ -87,16 +92,27 @@ async function boot(): Promise<void> {
8792
card = nextCard;
8893
expanded = isReviewTool(tool) && isExpandableCard(nextCard);
8994
reviewFilesExpanded = false;
95+
reviewDisplayModePending = false;
96+
reviewDisplayModeError = null;
9097
errorMessage = null;
9198
render();
9299
};
93100

94101
app.onhostcontextchanged = (ctx) => {
102+
const previousDisplayMode = hostContext?.displayMode;
95103
hostContext = {
96104
...hostContext,
97105
...ctx,
98106
};
99107
applyHostContext();
108+
if (
109+
previousDisplayMode !== hostContext.displayMode &&
110+
card &&
111+
isReviewTool(card.tool)
112+
) {
113+
render();
114+
return;
115+
}
100116
renderPayloadIfNeeded();
101117
};
102118

@@ -216,7 +232,10 @@ function renderEmpty(message: string, tone: "muted" | "error" = "muted"): void {
216232
}
217233

218234
async function renderPayloadIfNeeded(): Promise<void> {
219-
if (!card || !currentPayloadContainer || !expanded) return;
235+
const fullscreenReview = card &&
236+
isReviewTool(card.tool) &&
237+
hostContext?.displayMode === "fullscreen";
238+
if (!card || !currentPayloadContainer || (!expanded && !fullscreenReview)) return;
220239

221240
const target = currentPayloadContainer;
222241

@@ -262,12 +281,23 @@ async function renderPayloadIfNeeded(): Promise<void> {
262281
}
263282

264283
if (isReviewTool(card.tool) || isPatchTool(card.tool)) {
265-
const visibleFileCount = isReviewTool(card.tool) && !reviewFilesExpanded
284+
const presentation = isReviewTool(card.tool) && hostContext?.displayMode === "fullscreen"
285+
? "fullscreen"
286+
: "inline";
287+
const visibleFileCount = isReviewTool(card.tool) &&
288+
presentation === "inline" &&
289+
!reviewFilesExpanded
266290
? Math.max(3, (card.files ?? []).slice(0, 3).length)
267291
: undefined;
268292

269293
if (currentPayload) {
270-
currentPayload.update({ card, hostContext, errorMessage, visibleFileCount });
294+
currentPayload.update({
295+
card,
296+
hostContext,
297+
errorMessage,
298+
visibleFileCount,
299+
presentation,
300+
});
271301
return;
272302
}
273303

@@ -281,6 +311,7 @@ async function renderPayloadIfNeeded(): Promise<void> {
281311
hostContext,
282312
errorMessage,
283313
visibleFileCount,
314+
presentation,
284315
});
285316
return;
286317
}
@@ -351,11 +382,17 @@ function renderHeaderSummary(card: ToolResultCard): HTMLElement {
351382
function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void {
352383
unmountPayload();
353384

385+
if (hostContext?.displayMode === "fullscreen") {
386+
renderFullscreenReview(card, display);
387+
return;
388+
}
389+
354390
const files = card.files ?? [];
355391
const hiddenCount = Math.max(0, files.length - 3);
356392
const expandable = isExpandableCard(card);
357393
const main = element("main", { className: "shell" });
358394
const section = element("section", { className: "tool-card review" });
395+
const headerRow = element("div", { className: "review-header-row" });
359396
const header = element("button", {
360397
className: "tool-header review-header",
361398
type: "button",
@@ -389,7 +426,28 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void {
389426
renderChevron(expanded, expandable),
390427
);
391428

392-
section.append(header);
429+
headerRow.append(header);
430+
if (files.length > 0 && canRequestDisplayMode("fullscreen")) {
431+
const reviewButton = element("button", {
432+
className: "review-button",
433+
type: "button",
434+
text: reviewDisplayModePending ? "Opening…" : "Review",
435+
disabled: reviewDisplayModePending,
436+
});
437+
reviewButton.setAttribute("aria-busy", String(reviewDisplayModePending));
438+
reviewButton.addEventListener("click", () => {
439+
void requestReviewDisplayMode("fullscreen");
440+
});
441+
headerRow.append(reviewButton);
442+
}
443+
444+
section.append(headerRow);
445+
if (reviewDisplayModeError) {
446+
section.append(element("div", {
447+
className: "review-mode-error",
448+
text: reviewDisplayModeError,
449+
}));
450+
}
393451
if (expanded) {
394452
const body = element("div", { className: "review-summary" });
395453
const payload = element("div", { className: "review-payload" });
@@ -425,6 +483,82 @@ function renderReviewCard(card: ToolResultCard, display: ToolDisplay): void {
425483
renderPayloadIfNeeded();
426484
}
427485

486+
function renderFullscreenReview(card: ToolResultCard, display: ToolDisplay): void {
487+
const main = element("main", { className: "shell review-fullscreen-shell" });
488+
const section = element("section", { className: "review-fullscreen" });
489+
const header = element("header", { className: "review-fullscreen-header" });
490+
const titleGroup = element("div", { className: "review-fullscreen-title" });
491+
const icon = element("span", { className: "tool-icon", ariaHidden: "true" });
492+
icon.append(renderIcon(display.icon));
493+
494+
const heading = element("div", { className: "review-title-group" });
495+
heading.append(element("span", { className: "tool-title", text: "Review changes" }));
496+
if (display.label) {
497+
heading.append(element("span", {
498+
className: "tool-label",
499+
text: display.label,
500+
title: display.label,
501+
}));
502+
}
503+
titleGroup.append(icon, heading);
504+
505+
const actions = element("div", { className: "review-fullscreen-actions" });
506+
const closeButton = element("button", {
507+
className: "review-button",
508+
type: "button",
509+
text: reviewDisplayModePending ? "Closing…" : "Close review",
510+
disabled: reviewDisplayModePending,
511+
});
512+
closeButton.setAttribute("aria-busy", String(reviewDisplayModePending));
513+
closeButton.addEventListener("click", () => {
514+
void requestReviewDisplayMode("inline");
515+
});
516+
actions.append(renderHeaderSummary(card), closeButton);
517+
header.append(titleGroup, actions);
518+
519+
const body = element("div", { className: "review-fullscreen-body" });
520+
currentPayloadContainer = body;
521+
section.append(header);
522+
if (reviewDisplayModeError) {
523+
section.append(element("div", {
524+
className: "review-mode-error",
525+
text: reviewDisplayModeError,
526+
}));
527+
}
528+
section.append(body);
529+
main.append(section);
530+
appRoot.replaceChildren(main);
531+
renderPayloadIfNeeded();
532+
}
533+
534+
function canRequestDisplayMode(mode: "inline" | "fullscreen"): boolean {
535+
return Boolean(hostContext?.availableDisplayModes?.includes(mode));
536+
}
537+
538+
async function requestReviewDisplayMode(mode: "inline" | "fullscreen"): Promise<void> {
539+
if (!app || reviewDisplayModePending) return;
540+
541+
reviewDisplayModePending = true;
542+
reviewDisplayModeError = null;
543+
render();
544+
545+
try {
546+
const result = await app.requestDisplayMode({ mode });
547+
hostContext = {
548+
...hostContext,
549+
displayMode: result.mode,
550+
};
551+
if (result.mode === "fullscreen") expanded = true;
552+
} catch (requestError) {
553+
reviewDisplayModeError = requestError instanceof Error
554+
? requestError.message
555+
: "Unable to change the review display mode.";
556+
} finally {
557+
reviewDisplayModePending = false;
558+
render();
559+
}
560+
}
561+
428562
function renderChevron(isExpanded: boolean, visible: boolean): HTMLElement {
429563
const chevron = element("span", {
430564
className: visible ? `chevron ${isExpanded ? "expanded" : ""}` : "chevron",

0 commit comments

Comments
 (0)