Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,14 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;

// The right-panel surface launcher claims its shortcut letters while it is
// visible (data attribute set in RightPanelTabs); those keys open surfaces
// instead of typing into the composer.
const launcherKeys = document
.querySelector("[data-surface-launcher-keys]")
?.getAttribute("data-surface-launcher-keys");
if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false;

return true;
}

Expand Down
261 changes: 203 additions & 58 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
X,
} from "lucide-react";
import {
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactElement,
type ReactNode,
Expand All @@ -25,6 +26,7 @@ import type { RightPanelSurface } from "~/rightPanelStore";
import { cn } from "~/lib/utils";
import { readLocalApi } from "~/localApi";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { Kbd } from "~/components/ui/kbd";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";
import { ScrollArea } from "~/components/ui/scroll-area";
import { faviconUrlForOrigin } from "~/lib/favicon";
Expand Down Expand Up @@ -88,6 +90,28 @@ const SURFACE_DISABLED_REASONS = {
agents: "Agents are only available from a thread.",
} as const;

/** Overlays that must win over the launcher's letter shortcuts. */
const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [
'[data-slot="dialog-popup"]',
'[data-slot="alert-dialog-popup"]',
'[data-slot="command-dialog-popup"]',
'[data-slot="menu-popup"]',
'[data-slot="select-popup"]',
'[data-slot="popover-popup"]',
'[data-slot="combobox-popup"]',
'[data-slot="autocomplete-popup"]',
].join(",");
Comment thread
StiensWout marked this conversation as resolved.

/** One-line unavailability hints for the empty-state cards. */
const SURFACE_UNAVAILABLE_HINTS = {
browser: "Only available in the desktop app.",
terminal: "Available when a project is open.",
files: "Available when a project is open.",
diff: "Available for Git repositories.",
pullRequest: "No pull request on this branch yet.",
agents: "Available from a thread.",
} as const;

type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all";

function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
Expand Down Expand Up @@ -118,6 +142,13 @@ function SurfaceMenuItem(props: {
return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;
}

/**
* Card launcher shown when the right panel has no surfaces. Keyboard-first
* without palette chrome: a surface's letter opens it directly from anywhere
* outside a typing context, and arrows plus Enter work while the launcher is
* focused. The highlight only appears on hover or arrow use. Unavailable
* surfaces stay visible with a one-line reason.
*/
function RightPanelEmptyState(props: {
onAddBrowser: () => void;
onAddTerminal: () => void;
Expand All @@ -133,123 +164,237 @@ function RightPanelEmptyState(props: {
agentsAvailable: boolean;
liveAgentCount: number;
}) {
// -1 means no highlight: it only appears on hover or arrow use.
const [highlight, setHighlight] = useState(-1);

const actions = [
{
label: "Browser",
description: "Open a local app or URL.",
icon: Globe2,
shortcut: "B",
available: props.browserAvailable,
disabledReason: SURFACE_DISABLED_REASONS.browser,
disabledReason: SURFACE_UNAVAILABLE_HINTS.browser,
onClick: props.onAddBrowser,
badgeCount: 0,
},
{
label: "Terminal",
description: "Start a shell in this workspace.",
icon: TerminalSquare,
shortcut: "T",
available: props.terminalAvailable,
disabledReason: SURFACE_DISABLED_REASONS.terminal,
disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal,
onClick: props.onAddTerminal,
badgeCount: 0,
},
{
label: "Files",
description: "Browse and read workspace files.",
icon: Files,
shortcut: "F",
available: props.filesAvailable,
disabledReason: SURFACE_DISABLED_REASONS.files,
disabledReason: SURFACE_UNAVAILABLE_HINTS.files,
onClick: props.onAddFiles,
badgeCount: 0,
},
{
label: "Diff",
description: "Review changes in this thread.",
icon: FileDiff,
shortcut: "D",
available: props.diffAvailable,
disabledReason: SURFACE_DISABLED_REASONS.diff,
disabledReason: SURFACE_UNAVAILABLE_HINTS.diff,
onClick: props.onAddDiff,
badgeCount: 0,
},
{
label: "Pull request",
description: "Open the pull request for this thread's branch.",
description: "Open this branch's pull request.",
icon: GitPullRequest,
shortcut: "P",
available: props.pullRequestAvailable,
disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest,
onClick: props.onAddPullRequest,
badgeCount: 0,
},
{
label: "Agents",
description: "Watch subagents and workflows run.",
description: "Follow subagents and workflows.",
icon: Bot,
shortcut: "A",
available: props.agentsAvailable,
disabledReason: SURFACE_DISABLED_REASONS.agents,
disabledReason: SURFACE_UNAVAILABLE_HINTS.agents,
onClick: props.onAddAgents,
badgeCount: props.liveAgentCount,
},
] as const;

type SurfaceAction = (typeof actions)[number];

const availableActions = actions.filter((action) => action.available);
const highlightIndex =
availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1);

// Letter shortcuts work while the launcher is visible, not only while it
// is focused; focus moves around too easily (stray clicks) to carry them.
// Capture phase so app-level key handlers cannot swallow the event first;
// typing contexts and already-handled events are left alone.
const shortcutActionsRef = useRef(availableActions);
useEffect(() => {
shortcutActionsRef.current = availableActions;
});
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
const target = event.target;
if (target instanceof HTMLElement) {
if (target.closest("input, textarea, select")) return;
// An empty contenteditable (the chat composer at rest) does not
// count as typing; letters only become text once a draft exists.
const editable = target.isContentEditable ? target : target.closest("[contenteditable]");
if (editable && (editable.textContent ?? "").trim().length > 0) return;
Comment thread
StiensWout marked this conversation as resolved.
}
const action = shortcutActionsRef.current.find(
(candidate) => candidate.shortcut.toLowerCase() === event.key.toLowerCase(),
);
if (!action) return;
event.preventDefault();
event.stopPropagation();
action.onClick();
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
Comment thread
StiensWout marked this conversation as resolved.
}, []);

const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
if (availableActions.length === 0) return;
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault();
setHighlight((highlightIndex + 1) % availableActions.length);
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault();
setHighlight(
highlightIndex === -1
? availableActions.length - 1
: (highlightIndex - 1 + availableActions.length) % availableActions.length,
);
return;
}
if (event.key === "Enter") {
// A focused card button owns its own activation; only open from the
// highlight when the container itself has focus.
if (event.target instanceof HTMLElement && event.target.closest("button")) return;
const action = availableActions[highlightIndex];
if (!action) return;
event.preventDefault();
action.onClick();
}
Comment thread
StiensWout marked this conversation as resolved.
};

