diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c226a3a65c78..b2aa936ba480 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -27,6 +27,7 @@ import { useRightPanelStore, } from "../rightPanelStore"; import { + type PreviewMiniPlayerSource, selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; @@ -130,6 +131,17 @@ describe("agent browser close confirmation", () => { }); describe("floating browser preview", () => { + const isMiniPlayerVisible = ( + source: PreviewMiniPlayerSource | null, + renderedRightPanelSurface: RightPanelSurface | null, + rightPanelSurfaceVisible = true, + ) => + shouldRenderPreviewMiniPlayer({ + source, + renderedRightPanelSurface, + rightPanelSurfaceVisible, + }); + it("keeps agent preview intent when a user selects its browser tab and then switches away", () => { useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); @@ -143,7 +155,7 @@ describe("floating browser preview", () => { ref, ); const isFloating = () => - shouldRenderPreviewMiniPlayer( + isMiniPlayerVisible( selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref) ?.source ?? null, selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref), @@ -163,22 +175,25 @@ describe("floating browser preview", () => { it("only hides the duplicate while the same browser is rendered in the panel", () => { const tab = { kind: "browser", tabId: "tab-1" } as const; - expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); + expect(isMiniPlayerVisible(null, null, false)).toBe(false); expect( - shouldRenderPreviewMiniPlayer(tab, { + isMiniPlayerVisible(tab, { id: "browser:one", kind: "preview", resourceId: "tab-1", }), ).toBe(false); expect( - shouldRenderPreviewMiniPlayer(tab, { + isMiniPlayerVisible(tab, { id: "browser:two", kind: "preview", resourceId: "tab-2", }), ).toBe(true); - expect(shouldRenderPreviewMiniPlayer(tab, { id: "diff", kind: "diff" })).toBe(true); + expect(isMiniPlayerVisible(tab, { id: "diff", kind: "diff" })).toBe(true); + expect( + isMiniPlayerVisible(tab, { id: "browser:one", kind: "preview", resourceId: "tab-1" }, false), + ).toBe(true); }); it("only hides a floating device while that device is rendered in the panel", () => { @@ -196,22 +211,22 @@ describe("floating browser preview", () => { name: "Pixel", } as const; expect( - shouldRenderPreviewMiniPlayer(pixel, { + isMiniPlayerVisible(pixel, { id: "device:nucbox:emulator-5580", kind: "device", target, }), ).toBe(false); expect( - shouldRenderPreviewMiniPlayer(pixel, { + isMiniPlayerVisible(pixel, { id: "device:nucbox:emulator-5554", kind: "device", target: { ...target, deviceId: "emulator-5554" }, }), ).toBe(true); - expect(shouldRenderPreviewMiniPlayer(pixel, { id: "device", kind: "device" })).toBe(true); + expect(isMiniPlayerVisible(pixel, { id: "device", kind: "device" })).toBe(true); expect( - shouldRenderPreviewMiniPlayer(pixel, { + isMiniPlayerVisible(pixel, { id: "browser:one", kind: "preview", resourceId: "emulator-5580", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1dbe11f6c4c2..02ec2351a390 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -92,11 +92,14 @@ export function agentControlledBrowserCloseConfirmation( } /** The floating player hides only while the same source is rendered in the panel. */ -export function shouldRenderPreviewMiniPlayer( - source: PreviewMiniPlayerSource | null, - renderedRightPanelSurface: RightPanelSurface | null, -): boolean { +export function shouldRenderPreviewMiniPlayer(input: { + readonly source: PreviewMiniPlayerSource | null; + readonly renderedRightPanelSurface: RightPanelSurface | null; + readonly rightPanelSurfaceVisible: boolean; +}): boolean { + const { renderedRightPanelSurface, rightPanelSurfaceVisible, source } = input; if (source === null) return false; + if (!rightPanelSurfaceVisible) return true; if (source.kind === "browser") { return !( renderedRightPanelSurface?.kind === "preview" && diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d0d05146c489..2095a814bc70 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1960,10 +1960,12 @@ export default function ChatView(props: ChatViewProps) { const activeRightPanelKind = useRightPanelStore((state) => selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); - const diffOpen = activeRightPanelKind === "diff"; 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), ); @@ -1992,7 +1994,6 @@ export default function ChatView(props: ChatViewProps) { () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], [activeKnownTerminalIds, panelTerminalIds], ); - const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); @@ -2022,13 +2023,19 @@ export default function ChatView(props: ChatViewProps) { const rightPanelControlsAtRoot = rightPanelPresent && !shouldUseRightPanelSheet; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; - const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.source ?? null, - renderedRightPanelSurface, - ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; + const maximizedThreadTabActive = rightPanelMaximized && threadTabSelected; + const rightPanelSurfaceVisible = rightPanelOpen && !maximizedThreadTabActive; + const diffOpen = activeRightPanelKind === "diff" && rightPanelSurfaceVisible; + const previewPanelOpen = + activeRightPanelKind === "preview" && rightPanelSurfaceVisible && isPreviewSupportedInRuntime(); + const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer({ + source: activePreviewMiniPlayer?.source ?? null, + renderedRightPanelSurface, + rightPanelSurfaceVisible, + }); const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; useEffect(() => { @@ -3747,9 +3754,14 @@ export default function ChatView(props: ChatViewProps) { onDiffPanelOpen?.(); } if (activeThreadRef) { - useRightPanelStore.getState().toggle(activeThreadRef, "diff"); + const panels = useRightPanelStore.getState(); + if (maximizedThreadTabActive) { + panels.open(activeThreadRef, "diff"); + } else { + panels.toggle(activeThreadRef, "diff"); + } } - }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); + }, [activeThreadRef, diffOpen, isServerThread, maximizedThreadTabActive, onDiffPanelOpen]); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; const loadBalancingCandidates = useMemo( @@ -4765,11 +4777,19 @@ 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) { + if ( + rightPanelSurfaceVisible && + activeRightPanelSurface?.kind === "preview" && + activeRightPanelSurface.resourceId + ) { usePreviewMiniPlayerStore .getState() .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId)); - } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) { + } else if ( + rightPanelSurfaceVisible && + activeRightPanelSurface?.kind === "device" && + activeRightPanelSurface.target + ) { usePreviewMiniPlayerStore .getState() .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target }); @@ -4777,7 +4797,7 @@ export default function ChatView(props: ChatViewProps) { setMaximizedRightPanelThreadKey(null); useRightPanelStore.getState().close(activeThreadRef); } - }, [activeRightPanelSurface, activeThreadRef]); + }, [activeRightPanelSurface, activeThreadRef, rightPanelSurfaceVisible]); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -4939,10 +4959,13 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; - setMaximizedRightPanelThreadKey((threadKey) => - threadKey === routeThreadKey ? null : routeThreadKey, - ); - }, [canMaximizeRightPanel, routeThreadKey]); + if (activeThreadRef) useRightPanelStore.getState().selectPanelTab(activeThreadRef); + if (rightPanelMaximized) { + setMaximizedRightPanelThreadKey(null); + return; + } + setMaximizedRightPanelThreadKey(routeThreadKey); + }, [activeThreadRef, canMaximizeRightPanel, rightPanelMaximized, routeThreadKey]); const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; @@ -6680,7 +6703,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); @@ -6833,6 +6856,7 @@ export default function ChatView(props: ChatViewProps) { confirmAndUnpinThread, copyActiveThreadReference, getShortcutContext, + maximizedThreadTabActive, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -9092,7 +9116,9 @@ export default function ChatView(props: ChatViewProps) { // Suppressed while the Agents surface is visible: the roster itself is // on screen, so the toggle badge would be pointing at nothing. liveAgentCount={ - rightPanelOpen && activeRightPanelSurface?.kind === "agents" ? 0 : agentPanelModel.liveCount + rightPanelSurfaceVisible && activeRightPanelSurface?.kind === "agents" + ? 0 + : agentPanelModel.liveCount } onToggleTerminal={toggleTerminalVisibility} onToggleRightPanel={toggleRightPanel} @@ -9135,7 +9161,7 @@ export default function ChatView(props: ChatViewProps) { threadRef={activeThreadRef} tabId={renderedRightPanelSurface.resourceId} configuredUrls={configuredPreviewUrls} - visible={rightPanelOpen} + visible={rightPanelSurfaceVisible} onSendAnnotation={(annotation, image) => { void onSend(undefined, "foreground", { annotation, image }); }} @@ -9143,7 +9169,7 @@ export default function ChatView(props: ChatViewProps) { ) : renderedRightPanelSurface?.kind === "terminal" ? ( { closeRightPanelSurface(renderedRightPanelSurface); useRightPanelStore.getState().show(activeThreadRef); @@ -9333,16 +9359,23 @@ export default function ChatView(props: ChatViewProps) {
{/* Top bar */} {isElectron && rightPanelControlsAtRoot ? ( { + useRightPanelStore.getState().selectThreadTab(activeThreadRef); + }, + }, + } + : {})} surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} activeSurfaceId={renderedRightPanelSurface?.id ?? null} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index e0fb70b8080d..3e407d655a9a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -24,6 +24,7 @@ import { GitPullRequest, GitPullRequestArrow, Globe2, + MessageSquare, Plus, TerminalSquare, Volume2, @@ -62,6 +63,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 { pullRequestEnvironment } from "~/state/pullRequests"; @@ -78,6 +80,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; + }; /** Forwarded to PreviewPanelShell so this surface persists its own width. */ widthStorageKey?: string; /** Forwarded to PreviewPanelShell as the initial width before a user resize. */ @@ -828,6 +836,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { canScrollLeft: false, canScrollRight: false, }); + const threadTabActive = props.threadTab?.active === true; const updateTabScrollState = useCallback(() => { const viewport = tabScrollViewport(tabListRef.current); @@ -1046,10 +1055,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("[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); @@ -1110,6 +1119,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { (props.layoutControls ? "wco:pr-[var(--workspace-native-controls-inset)]" : "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]"), + props.mode === "inline" && props.maximized && "relative z-20", props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} data-right-panel-tabbar @@ -1122,8 +1132,24 @@ export function RightPanelTabs(props: RightPanelTabsProps) { data-right-panel-tab-list >
+ {props.threadTab ? ( + + + + ) : null} {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, props.previewSessions, props.terminalLabelsById); const previewTabId = previewTabIdOf(surface, props.previewSessions); @@ -1136,18 +1162,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ? (props.previewRuntimeTabId?.(previewTabId) ?? null) : null; return ( -
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", + "max-w-36 shrink-0 cursor-pointer", ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > {title} )} -
+ ); })} - {props.surfaces.length > 0 ? ( + {props.surfaces.length > 0 || props.threadTab ? ( ) : null}
-
+
{props.activeSurfaceId === null ? ( + confirmCloseTerminal(terminalId)} @@ -1697,7 +1690,7 @@ export default function ThreadTerminalDrawer({ > {terminalLabel} -
+ ); })}
diff --git a/apps/web/src/components/ui/panel-tab.tsx b/apps/web/src/components/ui/panel-tab.tsx new file mode 100644 index 000000000000..c77b1b6422f7 --- /dev/null +++ b/apps/web/src/components/ui/panel-tab.tsx @@ -0,0 +1,23 @@ +import type { ComponentProps } from "react"; + +import { cn } from "~/lib/utils"; + +interface PanelTabProps extends ComponentProps<"div"> { + active: boolean; +} + +export function PanelTab({ active, className, ...props }: PanelTabProps) { + return ( +
+ ); +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index e2299511b87c..2a5056ebc23b 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -17,7 +17,11 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")) const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); beforeEach(() => { - useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); + useRightPanelStore.setState({ + byThreadKey: {}, + selectedThreadTabKey: null, + userActionRevisionByThreadKey: {}, + }); }); describe("rightPanelStore", () => { @@ -109,6 +113,28 @@ describe("rightPanelStore", () => { number: 42, }); + it("returns to the panel when a manual panel action follows Thread selection", () => { + const store = useRightPanelStore.getState(); + store.open(refA, "diff"); + store.selectThreadTab(refA); + + expect(useRightPanelStore.getState().selectedThreadTabKey).toBe("env-1:thread-A"); + + store.activateSurface(refA, "diff"); + + expect(useRightPanelStore.getState().selectedThreadTabKey).toBeNull(); + }); + + it("keeps Thread selected when an automatic panel update arrives", () => { + const store = useRightPanelStore.getState(); + store.open(refA, "diff"); + store.selectThreadTab(refA); + const revision = store.getUserActionRevision(refA); + + expect(store.openProactive(refA, linkedPullRequest, revision)).toBe(true); + expect(useRightPanelStore.getState().selectedThreadTabKey).toBe("env-1:thread-A"); + }); + it.each(["diff-first", "pull-request-first"])( "prioritizes the linked pull request over browser and diff with %s delivery", (order) => { diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index c44c106c68c8..f0642fbc1188 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -115,8 +115,12 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; + /** Session-only selection for the transient Thread tab shown while maximized. */ + selectedThreadTabKey: string | null; /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ userActionRevisionByThreadKey: Record; + selectThreadTab: (ref: ScopedThreadRef) => void; + selectPanelTab: (ref: ScopedThreadRef) => void; getUserActionRevision: (ref: ScopedThreadRef) => number; /** * Open a surface on behalf of the app, not the user. Refused when the user @@ -335,6 +339,8 @@ const userAction = ( ], }; }), + selectedThreadTabKey: + state.selectedThreadTabKey === threadKey ? null : state.selectedThreadTabKey, userActionRevisionByThreadKey: { ...state.userActionRevisionByThreadKey, [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, @@ -480,7 +486,24 @@ export const useRightPanelStore = create()( persist( (set, get) => ({ byThreadKey: {}, + selectedThreadTabKey: null, userActionRevisionByThreadKey: {}, + selectThreadTab: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + return { + selectedThreadTabKey: threadKey, + userActionRevisionByThreadKey: { + ...state.userActionRevisionByThreadKey, + [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, + }, + }; + }), + selectPanelTab: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + return state.selectedThreadTabKey === threadKey ? { selectedThreadTabKey: null } : state; + }), getUserActionRevision: (ref) => get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, openProactive: (ref, surface, expectedUserActionRevision) => { @@ -847,14 +870,20 @@ export const useRightPanelStore = create()( const threadKey = scopedThreadKey(ref); if ( !(threadKey in state.byThreadKey) && - !(threadKey in state.userActionRevisionByThreadKey) + !(threadKey in state.userActionRevisionByThreadKey) && + state.selectedThreadTabKey !== threadKey ) { return state; } const { [threadKey]: _removed, ...rest } = state.byThreadKey; const { [threadKey]: _revision, ...userActionRevisionByThreadKey } = state.userActionRevisionByThreadKey; - return { byThreadKey: rest, userActionRevisionByThreadKey }; + return { + byThreadKey: rest, + selectedThreadTabKey: + state.selectedThreadTabKey === threadKey ? null : state.selectedThreadTabKey, + userActionRevisionByThreadKey, + }; }), }), {