Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
242 changes: 184 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,16 @@ const SURFACE_DISABLED_REASONS = {
agents: "Agents are only available from a thread.",
} as const;

/** 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 +130,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 +152,230 @@ 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.metaKey || event.ctrlKey || event.altKey) 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") {
const action = availableActions[highlightIndex];
if (!action) return;
event.preventDefault();
action.onClick();
}
Comment thread
StiensWout marked this conversation as resolved.
};

const focusOnMount = (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.
Outdated
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