| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | X, | 11 | X, |
| 12 | } from "lucide-react"; | 12 | } from "lucide-react"; |
| 13 | import { | 13 | import { |
| | 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"; |
| 25 | import { cn } from "~/lib/utils"; | 26 | import { cn } from "~/lib/utils"; |
| 26 | import { readLocalApi } from "~/localApi"; | 27 | import { readLocalApi } from "~/localApi"; |
| 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 28 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| | 29 | import { Kbd } from "~/components/ui/kbd"; |
| 28 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | 30 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; |
| 29 | import { ScrollArea } from "~/components/ui/scroll-area"; | 31 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 30 | import { faviconUrlForOrigin } from "~/lib/favicon"; | 32 | import { 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; |
| 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 | type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; | 115 | type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; |
| 92 | | 116 | |
| 93 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { | 117 | function 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 | } |
| 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 | function RightPanelEmptyState(props: { | 152 | function 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; |
| 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 | 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 | <button | 354 | <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> |