Skip to content
Closed
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
77 changes: 47 additions & 30 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2299,20 +2299,12 @@ export default function ChatView(props: ChatViewProps) {
const activeRightPanelKind = useRightPanelStore((state) =>
selectActiveRightPanel(state.byThreadKey, activeThreadRef),
);
const diffOpen = activeRightPanelKind === "diff";
const explicitDiffOpenRef = useRef<ScopedThreadRef | null>(null);
useLayoutEffect(() => {
const explicitThreadRef = explicitDiffOpenRef.current;
explicitDiffOpenRef.current = null;
// Generic openings always show Changes, including tab fallbacks and thread changes.
// A timeline click instead opens the specific turn/file the user requested.
if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) {
useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
}
}, [activeThreadRef, diffOpen]);
const rightPanelState = useRightPanelStore((state) =>
selectThreadRightPanelState(state.byThreadKey, activeThreadRef),
);
const threadTabSelected = useRightPanelStore((state) =>
activeThreadRef ? state.selectedThreadTabKey === scopedThreadKey(activeThreadRef) : false,
);
const activeRightPanelSurface = useRightPanelStore((state) =>
selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
);
Expand Down Expand Up @@ -2353,7 +2345,6 @@ export default function ChatView(props: ChatViewProps) {
),
[allocatableActiveTerminalIds, canReuseTerminal, environmentId],
);
const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;
const rightPanelOpen = rightPanelState.isOpen;
const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
usePanelAnimationSettings();
Expand Down Expand Up @@ -2384,12 +2375,18 @@ export default function ChatView(props: ChatViewProps) {
const rightPanelControlsAtRoot = rightPanelPresent && !shouldUsePlanSidebarSheet;
const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;
const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true;
const maximizedThreadTabActive = rightPanelMaximized && threadTabSelected;
const chatColumnMaximizedAway = rightPanelMaximized && !maximizedThreadTabActive;
const rightPanelSurfaceVisible = rightPanelOpen && !maximizedThreadTabActive;
const diffOpen = activeRightPanelKind === "diff" && rightPanelSurfaceVisible;
const previewPanelOpen =
activeRightPanelKind === "preview" && rightPanelSurfaceVisible && browserAvailable;
const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(
activePreviewMiniPlayer?.source ?? null,
renderedRightPanelSurface,
rightPanelSurfaceVisible ? renderedRightPanelSurface : null,
);
const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true;
const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
const [threadPanelPresentation, setThreadPanelPresentation] =
useState<ThreadPanelPresentation>("inline");
Expand Down Expand Up @@ -5853,19 +5850,20 @@ export default function ChatView(props: ChatViewProps) {
const closePreviewPanel = useCallback(() => {
if (activeThreadRef) {
// Closing the panel on a live browser or device floats it instead of dropping it.
if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {
const visibleSurface = rightPanelSurfaceVisible ? activeRightPanelSurface : null;
if (visibleSurface?.kind === "preview" && visibleSurface.resourceId) {
usePreviewMiniPlayerStore
.getState()
.open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId));
} else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) {
.open(activeThreadRef, browserMiniPlayerSource(visibleSurface.resourceId));
} else if (visibleSurface?.kind === "device" && visibleSurface.target) {
usePreviewMiniPlayerStore
.getState()
.open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target });
.open(activeThreadRef, { kind: "device", ...visibleSurface.target });
}
useRightPanelStore.getState().setMaximized(activeThreadRef, false);
useRightPanelStore.getState().close(activeThreadRef);
}
}, [activeRightPanelSurface, activeThreadRef]);
}, [activeRightPanelSurface, activeThreadRef, rightPanelSurfaceVisible]);
const togglePreviewPanel = useCallback(() => {
if (!activeThreadRef || !browserAvailable) return;
if (previewPanelOpen) {
Expand Down Expand Up @@ -6061,6 +6059,7 @@ export default function ChatView(props: ChatViewProps) {
}, [canMaximizeRightPanel, routeThreadRef]);
const toggleRightPanelMaximized = useCallback(() => {
if (!canMaximizeRightPanel || !activeThreadRef) return;
useRightPanelStore.getState().setThreadTabSelected(activeThreadRef, false);
useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized);
}, [activeThreadRef, canMaximizeRightPanel, rightPanelMaximized]);
const cleanupRightPanelSurfaces = useCallback(
Expand Down Expand Up @@ -8135,7 +8134,7 @@ export default function ChatView(props: ChatViewProps) {
if (command === "rightPanel.close") {
// Nothing open: leave the event alone so the shortcut keeps its
// native meaning (close window on desktop, close tab in a browser).
if (!activeRightPanelSurface) return;
if (!activeRightPanelSurface || maximizedThreadTabActive) return;
event.preventDefault();
event.stopPropagation();
if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface);
Expand Down Expand Up @@ -8358,6 +8357,7 @@ export default function ChatView(props: ChatViewProps) {
confirmAndUnpinThread,
copyActiveThreadReference,
getShortcutContext,
maximizedThreadTabActive,
openThreadFind,
closeThreadFind,
isThreadFindActive,
Expand Down Expand Up @@ -10949,12 +10949,11 @@ export default function ChatView(props: ChatViewProps) {
const onOpenTurnDiff = useCallback(
(runId: RunId, filePath?: string) => {
if (!isServerThread || !activeThreadRef) return;
explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef;
useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath);
useRightPanelStore.getState().open(activeThreadRef, "diff");
onDiffPanelOpen?.();
},
[activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen],
[activeThreadRef, isServerThread, onDiffPanelOpen],
);
// The revert handler is read from a ref at call-time so the callback
// reference is fully stable and never busts TimelineRowCtx identity.
Expand Down Expand Up @@ -11010,15 +11009,15 @@ export default function ChatView(props: ChatViewProps) {
threadRef={activeThreadRef}
tabId={renderedRightPanelSurface.resourceId}
configuredUrls={configuredPreviewUrls}
visible={rightPanelOpen}
visible={rightPanelSurfaceVisible}
onSendAnnotation={(annotation, image) => {
void onSend(undefined, "auto", "foreground", { annotation, image });
}}
/>
</Suspense>
) : renderedRightPanelSurface?.kind === "terminal" ? (
<PersistentThreadTerminalPanel
visible={rightPanelOpen}
visible={rightPanelSurfaceVisible}
threadRef={activeThreadRef}
surface={renderedRightPanelSurface}
launchContext={activeTerminalLaunchContext ?? null}
Expand Down Expand Up @@ -11060,7 +11059,7 @@ export default function ChatView(props: ChatViewProps) {
<PullRequestDetailPanel
getShortcutContext={getShortcutContext}
shortcutsEnabled={
rightPanelOpen && activeRightPanelSurface?.id === renderedRightPanelSurface.id
rightPanelSurfaceVisible && activeRightPanelSurface?.id === renderedRightPanelSurface.id
}
key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
environmentId={activeThread.environmentId}
Expand Down Expand Up @@ -11106,7 +11105,7 @@ export default function ChatView(props: ChatViewProps) {
threadRef={activeThreadRef}
key={renderedRightPanelSurface.id}
surface={renderedRightPanelSurface}
visible={rightPanelOpen}
visible={rightPanelSurfaceVisible}
onDismissSetup={() => {
closeRightPanelSurface(renderedRightPanelSurface);
useRightPanelStore.getState().show(activeThreadRef);
Expand Down Expand Up @@ -11227,7 +11226,7 @@ export default function ChatView(props: ChatViewProps) {
const panelToggleControls = (
<PanelLayoutControls
{...panelToggleControlProps}
showThreadPanelControl={!inlineRightPanelOwnsTitleBar}
showThreadPanelControl={!inlineRightPanelOwnsTitleBar || maximizedThreadTabActive}
/>
);
const threadPanelHeaderControl = (
Expand Down Expand Up @@ -11314,9 +11313,14 @@ export default function ChatView(props: ChatViewProps) {
<div
className={cn(
"flex min-h-0 min-w-0 flex-col overflow-x-hidden",
rightPanelMaximized ? "w-0 flex-none" : "flex-1",
maximizedThreadTabActive
? "absolute inset-0 z-10 bg-background pt-(--workspace-topbar-height)"
: rightPanelMaximized
? "w-0 flex-none"
: "flex-1",
)}
data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}
data-chat-column-maximized-away={chatColumnMaximizedAway ? "true" : "false"}
inert={chatColumnMaximizedAway || undefined}
>
{/* Top bar */}
<header
Expand All @@ -11333,6 +11337,8 @@ export default function ChatView(props: ChatViewProps) {
)
: "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center pl-(--workspace-gutter-start) pr-(--workspace-gutter-end)",
COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
// Retain the Thread details popover's anchor beneath the tab bar.
maximizedThreadTabActive && "invisible absolute inset-x-0 top-0",
)}
>
{isElectron && rightPanelControlsAtRoot ? (
Expand Down Expand Up @@ -11974,6 +11980,17 @@ export default function ChatView(props: ChatViewProps) {
keybindings={keybindings}
getShortcutContext={getShortcutContext}
maximized={rightPanelMaximized}
{...(rightPanelMaximized
? {
threadTab: {
label: activeThread.title,
active: maximizedThreadTabActive,
onActivate: () => {
useRightPanelStore.getState().setThreadTabSelected(activeThreadRef, true);
},
},
}
: {})}
inlineSize={previewPanelInlineSize}
surfaces={renderedRightPanelSurfaces}
environmentId={activeThreadRef.environmentId}
Expand Down
50 changes: 36 additions & 14 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
FileDiff,
Files,
Globe2,
MessageSquare,
Plus,
TerminalSquare,
} from "lucide-react";
Expand Down Expand Up @@ -75,6 +76,7 @@ import {
import { useBrowserDefaults } from "~/browser/browserDefaults";
import { ScrollArea } from "~/components/ui/scroll-area";
import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
import { PanelTab } from "~/components/ui/panel-tab";
import { faviconUrlForOrigin } from "~/lib/favicon";
import { useTheme } from "~/hooks/useTheme";
import { useDeviceState } from "~/state/device";
Expand All @@ -99,6 +101,12 @@ interface RightPanelTabsProps {
mode: PreviewPanelMode;
maximized?: boolean;
open?: boolean;
/** A transient, non-closing tab that shares the maximized workspace with panel surfaces. */
threadTab?: {
readonly label: string;
readonly active: boolean;
readonly onActivate: () => void;
};
keybindings: ResolvedKeybindingsConfig;
getShortcutContext: () => ShortcutMatchContext;
/** Forwarded to PreviewPanelShell so this surface persists its own width. */
Expand Down Expand Up @@ -849,13 +857,13 @@ function SortableTab({
disabled,
className,
...props
}: ComponentProps<"div"> & { surfaceId: string; disabled: boolean }) {
}: ComponentProps<typeof PanelTab> & { surfaceId: string; disabled: boolean }) {
const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: surfaceId,
disabled,
});
return (
<div
<PanelTab
{...props}
{...listeners}
ref={setNodeRef}
Expand All @@ -878,6 +886,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
canScrollLeft: false,
canScrollRight: false,
});
const threadTabActive = props.threadTab?.active === true;

if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false);

Expand Down Expand Up @@ -1112,10 +1121,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
);

useEffect(() => {
if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return;
if ((!props.activeSurfaceId && !threadTabActive) || !tabScrollState.hasOverflow) return;
const activeTab = tabListRef.current?.querySelector<HTMLElement>("[data-active-tab='true']");
activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [props.activeSurfaceId, tabScrollState.hasOverflow]);
}, [props.activeSurfaceId, tabScrollState.hasOverflow, threadTabActive]);

useEffect(() => {
const viewport = tabScrollViewport(tabListRef.current);
Expand Down Expand Up @@ -1175,6 +1184,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3",
ownsDesktopTitleBar && "drag-region",
ownsDesktopTitleBar && "wco:pr-(--workspace-native-controls-inset)",
props.mode === "inline" && props.maximized && "relative z-20",
props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
)}
data-right-panel-tabbar
Expand All @@ -1188,9 +1198,22 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
data-right-panel-tab-list
>
<div className="flex h-full w-max min-w-full items-center gap-1">
{props.threadTab ? (
<PanelTab active={threadTabActive} noDrag className="max-w-36 shrink-0">
<button
type="button"
aria-pressed={threadTabActive}
className="flex min-w-0 cursor-pointer items-center gap-1"
onClick={props.threadTab.onActivate}
>
<MessageSquare className="size-3 shrink-0" />
<span className="truncate">{props.threadTab.label}</span>
</button>
</PanelTab>
) : null}
<SortableTabList surfaces={props.surfaces} onMove={props.onMoveSurface}>
{props.surfaces.map((surface) => {
const active = surface.id === props.activeSurfaceId;
const active = !threadTabActive && surface.id === props.activeSurfaceId;
const pending = props.pendingSurfaceIds.has(surface.id);
const title = surfaceTitle(
surface,
Expand All @@ -1211,17 +1234,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
key={surface.id}
surfaceId={surface.id}
disabled={!props.onMoveSurface || renamingDevice === surface.id}
data-active-tab={active}
active={active}
noDrag={ownsDesktopTitleBar}
onMouseDown={handleTabMouseDown}
onAuxClick={(event) => handleTabAuxClick(event, surface)}
onContextMenu={(event) => void handleTabContextMenu(event, surface)}
className={cn(
"cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
ownsDesktopTitleBar && "[-webkit-app-region:no-drag]",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
className="max-w-36 shrink-0"
>
<PanelTabCloseButton
label={`Close ${title}`}
Expand Down Expand Up @@ -1471,7 +1489,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
/>
) : null}
</div>
<div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content>
<div
className={cn("flex min-h-0 flex-1 flex-col", threadTabActive && "invisible")}
data-right-panel-surface-content
inert={threadTabActive || undefined}
>
{props.activeSurfaceId === null ? (
<RightPanelEmptyState
onAddBrowser={props.onAddBrowser}
Expand Down
13 changes: 3 additions & 10 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
import { Button } from "~/components/ui/button";
import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";
import { PanelTab } from "~/components/ui/panel-tab";
import { stackedThreadToast, toastManager } from "~/components/ui/toast";
import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { cn } from "~/lib/utils";
Expand Down Expand Up @@ -1812,15 +1813,7 @@ export default function ThreadTerminalDrawer({
isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : ""
}`;
return (
<div
key={terminalId}
className={cn(
"group/tab flex h-6 w-full items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<PanelTab key={terminalId} active={isActive} className="w-full">
{canOperateTerminal ? (
<PanelTabCloseButton
label={closeTerminalLabel}
Expand All @@ -1839,7 +1832,7 @@ export default function ThreadTerminalDrawer({
>
<span className="truncate">{terminalLabel}</span>
</button>
</div>
</PanelTab>
);
})}
</div>
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/ui/panel-tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ComponentProps } from "react";

import { cn } from "~/lib/utils";

interface PanelTabProps extends ComponentProps<"div"> {
active: boolean;
noDrag?: boolean;
}

export function PanelTab({ active, noDrag = false, className, ...props }: PanelTabProps) {
return (
<div
{...props}
data-active-tab={active}
className={cn(
"group/tab flex h-6 cursor-pointer items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
noDrag && "[-webkit-app-region:no-drag]",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
className,
)}
/>
);
}
Loading
Loading