From a313273a1432580090def417b1a8ce710516c761 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:21:54 +1100 Subject: [PATCH 01/10] feat(web): optionally lay right panel surfaces out as scrolling columns Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.logic.ts | 28 +- apps/web/src/components/ChatView.tsx | 240 +++++++++++------- apps/web/src/components/RightPanelColumns.tsx | 143 +++++++++++ .../src/components/ThreadTerminalDrawer.tsx | 39 ++- .../web/src/components/device/DevicePanel.tsx | 4 +- .../src/components/preview/PreviewPanel.tsx | 3 + .../src/components/preview/PreviewView.tsx | 9 +- .../components/settings/SettingsPanels.tsx | 45 ++++ .../src/components/settings/settingsSearch.ts | 6 + docs/user/appearance.md | 9 + packages/contracts/src/settings.test.ts | 10 + packages/contracts/src/settings.ts | 11 + 12 files changed, 427 insertions(+), 120 deletions(-) create mode 100644 apps/web/src/components/RightPanelColumns.tsx diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1e79964caa0a9..3de13c50134f2 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -95,23 +95,27 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +/** Whether a right panel surface shows the same browser tab or device as the floating player. */ +export function surfaceShowsPreviewSource( + source: PreviewMiniPlayerSource, + surface: RightPanelSurface | null, +): boolean { + if (source.kind === "browser") { + return surface?.kind === "preview" && surface.resourceId === source.tabId; + } + return ( + surface?.kind === "device" && + surface.target?.hostId === source.hostId && + surface.target.deviceId === source.deviceId + ); +} + /** The floating player hides only while the same source is rendered in the panel. */ export function shouldRenderPreviewMiniPlayer( source: PreviewMiniPlayerSource | null, renderedRightPanelSurface: RightPanelSurface | null, ): boolean { - if (source === null) return false; - if (source.kind === "browser") { - return !( - renderedRightPanelSurface?.kind === "preview" && - renderedRightPanelSurface.resourceId === source.tabId - ); - } - return !( - renderedRightPanelSurface?.kind === "device" && - renderedRightPanelSurface.target?.hostId === source.hostId && - renderedRightPanelSurface.target.deviceId === source.deviceId - ); + return source !== null && !surfaceShowsPreviewSource(source, renderedRightPanelSurface); } export function shouldOpenProactivePullRequest( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b760810a38767..0c92f707e12ce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -286,6 +286,7 @@ import { import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; +import { RightPanelColumns, type RightPanelColumnView } from "./RightPanelColumns"; import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; @@ -533,6 +534,7 @@ import { shouldOpenProactiveTurnDiff, shouldReleaseTimelineAnchorForToolActivity, shouldRenderPreviewMiniPlayer, + surfaceShowsPreviewSource, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -1372,13 +1374,15 @@ interface PersistentThreadTerminalPanelProps { surface: Extract; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; + autoFocus: boolean; + offscreen: boolean; keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; - onSplitTerminal: () => void; - onSplitTerminalVertical: () => void; + onSplitTerminal: (surfaceId: string) => void; + onSplitTerminalVertical: (surfaceId: string) => void; onNewTerminal: () => void; - onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; + onActiveTerminalChange: (surfaceId: string, terminalId: string) => void; + onCloseTerminal: (surfaceId: string, terminalId: string) => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; @@ -1391,6 +1395,8 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane surface, launchContext, focusRequestId, + autoFocus, + offscreen, keybindings, onAddTerminalContext, onSplitTerminal, @@ -1520,15 +1526,17 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane ]} activeTerminalGroupId={surface.id} focusRequestId={focusRequestId} - onSplitTerminal={onSplitTerminal} - onSplitTerminalVertical={onSplitTerminalVertical} + autoFocus={autoFocus} + offscreen={offscreen} + onSplitTerminal={() => onSplitTerminal(surface.id)} + onSplitTerminalVertical={() => onSplitTerminalVertical(surface.id)} onNewTerminal={onNewTerminal} splitShortcutLabel={splitShortcutLabel} splitVerticalShortcutLabel={splitVerticalShortcutLabel} newShortcutLabel={newShortcutLabel} closeShortcutLabel={closeShortcutLabel} - onActiveTerminalChange={onActiveTerminalChange} - onCloseTerminal={onCloseTerminal} + onActiveTerminalChange={(terminalId) => onActiveTerminalChange(surface.id, terminalId)} + onCloseTerminal={(terminalId) => onCloseTerminal(surface.id, terminalId)} onHeightChange={() => undefined} onAddTerminalContext={onAddTerminalContext} terminalLabelsById={terminalLabelsById} @@ -2353,6 +2361,9 @@ export default function ChatView(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable; const rightPanelOpen = rightPanelState.isOpen; + const rightPanelLayout = useClientSettings((settings) => settings.rightPanelLayout); + // Bumped on every tab selection, so selecting the current tab still reveals its column. + const [rightPanelRevealRequestId, setRightPanelRevealRequestId] = useState(0); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const activeTerminalDrawerPresence = usePanelPresence( @@ -5937,14 +5948,19 @@ export default function ChatView(props: ChatViewProps) { hasTerminalWriteAccess, ]); const splitPanelTerminal = useCallback( - (direction: "horizontal" | "vertical" = "horizontal") => { + (direction: "horizontal" | "vertical" = "horizontal", surfaceId?: string) => { + // A column's own buttons name their surface; shortcuts split the selected one. + const surface = + surfaceId === undefined + ? activeRightPanelSurface + : rightPanelState.surfaces.find((entry) => entry.id === surfaceId); if ( !hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject || - activeRightPanelSurface?.kind !== "terminal" || - activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP + surface?.kind !== "terminal" || + surface.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { return; } @@ -5952,7 +5968,7 @@ export default function ChatView(props: ChatViewProps) { const cwd = gitCwd ?? activeProject.workspaceRoot; useRightPanelStore .getState() - .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); + .splitTerminal(activeThreadRef, surface.id, terminalId, direction); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ environmentId: activeThreadRef.environmentId, @@ -5978,46 +5994,43 @@ export default function ChatView(props: ChatViewProps) { gitCwd, openTerminal, hasTerminalWriteAccess, + rightPanelState.surfaces, ], ); - const splitPanelTerminalVertical = useCallback(() => { - splitPanelTerminal("vertical"); - }, [splitPanelTerminal]); + const splitColumnTerminal = useCallback( + (surfaceId: string) => splitPanelTerminal("horizontal", surfaceId), + [splitPanelTerminal], + ); + const splitColumnTerminalVertical = useCallback( + (surfaceId: string) => splitPanelTerminal("vertical", surfaceId), + [splitPanelTerminal], + ); + // Both take the terminal's own surface: side-by-side columns can report for + // a surface that is not the selected one, such as when its session exits. const activatePanelTerminal = useCallback( - (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; - useRightPanelStore - .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + (surfaceId: string, terminalId: string) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().activateTerminal(activeThreadRef, surfaceId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [activeRightPanelSurface, activeThreadRef], + [activeThreadRef], ); const closePanelTerminal = useCallback( - (terminalId: string) => { - if ( - !hasTerminalWriteAccess() || - !activeThreadRef || - activeRightPanelSurface?.kind !== "terminal" - ) - return; + (surfaceId: string, terminalId: string) => { + if (!hasTerminalWriteAccess() || !activeThreadRef) return; void closeTerminalMutation({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, }); storeCloseTerminal(activeThreadRef, terminalId); - useRightPanelStore - .getState() - .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); - setTerminalFocusRequestId((value) => value + 1); + const panels = useRightPanelStore.getState(); + const selected = + selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef)?.id === surfaceId; + panels.closeTerminal(activeThreadRef, surfaceId, terminalId); + // A column in the background must not pull focus when its session ends. + if (selected) setTerminalFocusRequestId((value) => value + 1); }, - [ - hasTerminalWriteAccess, - activeRightPanelSurface, - activeThreadRef, - closeTerminalMutation, - storeCloseTerminal, - ], + [hasTerminalWriteAccess, activeThreadRef, closeTerminalMutation, storeCloseTerminal], ); const requestCloseTerminal = useCallback( (terminalId: string) => { @@ -6036,17 +6049,28 @@ export default function ChatView(props: ChatViewProps) { if (!hasTerminalWriteAccess()) return; const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); void confirmTerminalClose([label]).then((confirmed) => { - if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - closePanelTerminal(terminalId); + if ( + confirmed && + readEnvironmentScope(environmentId, AuthTerminalOperateScope) && + activeRightPanelSurface?.kind === "terminal" + ) { + closePanelTerminal(activeRightPanelSurface.id, terminalId); } }); }, - [hasTerminalWriteAccess, activeTerminalLabelsById, closePanelTerminal, environmentId], + [ + hasTerminalWriteAccess, + activeRightPanelSurface, + activeTerminalLabelsById, + closePanelTerminal, + environmentId, + ], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + setRightPanelRevealRequestId((value) => value + 1); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); } @@ -11078,31 +11102,49 @@ export default function ChatView(props: ChatViewProps) { return ; } - const rightPanelContent = activeThreadRef ? ( - renderedRightPanelSurface?.kind === "preview" ? ( + const renderRightPanelSurface = ( + surface: RightPanelSurface, + { active, visible, unclipped }: RightPanelColumnView, + ) => { + if (!activeThreadRef) return null; + // A source showing in the floating player stays there; one native view has one owner. + const floating = + previewMiniPlayerVisible && + activePreviewMiniPlayer !== null && + surfaceShowsPreviewSource(activePreviewMiniPlayer.source, surface); + return floating ? ( +
+

Showing in the floating player.

+
+ ) : surface.kind === "preview" ? ( { void onSend(undefined, "auto", "foreground", { annotation, image }); }} /> - ) : renderedRightPanelSurface?.kind === "terminal" ? ( + ) : surface.kind === "terminal" ? ( + // `visible` follows the panel, not scroll position: a terminal claims focus + // when it becomes visible. Scrolled away, `offscreen` only pauses painting. - ) : renderedRightPanelSurface?.kind === "diff" ? ( + ) : surface.kind === "diff" ? ( - ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( + ) : surface.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( + ) : surface.kind === "pull-request" && !supportsPullRequests ? ( - ) : renderedRightPanelSurface?.kind === "pull-request" ? ( + ) : surface.kind === "pull-request" ? ( // No onClose: the surface tab's own X owns closing here, and a second X in the header // would be the same action twice. The thread context also drops the checkout button, so it // is only right for the thread's own pull request, whose branch is already under the @@ -11135,10 +11177,8 @@ export default function ChatView(props: ChatViewProps) { // checkable out like it is anywhere else. { if (activeThreadRef) @@ -11151,10 +11191,10 @@ export default function ChatView(props: ChatViewProps) { }} threadRef={activeThreadRef} reference={{ - projectId: renderedRightPanelSurface.projectId as ProjectId, - ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), - repository: renderedRightPanelSurface.repository, - number: renderedRightPanelSurface.number, + projectId: surface.projectId as ProjectId, + ...(surface.host ? { host: surface.host } : {}), + repository: surface.repository, + number: surface.number, }} context={pullRequestPanelContext( { @@ -11164,7 +11204,7 @@ export default function ChatView(props: ChatViewProps) { branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest, }, - renderedRightPanelSurface, + surface, )} composerDraftTarget={composerDraftTarget} onBack={ @@ -11173,31 +11213,31 @@ export default function ChatView(props: ChatViewProps) { : undefined } /> - ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + ) : surface.kind === "pull-requests" ? ( - ) : renderedRightPanelSurface?.kind === "device" ? ( + ) : surface.kind === "device" ? ( { - closeRightPanelSurface(renderedRightPanelSurface); + closeRightPanelSurface(surface); useRightPanelStore.getState().show(activeThreadRef); }} /> - ) : (renderedRightPanelSurface?.kind === "files" || - renderedRightPanelSurface?.kind === "file") && + ) : (surface.kind === "files" || surface.kind === "file") && ((activeProject && activeWorkspaceRoot) || - (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? ( + (surface.kind === "file" && surface.attachment)) ? ( - ) : null - ) : null; + ) : null; + }; + const rightPanelContent = renderedRightPanelSurface + ? renderRightPanelSurface(renderedRightPanelSurface, { + active: activeRightPanelSurface?.id === renderedRightPanelSurface.id, + visible: true, + unclipped: true, + }) + : null; + const inlineRightPanelBody = + rightPanelLayout === "columns" ? ( + + ) : ( + rightPanelContent + ); const threadDetailsPanelProps: ThreadDetailsPanelProps = { anchor: threadPanelPopoverAnchorRef, handle: threadPanelPopoverHandle, @@ -12072,7 +12116,7 @@ export default function ChatView(props: ChatViewProps) { pullRequestsAvailable={pullRequestsSurfaceAvailable} deviceAvailable={activeThreadRef !== null} > - {rightPanelContent} + {inlineRightPanelBody} ) : null} {rightPanelPresent && shouldUsePlanSidebarSheet && activeThreadRef ? ( diff --git a/apps/web/src/components/RightPanelColumns.tsx b/apps/web/src/components/RightPanelColumns.tsx new file mode 100644 index 0000000000000..89feec9fadba9 --- /dev/null +++ b/apps/web/src/components/RightPanelColumns.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import type { RightPanelSurface } from "../rightPanelStore"; + +export interface RightPanelColumnView { + /** The surface the tab bar has selected; it owns focus and keyboard shortcuts. */ + active: boolean; + /** Whether any part of the column is inside the strip. */ + visible: boolean; + /** + * Whether the whole column is inside the strip. Native browser views draw + * above the DOM and ignore its clipping, so they only show when this is true. + */ + unclipped: boolean; +} + +// A column counts as fully shown once this much of it is inside the strip. +const FULLY_VISIBLE_RATIO = 0.99; + +/** + * Lays every open right panel surface out side by side on a horizontally + * scrolling strip. The tab bar stays the overview: selecting a tab scrolls its + * column into view, and pressing inside a column selects its tab. Focus alone + * does not select: selecting a terminal focuses it, which would pull focus off + * whatever control the keyboard reached, and panels that focus themselves would + * take turns selecting. + */ +export function RightPanelColumns(props: { + surfaces: readonly RightPanelSurface[]; + activeSurfaceId: string | null; + /** Changes whenever a surface is selected, even the current one, to scroll it into view. */ + revealRequestId: number; + onActivate: (surface: RightPanelSurface) => void; + renderSurface: (surface: RightPanelSurface, view: RightPanelColumnView) => ReactNode; +}) { + const { surfaces, activeSurfaceId, revealRequestId, onActivate, renderSurface } = props; + const stripRef = useRef(null); + const columnRefs = useRef(new Map()); + const [visibility, setVisibility] = useState>( + () => new Map(), + ); + const surfaceIdsKey = surfaces.map((surface) => surface.id).join("\n"); + + useEffect(() => { + const strip = stripRef.current; + if (!strip) return; + // Forget removed columns, so one reopened under the same id starts hidden + // until the observer measures it. + const ids = new Set(surfaceIdsKey.split("\n")); + setVisibility((current) => + [...current.keys()].every((id) => ids.has(id)) + ? current + : new Map([...current].filter(([id]) => ids.has(id))), + ); + const observer = new IntersectionObserver( + (entries) => { + setVisibility((current) => { + const next = new Map(current); + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.rightPanelColumn; + if (!id) continue; + if (entry.intersectionRatio >= FULLY_VISIBLE_RATIO) next.set(id, "full"); + else if (entry.isIntersecting) next.set(id, "partial"); + else next.delete(id); + } + if ( + next.size === current.size && + [...next].every(([id, value]) => current.get(id) === value) + ) { + return current; + } + return next; + }); + }, + { root: strip, threshold: [0, FULLY_VISIBLE_RATIO] }, + ); + for (const id of ids) { + const column = columnRefs.current.get(id); + if (column) observer.observe(column); + } + return () => observer.disconnect(); + }, [surfaceIdsKey]); + + useEffect(() => { + if (!activeSurfaceId) return; + const column = columnRefs.current.get(activeSurfaceId); + if (!column) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + column.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: reduceMotion ? "auto" : "smooth", + }); + }, [activeSurfaceId, revealRequestId]); + + return ( +
+ {surfaces.map((surface) => { + const active = surface.id === activeSurfaceId; + return ( +
{ + if (element) columnRefs.current.set(surface.id, element); + else columnRefs.current.delete(surface.id); + }} + data-right-panel-column={surface.id} + data-active-column={active} + className={cn( + // One surface fills the panel; more share it in halves, never + // narrower than a usable column, and scroll past the edge. + "relative flex min-h-0 shrink-0 snap-start flex-col border-l border-border first:border-l-0", + surfaces.length === 1 ? "w-full" : "w-[max(50%,min(100%,26rem))]", + )} + onPointerDownCapture={() => { + if (!active) onActivate(surface); + }} + > + {surfaces.length > 1 ? ( + + ) : null} + {renderSurface(surface, { + active, + visible: visibility.has(surface.id), + unclipped: visibility.get(surface.id) === "full", + })} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index f6509289d45ae..abe3c674f9a0f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -350,6 +350,8 @@ interface TerminalViewportProps { focusRequestId: number; autoFocus: boolean; visible: boolean; + /** Scrolled out of view: stops painting without counting as hidden for focus. */ + offscreen?: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; @@ -376,13 +378,16 @@ export function TerminalViewport({ focusRequestId, autoFocus, visible, + offscreen = false, resizeEpoch, drawerHeight, keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); - const visibleRef = useRef(visible); + const painting = visible && !offscreen; + const visibleRef = useRef(painting); + const pendingFocusRef = useRef(false); const environmentId = threadRef.environmentId; const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const hasTerminalWriteAccess = useEffectEvent(() => @@ -526,9 +531,13 @@ export function TerminalViewport({ }, [resizeSessionGeneration]); useLayoutEffect(() => { - visibleRef.current = visible; - terminalRef.current?.setVisible(visible); - }, [visible]); + visibleRef.current = painting; + terminalRef.current?.setVisible(painting); + if (painting && pendingFocusRef.current) { + pendingFocusRef.current = false; + terminalRef.current?.focus(); + } + }, [painting]); useEffect(() => { const current = terminalFontRef.current; @@ -1036,10 +1045,20 @@ export function TerminalViewport({ }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); useEffect(() => { + pendingFocusRef.current = false; if (!autoFocus || !canOperateTerminal || !visible) return; // Claim focus when requested, then hand it to the terminal once ready only // if the user has not focused something else in the meantime. (terminalRef.current ?? containerRef.current)?.focus(); + if (visibleRef.current) return; + // Scrolled out of view, the surface refuses focus until it paints again; + // keep the request until then unless the user focuses something else. + pendingFocusRef.current = true; + const cancel = (event: FocusEvent) => { + if (!containerRef.current?.contains(event.target as Node)) pendingFocusRef.current = false; + }; + document.addEventListener("focusin", cancel); + return () => document.removeEventListener("focusin", cancel); }, [autoFocus, canOperateTerminal, focusRequestId, visible]); useEffect(() => { @@ -1082,6 +1101,10 @@ interface ThreadTerminalDrawerProps { terminalGroups: ThreadTerminalGroup[]; activeTerminalGroupId: string; focusRequestId: number; + /** Off for a panel shown beside others, so it only takes focus once selected. */ + autoFocus?: boolean; + /** A panel column scrolled out of view; pauses painting only. */ + offscreen?: boolean; onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; @@ -1158,6 +1181,8 @@ export default function ThreadTerminalDrawer({ terminalGroups, activeTerminalGroupId, focusRequestId, + autoFocus = true, + offscreen = false, onSplitTerminal, onSplitTerminalVertical, onNewTerminal, @@ -1677,8 +1702,9 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} - autoFocus={terminalId === resolvedActiveTerminalId} + autoFocus={autoFocus && terminalId === resolvedActiveTerminalId} visible={visible} + offscreen={offscreen} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} keybindings={keybindings} @@ -1707,8 +1733,9 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} - autoFocus + autoFocus={autoFocus} visible={visible} + offscreen={offscreen} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} keybindings={keybindings} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 7e6804043f440..00147efae1bd9 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -36,6 +36,8 @@ export function DevicePanel(props: { readonly threadRef: ScopedThreadRef; readonly surface: Extract; readonly visible: boolean; + /** Whether this surface is the selected one; only it may raise the setup dialog. */ + readonly selected?: boolean; readonly onDismissSetup: () => void; }) { const { environmentId, threadId } = props.threadRef; @@ -153,7 +155,7 @@ export function DevicePanel(props: { if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( { if (!isOpen) props.onDismissSetup(); }} diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index 81501ae7d7273..e9d5f9d2d15d2 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -19,6 +19,7 @@ interface Props { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; visible: boolean; + shortcutsEnabled?: boolean; onSendAnnotation?: ( annotation: PreviewAnnotationPayload, image: ComposerImageAttachment | null, @@ -31,6 +32,7 @@ export function PreviewPanel({ tabId, configuredUrls, visible, + shortcutsEnabled, onSendAnnotation, }: Props) { const available = usePreviewAvailable(threadRef.environmentId); @@ -56,6 +58,7 @@ export function PreviewPanel({ {...(tabId !== undefined ? { tabId } : {})} configuredUrls={configuredUrls} visible={visible} + {...(shortcutsEnabled !== undefined ? { shortcutsEnabled } : {})} {...(onSendAnnotation ? { onSendAnnotation } : {})} /> diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 161fc4c888c87..2f7406040f903 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -98,6 +98,8 @@ interface Props { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; visible: boolean; + /** Whether toolbar shortcuts reach this view. Defaults to `visible`. */ + shortcutsEnabled?: boolean; onSendAnnotation?: ( annotation: PreviewAnnotationPayload, image: ComposerImageAttachment | null, @@ -147,6 +149,7 @@ export function PreviewView({ tabId: requestedTabId, configuredUrls, visible, + shortcutsEnabled = visible, onSendAnnotation, }: Props) { const [focusUrlNonce, setFocusUrlNonce] = useState(undefined); @@ -934,10 +937,10 @@ export function PreviewView({ }; }, [runtimeTabId]); - // Subscribe only while visible; `toggle-panel` is owned by ChatView's + // Subscribe only while enabled; `toggle-panel` is owned by ChatView's // URL-aware handler regardless of whether the panel is currently mounted. useEffect(() => { - if (!visible) return; + if (!shortcutsEnabled) return; return subscribePreviewAction((action) => { switch (action) { case "refresh": @@ -959,7 +962,7 @@ export function PreviewView({ return; } }); - }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]); + }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, shortcutsEnabled]); return (
= { full: "Full", }; +const RIGHT_PANEL_LAYOUT_LABELS: Record = { + tabs: "Tabs", + columns: "Scrolling columns", +}; + const DIFF_LAYOUT_LABELS: Record = { stacked: "Stacked", split: "Split", @@ -548,6 +554,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Diff colors"] : []), ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []), + ...(settings.rightPanelLayout !== DEFAULT_UNIFIED_SETTINGS.rightPanelLayout + ? ["Right panel layout"] + : []), ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ["Panel animations"] : []), @@ -675,6 +684,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.appearanceContrast, settings.diffColorScheme, settings.chatWidth, + settings.rightPanelLayout, settings.enableAgentBrowserAccess, settings.confirmQuit, settings.confirmThreadArchive, @@ -792,6 +802,7 @@ export function useSettingsRestore(onRestored?: () => void) { appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth, + rightPanelLayout: DEFAULT_UNIFIED_SETTINGS.rightPanelLayout, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled, @@ -1457,6 +1468,40 @@ export function AppearanceSettingsPanel() {
} /> + + + updateSettings({ rightPanelLayout: DEFAULT_UNIFIED_SETTINGS.rightPanelLayout }) + } + /> + ) : null + } + control={ +
+ +
+ } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0ee55ef3785b4..509046dfb9fe9 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -276,6 +276,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["wide full width column layout messages composer monitor"], }, + { + id: "right-panel-layout", + title: "Right panel layout", + to: "/settings/appearance", + searchTerms: ["tabs columns side by side horizontal scrolling niri paperwm strip surfaces"], + }, { id: "panel-animations", title: "Panel animations", diff --git a/docs/user/appearance.md b/docs/user/appearance.md index ec02a28ed787a..163b5e4a00301 100644 --- a/docs/user/appearance.md +++ b/docs/user/appearance.md @@ -29,6 +29,15 @@ The controls retreat as the composer docks after you send the first message. Turn on **Composer context** to keep those controls visible after the thread starts. This preference applies to the web and desktop clients. +## Right panel layout + +The right panel shows one surface at a time behind tabs. Set **Right panel layout** to +**Scrolling columns** to place every open surface side by side instead, so you can keep a diff, +terminal, and browser in view together. Scroll sideways to move between columns; selecting a tab +brings its column into view. A browser column shows its page only while the whole column is in +view. On narrow windows the panel opens as a sheet and keeps tabs. This preference applies to the +web and desktop clients. + ## Motion The main sidebar, right panel, and terminal drawer open and close immediately by default. Move the diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index deafcf507165a..fecf18bda101a 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -597,6 +597,16 @@ describe("ClientSettings panel animations", () => { }); }); +describe("ClientSettings right panel layout", () => { + it("keeps tabs unless columns are chosen", () => { + expect(decodeClientSettings({}).rightPanelLayout).toBe("tabs"); + expect(decodeClientSettingsPatch({ rightPanelLayout: "columns" })).toEqual({ + rightPanelLayout: "columns", + }); + expect(() => decodeClientSettingsPatch({ rightPanelLayout: "grid" })).toThrow(); + }); +}); + describe("ClientSettings environment identification", () => { it("defaults to artwork and accepts each presentation mode", () => { expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d3fd53e31f12..92203e746f58f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -318,6 +318,13 @@ export type ModelPreferences = { readonly providerModelPreferences: typeof ProviderModelPreferences.Type; }; +/** + * How the right panel shows its open surfaces: one at a time behind tabs, or + * side by side as columns on a horizontally scrolling strip. + */ +export const RightPanelLayout = Schema.Literals(["tabs", "columns"]); +export type RightPanelLayout = typeof RightPanelLayout.Type; + export const ClientSettingsSchema = Schema.Struct({ notificationMode: NotificationMode.pipe( Schema.withDecodingDefault(Effect.succeed("off" as const)), @@ -327,6 +334,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("red-green" as const)), ), chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))), + rightPanelLayout: RightPanelLayout.pipe( + Schema.withDecodingDefault(Effect.succeed("tabs" as const)), + ), loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( @@ -1668,6 +1678,7 @@ export const ClientSettingsPatch = Schema.Struct({ inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), diffColorScheme: Schema.optionalKey(DiffColorScheme), chatWidth: Schema.optionalKey(ChatWidth), + rightPanelLayout: Schema.optionalKey(RightPanelLayout), loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), From 5d8638a8ade717a5fe7d704e3ec0d599b57b1105 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:25:49 +1100 Subject: [PATCH 02/10] feat(web): put the chat in the scrolling columns strip Scrolling columns no longer keep a separate side panel. The chat is the first column of one horizontally scrolling strip, followed by a column per open surface, each with its own one-tab header. Narrow windows keep the tabs sheet. - The chat column keeps its place in the tree, so switching layouts or opening the panel does not remount it. - The chat's terminal drawer pauses painting while the chat is scrolled away, and drawer actions scroll the chat back into view. The drawer and panel terminals take separate focus requests. - The floating player shows only while the chat is wholly in view. - Column headers keep bulk close actions and desktop window dragging. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 1479 +++++++++-------- apps/web/src/components/RightPanelColumns.tsx | 182 +- apps/web/src/components/RightPanelTabs.tsx | 39 +- .../src/components/ThreadTerminalDrawer.tsx | 11 +- .../components/settings/SettingsPanels.tsx | 2 +- docs/user/appearance.md | 10 +- 6 files changed, 944 insertions(+), 779 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0c92f707e12ce..4b6c453803d82 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -286,7 +286,11 @@ import { import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; -import { RightPanelColumns, type RightPanelColumnView } from "./RightPanelColumns"; +import { + type ChatColumnView, + RightPanelColumns, + type RightPanelColumnView, +} from "./RightPanelColumns"; import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; @@ -978,12 +982,15 @@ interface PersistentThreadTerminalDrawerProps { closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; + /** Pauses painting while the chat column is scrolled out of view. */ + offscreen: boolean; } const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ threadRef, threadId, active, + offscreen, launchContext, focusRequestId, splitShortcutLabel, @@ -1341,6 +1348,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra worktreePath={effectiveWorktreePath} runtimeEnv={runtimeEnv} visible={visible} + offscreen={offscreen} height={terminalUiState.terminalHeight} // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. terminalIds={canOperateTerminal ? terminalUiState.terminalIds : serverOrderedTerminalIds} @@ -1948,7 +1956,17 @@ export default function ChatView(props: ChatViewProps) { // Tracks whether the user explicitly dismissed the sidebar for the active turn. // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. + // Panel terminals and the drawer under the chat take separate focus + // requests: with both on screen, one must not take the other's focus. const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const [drawerTerminalFocusRequestId, setDrawerTerminalFocusRequestId] = useState(0); + // Bumped when the drawer is opened or asked for focus, so scrolling columns + // bring the chat column back into view first. + const [chatRevealRequestId, setChatRevealRequestId] = useState(0); + const requestDrawerTerminalFocus = useCallback(() => { + setDrawerTerminalFocusRequestId((value) => value + 1); + setChatRevealRequestId((value) => value + 1); + }, []); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = @@ -2393,11 +2411,24 @@ export default function ChatView(props: ChatViewProps) { const rightPanelControlsAtRoot = rightPanelPresent && !shouldUsePlanSidebarSheet; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; - const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.source ?? null, - renderedRightPanelSurface, - ); - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + // Scrolling columns put the chat and the panel surfaces on one strip, so the + // panel has no separate width to maximize. Narrow windows keep the sheet. + const rightPanelColumnsLayout = rightPanelLayout === "columns" && !shouldUsePlanSidebarSheet; + const [chatColumnView, setChatColumnView] = useState({ + visible: true, + unclipped: true, + }); + // The floating player's native view ignores DOM clipping, so it only shows + // while the chat column it floats over is wholly in view. While hidden, a + // column showing the same source takes the view back. + const previewMiniPlayerVisible = + shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.source ?? null, + renderedRightPanelSurface, + ) && + (!rightPanelColumnsLayout || chatColumnView.unclipped); + const canMaximizeRightPanel = + rightPanelOpen && !shouldUsePlanSidebarSheet && !rightPanelColumnsLayout; const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const [threadPanelPresentation, setThreadPanelPresentation] = @@ -4875,7 +4906,7 @@ export default function ChatView(props: ChatViewProps) { } else { storeSplitTerminal(activeThreadRef, terminalId); } - setTerminalFocusRequestId((value) => value + 1); + requestDrawerTerminalFocus(); void openTerminal({ environmentId, input: { @@ -4891,6 +4922,7 @@ export default function ChatView(props: ChatViewProps) { }); }, [ + requestDrawerTerminalFocus, activeProject, activeThreadId, allocateTerminalId, @@ -4915,7 +4947,7 @@ export default function ChatView(props: ChatViewProps) { } const terminalId = allocateTerminalId(); storeNewTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); + requestDrawerTerminalFocus(); void openTerminal({ environmentId, input: { @@ -4930,6 +4962,7 @@ export default function ChatView(props: ChatViewProps) { }, }); }, [ + requestDrawerTerminalFocus, activeProject, activeThreadId, allocateTerminalId, @@ -4967,9 +5000,10 @@ export default function ChatView(props: ChatViewProps) { } })(); storeCloseTerminal(activeThreadRef, terminalId); - setTerminalFocusRequestId((value) => value + 1); + requestDrawerTerminalFocus(); }, [ + requestDrawerTerminalFocus, activeThreadId, activeThreadRef, closeTerminalMutation, @@ -5017,7 +5051,7 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef) { return; } - setTerminalFocusRequestId((value) => value + 1); + requestDrawerTerminalFocus(); const runtimeEnv = projectScriptRuntimeEnv({ project: { @@ -5101,6 +5135,7 @@ export default function ChatView(props: ChatViewProps) { } }, [ + requestDrawerTerminalFocus, activeProject, activeThread, activeThreadId, @@ -5579,6 +5614,9 @@ export default function ChatView(props: ChatViewProps) { ); if (surface && requested?.force === true) { useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); + // A forced reveal of the already selected surface must still scroll + // its column back into view. + setRightPanelRevealRequestId((value) => value + 1); } else if (!surface) { usePreviewMiniPlayerStore .getState() @@ -8000,12 +8038,16 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!activeThreadKey) return; + const seen = activeThreadKey in terminalUiOpenByThreadRef.current; const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; const current = Boolean(terminalUiState.terminalOpen); if (!previous && current) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; - setTerminalFocusRequestId((value) => value + 1); + // A drawer restored open on a thread's first visit is not a request to + // see it, so the selected column keeps its place in the strip. + if (seen) requestDrawerTerminalFocus(); + else setDrawerTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; @@ -8018,7 +8060,7 @@ export default function ChatView(props: ChatViewProps) { } terminalUiOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + }, [activeThreadKey, focusComposer, requestDrawerTerminalFocus, terminalUiState.terminalOpen]); const getShortcutContext = useCallback( (eventTarget: EventTarget | null = document.activeElement) => ({ @@ -11102,6 +11144,12 @@ export default function ChatView(props: ChatViewProps) { return ; } + // Whether the floating player actually renders: browser sources also need + // preview access. Columns use the same answer to decide who owns the view. + const previewMiniPlayerShown = + previewMiniPlayerVisible && + activePreviewMiniPlayer !== null && + (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview); const renderRightPanelSurface = ( surface: RightPanelSurface, { active, visible, unclipped }: RightPanelColumnView, @@ -11109,7 +11157,7 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef) return null; // A source showing in the floating player stays there; one native view has one owner. const floating = - previewMiniPlayerVisible && + previewMiniPlayerShown && activePreviewMiniPlayer !== null && surfaceShowsPreviewSource(activePreviewMiniPlayer.source, surface); return floating ? ( @@ -11268,18 +11316,60 @@ export default function ChatView(props: ChatViewProps) { unclipped: true, }) : null; - const inlineRightPanelBody = - rightPanelLayout === "columns" ? ( - - ) : ( - rightPanelContent - ); + const rightPanelTabsProps = { + keybindings, + getShortcutContext, + environmentId: activeThreadRef?.environmentId ?? null, + pendingSurfaceIds: pendingFileSurfaceIds, + previewSessions: activePreviewState.sessions, + desktopByTabId: activePreviewState.desktopByTabId, + previewRuntimeTabId: resolvePreviewRuntimeTabId, + terminalLabelsById: activeTerminalLabelsById, + onActivate: activateRightPanelSurface, + onCloseSurface: closeRightPanelSurface, + onRenameDevice: (surfaceId: string, title: string) => { + if (activeThreadRef) + useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); + }, + onCloseOtherSurfaces: closeOtherRightPanelSurfaces, + onCloseSurfacesToRight: closeRightPanelSurfacesToRight, + onCloseAllSurfaces: closeAllRightPanelSurfaces, + onCopyFilePath: copyRightPanelFilePath, + onAddBrowser: () => createBrowserSurface(), + onAddBrowserInProfile: createBrowserSurface, + onAddTerminal: addTerminalSurface, + onAddDiff: addDiffSurface, + onAddFiles: addFilesSurface, + onAddPullRequest: addPullRequestSurface, + onAddPullRequests: addPullRequestsSurface, + onAddDevice: addDeviceSurface, + browserAvailable: canOperatePreview && browserAvailable, + terminalAvailable: activeProject !== null && canOperateTerminal, + diffAvailable: isServerThread && isGitRepo, + filesAvailable: activeProject !== null, + pullRequestAvailable: pullRequestSurfaceAvailable, + pullRequestsAvailable: pullRequestsSurfaceAvailable, + deviceAvailable: activeThreadRef !== null, + }; + // In scrolling columns each surface is a column with its own one-tab header, + // so closing, renaming, muting and the tab menu work the same as in tabs. + const columnSurfaces = rightPanelOpen && activeThreadRef ? rightPanelState.surfaces : []; + const lastColumnSurfaceId = columnSurfaces.at(-1)?.id; + const renderRightPanelColumn = (surface: RightPanelSurface, view: RightPanelColumnView) => ( + + {renderRightPanelSurface(surface, view)} + + ); const threadDetailsPanelProps: ThreadDetailsPanelProps = { anchor: threadPanelPopoverAnchorRef, handle: threadPanelPopoverHandle, @@ -11370,10 +11460,13 @@ export default function ChatView(props: ChatViewProps) { // Keep one viewport anchor inside the header's no-drag region. The // header can shrink behind the right panel without moving the controls. "pointer-events-none fixed top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]", + // Scrolling column headers pass beneath the controls; an opaque + // backing keeps them from showing through. + rightPanelColumnsLayout && rightPanelOpen && "bg-background pl-1", )} data-workspace-titlebar-controls > - {!shouldUsePlanSidebarSheet ? ( + {!shouldUsePlanSidebarSheet && !rightPanelColumnsLayout ? (
{rightPanelControlsAtRoot ? panelLayoutControls : null} -
+ {null} + + ) : undefined + } > - {/* Top bar */} -
- {isElectron && rightPanelControlsAtRoot ? ( - - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - -
- - {/* Main content area with optional plan sidebar */} -
- {/* Chat column */} - - - {isWorkspaceFileDragActive ? ( -
-
-
-
- ) : null} - {/* Banners overlay the timeline without changing its content height. */} -
- setDismissedProviderStatusBannerKey(providerStatusBannerKey)} - onOpenProviderSetup={openProviderSetup} - /> - { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} - /> -
- {/* Messages Wrapper */} -
- {/* Messages — LegendList handles virtualization and scrolling internally */} - {} : onForkFromRun} - onRollbackCheckpoint={onTimelineRollbackCheckpoint} - supportsConversationRollback={ - !paintOnlyDisplayedTimeline && supportsConversationRollback - } - onRevertToTurnCount={ - paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn - } - {...(!paintOnlyDisplayedTimeline - ? { onUseArtifactTemplate: useArtifactTemplate, onSendAppMessage: sendAppMessage } - : {})} - isRevertingCheckpoint={isRevertingCheckpoint} - onImageExpand={onExpandTimelineImage} - onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment} - onFileDownload={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment - } - markdownCwd={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.markdownCwd ?? undefined) - : (gitCwd ?? undefined) - } - resolvedTheme={resolvedTheme} - timestampFormat={timestampFormat} - workspaceRoot={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.workspaceRoot ?? undefined) - : activeWorkspaceRoot - } - skills={timelineSkills} - anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} - onAnchorReady={onTimelineAnchorReady} - onAnchorSizeChanged={onTimelineAnchorSizeChanged} - contentInsetEndAdjustment={composerTimelineInset} - liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} - onIsAtEndChange={onIsAtEndChange} - onContentOverflowChange={setTimelineOverflows} - onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} - onManualNavigation={cancelTimelineLiveFollowForUserNavigation} - cancelPositionRestoreRef={cancelPositionRestoreRef} - hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} - topFadeEnabled={!hasTimelineTopBanner} - {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined - ? {} - : { historyControls: threadHistoryControls })} + {isElectron && rightPanelControlsAtRoot ? ( + - - {/* scroll to end pill — shown when user has scrolled away from the live edge */} - {showScrollToBottom && !isThreadFindActive && ( + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} + + + + {/* Main content area with optional plan sidebar */} +
+ {/* Chat column */} + + + {isWorkspaceFileDragActive ? (
- +
- )} -
- - {/* Input bar — centered for an empty draft, docked after sending. */} -
+ ) : null} + {/* Banners overlay the timeline without changing its content height. */} +
+ setDismissedProviderStatusBannerKey(providerStatusBannerKey)} + onOpenProviderSetup={openProviderSetup} + /> + { + setThreadError(activeThread.id, null); + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + }} + /> +
+ {/* Messages Wrapper */} +
+ {/* Messages — LegendList handles virtualization and scrolling internally */} + {} : onForkFromRun} + onRollbackCheckpoint={onTimelineRollbackCheckpoint} + supportsConversationRollback={ + !paintOnlyDisplayedTimeline && supportsConversationRollback + } + onRevertToTurnCount={ + paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn + } + {...(!paintOnlyDisplayedTimeline + ? { + onUseArtifactTemplate: useArtifactTemplate, + onSendAppMessage: sendAppMessage, + } + : {})} + isRevertingCheckpoint={isRevertingCheckpoint} + onImageExpand={onExpandTimelineImage} + onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment} + onFileDownload={ + paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment + } + markdownCwd={ + paintOnlyDisplayedTimeline + ? (heldPaintContext?.markdownCwd ?? undefined) + : (gitCwd ?? undefined) + } + resolvedTheme={resolvedTheme} + timestampFormat={timestampFormat} + workspaceRoot={ + paintOnlyDisplayedTimeline + ? (heldPaintContext?.workspaceRoot ?? undefined) + : activeWorkspaceRoot + } + skills={timelineSkills} + anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} + onAnchorReady={onTimelineAnchorReady} + onAnchorSizeChanged={onTimelineAnchorSizeChanged} + contentInsetEndAdjustment={composerTimelineInset} + liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + onIsAtEndChange={onIsAtEndChange} + onContentOverflowChange={setTimelineOverflows} + onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} + onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + cancelPositionRestoreRef={cancelPositionRestoreRef} + hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} + topFadeEnabled={!hasTimelineTopBanner} + {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined + ? {} + : { historyControls: threadHistoryControls })} + /> + + {/* scroll to end pill — shown when user has scrolled away from the live edge */} + {showScrollToBottom && !isThreadFindActive && ( +
+ +
+ )} +
+ + {/* Input bar — centered for an empty draft, docked after sending. */}
- {isDraftHeroState ? ( -
-
- -
-
- ) : null}
- +
+ +
+
+ ) : null} +
- -
- {showProviderSubagentBar ? ( - onOpenRelatedThread(parentThreadLink.threadId) - : null - } - /> - ) : null} - {!composerMounted ? null : ( - - )} -
-
-
-
- {mountComposerModelStrip ? ( - -
+ {showProviderSubagentBar ? ( + onOpenRelatedThread(parentThreadLink.threadId) + : null + } /> - - ) : null} - {mountComposerContextStrip && ( -
- -
- )} + )} +
+ +
+
+ {mountComposerModelStrip ? ( + +
+ + ) : null} + {mountComposerContextStrip && ( +
+ +
+ )} +
-
- -
+ +
+
-
- {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? ( - - ) : null} - {activeThreadRef && - activePreviewMiniPlayer && - previewMiniPlayerVisible && - (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? ( - - ) : null} - - - - - - Switch to{" "} - - {localCheckoutBranchMismatch?.threadBranch ?? ""} - - ? - - - You have uncommitted changes. They'll carry over to the other branch, or block - the switch if they conflict. - - - - }>Cancel - - - - - - - - {pullRequestDialogState ? ( - { - if (!open) { - closePullRequestDialog(); - } - }} - onPrepared={handlePreparedPullRequestThread} + {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? ( + + ) : null} + {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerShown ? ( + + ) : null} + + + + + + Switch to{" "} + + {localCheckoutBranchMismatch?.threadBranch ?? ""} + + ? + + + You have uncommitted changes. They'll carry over to the other branch, or block + the switch if they conflict. + + + + }> + Cancel + + + + + + + + + {pullRequestDialogState ? ( + { + if (!open) { + closePullRequestDialog(); + } + }} + onPrepared={handlePreparedPullRequestThread} + /> + ) : null} + + {/* end chat column */} +
+ {/* end horizontal flex container */} + + {mountedTerminalThreadRefs.map( + ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( + - ) : null} - - {/* end chat column */} + ), + )}
- {/* end horizontal flex container */} - - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} -
+ - {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? ( + {rightPanelPresent && + !shouldUsePlanSidebarSheet && + !rightPanelColumnsLayout && + activeThreadRef ? ( { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfaces} - onCloseSurfacesToRight={closeRightPanelSurfacesToRight} - onCloseAllSurfaces={closeAllRightPanelSurfaces} + // Only a bar holding every surface can reorder them; column headers hold one. onMoveSurface={moveRightPanelSurface} - onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} + {...rightPanelTabsProps} > - {inlineRightPanelBody} + {rightPanelContent} ) : null} {rightPanelPresent && shouldUsePlanSidebarSheet && activeThreadRef ? ( @@ -12128,8 +12233,6 @@ export default function ChatView(props: ChatViewProps) { { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfaces} - onCloseSurfacesToRight={closeRightPanelSurfacesToRight} - onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} - onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} + {...rightPanelTabsProps} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelColumns.tsx b/apps/web/src/components/RightPanelColumns.tsx index 89feec9fadba9..4d038a8513ada 100644 --- a/apps/web/src/components/RightPanelColumns.tsx +++ b/apps/web/src/components/RightPanelColumns.tsx @@ -4,7 +4,7 @@ import { cn } from "~/lib/utils"; import type { RightPanelSurface } from "../rightPanelStore"; export interface RightPanelColumnView { - /** The surface the tab bar has selected; it owns focus and keyboard shortcuts. */ + /** The selected surface; it owns focus and keyboard shortcuts. */ active: boolean; /** Whether any part of the column is inside the strip. */ visible: boolean; @@ -15,39 +15,65 @@ export interface RightPanelColumnView { unclipped: boolean; } -// A column counts as fully shown once this much of it is inside the strip. +// A column counts as shown once this much of it is inside the strip, and as +// fully shown past the second ratio. The first is above zero because a column +// that only touches the strip's edge still reports as intersecting. +const VISIBLE_RATIO = 0.01; const FULLY_VISIBLE_RATIO = 0.99; +// Visibility key for the chat column; surface ids never contain a newline. +const CHAT_COLUMN_KEY = "\nchat"; + +export interface ChatColumnView { + visible: boolean; + unclipped: boolean; +} /** - * Lays every open right panel surface out side by side on a horizontally - * scrolling strip. The tab bar stays the overview: selecting a tab scrolls its - * column into view, and pressing inside a column selects its tab. Focus alone - * does not select: selecting a terminal focuses it, which would pull focus off + * Lays the chat (`children`) and every open right panel surface out side by + * side on one horizontally scrolling strip. Selecting a surface scrolls its + * column into view, and pressing inside a column selects it. Focus alone does + * not select: selecting a terminal focuses it, which would pull focus off * whatever control the keyboard reached, and panels that focus themselves would * take turns selecting. + * + * When `enabled` is false the strip and chat wrapper render as `display: + * contents`, so the chat keeps its place in the tree across layout changes and + * is not remounted. */ export function RightPanelColumns(props: { + enabled: boolean; surfaces: readonly RightPanelSurface[]; activeSurfaceId: string | null; /** Changes whenever a surface is selected, even the current one, to scroll it into view. */ revealRequestId: number; onActivate: (surface: RightPanelSurface) => void; renderSurface: (surface: RightPanelSurface, view: RightPanelColumnView) => ReactNode; + /** Shown as the only panel column when the panel is open without surfaces. */ + emptyColumn?: ReactNode; + /** Reports when the chat column scrolls in or out of view, so it can pause offscreen work. */ + onChatViewChange?: (view: ChatColumnView) => void; + /** Changes when something in the chat column, such as its terminal drawer, needs to be seen. */ + chatRevealRequestId?: number; + children: ReactNode; }) { - const { surfaces, activeSurfaceId, revealRequestId, onActivate, renderSurface } = props; + const { enabled, surfaces, activeSurfaceId, revealRequestId, onActivate, renderSurface } = props; const stripRef = useRef(null); + const chatColumnRef = useRef(null); const columnRefs = useRef(new Map()); + // The chat starts at the strip's left edge, so it starts fully shown. const [visibility, setVisibility] = useState>( - () => new Map(), + () => new Map([[CHAT_COLUMN_KEY, "full"]]), ); const surfaceIdsKey = surfaces.map((surface) => surface.id).join("\n"); + const emptyColumn = surfaces.length === 0 ? (props.emptyColumn ?? null) : null; + const hasPanelColumns = enabled && (surfaces.length > 0 || emptyColumn !== null); useEffect(() => { const strip = stripRef.current; - if (!strip) return; + if (!strip || !enabled) return; // Forget removed columns, so one reopened under the same id starts hidden // until the observer measures it. - const ids = new Set(surfaceIdsKey.split("\n")); + const ids = new Set([CHAT_COLUMN_KEY, ...surfaceIdsKey.split("\n")]); setVisibility((current) => [...current.keys()].every((id) => ids.has(id)) ? current @@ -61,7 +87,7 @@ export function RightPanelColumns(props: { const id = (entry.target as HTMLElement).dataset.rightPanelColumn; if (!id) continue; if (entry.intersectionRatio >= FULLY_VISIBLE_RATIO) next.set(id, "full"); - else if (entry.isIntersecting) next.set(id, "partial"); + else if (entry.intersectionRatio >= VISIBLE_RATIO) next.set(id, "partial"); else next.delete(id); } if ( @@ -73,17 +99,36 @@ export function RightPanelColumns(props: { return next; }); }, - { root: strip, threshold: [0, FULLY_VISIBLE_RATIO] }, + { root: strip, threshold: [0, VISIBLE_RATIO, FULLY_VISIBLE_RATIO] }, ); + if (chatColumnRef.current) observer.observe(chatColumnRef.current); for (const id of ids) { const column = columnRefs.current.get(id); if (column) observer.observe(column); } return () => observer.disconnect(); - }, [surfaceIdsKey]); + }, [enabled, surfaceIdsKey]); + + const chatVisible = visibility.has(CHAT_COLUMN_KEY); + const chatUnclipped = visibility.get(CHAT_COLUMN_KEY) === "full"; + const onChatViewChange = props.onChatViewChange; + useEffect(() => { + onChatViewChange?.({ visible: chatVisible, unclipped: chatUnclipped }); + }, [chatVisible, chatUnclipped, onChatViewChange]); + + const chatRevealRequestId = props.chatRevealRequestId ?? 0; + useEffect(() => { + if (!enabled || chatRevealRequestId === 0) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + chatColumnRef.current?.scrollIntoView({ + block: "nearest", + inline: "nearest", + behavior: reduceMotion ? "auto" : "smooth", + }); + }, [enabled, chatRevealRequestId]); useEffect(() => { - if (!activeSurfaceId) return; + if (!enabled || !activeSurfaceId) return; const column = columnRefs.current.get(activeSurfaceId); if (!column) return; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; @@ -92,52 +137,79 @@ export function RightPanelColumns(props: { inline: "nearest", behavior: reduceMotion ? "auto" : "smooth", }); - }, [activeSurfaceId, revealRequestId]); + }, [enabled, activeSurfaceId, revealRequestId]); + + // Every column, the chat included, takes half the strip when it shares it, + // never narrower than a usable column, and later ones scroll past the edge. + const columnClassName = + "relative flex min-h-0 w-[max(50%,min(100%,26rem))] shrink-0 snap-start flex-col border-l border-border"; return (
- {surfaces.map((surface) => { - const active = surface.id === activeSurfaceId; - return ( -
{ - if (element) columnRefs.current.set(surface.id, element); - else columnRefs.current.delete(surface.id); - }} - data-right-panel-column={surface.id} - data-active-column={active} - className={cn( - // One surface fills the panel; more share it in halves, never - // narrower than a usable column, and scroll past the edge. - "relative flex min-h-0 shrink-0 snap-start flex-col border-l border-border first:border-l-0", - surfaces.length === 1 ? "w-full" : "w-[max(50%,min(100%,26rem))]", - )} - onPointerDownCapture={() => { - if (!active) onActivate(surface); - }} - > - {surfaces.length > 1 ? ( - - ) : null} - {renderSurface(surface, { - active, - visible: visibility.has(surface.id), - unclipped: visibility.get(surface.id) === "full", - })} -
- ); - })} +
+ {props.children} +
+ {enabled + ? surfaces.map((surface) => { + const active = surface.id === activeSurfaceId; + return ( +
{ + if (element) columnRefs.current.set(surface.id, element); + else columnRefs.current.delete(surface.id); + }} + data-right-panel-column={surface.id} + data-active-column={active} + className={columnClassName} + onPointerDownCapture={() => { + if (!active) onActivate(surface); + }} + > + + {renderSurface(surface, { + active, + visible: visibility.has(surface.id), + unclipped: visibility.get(surface.id) === "full", + })} +
+ ); + }) + : null} + {enabled && emptyColumn !== null ? ( +
{emptyColumn}
+ ) : null}
); } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5c8323a7ae34d..c2bb114599d34 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -151,6 +151,15 @@ interface RightPanelTabsProps { pullRequestsAvailable: boolean; deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; + /** Hides the add-surface button, for scrolling columns where only the last column shows it. */ + showAddSurface?: boolean; + /** + * Every open surface, when `surfaces` holds only this bar's share of them + * (one per scrolling column). The tab menu's bulk close actions use it. + */ + allSurfaces?: readonly RightPanelSurface[]; + /** Makes an embedded tab bar a desktop titlebar drag region, as the inline one is. */ + titleBar?: boolean; children: ReactNode; } @@ -866,7 +875,9 @@ function SortableTab({ } export function RightPanelTabs(props: RightPanelTabsProps) { - const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + const ownsDesktopTitleBar = isElectron && (props.mode === "inline" || props.titleBar === true); + // Only the inline panel sits at the window edge under the native and layout controls. + const reservesTitleBarControls = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); @@ -879,7 +890,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { canScrollRight: false, }); - if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); + // Scrolling columns give every column a tab bar; only the one showing the + // add button owns it and its shortcut, so one press opens one menu. + const ownsAddSurface = props.open !== false && props.showAddSurface !== false; + if (!ownsAddSurface && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => { if (event.defaultPrevented || event.isComposing) return; @@ -898,10 +912,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { } }); useEffect(() => { - if (props.open === false) return; + if (!ownsAddSurface) return; document.addEventListener("keydown", onNewSurfaceKeyDown, true); return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); - }, [props.open]); + }, [ownsAddSurface]); const updateTabScrollState = useCallback(() => { const viewport = tabScrollViewport(tabListRef.current); @@ -1010,7 +1024,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const api = readLocalApi(); if (!api) return; - const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id); + const menuSurfaces = props.allSurfaces ?? props.surfaces; + const surfaceIndex = menuSurfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const items: ContextMenuItem[] = []; @@ -1043,17 +1058,17 @@ export function RightPanelTabs(props: RightPanelTabsProps) { { id: "close-others", label: "Close others", - disabled: props.surfaces.length <= 1, + disabled: menuSurfaces.length <= 1, }, { id: "close-to-right", label: "Close to the right", - disabled: surfaceIndex >= props.surfaces.length - 1, + disabled: surfaceIndex >= menuSurfaces.length - 1, }, { id: "close-all", label: "Close all", - disabled: props.surfaces.length === 0, + disabled: menuSurfaces.length === 0, }, ); @@ -1174,7 +1189,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // controls a few pixels higher and the cluster jumps on open. props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3", ownsDesktopTitleBar && "drag-region", - ownsDesktopTitleBar && "wco:pr-(--workspace-native-controls-inset)", + reservesTitleBarControls && "wco:pr-(--workspace-native-controls-inset)", props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} data-right-panel-tabbar @@ -1325,7 +1340,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); })} - {props.open !== false ? ( + {ownsAddSurface ? ( ) : null} {props.layoutControls} - {ownsDesktopTitleBar && !props.layoutControls ? ( + {reservesTitleBarControls && !props.layoutControls ? ( // Keeps the tabs clear of the window controls when the layout toggles live elsewhere. ) : null} - {ownsDesktopTitleBar ? ( + {reservesTitleBarControls ? ( { visibleRef.current = painting; terminalRef.current?.setVisible(painting); + if (!canOperateTerminal) pendingFocusRef.current = false; if (painting && pendingFocusRef.current) { pendingFocusRef.current = false; terminalRef.current?.focus(); } - }, [painting]); + }, [canOperateTerminal, painting]); useEffect(() => { const current = terminalFontRef.current; @@ -1048,8 +1049,12 @@ export function TerminalViewport({ pendingFocusRef.current = false; if (!autoFocus || !canOperateTerminal || !visible) return; // Claim focus when requested, then hand it to the terminal once ready only - // if the user has not focused something else in the meantime. - (terminalRef.current ?? containerRef.current)?.focus(); + // if the user has not focused something else in the meantime. Claiming + // must not scroll: the drawer can sit in a scrolled-away chat column, and + // revealing it is the caller's decision. + const terminal = terminalRef.current; + if (terminal) terminal.focus(); + else containerRef.current?.focus({ preventScroll: true }); if (visibleRef.current) return; // Scrolled out of view, the surface refuses focus until it paints again; // keep the request until then unless the user focuses something else. diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2b57bc6fa22c4..6b9678298fb1e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1471,7 +1471,7 @@ export function AppearanceSettingsPanel() { Date: Mon, 12 Oct 2026 11:19:35 +1100 Subject: [PATCH 03/10] fix(web): only hand the floating player to a column that shows it In the scrolling columns layout the floating browser or device player hid whenever its source was the selected surface, even after that column had scrolled out of view, leaving the source shown nowhere. It now gives way only to a column that is wholly in view and shows the same source. Co-Authored-By: Claude Opus 5.5 --- .../web/src/components/ChatView.logic.test.ts | 93 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 20 ++++ apps/web/src/components/ChatView.tsx | 31 +++++-- apps/web/src/components/RightPanelColumns.tsx | 16 ++++ 4 files changed, 151 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f870182b1b2d5..a42651169db93 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -62,6 +62,7 @@ import { shouldRetargetThreadPullRequestPanel, shouldOpenProactiveTurnDiff, shouldRenderPreviewMiniPlayer, + shouldRenderPreviewMiniPlayerInColumns, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, @@ -914,6 +915,98 @@ describe("floating browser preview", () => { }); }); +describe("floating preview in scrolling columns", () => { + const browserColumn = { id: "browser:one", kind: "preview", resourceId: "tab-1" } as const; + const deviceColumn = { + id: "device:one", + kind: "device", + target: { hostId: "host-1", deviceId: "device-1", platform: "ios", name: "iPhone" }, + } as const; + const diffColumn = { id: "diff", kind: "diff" } as const; + const surfaces: RightPanelSurface[] = [diffColumn, browserColumn, deviceColumn]; + + it("returns to the floating player when the selected source column scrolls away", () => { + const source = { kind: "browser", tabId: "tab-1" } as const; + // Scrolled over to the selected browser column: the column owns the view. + expect( + shouldRenderPreviewMiniPlayerInColumns({ + source, + chatUnclipped: false, + surfaces, + unclippedSurfaceIds: new Set([browserColumn.id]), + }), + ).toBe(false); + // Back on the chat with the browser column still selected but offscreen. + expect( + shouldRenderPreviewMiniPlayerInColumns({ + source, + chatUnclipped: true, + surfaces, + unclippedSurfaceIds: new Set([diffColumn.id]), + }), + ).toBe(true); + // Chat and the browser column both wholly in view: the column shows it. + expect( + shouldRenderPreviewMiniPlayerInColumns({ + source, + chatUnclipped: true, + surfaces, + unclippedSurfaceIds: new Set([browserColumn.id]), + }), + ).toBe(false); + }); + + it("matches device sources by host and device", () => { + expect( + shouldRenderPreviewMiniPlayerInColumns({ + source: { + kind: "device", + hostId: "host-1", + deviceId: "device-1", + platform: "ios", + name: "iPhone", + }, + chatUnclipped: true, + surfaces, + unclippedSurfaceIds: new Set([deviceColumn.id]), + }), + ).toBe(false); + expect( + shouldRenderPreviewMiniPlayerInColumns({ + source: { + kind: "device", + hostId: "host-1", + deviceId: "device-1", + platform: "ios", + name: "iPhone", + }, + chatUnclipped: true, + surfaces, + unclippedSurfaceIds: new Set(), + }), + ).toBe(true); + }); + + it("stays hidden while the chat column is clipped or there is no source", () => { + expect( + shouldRenderPreviewMiniPlayerInColumns({ + source: { kind: "browser", tabId: "tab-1" }, + chatUnclipped: false, + surfaces, + unclippedSurfaceIds: new Set(), + }), + ).toBe(false); + expect( + shouldRenderPreviewMiniPlayerInColumns({ + source: null, + chatUnclipped: true, + surfaces, + unclippedSurfaceIds: new Set(), + }), + ).toBe(false); + }); +}); + describe("proactive panels", () => { it("opens an existing pull request on entry and follows newly observed links", () => { expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(true); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3de13c50134f2..2132cd2b872c8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -118,6 +118,26 @@ export function shouldRenderPreviewMiniPlayer( return source !== null && !surfaceShowsPreviewSource(source, renderedRightPanelSurface); } +/** + * In the scrolling columns layout the floating player floats over the chat, so + * it shows only while the chat column is wholly in view, and hides only while a + * column that is wholly in view shows the same source. A selected source column + * scrolled out of view does not count: its native view is hidden there too. + */ +export function shouldRenderPreviewMiniPlayerInColumns(input: { + source: PreviewMiniPlayerSource | null; + chatUnclipped: boolean; + surfaces: readonly RightPanelSurface[]; + unclippedSurfaceIds: ReadonlySet; +}): boolean { + const { source } = input; + if (source === null || !input.chatUnclipped) return false; + return !input.surfaces.some( + (surface) => + input.unclippedSurfaceIds.has(surface.id) && surfaceShowsPreviewSource(source, surface), + ); +} + export function shouldOpenProactivePullRequest( previousTargetKey: string | null | undefined, targetKey: string | null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4b6c453803d82..c4310b3e916a5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -538,6 +538,7 @@ import { shouldOpenProactiveTurnDiff, shouldReleaseTimelineAnchorForToolActivity, shouldRenderPreviewMiniPlayer, + shouldRenderPreviewMiniPlayerInColumns, surfaceShowsPreviewSource, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -2418,15 +2419,26 @@ export default function ChatView(props: ChatViewProps) { visible: true, unclipped: true, }); - // The floating player's native view ignores DOM clipping, so it only shows - // while the chat column it floats over is wholly in view. While hidden, a - // column showing the same source takes the view back. - const previewMiniPlayerVisible = - shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.source ?? null, - renderedRightPanelSurface, - ) && - (!rightPanelColumnsLayout || chatColumnView.unclipped); + const [unclippedColumnSurfaceIds, setUnclippedColumnSurfaceIds] = useState>( + () => new Set(), + ); + const handleUnclippedColumnSurfacesChange = useCallback((surfaceIds: readonly string[]) => { + setUnclippedColumnSurfaceIds(new Set(surfaceIds)); + }, []); + // The floating player's native view ignores DOM clipping, so in columns it + // only shows while the chat column it floats over is wholly in view, and + // gives way only to a column that is actually showing the same source. + const previewMiniPlayerVisible = rightPanelColumnsLayout + ? shouldRenderPreviewMiniPlayerInColumns({ + source: activePreviewMiniPlayer?.source ?? null, + chatUnclipped: chatColumnView.unclipped, + surfaces: rightPanelOpen ? rightPanelState.surfaces : [], + unclippedSurfaceIds: unclippedColumnSurfaceIds, + }) + : shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.source ?? null, + renderedRightPanelSurface, + ); const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet && !rightPanelColumnsLayout; const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; @@ -11534,6 +11546,7 @@ export default function ChatView(props: ChatViewProps) { onActivate={activateRightPanelSurface} renderSurface={renderRightPanelColumn} onChatViewChange={setChatColumnView} + onUnclippedSurfacesChange={handleUnclippedColumnSurfacesChange} chatRevealRequestId={chatRevealRequestId} emptyColumn={ rightPanelOpen && activeThreadRef ? ( diff --git a/apps/web/src/components/RightPanelColumns.tsx b/apps/web/src/components/RightPanelColumns.tsx index 4d038a8513ada..82f7a48f4666c 100644 --- a/apps/web/src/components/RightPanelColumns.tsx +++ b/apps/web/src/components/RightPanelColumns.tsx @@ -52,6 +52,8 @@ export function RightPanelColumns(props: { emptyColumn?: ReactNode; /** Reports when the chat column scrolls in or out of view, so it can pause offscreen work. */ onChatViewChange?: (view: ChatColumnView) => void; + /** Reports the ids of the surface columns that are wholly in view. */ + onUnclippedSurfacesChange?: (surfaceIds: readonly string[]) => void; /** Changes when something in the chat column, such as its terminal drawer, needs to be seen. */ chatRevealRequestId?: number; children: ReactNode; @@ -116,6 +118,20 @@ export function RightPanelColumns(props: { onChatViewChange?.({ visible: chatVisible, unclipped: chatUnclipped }); }, [chatVisible, chatUnclipped, onChatViewChange]); + // Joined so the effect below only fires when the set of wholly shown columns changes. + const unclippedSurfaceIdsKey = enabled + ? surfaces + .filter((surface) => visibility.get(surface.id) === "full") + .map((surface) => surface.id) + .join("\n") + : ""; + const onUnclippedSurfacesChange = props.onUnclippedSurfacesChange; + useEffect(() => { + onUnclippedSurfacesChange?.( + unclippedSurfaceIdsKey === "" ? [] : unclippedSurfaceIdsKey.split("\n"), + ); + }, [unclippedSurfaceIdsKey, onUnclippedSurfacesChange]); + const chatRevealRequestId = props.chatRevealRequestId ?? 0; useEffect(() => { if (!enabled || chatRevealRequestId === 0) return; From bf8aa22154e0a3b6ee8385d23231b921f4a5d0e6 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Tue, 20 Oct 2026 11:42:41 +0000 Subject: [PATCH 04/10] fix(web): scope editor shortcuts to the active visible column --- apps/web/src/components/ChatView.tsx | 20 +++++++++++++------ .../chat/RightPanelSurfaceContent.tsx | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 760ca887f50a9..bb11e8d5e9e60 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8289,7 +8289,9 @@ export default function ChatView(props: ChatViewProps) { progressive: serverConfig?.threadFindProgressive === true, thread: activeThreadRef, enabled: - isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline, + isServerThread && + serverConfig?.threadFind === true && + !paintOnlyDisplayedTimeline, content: serverProjection ?? undefined, }} controlsRef={threadFindControlsRef} @@ -8565,8 +8567,8 @@ export default function ChatView(props: ChatViewProps) { reportedModelSelection={reportedModelSelection} multipleModelSelections={multipleModelSelections} supportsMultipleModels={ - serverConfig?.environment.capabilities.requiredWorktreeBootstrap === - true + serverConfig?.environment.capabilities + .requiredWorktreeBootstrap === true } onMultipleModelSelectionsChange={setMultipleModelSelectionsStable} composerRef={composerRef} @@ -8675,13 +8677,17 @@ export default function ChatView(props: ChatViewProps) { pullRequestRepository={ supportsPullRequests ? activeProjectRepository : null } - threadPullRequests={(activeThreadShell ?? activeThread)?.pullRequests} + threadPullRequests={ + (activeThreadShell ?? activeThread)?.pullRequests + } restingControlsHost={restingComposerControlsHost} restingControlsHaveLeadingContext={ mountComposerContextStrip && (isGitRepo || showComposerEnvironmentIndicator) } - onRestingControlsVisibilityChange={setRestingComposerControlsVisible} + onRestingControlsVisibilityChange={ + setRestingComposerControlsVisible + } getTimelineScrollableNode={getTimelineScrollableNodeStable} isTimelineAtLogicalEnd={isTimelineAtLogicalEndStable} timelineOverflows={timelineOverflows} @@ -8872,7 +8878,9 @@ export default function ChatView(props: ChatViewProps) { ? onAutoEnvironmentStable : undefined } - {...(canChangeEnvironment ? { onEnvironmentChange: onEnvironmentChangeStable } : {})} + {...(canChangeEnvironment + ? { onEnvironmentChange: onEnvironmentChangeStable } + : {})} onEnvModeChange={onEnvModeChangeStable} envMode={envMode} {...(canOverrideServerThreadEnvMode diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 9f81cbe4be9ba..1c0fa9475a1fb 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -210,7 +210,7 @@ export function RightPanelSurfaceContent(props: { composerDraftTarget={props.composerDraftTarget} keybindings={props.keybindings} availableEditors={files.availableEditors} - openInShortcutEnabled={visible} + openInShortcutEnabled={visible && onScreen && active} relativePath={surface.kind === "file" ? surface.relativePath : null} {...(attachment ? { attachment } : {})} revealLine={surface.kind === "file" ? (surface.revealLine ?? null) : null} From 1b25d9d9fd7fedcb4e07b8a8e2197be322c54252 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 31 Oct 2026 12:16:23 +0000 Subject: [PATCH 05/10] chore(web): adapt columns lifecycle to current source checks --- apps/web/src/components/RightPanelColumns.tsx | 18 +++++++----------- .../src/components/ThreadTerminalDrawer.tsx | 10 ++++++++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/RightPanelColumns.tsx b/apps/web/src/components/RightPanelColumns.tsx index d412633591e80..da54e9c9cc3fe 100644 --- a/apps/web/src/components/RightPanelColumns.tsx +++ b/apps/web/src/components/RightPanelColumns.tsx @@ -28,17 +28,8 @@ export interface ChatColumnView { unclipped: boolean; } -/** - * Lays the chat (`children`) and every open right panel surface out side by - * side on one horizontally scrolling strip. Selecting a surface scrolls its - * column into view, and pressing inside a column selects it. Focus alone does - * not select: selecting a terminal focuses it, which would pull focus off - * whatever control the keyboard reached, and panels that focus themselves would - * take turns selecting. - * - * When `enabled` is false the strip and chat wrapper render as `display: - * contents`, so the chat keeps its place in the tree across layout changes and - * is not remounted. +/** Pointer selection preserves focus in edited controls; terminal focus must not reselect columns. + * Disabled wrappers use display:contents to retain the chat subtree across layout changes. */ export function RightPanelColumns(props: { enabled: boolean; @@ -70,6 +61,7 @@ export function RightPanelColumns(props: { const emptyColumn = surfaces.length === 0 ? (props.emptyColumn ?? null) : null; const hasPanelColumns = enabled && (surfaces.length > 0 || emptyColumn !== null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observe DOM clipping to pause offscreen terminals and native views useEffect(() => { const strip = stripRef.current; if (!strip || !enabled) return; @@ -114,6 +106,7 @@ export function RightPanelColumns(props: { const chatVisible = visibility.has(CHAT_COLUMN_KEY); const chatUnclipped = visibility.get(CHAT_COLUMN_KEY) === "full"; const onChatViewChange = props.onChatViewChange; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publish chat DOM intersection measurements to terminal and preview presentation owners useEffect(() => { onChatViewChange?.({ visible: chatVisible, unclipped: chatUnclipped }); }, [chatVisible, chatUnclipped, onChatViewChange]); @@ -126,6 +119,7 @@ export function RightPanelColumns(props: { .join("\n") : ""; const onUnclippedSurfacesChange = props.onUnclippedSurfacesChange; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publish measured column clipping to native preview presentation useEffect(() => { onUnclippedSurfacesChange?.( unclippedSurfaceIdsKey === "" ? [] : unclippedSurfaceIdsKey.split("\n"), @@ -133,6 +127,7 @@ export function RightPanelColumns(props: { }, [unclippedSurfaceIdsKey, onUnclippedSurfacesChange]); const chatRevealRequestId = props.chatRevealRequestId ?? 0; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveal the chat DOM column for an imperative terminal focus request useEffect(() => { if (!enabled || chatRevealRequestId === 0) return; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; @@ -143,6 +138,7 @@ export function RightPanelColumns(props: { }); }, [enabled, chatRevealRequestId]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scroll the selected surface DOM column into view, including repeated selections useEffect(() => { if (!enabled || !activeSurfaceId) return; const column = columnRefs.current.get(activeSurfaceId); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index e97e52c7165c1..107924f2361b2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1145,8 +1145,14 @@ function TerminalSessionViewport({ const attachContainer = (mount: HTMLDivElement) => { containerRef.current = mount; const focus = () => { - if (logicallyVisibleRef.current && !visibleRef.current && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) { - mount.closest("[data-right-panel-column]")?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" }); + if ( + logicallyVisibleRef.current && + !visibleRef.current && + readEnvironmentScope(environmentId, AuthTerminalOperateScope) + ) { + mount + .closest("[data-right-panel-column]") + ?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" }); } return focusTerminalViewport({ mount, From 6f8ffeb6b318fb78856ff4145b4e6bd3d528386b Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sat, 31 Oct 2026 12:18:03 +0000 Subject: [PATCH 06/10] fix(web): preserve current chat composer tree in column layout --- apps/web/src/components/ChatView.tsx | 1163 +++++++++++--------------- 1 file changed, 484 insertions(+), 679 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 014fa0c0b22eb..e4a35126091e3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1075,7 +1075,8 @@ export default function ChatView(props: ChatViewProps) { const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable; const rightPanelOpen = rightPanelState.isOpen; const rightPanelLayout = useClientSettings((settings) => settings.rightPanelLayout); - const rightPanelColumnsLayout = rightPanelLayout === "columns" && !shouldUsePlanSidebarSheet && !embedded; + const rightPanelColumnsLayout = + rightPanelLayout === "columns" && !shouldUsePlanSidebarSheet && !embedded; const [rightPanelRevealRequestId, setRightPanelRevealRequestId] = useState(0); const [chatColumnView, setChatColumnView] = useState({ visible: true, @@ -1118,7 +1119,8 @@ export default function ChatView(props: ChatViewProps) { ); const canMaximizeRightPanel = !embedded && rightPanelOpen && !shouldUsePlanSidebarSheet && !rightPanelColumnsLayout; - const rightPanelMaximized = !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; + const rightPanelMaximized = + !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); const [chatCanvasStore] = useState(createChatCanvasStore); @@ -3249,7 +3251,10 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} - sideChatParentPanel={{ openToolDiff: onOpenToolDiffStable, runShellCommand: runSideChatShellCommandStable }} + sideChatParentPanel={{ + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, @@ -3404,532 +3409,204 @@ export default function ChatView(props: ChatViewProps) { revealRequestId={rightPanelRevealRequestId} onActivate={activateRightPanelSurfaceStable} renderSurface={renderRightPanelColumn} - emptyColumn={rightPanelOpen ? renderRightPanelColumn(null, {active:false,visible:true,unclipped:true}) : null} + emptyColumn={ + rightPanelOpen + ? renderRightPanelColumn(null, { active: false, visible: true, unclipped: true }) + : null + } onChatViewChange={setChatColumnView} onUnclippedSurfacesChange={handleUnclippedColumnSurfacesChange} > -
- {embedded ? null : ( -
- {isElectron && rightPanelControlsAtRoot ? ( - - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - -
- )} - -
- - - {isWorkspaceFileDragActive ? : null} - -
+ {embedded ? null : ( +
- + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} + - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - -
+ + )} - {showScrollToBottom && !isThreadFindActive && ( - scrollToEnd(true)} - /> - )} -
- -
{ - setComposerOverlayElement(overlay); - attachFollowComposerOverlay(overlay); +
+ -
-
- {isDraftHeroState ? ( -
-
- -
-
- ) : null} -
- - -
- {showProviderSubagentBar ? ( - - ) : null} - {!composerMounted ? null : ( - - )} -
- {threadStatusLine} - - ) + + {isWorkspaceFileDragActive ? : null} + +
+ {} : onForkFromRunStable} - onRollbackCheckpoint={onTimelineRollbackCheckpointStable} - supportsConversationRollback={ - !paintOnlyDisplayedTimeline && supportsConversationRollback - } + parentThreadLink={parentThreadLink} + workflowOpening={workflowOpening} + supportsConversationRollback={supportsConversationRollback} threadStartCheckpointReady={threadStartCheckpointReady} availableRevertTurnCounts={availableRevertTurnCounts} - onRevertToTurnCount={ - paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurnStable - } - {...(!paintOnlyDisplayedTimeline - ? { - onUseArtifactTemplate: insertArtifactTemplateStable, - onSendAppMessage: sendAppMessageStable, - } - : {})} + onRevertToTurnCount={onRevertTimelineTurnStable} + onUseArtifactTemplate={insertArtifactTemplateStable} + onSendAppMessage={sendAppMessageStable} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImageStable} - onFileOpen={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachmentStable - } - onFileDownload={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachmentStable - } - markdownCwd={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.markdownCwd ?? undefined) - : (gitCwd ?? undefined) - } + onFileOpen={openFileAttachmentStable} + onFileDownload={downloadFileAttachmentStable} + markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} - workspaceRoot={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.workspaceRoot ?? undefined) - : activeWorkspaceRoot - } + workspaceRoot={activeWorkspaceRoot} skills={timelineSkills} - anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} + anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReadyStable} onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} - liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChangeStable} - onContentOverflowChange={setTimelineOverflows} - onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} - onManualNavigation={cancelTimelineLiveFollowForUserNavigationStable} - cancelPositionRestoreRef={cancelPositionRestoreRef} + onViewportAttach={attachTimelineViewport} + onFooterGrow={onTimelineFooterGrowStable} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} emptyPlaceholderInset={composerReservedHeight} topFadeEnabled={!hasTimelineTopBanner} - {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined + {...(threadHistoryControls === undefined ? {} : { historyControls: threadHistoryControls })} /> @@ -3937,16 +3614,18 @@ export default function ChatView(props: ChatViewProps) { {showScrollToBottom && !isThreadFindActive && ( { - composerRef.current?.restoreAfterTimelineReachedEnd(); - scrollToEnd(true); - }} + summary={scrollToEndSummary} + onScrollToEnd={() => scrollToEnd(true)} /> )}
{ + setComposerOverlayElement(overlay); + attachFollowComposerOverlay(overlay); + }} inert={isRevertingCheckpoint} data-chat-composer-overlay="true" className={ @@ -3970,17 +3649,69 @@ export default function ChatView(props: ChatViewProps) { : undefined } > - {showComposerContextStrip && ( -
- +
+
+ ) : null} +
+ + +
+ {showProviderSubagentBar ? ( + + ) : null} + {!composerMounted ? null : ( + - ), - })} + environmentUnavailable={activeEnvironmentUnavailableState} + activePendingApproval={activePendingApproval} + pendingApprovals={pendingApprovals} + onHeldRequestIdsChange={onHeldRequestIdsChange} + pendingUserInputs={pendingUserInputs} + activePendingProgress={activePendingProgress} + activePendingResolvedAnswers={activePendingResolvedAnswers} + activePendingIsResponding={activePendingIsResponding} + activePendingDraftAnswers={activePendingDraftAnswers} + activePendingQuestionIndex={activePendingQuestionIndex} + respondingRequestIds={respondingRequestIds} + showPlanFollowUpPrompt={showPlanFollowUpPrompt} + activeProposedPlan={activeProposedPlan} + threadSyncPhase={ + activeEnvironmentUnavailable ? null : threadSyncPhase + } + runtimeMode={runtimeMode} + defaultRuntimeMode={defaultRuntimeMode} + interactionMode={interactionMode} + lockedProvider={modelPickerLockedProvider} + providerStatuses={providerStatuses as ServerProvider[]} + providerCatalogKnown={serverConfig !== null} + activeProjectDefaultModelSelection={ + activeProjectDefaultModelSelection + } + activeThreadModelSelection={activeThread?.modelSelection} + activeProjectId={activeProject?.id ?? null} + activeContextWindow={activeContextWindow} + compactThreadUnavailable={compactThreadUnavailable} + compactDisabled={compactDisabled} + compactDisabledReason={compactDisabledReason} + resolvedTheme={resolvedTheme} + settings={settings} + keybindings={keybindings} + terminalOpen={Boolean(terminalUiState.terminalOpen)} + gitCwd={gitCwd} + pullRequestProjectId={ + supportsPullRequests ? (activeProject?.id ?? null) : null + } + pullRequestRepository={ + supportsPullRequests ? activeProjectRepository : null + } + threadPullRequests={ + (activeThreadShell ?? activeThread)?.pullRequests + } + onComposerOverlayHeightChange={publishComposerOverlayHeightStable} + permissionsShownInStrip={ + showComposerContextStrip && !isServerThread + } + promptRef={promptRef} + composerImagesRef={composerImagesRef} + composerFilesRef={composerFilesRef} + composerTerminalContextsRef={composerTerminalContextsRef} + onPageScrollKeyDown={onComposerPageScrollKeyDownStable} + onPageScrollKeyUp={onComposerPageScrollKeyUpStable} + onPageScrollRelease={onComposerPageScrollReleaseStable} + onCompactContext={onComposerCompactContextStable} + onSend={onComposerSendStable} + onResume={onComposerResumeStable} + onInterrupt={onInterruptStable} + onImplementPlanInNewThread={onImplementPlanInNewThreadStable} + onSelectActivePendingUserInputOption={ + onSelectActivePendingUserInputOptionStable + } + onAdvanceActivePendingUserInput={ + onAdvanceActivePendingUserInputStable + } + onDismissActivePendingUserInput={onDismissUserInputStable} + onPreviousActivePendingUserInputQuestion={ + onPreviousActivePendingUserInputQuestionStable + } + onChangeActivePendingUserInputCustomAnswer={ + onChangeActivePendingUserInputCustomAnswer + } + onProviderModelSelect={onProviderModelSelectStable} + onOpenProviderSetup={openProviderSetupStable} + getModelDisabledReason={getModelDisabledReason} + toggleInteractionMode={toggleInteractionModeStable} + handleRuntimeModeChange={handleRuntimeModeChangeStable} + handleInteractionModeChange={handleInteractionModeChangeStable} + focusComposer={focusComposerStable} + scheduleComposerFocus={scheduleComposerFocusStable} + setThreadError={setThreadError} + onExpandImage={onExpandTimelineImageStable} + onFileOpen={openFileAttachmentStable} + editingQueuedAttachments={queuedRunEdit.editingAttachments} + onRemoveEditingQueuedAttachment={ + queuedRunEdit.removeEditingAttachment + } /> )}
@@ -4053,24 +3857,7 @@ export default function ChatView(props: ChatViewProps) { data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined} className="relative z-0" > - {mountComposerModelStrip ? ( - -
- - ) : null} - {mountComposerContextStrip && ( + {showComposerContextStrip && (
+ ), + })} />
)}
-
- {isDraftHeroState && composerBannerItems.length > 0 ? ( - // Out of flow, like the headline, so a notice never moves the centered composer. -
- -
- ) : null} - -
+ {isDraftHeroState && composerBannerItems.length > 0 ? ( + // Out of flow, like the headline, so a notice never moves the centered composer. +
+ +
+ ) : null} + +
+
- {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser && !embedded ? ( - - ) : null} - {activeThreadRef && shownPreviewMiniPlayer && !embedded ? ( - - - {embedded ? null : ( - <> - + ) : null} + {activeThreadRef && shownPreviewMiniPlayer && !embedded ? ( + + ) : null} - + {embedded ? null : ( + <> + - - - )} -
-
+ - {embedded ? null : ( - - )} -
+ + + )} +
+
+ {embedded ? null : ( + + )} +
{rightPanelPresent && !rightPanelColumnsLayout && activeThreadRef && !embedded ? ( From 163ee4c22281d1565db2d4c74442339e4645ab7c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 18:49:53 +0000 Subject: [PATCH 07/10] fix(web): preserve panel actions in scrolling columns --- apps/web/src/components/ChatView.tsx | 9 +- apps/web/src/components/RightPanelColumns.tsx | 118 +++++++++--------- apps/web/src/components/RightPanelTabs.tsx | 14 ++- 3 files changed, 78 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e4a35126091e3..03c11cc65ff13 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1076,7 +1076,7 @@ export default function ChatView(props: ChatViewProps) { const rightPanelOpen = rightPanelState.isOpen; const rightPanelLayout = useClientSettings((settings) => settings.rightPanelLayout); const rightPanelColumnsLayout = - rightPanelLayout === "columns" && !shouldUsePlanSidebarSheet && !embedded; + rightPanelLayout === "columns" && !prefersPlanSidebarSheet && !embedded; const [rightPanelRevealRequestId, setRightPanelRevealRequestId] = useState(0); const [chatColumnView, setChatColumnView] = useState({ visible: true, @@ -1117,8 +1117,7 @@ export default function ChatView(props: ChatViewProps) { activePreviewMiniPlayer?.source ?? null, renderedRightPanelSurface, ); - const canMaximizeRightPanel = - !embedded && rightPanelOpen && !shouldUsePlanSidebarSheet && !rightPanelColumnsLayout; + const canMaximizeRightPanel = !embedded && rightPanelOpen && !rightPanelColumnsLayout; const rightPanelMaximized = !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; @@ -3385,6 +3384,9 @@ export default function ChatView(props: ChatViewProps) { pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} deviceAvailable={activeThreadRef !== null} + onAddSideChat={sideChatsOffered ? openSideChatStable : undefined} + sideChatAvailable={sideChat.available} + sideChatDisabledReason={sideChatDisabledReason} > {renderRightPanelSurface(surface, view)} @@ -3408,6 +3410,7 @@ export default function ChatView(props: ChatViewProps) { activeSurfaceId={activeRightPanelSurface?.id ?? null} revealRequestId={rightPanelRevealRequestId} onActivate={activateRightPanelSurfaceStable} + onMoveSurface={moveRightPanelSurfaceStable} renderSurface={renderRightPanelColumn} emptyColumn={ rightPanelOpen diff --git a/apps/web/src/components/RightPanelColumns.tsx b/apps/web/src/components/RightPanelColumns.tsx index da54e9c9cc3fe..7112cd968ef61 100644 --- a/apps/web/src/components/RightPanelColumns.tsx +++ b/apps/web/src/components/RightPanelColumns.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; import type { RightPanelSurface } from "../rightPanelStore"; +import { SortableTabList } from "./RightPanelTabs"; export interface RightPanelColumnView { /** The selected surface; it owns focus and keyboard shortcuts. */ @@ -38,6 +39,7 @@ export function RightPanelColumns(props: { /** Changes whenever a surface is selected, even the current one, to scroll it into view. */ revealRequestId: number; onActivate: (surface: RightPanelSurface) => void; + onMoveSurface: (surfaceId: string, toIndex: number) => void; renderSurface: (surface: RightPanelSurface, view: RightPanelColumnView) => ReactNode; /** Shown as the only panel column when the panel is open without surfaces. */ emptyColumn?: ReactNode; @@ -159,71 +161,73 @@ export function RightPanelColumns(props: { "relative flex min-h-0 w-[max(50%,min(100%,26rem))] shrink-0 snap-start flex-col border-l border-border"; return ( -
+
- {props.children} +
+ {props.children} +
+ {enabled + ? surfaces.map((surface) => { + const active = surface.id === activeSurfaceId; + return ( +
{ + if (element) columnRefs.current.set(surface.id, element); + else columnRefs.current.delete(surface.id); + }} + data-right-panel-column={surface.id} + data-active-column={active} + className={columnClassName} + onPointerDownCapture={() => { + if (!active) onActivate(surface); + }} + > + + {renderSurface(surface, { + active, + visible: visibility.has(surface.id), + unclipped: visibility.get(surface.id) === "full", + })} +
+ ); + }) + : null} + {enabled && emptyColumn !== null ? ( +
{emptyColumn}
+ ) : null}
- {enabled - ? surfaces.map((surface) => { - const active = surface.id === activeSurfaceId; - return ( -
{ - if (element) columnRefs.current.set(surface.id, element); - else columnRefs.current.delete(surface.id); - }} - data-right-panel-column={surface.id} - data-active-column={active} - className={columnClassName} - onPointerDownCapture={() => { - if (!active) onActivate(surface); - }} - > - - {renderSurface(surface, { - active, - visible: visibility.has(surface.id), - unclipped: visibility.get(surface.id) === "full", - })} -
- ); - }) - : null} - {enabled && emptyColumn !== null ? ( -
{emptyColumn}
- ) : null} -
+ ); } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index c19882228fb53..21d1cdaea947e 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -53,6 +53,8 @@ import { type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, + createContext, + useContext, useEffect, useEffectEvent, useRef, @@ -832,11 +834,16 @@ function PullRequestSurfaceIcon({ return ; } -function SortableTabList(props: { +const SurfaceSortingContext = createContext(false); +const COLUMN_DRAG_MODIFIERS = [restrictToHorizontalAxis]; + +export function SortableTabList(props: { surfaces: readonly RightPanelSurface[]; onMove: ((surfaceId: string, toIndex: number) => void) | undefined; + columns?: boolean; children: ReactNode; }) { + const sharedSorting = useContext(SurfaceSortingContext); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); const surfaceIds = props.surfaces.map((surface) => surface.id); const { onMove, surfaces } = props; @@ -845,15 +852,16 @@ function SortableTabList(props: { const toIndex = surfaces.findIndex((surface) => surface.id === over.id); if (toIndex >= 0) onMove?.(String(active.id), toIndex); }; + if (sharedSorting) return props.children; return ( - {props.children} + {props.children} ); From 6d54b02ab7bbdd1eeda51f8af513339142a352c7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 19:04:13 +0000 Subject: [PATCH 08/10] fix(web): preserve keyboard focus in scrolling columns --- apps/web/src/components/ChatView.tsx | 1 + apps/web/src/components/RightPanelColumns.tsx | 3 +++ apps/web/src/components/RightPanelTabs.tsx | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 03c11cc65ff13..d545130d712c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3343,6 +3343,7 @@ export default function ChatView(props: ChatViewProps) { ) => ( { if (!active) onActivate(surface); }} + onFocusCapture={() => { + if (!active) onActivate(surface); + }} > Date: Sun, 1 Nov 2026 03:21:27 +0000 Subject: [PATCH 09/10] fix(web): reveal reopened columns and target native browser focus --- apps/desktop/src/preload.ts | 8 ++++-- apps/desktop/src/preview/Manager.test.ts | 6 ++++- apps/desktop/src/preview/Manager.ts | 6 ++++- apps/web/src/components/RightPanelColumns.tsx | 4 ++- .../components/chat/useRightPanelActions.ts | 1 + .../src/components/preview/PreviewView.tsx | 25 +++++++++++++------ packages/contracts/src/ipc.ts | 2 +- 7 files changed, 38 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 950ed972c6453..092c84138b74c 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -209,9 +209,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { - const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { + const wrappedListener = ( + _event: Electron.IpcRendererEvent, + action: unknown, + sourceTabId: unknown, + ) => { if (typeof action !== "string") return; - listener(action); + listener(action, typeof sourceTabId === "string" ? sourceTabId : undefined); }; ipcRenderer.on(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index d917af72d389b..fceb32c87ca4b 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -654,7 +654,11 @@ describe("PreviewManager", () => { ]); beforeInput({ preventDefault } as never, { ...key, key: "l", shift: false } as never); expect(focus).toHaveBeenCalledOnce(); - expect(send).toHaveBeenLastCalledWith("desktop:menu-action", "preview.focusUrl"); + expect(send).toHaveBeenLastCalledWith( + "desktop:menu-action", + "preview.focusUrl", + "tab_older", + ); expect(focus.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]!); beforeInput( { preventDefault } as never, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0087444a8412c..891f37fb0dcd7 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1484,7 +1484,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function // DOM focus in the renderer cannot by itself release the guest's native keyboard focus. host.focus(); } - host.send(MENU_ACTION_CHANNEL, forwarded.command); + if (forwarded.command === "preview.focusUrl") { + host.send(MENU_ACTION_CHANNEL, forwarded.command, tabId); + } else { + host.send(MENU_ACTION_CHANNEL, forwarded.command); + } } return; } diff --git a/apps/web/src/components/RightPanelColumns.tsx b/apps/web/src/components/RightPanelColumns.tsx index 463b84033d61b..3743aaf0d0541 100644 --- a/apps/web/src/components/RightPanelColumns.tsx +++ b/apps/web/src/components/RightPanelColumns.tsx @@ -140,6 +140,8 @@ export function RightPanelColumns(props: { }); }, [enabled, chatRevealRequestId]); + const activeSurface = surfaces.find((surface) => surface.id === activeSurfaceId); + const fileRevealRequestId = activeSurface?.kind === "file" ? activeSurface.revealRequestId : 0; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scroll the selected surface DOM column into view, including repeated selections useEffect(() => { if (!enabled || !activeSurfaceId) return; @@ -153,7 +155,7 @@ export function RightPanelColumns(props: { }); // A repeated selection must reveal the column again even when its id is unchanged. // eslint-disable-next-line react/exhaustive-effect-dependencies - }, [enabled, activeSurfaceId, revealRequestId]); + }, [enabled, activeSurfaceId, revealRequestId, fileRevealRequestId]); // Every column, the chat included, takes half the strip when it shares it, // never narrower than a usable column, and later ones scroll past the edge. diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index 1155398304d85..3ba9dbc1f31f9 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -135,6 +135,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const createBrowserSurfaceStable = useStableCallback(createBrowserSurface); const addDiffSurface = () => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; + onRevealSurface(); useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 4f978522479e7..b7b5c5f4e87a9 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -864,16 +864,25 @@ export function PreviewView({ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview actions while the view is visible useEffect(() => { if (!shortcutsEnabled) return; - const unsubscribeAction = subscribePreviewAction((action) => onPreviewAction(action)); - const unsubscribeMenu = window.desktopBridge?.onMenuAction((action) => { - if (action === "preview.focusUrl") onPreviewAction("focus-url"); - }); - return () => { - unsubscribeAction(); - unsubscribeMenu?.(); - }; + return subscribePreviewAction((action) => onPreviewAction(action)); }, [shortcutsEnabled]); + const onDesktopMenuAction = useEffectEvent((action: string, sourceTabId?: string) => { + if ( + action === "preview.focusUrl" && + (sourceTabId ? sourceTabId === runtimeTabId : shortcutsEnabled) + ) { + onPreviewAction("focus-url"); + } + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- routes native guest menu actions to their visible browser column + useEffect(() => { + if (!visible) return; + return window.desktopBridge?.onMenuAction((action, sourceTabId) => + onDesktopMenuAction(action, sourceTabId), + ); + }, [visible]); + return (
Promise; probeRemoteEditors: () => Promise; pasteAsText: () => Promise; - onMenuAction: (listener: (action: string) => void) => () => void; + onMenuAction: (listener: (action: string, sourceTabId?: string) => void) => () => void; onSnapShotEvent: (listener: (event: DesktopSnapShotEvent) => void) => () => void; onQuitShortcut: (listener: (event: QuitShortcutHintEvent) => void) => () => void; getWindowFullscreenState: () => boolean; From 6982e7902048ca13c6e5f0ad04ba6f8598916bd5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 03:24:49 +0000 Subject: [PATCH 10/10] fix(web): reveal columns for every panel open action --- apps/web/src/components/ChatView.tsx | 4 +--- apps/web/src/components/RightPanelColumns.tsx | 10 ++++------ apps/web/src/components/chat/useRightPanelActions.ts | 4 ---- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bd53145b6db6b..5b0b1afe33d3c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1068,7 +1068,6 @@ export default function ChatView(props: ChatViewProps) { const rightPanelLayout = useClientSettings((settings) => settings.rightPanelLayout); const rightPanelColumnsLayout = rightPanelLayout === "columns" && !prefersPlanSidebarSheet && !embedded; - const [rightPanelRevealRequestId, setRightPanelRevealRequestId] = useState(0); const [chatColumnView, setChatColumnView] = useState({ visible: true, unclipped: true, @@ -2387,7 +2386,6 @@ export default function ChatView(props: ChatViewProps) { shortcuts: rightPanelShortcuts, } = useRightPanelActions({ activeThreadRef, - onRevealSurface: () => setRightPanelRevealRequestId((value) => value + 1), activeProject, activeRightPanelSurface, activePreviewState, @@ -3402,7 +3400,7 @@ export default function ChatView(props: ChatViewProps) { enabled={rightPanelColumnsLayout} surfaces={columnSurfaces} activeSurfaceId={activeRightPanelSurface?.id ?? null} - revealRequestId={rightPanelRevealRequestId} + revealRequest={rightPanelState} onActivate={activateRightPanelSurfaceStable} onMoveSurface={moveRightPanelSurfaceStable} renderSurface={renderRightPanelColumn} diff --git a/apps/web/src/components/RightPanelColumns.tsx b/apps/web/src/components/RightPanelColumns.tsx index 3743aaf0d0541..c616cb7184101 100644 --- a/apps/web/src/components/RightPanelColumns.tsx +++ b/apps/web/src/components/RightPanelColumns.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "~/lib/utils"; -import type { RightPanelSurface } from "../rightPanelStore"; +import type { RightPanelSurface, ThreadRightPanelState } from "../rightPanelStore"; import { SortableTabList } from "./RightPanelTabs"; export interface RightPanelColumnView { @@ -37,7 +37,7 @@ export function RightPanelColumns(props: { surfaces: readonly RightPanelSurface[]; activeSurfaceId: string | null; /** Changes whenever a surface is selected, even the current one, to scroll it into view. */ - revealRequestId: number; + revealRequest: ThreadRightPanelState; onActivate: (surface: RightPanelSurface) => void; onMoveSurface: (surfaceId: string, toIndex: number) => void; renderSurface: (surface: RightPanelSurface, view: RightPanelColumnView) => ReactNode; @@ -51,7 +51,7 @@ export function RightPanelColumns(props: { chatRevealRequestId?: number; children: ReactNode; }) { - const { enabled, surfaces, activeSurfaceId, revealRequestId, onActivate, renderSurface } = props; + const { enabled, surfaces, activeSurfaceId, revealRequest, onActivate, renderSurface } = props; const stripRef = useRef(null); const chatColumnRef = useRef(null); const columnRefs = useRef(new Map()); @@ -140,8 +140,6 @@ export function RightPanelColumns(props: { }); }, [enabled, chatRevealRequestId]); - const activeSurface = surfaces.find((surface) => surface.id === activeSurfaceId); - const fileRevealRequestId = activeSurface?.kind === "file" ? activeSurface.revealRequestId : 0; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scroll the selected surface DOM column into view, including repeated selections useEffect(() => { if (!enabled || !activeSurfaceId) return; @@ -155,7 +153,7 @@ export function RightPanelColumns(props: { }); // A repeated selection must reveal the column again even when its id is unchanged. // eslint-disable-next-line react/exhaustive-effect-dependencies - }, [enabled, activeSurfaceId, revealRequestId, fileRevealRequestId]); + }, [enabled, activeSurfaceId, revealRequest]); // Every column, the chat included, takes half the strip when it shares it, // never narrower than a usable column, and later ones scroll past the edge. diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index 3ba9dbc1f31f9..0f8e8e3ce303f 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -36,7 +36,6 @@ import { previewEnvironment } from "../../state/preview"; import { useAtomCommand } from "../../state/use-atom-command"; import type { ChatShortcutCommands } from "./useChatShortcuts"; interface RightPanelActionsInput { - readonly onRevealSurface: () => void; readonly activeThreadRef: ScopedThreadRef | null; readonly activeProject: EnvironmentProject | null; readonly activeRightPanelSurface: RightPanelSurface | null; @@ -72,7 +71,6 @@ interface RightPanelActionsInput { export function useRightPanelActions(input: RightPanelActionsInput) { const { closeRightPanelSurface, - onRevealSurface, activeThreadRef, activeProject, activeRightPanelSurface, @@ -135,7 +133,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const createBrowserSurfaceStable = useStableCallback(createBrowserSurface); const addDiffSurface = () => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - onRevealSurface(); useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); @@ -204,7 +201,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); const activateRightPanelSurface = (surface: RightPanelSurface) => { if (!activeThreadRef) return; - onRevealSurface(); explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ surfaceKind: surface.kind, diffOpen,