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 {...@@ -480,6 +480,14 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
480 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;480 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
481 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;481 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
483 return true;491 return true;
484}492}
485493
apps/web/src/components/RightPanelTabs.tsx+203-58
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 X,11 X,
12} from "lucide-react";12} from "lucide-react";
13import {13import {
14 type KeyboardEvent as ReactKeyboardEvent,
14 type MouseEvent as ReactMouseEvent,15 type MouseEvent as ReactMouseEvent,
15 type ReactElement,16 type ReactElement,
16 type ReactNode,17 type ReactNode,
...@@ -25,6 +26,7 @@ import type { RightPanelSurface } from "~/rightPanelStore";...@@ -25,6 +26,7 @@ import type { RightPanelSurface } from "~/rightPanelStore";
25import { cn } from "~/lib/utils";26import { cn } from "~/lib/utils";
26import { readLocalApi } from "~/localApi";27import { readLocalApi } from "~/localApi";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";28import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
29import { Kbd } from "~/components/ui/kbd";
28import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";30import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
29import { ScrollArea } from "~/components/ui/scroll-area";31import { ScrollArea } from "~/components/ui/scroll-area";
30import { faviconUrlForOrigin } from "~/lib/favicon";32import { faviconUrlForOrigin } from "~/lib/favicon";
...@@ -88,6 +90,28 @@ const SURFACE_DISABLED_REASONS = {...@@ -88,6 +90,28 @@ const SURFACE_DISABLED_REASONS = {
88 agents: "Agents are only available from a thread.",90 agents: "Agents are only available from a thread.",
89} as const;91} 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
91type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all";115type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all";
92116
93function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {117function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
...@@ -118,6 +142,13 @@ function SurfaceMenuItem(props: {...@@ -118,6 +142,13 @@ function SurfaceMenuItem(props: {
118 return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;142 return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;
119}143}
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 */
121function RightPanelEmptyState(props: {152function RightPanelEmptyState(props: {
122 onAddBrowser: () => void;153 onAddBrowser: () => void;
123 onAddTerminal: () => void;154 onAddTerminal: () => void;
...@@ -133,13 +164,17 @@ function RightPanelEmptyState(props: {...@@ -133,13 +164,17 @@ function RightPanelEmptyState(props: {
133 agentsAvailable: boolean;164 agentsAvailable: boolean;
134 liveAgentCount: number;165 liveAgentCount: number;
135}) {166}) {
167 // -1 means no highlight: it only appears on hover or arrow use.
168 const [highlight, setHighlight] = useState(-1);
169
136 const actions = [170 const actions = [
137 {171 {
138 label: "Browser",172 label: "Browser",
139 description: "Open a local app or URL.",173 description: "Open a local app or URL.",
140 icon: Globe2,174 icon: Globe2,
175 shortcut: "B",
141 available: props.browserAvailable,176 available: props.browserAvailable,
142 disabledReason: SURFACE_DISABLED_REASONS.browser,177 disabledReason: SURFACE_UNAVAILABLE_HINTS.browser,
143 onClick: props.onAddBrowser,178 onClick: props.onAddBrowser,
144 badgeCount: 0,179 badgeCount: 0,
145 },180 },
...@@ -147,8 +182,9 @@ function RightPanelEmptyState(props: {...@@ -147,8 +182,9 @@ function RightPanelEmptyState(props: {
147 label: "Terminal",182 label: "Terminal",
148 description: "Start a shell in this workspace.",183 description: "Start a shell in this workspace.",
149 icon: TerminalSquare,184 icon: TerminalSquare,
185 shortcut: "T",
150 available: props.terminalAvailable,186 available: props.terminalAvailable,
151 disabledReason: SURFACE_DISABLED_REASONS.terminal,187 disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal,
152 onClick: props.onAddTerminal,188 onClick: props.onAddTerminal,
153 badgeCount: 0,189 badgeCount: 0,
154 },190 },
...@@ -156,8 +192,9 @@ function RightPanelEmptyState(props: {...@@ -156,8 +192,9 @@ function RightPanelEmptyState(props: {
156 label: "Files",192 label: "Files",
157 description: "Browse and read workspace files.",193 description: "Browse and read workspace files.",
158 icon: Files,194 icon: Files,
195 shortcut: "F",
159 available: props.filesAvailable,196 available: props.filesAvailable,
160 disabledReason: SURFACE_DISABLED_REASONS.files,197 disabledReason: SURFACE_UNAVAILABLE_HINTS.files,
161 onClick: props.onAddFiles,198 onClick: props.onAddFiles,
162 badgeCount: 0,199 badgeCount: 0,
163 },200 },
...@@ -165,91 +202,199 @@ function RightPanelEmptyState(props: {...@@ -165,91 +202,199 @@ function RightPanelEmptyState(props: {
165 label: "Diff",202 label: "Diff",
166 description: "Review changes in this thread.",203 description: "Review changes in this thread.",
167 icon: FileDiff,204 icon: FileDiff,
205 shortcut: "D",
168 available: props.diffAvailable,206 available: props.diffAvailable,
169 disabledReason: SURFACE_DISABLED_REASONS.diff,207 disabledReason: SURFACE_UNAVAILABLE_HINTS.diff,
170 onClick: props.onAddDiff,208 onClick: props.onAddDiff,
171 badgeCount: 0,209 badgeCount: 0,
172 },210 },
173 {211 {
174 label: "Pull request",212 label: "Pull request",
175 description: "Open the pull request for this thread's branch.",213 description: "Open this branch's pull request.",
176 icon: GitPullRequest,214 icon: GitPullRequest,
215 shortcut: "P",
177 available: props.pullRequestAvailable,216 available: props.pullRequestAvailable,
178 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,217 disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,
179 onClick: props.onAddPullRequest,218 onClick: props.onAddPullRequest,
180 badgeCount: 0,219 badgeCount: 0,
181 },220 },
182 {221 {
183 label: "Agents",222 label: "Agents",
184 description: "Watch subagents and workflows run.",223 description: "Follow subagents and workflows.",
185 icon: Bot,224 icon: Bot,
225 shortcut: "A",
186 available: props.agentsAvailable,226 available: props.agentsAvailable,
187 disabledReason: SURFACE_DISABLED_REASONS.agents,227 disabledReason: SURFACE_UNAVAILABLE_HINTS.agents,
188 onClick: props.onAddAgents,228 onClick: props.onAddAgents,
189 badgeCount: props.liveAgentCount,229 badgeCount: props.liveAgentCount,
190 },230 },
191 ] as const;231 ] 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
193 return (330 return (
194 <div className="flex min-h-0 flex-1 items-center justify-center p-6">331 <div
195 <div className="w-full max-w-xl">332 ref={focusOnMount}
196 <div className="mb-5 text-center">333 tabIndex={0}
197 <h3 className="text-sm font-medium text-foreground">Open a surface</h3>334 onKeyDown={handleKeyDown}
198 <p className="mt-1 text-xs text-muted-foreground">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">
199 Choose what to show in the right panel.348 Choose what to show in the right panel.
200 </p>349 </p>
201 </div>350 </div>
202 <div className="grid grid-cols-2 gap-2">351 <div className="grid grid-cols-2 gap-2">
203 {actions.map((action) => {352 {actions.map((action) =>
204 const Icon = action.icon;353 action.available ? (
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 = (
237 <button354 <button
355 key={action.label}
238 type="button"356 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"357 onClick={action.onClick}
240 aria-disabled="true"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 )}
241 >369 >
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>
243 </button>378 </button>
244 );379 ) : (
245 return (380 <div
246 <DisabledReasonTooltip
247 key={action.label}381 key={action.label}
248 reason={action.disabledReason}382 className={cn(
249 trigger={disabledCard}383 "relative flex w-full flex-col items-start p-4 opacity-40",
250 />384 cardShellClass,
251 );385 )}
252 })}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 )}
253 </div>398 </div>
254 </div>399 </div>
255 </div>400 </div>