authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-10 16:41:54-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-10 16:41:54-03:00
logdfa345b3697a9117364cc6730a1598d6c27096f2
tree47b96a8f9299fa7054907b4752c3b627dcd487e4
parentd2eeacd8ccde4d8763ca315b4cddf2964db93329
signature Signed by PGP key B5690EEEBB952194

feat(web): use a compact right-panel surface menu (#11111)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

4 files changed, 47 insertions(+), 61 deletions(-)

apps/web/src/components/DiffPanelShell.tsx+1-1
...@@ -13,7 +13,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {...@@ -13,7 +13,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
13 "flex items-center justify-between gap-2",13 "flex items-center justify-between gap-2",
14 mode === "embedded" ? "px-2" : "px-4",14 mode === "embedded" ? "px-2" : "px-4",
15 shouldUseDragRegion15 shouldUseDragRegion
16 ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]"16 ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]"
17 : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent",17 : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent",
18 );18 );
19}19}
apps/web/src/components/RightPanelTabs.tsx+38-58
...@@ -171,7 +171,7 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [...@@ -171,7 +171,7 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [
171 '[data-slot="autocomplete-popup"]',171 '[data-slot="autocomplete-popup"]',
172].join(",");172].join(",");
173173
174/** One-line unavailability hints for the empty-state cards. */174/** One-line unavailability hints for the empty-state rows. */
175const SURFACE_UNAVAILABLE_HINTS = {175const SURFACE_UNAVAILABLE_HINTS = {
176 browser: "Only available in the desktop app.",176 browser: "Only available in the desktop app.",
177 terminal: "Available when a project is open.",177 terminal: "Available when a project is open.",
...@@ -304,7 +304,7 @@ function SurfaceMenuItem(props: {...@@ -304,7 +304,7 @@ function SurfaceMenuItem(props: {
304}304}
305305
306/**306/**
307 * Card launcher shown when the right panel has no surfaces. Keyboard-first307 * List launcher shown when the right panel has no surfaces. Keyboard-first
308 * without palette chrome: a surface's letter opens it directly from anywhere308 * without palette chrome: a surface's letter opens it directly from anywhere
309 * outside a typing context, and arrows plus Enter work while the launcher is309 * outside a typing context, and arrows plus Enter work while the launcher is
310 * focused. The highlight only appears on hover or arrow use. Unavailable310 * focused. The highlight only appears on hover or arrow use. Unavailable
...@@ -337,7 +337,6 @@ function RightPanelEmptyState(props: {...@@ -337,7 +337,6 @@ function RightPanelEmptyState(props: {
337 const actions = [337 const actions = [
338 {338 {
339 label: "Browser",339 label: "Browser",
340 description: "Open a local app or URL.",
341 icon: Globe2,340 icon: Globe2,
342 shortcut: "B",341 shortcut: "B",
343 available: props.browserAvailable,342 available: props.browserAvailable,
...@@ -347,7 +346,6 @@ function RightPanelEmptyState(props: {...@@ -347,7 +346,6 @@ function RightPanelEmptyState(props: {
347 },346 },
348 {347 {
349 label: "Terminal",348 label: "Terminal",
350 description: "Start a shell in this workspace.",
351 icon: TerminalSquare,349 icon: TerminalSquare,
352 shortcut: "T",350 shortcut: "T",
353 available: props.terminalAvailable,351 available: props.terminalAvailable,
...@@ -357,7 +355,6 @@ function RightPanelEmptyState(props: {...@@ -357,7 +355,6 @@ function RightPanelEmptyState(props: {
357 },355 },
358 {356 {
359 label: "Files",357 label: "Files",
360 description: "Browse and read workspace files.",
361 icon: Files,358 icon: Files,
362 shortcut: "F",359 shortcut: "F",
363 available: props.filesAvailable,360 available: props.filesAvailable,
...@@ -367,7 +364,6 @@ function RightPanelEmptyState(props: {...@@ -367,7 +364,6 @@ function RightPanelEmptyState(props: {
367 },364 },
368 {365 {
369 label: "Diff",366 label: "Diff",
370 description: "Review changes in this thread.",
371 icon: FileDiff,367 icon: FileDiff,
372 shortcut: "D",368 shortcut: "D",
373 available: props.diffAvailable,369 available: props.diffAvailable,
...@@ -377,7 +373,6 @@ function RightPanelEmptyState(props: {...@@ -377,7 +373,6 @@ function RightPanelEmptyState(props: {
377 },373 },
378 {374 {
379 label: "Pull request",375 label: "Pull request",
380 description: "Open this branch's pull request.",
381 icon: GitPullRequest,376 icon: GitPullRequest,
382 shortcut: "P",377 shortcut: "P",
383 available: props.pullRequestAvailable,378 available: props.pullRequestAvailable,
...@@ -387,7 +382,6 @@ function RightPanelEmptyState(props: {...@@ -387,7 +382,6 @@ function RightPanelEmptyState(props: {
387 },382 },
388 {383 {
389 label: "Linked pull requests",384 label: "Linked pull requests",
390 description: "Every pull request this thread has linked, stacks included.",
391 icon: GitPullRequestArrow,385 icon: GitPullRequestArrow,
392 shortcut: "L",386 shortcut: "L",
393 available: props.pullRequestsAvailable,387 available: props.pullRequestsAvailable,
...@@ -397,7 +391,6 @@ function RightPanelEmptyState(props: {...@@ -397,7 +391,6 @@ function RightPanelEmptyState(props: {
397 },391 },
398 {392 {
399 label: "Agents",393 label: "Agents",
400 description: "Follow subagents and workflows.",
401 icon: Bot,394 icon: Bot,
402 shortcut: "A",395 shortcut: "A",
403 available: props.agentsAvailable,396 available: props.agentsAvailable,
...@@ -464,9 +457,8 @@ function RightPanelEmptyState(props: {...@@ -464,9 +457,8 @@ function RightPanelEmptyState(props: {
464 return;457 return;
465 }458 }
466 if (event.key === "Enter") {459 if (event.key === "Enter") {
467 // A focused card button owns its own activation; only open from the460 // Only activate the highlight when the launcher itself has focus.
468 // highlight when the container itself has focus.461 if (event.target !== event.currentTarget) return;
469 if (event.target instanceof HTMLElement && event.target.closest("button")) return;
470 const action = availableActions[highlightIndex];462 const action = availableActions[highlightIndex];
471 if (!action) return;463 if (!action) return;
472 event.preventDefault();464 event.preventDefault();
...@@ -500,10 +492,6 @@ function RightPanelEmptyState(props: {...@@ -500,10 +492,6 @@ function RightPanelEmptyState(props: {
500 );492 );
501 };493 };
502494
503 const cardShellClass =
504 "rounded-lg border border-border/80 bg-card dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5";
505 const highlightedCardClass = "bg-accent/60 dark:inset-ring-white/20";
506
507 return (495 return (
508 <div496 <div
509 ref={focusOnMount}497 ref={focusOnMount}
...@@ -514,24 +502,19 @@ function RightPanelEmptyState(props: {...@@ -514,24 +502,19 @@ function RightPanelEmptyState(props: {
514 className={cn(502 className={cn(
515 "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pt-6 outline-none",503 "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pt-6 outline-none",
516 // The panel topbar sits above this container; matching bottom padding504 // The panel topbar sits above this container; matching bottom padding
517 // keeps the cards centered against the full panel, not the leftover.505 // keeps the list centered against the full panel, not the leftover.
518 "pb-[calc(var(--workspace-topbar-height)+--spacing(6))]",506 "pb-[calc(var(--workspace-topbar-height)+--spacing(6))]",
519 )}507 )}
520 >508 >
521 <div className="relative w-full max-w-lg">509 <div className="w-full max-w-xs">
522 <div className="absolute inset-x-0 bottom-full mb-5 text-center">510 <h3 className="mb-3 text-center font-medium text-foreground text-sm">Open a surface</h3>
523 <h3 className="font-medium text-foreground text-sm">Open a surface</h3>511 <div className="flex flex-col gap-0.5">
524 <p className="mt-1 text-muted-foreground text-xs">
525 Choose what to show in the right panel.
526 </p>
527 </div>
528 <div className="grid grid-cols-2 gap-2">
529 {actions.map((action) =>512 {actions.map((action) =>
530 action.available ? (513 action.available ? (
531 // The card is itself a button, so the profile chooser sits beside514 // The row is itself a button, so the profile chooser sits beside
532 // it in a wrapper rather than inside it. Hover lives on the515 // it in a wrapper rather than inside it. Hover lives on the
533 // wrapper: the chooser overlays the card, and a pointer moving516 // wrapper: the chooser overlays the row, and a pointer moving
534 // onto it must not read as leaving the card.517 // onto it must not read as leaving the row.
535 <div518 <div
536 key={action.label}519 key={action.label}
537 className="group relative"520 className="group relative"
...@@ -546,25 +529,23 @@ function RightPanelEmptyState(props: {...@@ -546,25 +529,23 @@ function RightPanelEmptyState(props: {
546 type="button"529 type="button"
547 onClick={action.onClick}530 onClick={action.onClick}
548 className={cn(531 className={cn(
549 // Full height: the wrapper is the grid item that stretches532 "flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-[var(--control-radius)] px-2.5 text-left text-sm transition-colors group-hover:bg-accent/60",
550 // to the row, so the button must fill it to stay level with533 isHighlighted(action) && "bg-accent/60",
551 // its neighbour and keep the chooser anchored inside.
552 "relative flex h-full w-full cursor-pointer flex-col items-start p-4 text-left transition group-hover:border-border group-hover:bg-accent/60",
553 cardShellClass,
554 isHighlighted(action) && highlightedCardClass,
555 )}534 )}
556 >535 >
557 <Kbd className="absolute top-3 right-3">{action.shortcut}</Kbd>536 {actionIcon(action, "size-4")}
558 <span className="flex items-center gap-2 pe-8">537 <span
559 {actionIcon(action)}538 className={cn(
560 <span className="font-medium text-sm">{action.label}</span>539 "min-w-0 flex-1 truncate",
561 </span>540 action.label === "Browser" && props.browserProfiles.length > 1 && "pr-7",
562 <span className="mt-1.5 text-muted-foreground text-xs leading-relaxed">541 )}
563 {action.description}542 >
543 {action.label}
564 </span>544 </span>
545 <Kbd>{action.shortcut}</Kbd>
565 </button>546 </button>
566 {/*547 {/*
567 Same choice the tab bar's "+" menu offers: the card opens the548 Same choice the tab bar's "+" menu offers: the row opens the
568 default profile, the chevron picks another. Only worth showing549 default profile, the chevron picks another. Only worth showing
569 once there is something to choose between.550 once there is something to choose between.
570 */}551 */}
...@@ -574,7 +555,7 @@ function RightPanelEmptyState(props: {...@@ -574,7 +555,7 @@ function RightPanelEmptyState(props: {
574 render={555 render={
575 <Button556 <Button
576 aria-label="Open browser in a profile"557 aria-label="Open browser in a profile"
577 className="absolute right-3 bottom-3 [--control-icon-color:currentColor]"558 className="absolute top-1/2 right-8 -translate-y-1/2 [--control-icon-color:currentColor]"
578 size="icon-xs"559 size="icon-xs"
579 variant="ghost-muted"560 variant="ghost-muted"
580 />561 />
...@@ -601,22 +582,21 @@ function RightPanelEmptyState(props: {...@@ -601,22 +582,21 @@ function RightPanelEmptyState(props: {
601 ) : null}582 ) : null}
602 </div>583 </div>
603 ) : (584 ) : (
604 <div585 <DisabledReasonTooltip
605 key={action.label}586 key={action.label}
606 className={cn(587 reason={action.disabledReason}
607 "relative flex w-full flex-col items-start p-4 opacity-40",588 trigger={
608 cardShellClass,589 <div
609 )}590 tabIndex={0}
610 >591 aria-disabled="true"
611 <Kbd className="absolute top-3 right-3">{action.shortcut}</Kbd>592 className="flex h-8 w-full cursor-default items-center gap-2.5 rounded-[var(--control-radius)] px-2.5 text-left text-sm opacity-50"
612 <span className="flex items-center gap-2 pe-8">593 >
613 {actionIcon(action)}594 {actionIcon(action, "size-4")}
614 <span className="font-medium text-sm">{action.label}</span>595 <span className="min-w-0 flex-1 truncate">{action.label}</span>
615 </span>596 <Kbd>{action.shortcut}</Kbd>
616 <span className="mt-1.5 text-muted-foreground text-xs leading-relaxed">597 </div>
617 {action.disabledReason}598 }
618 </span>599 />
619 </div>
620 ),600 ),
621 )}601 )}
622 </div>602 </div>
apps/web/src/components/ui/toast.tsx+1-1
...@@ -560,7 +560,7 @@ function Toasts({ position }: { position: ToastPosition }) {...@@ -560,7 +560,7 @@ function Toasts({ position }: { position: ToastPosition }) {
560 <Toast.Portal data-slot="toast-portal">560 <Toast.Portal data-slot="toast-portal">
561 <Toast.Viewport561 <Toast.Viewport
562 className={cn(562 className={cn(
563 "fixed z-100 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-header-offset:52px] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",563 "fixed z-100 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-header-offset:var(--workspace-topbar-height)] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
564 // Vertical positioning564 // Vertical positioning
565 "data-[position*=top]:top-[calc(var(--toast-inset)+var(--toast-header-offset))]",565 "data-[position*=top]:top-[calc(var(--toast-inset)+var(--toast-header-offset))]",
566 "data-[position*=bottom]:bottom-(--toast-inset)",566 "data-[position*=bottom]:bottom-(--toast-inset)",
apps/web/src/routes/__root.tsx+7-1
...@@ -243,7 +243,13 @@ function GlassAppearanceSync() {...@@ -243,7 +243,13 @@ function GlassAppearanceSync() {
243 const glassOpacity = useClientSettings((settings) => settings.glassOpacity);243 const glassOpacity = useClientSettings((settings) => settings.glassOpacity);
244244
245 useEffect(() => {245 useEffect(() => {
246 document.documentElement.style.setProperty("--glass-opacity", `${glassOpacity}%`);246 const style = document.documentElement.style;
247 style.setProperty("--glass-opacity", `${glassOpacity}%`);
248 if (glassOpacity === 100) {
249 style.setProperty("--glass-blur", "0px");
250 } else {
251 style.removeProperty("--glass-blur");
252 }
247 }, [glassOpacity]);253 }, [glassOpacity]);
248254
249 return null;255 return null;