// Stable identity so React only runs this callback ref on mount/unmount;
// an inline arrow would re-attach and re-focus on every render.
const focusOnMount = useCallback((node: HTMLDivElement | null) => {
node?.focus();
}, []);

const isHighlighted = (action: SurfaceAction) =>
highlightIndex !== -1 && availableActions[highlightIndex] === action;

const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => {
const Icon = action.icon;
return (
<span className="relative inline-flex shrink-0">
<Icon className={iconClassName} />
{action.badgeCount > 0 ? (
<span
aria-hidden
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"
>
{action.badgeCount}
</span>
) : null}
</span>
);
};

const cardShellClass =
"rounded-lg border border-border/80 bg-card dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5";
const highlightedCardClass = "bg-accent/60 dark:inset-ring-white/20";

return (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="w-full max-w-xl">
<div className="mb-5 text-center">
<h3 className="text-sm font-medium text-foreground">Open a surface</h3>
<p className="mt-1 text-xs text-muted-foreground">
<div
ref={focusOnMount}
Comment thread
StiensWout marked this conversation as resolved.
tabIndex={0}
onKeyDown={handleKeyDown}
aria-label="Open a surface"
data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}
className={cn(
"flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pt-6 outline-none",
// The panel topbar sits above this container; matching bottom padding
// keeps the cards centered against the full panel, not the leftover.
"pb-[calc(var(--workspace-topbar-height)+--spacing(6))]",
)}
>
<div className="relative w-full max-w-lg">
<div className="absolute inset-x-0 bottom-full mb-5 text-center">
<h3 className="font-medium text-foreground text-sm">Open a surface</h3>
<p className="mt-1 text-muted-foreground text-xs">
Choose what to show in the right panel.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{actions.map((action) => {
const Icon = action.icon;
const content = (
<>
<span className="relative mb-3 inline-flex">
<Icon className="size-5" />
{action.badgeCount > 0 ? (
<span
aria-hidden
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"
>
{action.badgeCount}
</span>
) : null}
</span>
<span className="text-sm font-medium">{action.label}</span>
<span className="mt-1 text-xs leading-relaxed text-muted-foreground">
{action.description}
</span>
</>
);
if (action.available) {
return (
<button
key={action.label}
type="button"
onClick={action.onClick}
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"
>
{content}
</button>
);
}
const disabledCard = (
{actions.map((action) =>
action.available ? (
<button
key={action.label}
type="button"
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"
aria-disabled="true"
onClick={action.onClick}
onMouseEnter={() => setHighlight(availableActions.indexOf(action))}
onMouseLeave={() =>
setHighlight((current) =>
current === availableActions.indexOf(action) ? -1 : current,
)
}
className={cn(
"relative flex w-full cursor-pointer flex-col items-start p-4 text-left transition hover:border-border hover:bg-accent/60",
cardShellClass,
isHighlighted(action) && highlightedCardClass,
)}
>
{content}
<Kbd className="absolute top-3 right-3">{action.shortcut}</Kbd>
<span className="flex items-center gap-2 pe-8">
{actionIcon(action)}
<span className="font-medium text-sm">{action.label}</span>
</span>
<span className="mt-1.5 text-muted-foreground text-xs leading-relaxed">
{action.description}
</span>
</button>
);
return (
<DisabledReasonTooltip
) : (
<div
key={action.label}
reason={action.disabledReason}
trigger={disabledCard}
/>
);
})}
className={cn(
"relative flex w-full flex-col items-start p-4 opacity-40",
cardShellClass,
)}
>
<Kbd className="absolute top-3 right-3">{action.shortcut}</Kbd>
<span className="flex items-center gap-2 pe-8">
{actionIcon(action)}
<span className="font-medium text-sm">{action.label}</span>
</span>
<span className="mt-1.5 text-muted-foreground text-xs leading-relaxed">
{action.disabledReason}
</span>
</div>
),
)}
</div>
</div>
</div>
Expand Down
Loading