authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-12 11:58:46+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-12 15:28:46+05:30
logb54bfc9312b030bad9d89771fbf561ccbfb2d315
tree6950709394fa999d72b645a31c0c6f367992bd5a
parente1378a1f4d6a78d51c2d583997121f6adc0ad6cc
signature Signed by PGP key B5690EEEBB952194

feat(web): a better right panel empty state (#6258)


2 files changed, 211 insertions(+), 58 deletions(-)

apps/web/src/components/ChatView.tsx+8
......@@ -480,6 +480,14 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
480480 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
481481 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;
482482
483 // The right-panel surface launcher claims its shortcut letters while it is
484 // visible (data attribute set in RightPanelTabs); those keys open surfaces
485 // instead of typing into the composer.
486 const launcherKeys = document
487 .querySelector("[data-surface-launcher-keys]")
488 ?.getAttribute("data-surface-launcher-keys");
489 if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false;
490
483491 return true;
484492}
485493
apps/web/src/components/RightPanelTabs.tsx+203-58
......@@ -11,6 +11,7 @@ import {
1111 X,
1212} from "lucide-react";
1313import {
14 type KeyboardEvent as ReactKeyboardEvent,
1415 type MouseEvent as ReactMouseEvent,
1516 type ReactElement,
1617 type ReactNode,
......@@ -25,6 +26,7 @@ import type { RightPanelSurface } from "~/rightPanelStore";
2526import { cn } from "~/lib/utils";
2627import { readLocalApi } from "~/localApi";
2728import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
29import { Kbd } from "~/components/ui/kbd";
2830import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
2931import { ScrollArea } from "~/components/ui/scroll-area";
3032import { faviconUrlForOrigin } from "~/lib/favicon";
......@@ -88,6 +90,28 @@ const SURFACE_DISABLED_REASONS = {
8890 agents: "Agents are only available from a thread.",
8991} as const;
9092
93/** Overlays that must win over the launcher's letter shortcuts. */
94const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [
95 '[data-slot="dialog-popup"]',
96 '[data-slot="alert-dialog-popup"]',
97 '[data-slot="command-dialog-popup"]',
98 '[data-slot="menu-popup"]',
99 '[data-slot="select-popup"]',
100 '[data-slot="popover-popup"]',
101 '[data-slot="combobox-popup"]',
102 '[data-slot="autocomplete-popup"]',
103].join(",");
104
105/** One-line unavailability hints for the empty-state cards. */
106const SURFACE_UNAVAILABLE_HINTS = {
107 browser: "Only available in the desktop app.",
108 terminal: "Available when a project is open.",
109 files: "Available when a project is open.",
110 diff: "Available for Git repositories.",
111 pullRequest: "No pull request on this branch yet.",
112 agents: "Available from a thread.",
113} as const;
114
91115type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all";
92116
93117function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
......@@ -118,6 +142,13 @@ function SurfaceMenuItem(props: {
118142 return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;
119143}
120144
145/**
146 * Card launcher shown when the right panel has no surfaces. Keyboard-first
147 * without palette chrome: a surface's letter opens it directly from anywhere
148 * outside a typing context, and arrows plus Enter work while the launcher is
149 * focused. The highlight only appears on hover or arrow use. Unavailable
150 * surfaces stay visible with a one-line reason.
151 */
121152function RightPanelEmptyState(props: {
122153 onAddBrowser: () => void;
123154 onAddTerminal: () => void;
......@@ -133,13 +164,17 @@ function RightPanelEmptyState(props: {
133164 agentsAvailable: boolean;
134165 liveAgentCount: number;
135166}) {
167 // -1 means no highlight: it only appears on hover or arrow use.
168 const [highlight, setHighlight] = useState(-1);
169
136170 const actions = [
137171 {
138172 label: "Browser",
139173 description: "Open a local app or URL.",
140174 icon: Globe2,
175 shortcut: "B",
141176 available: props.browserAvailable,
142 disabledReason: SURFACE_DISABLED_REASONS.browser,
177 disabledReason: SURFACE_UNAVAILABLE_HINTS.browser,
143178 onClick: props.onAddBrowser,
144179 badgeCount: 0,
145180 },
......@@ -147,8 +182,9 @@ function RightPanelEmptyState(props: {
147182 label: "Terminal",
148183 description: "Start a shell in this workspace.",
149184 icon: TerminalSquare,
185 shortcut: "T",
150186 available: props.terminalAvailable,
151 disabledReason: SURFACE_DISABLED_REASONS.terminal,
187 disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal,
152188 onClick: props.onAddTerminal,
153189 badgeCount: 0,
154190 },
......@@ -156,8 +192,9 @@ function RightPanelEmptyState(props: {
156192 label: "Files",
157193 description: "Browse and read workspace files.",
158194 icon: Files,
195 shortcut: "F",
159196 available: props.filesAvailable,
160 disabledReason: SURFACE_DISABLED_REASONS.files,
197 disabledReason: SURFACE_UNAVAILABLE_HINTS.files,
161198 onClick: props.onAddFiles,
162199 badgeCount: 0,
163200 },
......@@ -165,91 +202,199 @@ function RightPanelEmptyState(props: {
165202 label: "Diff",
166203 description: "Review changes in this thread.",
167204 icon: FileDiff,
205 shortcut: "D",
168206 available: props.diffAvailable,
169 disabledReason: SURFACE_DISABLED_REASONS.diff,
207 disabledReason: SURFACE_UNAVAILABLE_HINTS.diff,
170208 onClick: props.onAddDiff,
171209 badgeCount: 0,
172210 },
173211 {
174212 label: "Pull request",
175 description: "Open the pull request for this thread's branch.",
213 description: "Open this branch's pull request.",
176214 icon: GitPullRequest,
215 shortcut: "P",
177216 available: props.pullRequestAvailable,
178 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
217 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,
179218 onClick: props.onAddPullRequest,
180219 badgeCount: 0,
181220 },
182221 {
183222 label: "Agents",
184 description: "Watch subagents and workflows run.",
223 description: "Follow subagents and workflows.",
185224 icon: Bot,
225 shortcut: "A",
186226 available: props.agentsAvailable,
187 disabledReason: SURFACE_DISABLED_REASONS.agents,
227 disabledReason: SURFACE_UNAVAILABLE_HINTS.agents,
188228 onClick: props.onAddAgents,
189229 badgeCount: props.liveAgentCount,
190230 },
191231 ] as const;
192232
233 type SurfaceAction = (typeof actions)[number];
234
235 const availableActions = actions.filter((action) => action.available);
236 const highlightIndex =
237 availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1);
238
239 // Letter shortcuts work while the launcher is visible, not only while it
240 // is focused; focus moves around too easily (stray clicks) to carry them.
241 // Capture phase so app-level key handlers cannot swallow the event first;
242 // typing contexts and already-handled events are left alone.
243 const shortcutActionsRef = useRef(availableActions);
244 useEffect(() => {
245 shortcutActionsRef.current = availableActions;
246 });
247 useEffect(() => {
248 const handler = (event: KeyboardEvent) => {
249 if (event.defaultPrevented || event.isComposing) return;
250 if (event.metaKey || event.ctrlKey || event.altKey) return;
251 if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
252 const target = event.target;
253 if (target instanceof HTMLElement) {
254 if (target.closest("input, textarea, select")) return;
255 // An empty contenteditable (the chat composer at rest) does not
256 // count as typing; letters only become text once a draft exists.
257 const editable = target.isContentEditable ? target : target.closest("[contenteditable]");
258 if (editable && (editable.textContent ?? "").trim().length > 0) return;
259 }
260 const action = shortcutActionsRef.current.find(
261 (candidate) => candidate.shortcut.toLowerCase() === event.key.toLowerCase(),
262 );
263 if (!action) return;
264 event.preventDefault();
265 event.stopPropagation();
266 action.onClick();
267 };
268 window.addEventListener("keydown", handler, true);
269 return () => window.removeEventListener("keydown", handler, true);
270 }, []);
271
272 const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
273 if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
274 if (availableActions.length === 0) return;
275 if (event.key === "ArrowDown" || event.key === "ArrowRight") {
276 event.preventDefault();
277 setHighlight((highlightIndex + 1) % availableActions.length);
278 return;
279 }
280 if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
281 event.preventDefault();
282 setHighlight(
283 highlightIndex === -1
284 ? availableActions.length - 1
285 : (highlightIndex - 1 + availableActions.length) % availableActions.length,
286 );
287 return;
288 }
289 if (event.key === "Enter") {
290 // A focused card button owns its own activation; only open from the
291 // highlight when the container itself has focus.
292 if (event.target instanceof HTMLElement && event.target.closest("button")) return;
293 const action = availableActions[highlightIndex];
294 if (!action) return;
295 event.preventDefault();
296 action.onClick();
297 }
298 };
299
300 // Stable identity so React only runs this callback ref on mount/unmount;
301 // an inline arrow would re-attach and re-focus on every render.
302 const focusOnMount = useCallback((node: HTMLDivElement | null) => {
303 node?.focus();
304 }, []);
305
306 const isHighlighted = (action: SurfaceAction) =>
307 highlightIndex !== -1 && availableActions[highlightIndex] === action;
308
309 const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => {
310 const Icon = action.icon;
311 return (
312 <span className="relative inline-flex shrink-0">
313 <Icon className={iconClassName} />
314 {action.badgeCount > 0 ? (
315 <span
316 aria-hidden
317 className="absolute -top-1.5 -right-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-[9px] font-semibold tabular-nums text-white"
318 >
319 {action.badgeCount}
320 </span>
321 ) : null}
322 </span>
323 );
324 };
325
326 const cardShellClass =
327 "rounded-lg border border-border/80 bg-card dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5";
328 const highlightedCardClass = "bg-accent/60 dark:inset-ring-white/20";
329
193330 return (
194 <div className="flex min-h-0 flex-1 items-center justify-center p-6">
195 <div className="w-full max-w-xl">
196 <div className="mb-5 text-center">
197 <h3 className="text-sm font-medium text-foreground">Open a surface</h3>
198 <p className="mt-1 text-xs text-muted-foreground">
331 <div
332 ref={focusOnMount}
333 tabIndex={0}
334 onKeyDown={handleKeyDown}
335 aria-label="Open a surface"
336 data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}
337 className={cn(
338 "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pt-6 outline-none",
339 // The panel topbar sits above this container; matching bottom padding
340 // keeps the cards centered against the full panel, not the leftover.
341 "pb-[calc(var(--workspace-topbar-height)+--spacing(6))]",
342 )}
343 >
344 <div className="relative w-full max-w-lg">
345 <div className="absolute inset-x-0 bottom-full mb-5 text-center">
346 <h3 className="font-medium text-foreground text-sm">Open a surface</h3>
347 <p className="mt-1 text-muted-foreground text-xs">
199348 Choose what to show in the right panel.
200349 </p>
201350 </div>
202351 <div className="grid grid-cols-2 gap-2">
203 {actions.map((action) => {
204 const Icon = action.icon;
205 const content = (
206 <>
207 <span className="relative mb-3 inline-flex">
208 <Icon className="size-5" />
209 {action.badgeCount > 0 ? (
210 <span
211 aria-hidden
212 className="absolute -top-1.5 -right-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-[9px] font-semibold tabular-nums text-white"
213 >
214 {action.badgeCount}
215 </span>
216 ) : null}
217 </span>
218 <span className="text-sm font-medium">{action.label}</span>
219 <span className="mt-1 text-xs leading-relaxed text-muted-foreground">
220 {action.description}
221 </span>
222 </>
223 );
224 if (action.available) {
225 return (
226 <button
227 key={action.label}
228 type="button"
229 onClick={action.onClick}
230 className="cursor-pointer flex min-h-28 w-full flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left transition hover:border-border hover:bg-accent/60 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5"
231 >
232 {content}
233 </button>
234 );
235 }
236 const disabledCard = (
352 {actions.map((action) =>
353 action.available ? (
237354 <button
355 key={action.label}
238356 type="button"
239 className="flex min-h-28 w-full cursor-not-allowed flex-col items-start rounded-lg border border-border/80 bg-card p-4 text-left opacity-40 dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5"
240 aria-disabled="true"
357 onClick={action.onClick}
358 onMouseEnter={() => setHighlight(availableActions.indexOf(action))}
359 onMouseLeave={() =>
360 setHighlight((current) =>
361 current === availableActions.indexOf(action) ? -1 : current,
362 )
363 }
364 className={cn(
365 "relative flex w-full cursor-pointer flex-col items-start p-4 text-left transition hover:border-border hover:bg-accent/60",
366 cardShellClass,
367 isHighlighted(action) && highlightedCardClass,
368 )}
241369 >
242 {content}
370 <Kbd className="absolute top-3 right-3">{action.shortcut}</Kbd>
371 <span className="flex items-center gap-2 pe-8">
372 {actionIcon(action)}
373 <span className="font-medium text-sm">{action.label}</span>
374 </span>
375 <span className="mt-1.5 text-muted-foreground text-xs leading-relaxed">
376 {action.description}
377 </span>
243378 </button>
244 );
245 return (
246 <DisabledReasonTooltip
379 ) : (
380 <div
247381 key={action.label}
248 reason={action.disabledReason}
249 trigger={disabledCard}
250 />
251 );
252 })}
382 className={cn(
383 "relative flex w-full flex-col items-start p-4 opacity-40",
384 cardShellClass,
385 )}
386 >
387 <Kbd className="absolute top-3 right-3">{action.shortcut}</Kbd>
388 <span className="flex items-center gap-2 pe-8">
389 {actionIcon(action)}
390 <span className="font-medium text-sm">{action.label}</span>
391 </span>
392 <span className="mt-1.5 text-muted-foreground text-xs leading-relaxed">
393 {action.disabledReason}
394 </span>
395 </div>
396 ),
397 )}
253398 </div>
254399 </div>
255400 </div>