| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | X, |
| 12 | 12 | } from "lucide-react"; |
| 13 | 13 | import { |
| 14 | type KeyboardEvent as ReactKeyboardEvent, |
| 14 | 15 | type MouseEvent as ReactMouseEvent, |
| 15 | 16 | type ReactElement, |
| 16 | 17 | type ReactNode, |
| ... | ... | @@ -25,6 +26,7 @@ import type { RightPanelSurface } from "~/rightPanelStore"; |
| 25 | 26 | import { cn } from "~/lib/utils"; |
| 26 | 27 | import { readLocalApi } from "~/localApi"; |
| 27 | 28 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 29 | import { Kbd } from "~/components/ui/kbd"; |
| 28 | 30 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 29 | 31 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 30 | 32 | import { faviconUrlForOrigin } from "~/lib/favicon"; |
| ... | ... | @@ -88,6 +90,28 @@ const SURFACE_DISABLED_REASONS = { |
| 88 | 90 | agents: "Agents are only available from a thread.", |
| 89 | 91 | } as const; |
| 90 | 92 | |
| 93 | /** Overlays that must win over the launcher's letter shortcuts. */ |
| 94 | const 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. */ |
| 106 | const 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 | |
| 91 | 115 | type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; |
| 92 | 116 | |
| 93 | 117 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { |
| ... | ... | @@ -118,6 +142,13 @@ function SurfaceMenuItem(props: { |
| 118 | 142 | return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />; |
| 119 | 143 | } |
| 120 | 144 | |
| 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 | */ |
| 121 | 152 | function RightPanelEmptyState(props: { |
| 122 | 153 | onAddBrowser: () => void; |
| 123 | 154 | onAddTerminal: () => void; |
| ... | ... | @@ -133,13 +164,17 @@ function RightPanelEmptyState(props: { |
| 133 | 164 | agentsAvailable: boolean; |
| 134 | 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 | 170 | const actions = [ |
| 137 | 171 | { |
| 138 | 172 | label: "Browser", |
| 139 | 173 | description: "Open a local app or URL.", |
| 140 | 174 | icon: Globe2, |
| 175 | shortcut: "B", |
| 141 | 176 | available: props.browserAvailable, |
| 142 | | disabledReason: SURFACE_DISABLED_REASONS.browser, |
| 177 | disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, |
| 143 | 178 | onClick: props.onAddBrowser, |
| 144 | 179 | badgeCount: 0, |
| 145 | 180 | }, |
| ... | ... | @@ -147,8 +182,9 @@ function RightPanelEmptyState(props: { |
| 147 | 182 | label: "Terminal", |
| 148 | 183 | description: "Start a shell in this workspace.", |
| 149 | 184 | icon: TerminalSquare, |
| 185 | shortcut: "T", |
| 150 | 186 | available: props.terminalAvailable, |
| 151 | | disabledReason: SURFACE_DISABLED_REASONS.terminal, |
| 187 | disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, |
| 152 | 188 | onClick: props.onAddTerminal, |
| 153 | 189 | badgeCount: 0, |
| 154 | 190 | }, |
| ... | ... | @@ -156,8 +192,9 @@ function RightPanelEmptyState(props: { |
| 156 | 192 | label: "Files", |
| 157 | 193 | description: "Browse and read workspace files.", |
| 158 | 194 | icon: Files, |
| 195 | shortcut: "F", |
| 159 | 196 | available: props.filesAvailable, |
| 160 | | disabledReason: SURFACE_DISABLED_REASONS.files, |
| 197 | disabledReason: SURFACE_UNAVAILABLE_HINTS.files, |
| 161 | 198 | onClick: props.onAddFiles, |
| 162 | 199 | badgeCount: 0, |
| 163 | 200 | }, |
| ... | ... | @@ -165,91 +202,199 @@ function RightPanelEmptyState(props: { |
| 165 | 202 | label: "Diff", |
| 166 | 203 | description: "Review changes in this thread.", |
| 167 | 204 | icon: FileDiff, |
| 205 | shortcut: "D", |
| 168 | 206 | available: props.diffAvailable, |
| 169 | | disabledReason: SURFACE_DISABLED_REASONS.diff, |
| 207 | disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, |
| 170 | 208 | onClick: props.onAddDiff, |
| 171 | 209 | badgeCount: 0, |
| 172 | 210 | }, |
| 173 | 211 | { |
| 174 | 212 | label: "Pull request", |
| 175 | | description: "Open the pull request for this thread's branch.", |
| 213 | description: "Open this branch's pull request.", |
| 176 | 214 | icon: GitPullRequest, |
| 215 | shortcut: "P", |
| 177 | 216 | available: props.pullRequestAvailable, |
| 178 | | disabledReason: SURFACE_DISABLED_REASONS.pullRequest, |
| 217 | disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, |
| 179 | 218 | onClick: props.onAddPullRequest, |
| 180 | 219 | badgeCount: 0, |
| 181 | 220 | }, |
| 182 | 221 | { |
| 183 | 222 | label: "Agents", |
| 184 | | description: "Watch subagents and workflows run.", |
| 223 | description: "Follow subagents and workflows.", |
| 185 | 224 | icon: Bot, |
| 225 | shortcut: "A", |
| 186 | 226 | available: props.agentsAvailable, |
| 187 | | disabledReason: SURFACE_DISABLED_REASONS.agents, |
| 227 | disabledReason: SURFACE_UNAVAILABLE_HINTS.agents, |
| 188 | 228 | onClick: props.onAddAgents, |
| 189 | 229 | badgeCount: props.liveAgentCount, |
| 190 | 230 | }, |
| 191 | 231 | ] as const; |
| 192 | 232 | |
| 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 | 330 | 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"> |
| 199 | 348 | Choose what to show in the right panel. |
| 200 | 349 | </p> |
| 201 | 350 | </div> |
| 202 | 351 | <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 ? ( |
| 237 | 354 | <button |
| 355 | key={action.label} |
| 238 | 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" |
| 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 | )} |
| 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 | 378 | </button> |
| 244 | | ); |
| 245 | | return ( |
| 246 | | <DisabledReasonTooltip |
| 379 | ) : ( |
| 380 | <div |
| 247 | 381 | 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 | )} |
| 253 | 398 | </div> |
| 254 | 399 | </div> |
| 255 | 400 | </div> |