From 20a509df8960eacd3b947235c42dd8dc8eeae894 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Thu, 4 Jun 2026 14:31:21 -0700 Subject: [PATCH] feat: arc-style sidebar-driven split view, remove top session tabs Remove the ephemeral top tab bar and use the sidebar as the single source of truth for session navigation (Arc browser pattern). Changes: - Remove sessionTabs useState and the top tab bar UI from SessionPage - Move splitSessionId to persisted session-management-store (Zustand) so split state survives page reloads - Add split indicators (Columns2 icon + primary left border) on the sidebar SessionMenuItem for the split session - Add 'Open in split view' / 'Close split' to both SessionActions dropdown and right-click SessionContextMenu - Add drag-to-split: dragging a session from the sidebar onto the main content area reveals a drop zone to create a side-by-side view - Wire splitSessionId and split callbacks through SidebarContext - Sidebar onOpenSession now routes directly instead of going through the removed openSessionTab wrapper --- .../domains/session/chat/session-page.tsx | 177 ++++++++---------- .../session/sidebar/app-sidebar-provider.tsx | 3 + .../domains/session/sidebar/app-sidebar.tsx | 41 +++- .../sidebar/session-management-store.ts | 21 ++- .../app/src/react-app/shell/session-route.tsx | 24 ++- 5 files changed, 163 insertions(+), 103 deletions(-) diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index c75890b294..2a766c7461 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -2,7 +2,7 @@ import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePanelRef } from "react-resizable-panels"; -import { Columns2, FileText, Globe, Mic2, Settings2, X, Zap } from "lucide-react"; +import { FileText, Globe, Mic2, Settings2, Zap } from "lucide-react"; import { t } from "../../../../i18n"; import { OPENWORK_EXTENSION_CATALOG } from "../../../../app/constants"; @@ -33,7 +33,7 @@ import { ConfirmModal } from "../../../design-system/modals/confirm-modal"; import ProviderAuthModal, { type ProviderAuthModalProps } from "../../connections/provider-auth/provider-auth-modal"; import { RenameSessionModal } from "../modals/rename-session-modal"; import { AppSidebar } from "../sidebar/app-sidebar"; -import { useSessionManagementStore } from "../sidebar/session-management-store"; +import { useSessionManagementStore, useSplitSessionId } from "../sidebar/session-management-store"; import { SessionSurface, type SessionSurfaceProps } from "../surface/session-surface"; import { SidebarInset, @@ -70,10 +70,8 @@ const STARTUP_SKELETON_ROWS = [ const GLOBAL_VOICE_SIDE_PANEL_KEY = "__openwork_voice__"; const EMPTY_TRANSCRIPT_TARGETS: OpenTarget[] = []; -type OpenSessionTab = { - workspaceId: string; - sessionId: string; -}; +/** Drag data type for dragging sessions from the sidebar to the split drop zone. */ +const SESSION_SPLIT_DRAG_TYPE = "application/x-openwork-session-id"; type StatusBarOverrides = Pick< StatusBarProps, @@ -311,8 +309,8 @@ export function SessionPage(props: SessionPageProps) { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false); const [sessionActionId, setSessionActionId] = useState(null); - const [sessionTabs, setSessionTabs] = useState([]); - const [splitSessionId, setSplitSessionId] = useState(null); + const splitSessionId = useSplitSessionId(props.selectedWorkspaceId); + const setSplitSession = useSessionManagementStore((s) => s.setSplitSession); const [createGroupOpen, setCreateGroupOpen] = useState(false); const [createGroupLabel, setCreateGroupLabel] = useState(""); const [createGroupWorkspaceId, setCreateGroupWorkspaceId] = useState(null); @@ -561,28 +559,17 @@ export function SessionPage(props: SessionPageProps) { () => sessionTitleForId(props.sidebar.workspaceSessionGroups, props.selectedSessionId), [props.selectedSessionId, props.sidebar.workspaceSessionGroups], ); - useEffect(() => { - setSessionTabs((current) => { - const currentWorkspaceTabs = current.filter((tab) => tab.workspaceId === props.selectedWorkspaceId); - const next = props.selectedSessionId && !currentWorkspaceTabs.some((tab) => tab.sessionId === props.selectedSessionId) - ? [...currentWorkspaceTabs, { workspaceId: props.selectedWorkspaceId, sessionId: props.selectedSessionId }] - : currentWorkspaceTabs; - return next.filter((tab) => ( - tab.sessionId === props.selectedSessionId || - sessionExistsInWorkspace(props.sidebar.workspaceSessionGroups, tab.workspaceId, tab.sessionId) - )); - }); - }, [props.selectedSessionId, props.selectedWorkspaceId, props.sidebar.workspaceSessionGroups]); + // Auto-clear stale split when the split session no longer exists or equals the active session. useEffect(() => { if (!splitSessionId) return; if (splitSessionId === props.selectedSessionId) { - setSplitSessionId(null); + setSplitSession(props.selectedWorkspaceId, null); return; } if (!sessionExistsInWorkspace(props.sidebar.workspaceSessionGroups, props.selectedWorkspaceId, splitSessionId)) { - setSplitSessionId(null); + setSplitSession(props.selectedWorkspaceId, null); } - }, [props.selectedSessionId, props.selectedWorkspaceId, props.sidebar.workspaceSessionGroups, splitSessionId]); + }, [props.selectedSessionId, props.selectedWorkspaceId, props.sidebar.workspaceSessionGroups, splitSessionId, setSplitSession]); const sessionActionTitle = useMemo( () => sessionTitleForId(props.sidebar.workspaceSessionGroups, sessionActionId), [props.sidebar.workspaceSessionGroups, sessionActionId], @@ -645,27 +632,16 @@ export function SessionPage(props: SessionPageProps) { ); const canRenderSplitSurface = Boolean(canRenderReactSurface && splitSessionId && splitSessionId !== props.selectedSessionId); - const openSessionTab = useCallback((workspaceId: string, sessionId: string) => { - setSessionTabs((current) => { - const next = current.filter((tab) => tab.workspaceId === workspaceId); - if (next.some((tab) => tab.sessionId === sessionId)) return next; - return [...next, { workspaceId, sessionId }]; - }); - props.sidebar.onOpenSession(workspaceId, sessionId); - }, [props.sidebar]); + const handleSplitSession = useCallback((workspaceId: string, sessionId: string) => { + setSplitSession(workspaceId, sessionId); + }, [setSplitSession]); - const closeSessionTab = useCallback((sessionId: string) => { - setSessionTabs((current) => current.filter((tab) => tab.sessionId !== sessionId)); - setSplitSessionId((current) => current === sessionId ? null : current); - if (sessionId !== props.selectedSessionId) return; + const handleCloseSplit = useCallback((workspaceId: string) => { + setSplitSession(workspaceId, null); + }, [setSplitSession]); - const nextTab = sessionTabs.find((tab) => tab.sessionId !== sessionId && tab.workspaceId === props.selectedWorkspaceId); - if (nextTab) { - props.sidebar.onOpenSession(nextTab.workspaceId, nextTab.sessionId); - return; - } - props.sidebar.onSelectWorkspace(props.selectedWorkspaceId); - }, [props.selectedSessionId, props.selectedWorkspaceId, props.sidebar, sessionTabs]); + // Drop zone state for drag-to-split + const [splitDropActive, setSplitDropActive] = useState(false); useEffect(() => { if (!showSessionLoadingState) { @@ -736,6 +712,7 @@ export function SessionPage(props: SessionPageProps) { selectedWorkspaceId={props.sidebar.selectedWorkspaceId} developerMode={props.sidebar.developerMode} selectedSessionId={props.sidebar.selectedSessionId} + splitSessionId={splitSessionId} showInitialLoading={sidebarInitialLoading} showSessionActions={Boolean(props.onRenameSession || props.onDeleteSession || props.onArchiveSession)} sessionStatusById={props.sidebar.sessionStatusById} @@ -743,7 +720,7 @@ export function SessionPage(props: SessionPageProps) { workspaceConnectionStateById={props.sidebar.workspaceConnectionStateById} newTaskDisabled={props.sidebar.newTaskDisabled} onSelectWorkspace={props.sidebar.onSelectWorkspace} - onOpenSession={openSessionTab} + onOpenSession={props.sidebar.onOpenSession} onPrefetchSession={props.sidebar.onPrefetchSession} onCreateTaskInWorkspace={props.sidebar.onCreateTaskInWorkspace} onOpenRenameSession={props.onRenameSession ? openRenameModal : undefined} @@ -759,6 +736,8 @@ export function SessionPage(props: SessionPageProps) { setCreateGroupLabel(""); setCreateGroupOpen(true); }} + onSplitSession={handleSplitSession} + onCloseSplit={handleCloseSplit} onOpenRenameWorkspace={props.sidebar.onOpenRenameWorkspace} onShareWorkspace={props.sidebar.onShareWorkspace} onRevealWorkspace={props.sidebar.onRevealWorkspace} @@ -823,7 +802,21 @@ export function SessionPage(props: SessionPageProps) {
-
+
{ + if (!canRenderSplitSurface && e.dataTransfer.types.includes(SESSION_SPLIT_DRAG_TYPE)) { + e.preventDefault(); + setSplitDropActive(true); + } + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setSplitDropActive(false); + } + }} + onDrop={() => setSplitDropActive(false)} + > {showStartupSkeleton ? (
@@ -869,64 +862,9 @@ export function SessionPage(props: SessionPageProps) { {!showDelayedSessionLoadingState && canRenderReactSurface ? (
- {sessionTabs.length > 0 ? ( -
- {sessionTabs.map((tab) => { - const title = sessionTitleForId(props.sidebar.workspaceSessionGroups, tab.sessionId) || t("session.default_title"); - const active = tab.sessionId === props.selectedSessionId; - const split = tab.sessionId === splitSessionId; - return ( -
- - - -
- ); - })} -
- ) : null}
+ ) : !canRenderSplitSurface ? ( + /* Drag-to-split drop zone: drag a session from the sidebar here to split */ +
{ + if (e.dataTransfer.types.includes(SESSION_SPLIT_DRAG_TYPE)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setSplitDropActive(true); + } + }} + onDragEnter={(e) => { + if (e.dataTransfer.types.includes(SESSION_SPLIT_DRAG_TYPE)) { + setSplitDropActive(true); + } + }} + onDragLeave={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setSplitDropActive(false); + } + }} + onDrop={(e) => { + setSplitDropActive(false); + const sessionId = e.dataTransfer.getData(SESSION_SPLIT_DRAG_TYPE); + if (sessionId && sessionId !== props.selectedSessionId) { + setSplitSession(props.selectedWorkspaceId, sessionId); + } + }} + aria-label="Drop a session here to open side-by-side" + > + {splitDropActive ? ( + + Drop to split + + ) : null} +
) : null}
diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx index b8efd3bc1e..6bf5e4969a 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx @@ -5,6 +5,7 @@ import type { WorkspaceConnectionState } from "../../../../app/types"; export type SidebarContextValue = { selectedWorkspaceId: string; selectedSessionId: string | null; + splitSessionId: string | null; developerMode: boolean; showSessionActions?: boolean; sessionStatusById?: Record; @@ -19,6 +20,8 @@ export type SidebarContextValue = { onOpenDeleteSession?: (sessionId: string) => void; onArchiveSession?: (sessionId: string, archived: boolean) => void; onOpenCreateGroupModal?: (workspaceId: string) => void; + onSplitSession?: (workspaceId: string, sessionId: string) => void; + onCloseSplit?: (workspaceId: string) => void; onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onRevealWorkspace: (workspaceId: string) => void; diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 9b29f12423..870f1a59fb 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -5,6 +5,7 @@ import { Archive, ArchiveRestore, ChevronRight, + Columns2, FolderPlus, Loader2, MoreHorizontal, @@ -19,6 +20,7 @@ import { Settings, FolderOpen, Tag, + X, } from "lucide-react"; import { LazyMotion, Reorder, domMax, m, useDragControls } from "motion/react"; @@ -202,6 +204,8 @@ function SessionGroupSubmenu({ workspaceId, sessionId }: { workspaceId: string; function SessionActions({ className, sessionId, workspaceId, isPinned, isArchived }: SessionActionsProps) { const ctx = useSidebarContext(); const store = useSessionManagementStore; + const isSplit = ctx.splitSessionId === sessionId; + const isActive = ctx.selectedSessionId === sessionId; if (!useCanManageSession()) return null; return ( @@ -214,6 +218,12 @@ function SessionActions({ className, sessionId, workspaceId, isPinned, isArchive } /> + {ctx.onSplitSession && !isActive ? ( + isSplit ? ctx.onCloseSplit?.(workspaceId) : ctx.onSplitSession?.(workspaceId, sessionId)}> + {isSplit ? : } + {isSplit ? "Close split" : "Open in split view"} + + ) : null} store.getState().togglePin(sessionId)}> {isPinned ? : } {isPinned ? t("session_management.unpin_session") : t("session_management.pin_session")} @@ -258,12 +268,20 @@ type SessionContextMenuProps = { function SessionContextMenu({ children, sessionId, workspaceId, isPinned, isArchived }: SessionContextMenuProps) { const ctx = useSidebarContext(); const store = useSessionManagementStore; + const isSplit = ctx.splitSessionId === sessionId; + const isActive = ctx.selectedSessionId === sessionId; if (!useCanManageSession()) return children; return ( + {ctx.onSplitSession && !isActive ? ( + isSplit ? ctx.onCloseSplit?.(workspaceId) : ctx.onSplitSession?.(workspaceId, sessionId)}> + {isSplit ? : } + {isSplit ? "Close split" : "Open in split view"} + + ) : null} store.getState().togglePin(sessionId)}> {isPinned ? : } {isPinned ? t("session_management.unpin_session") : t("session_management.pin_session")} @@ -473,6 +491,7 @@ export type AppSidebarProps = { selectedWorkspaceId: string; developerMode: boolean; selectedSessionId: string | null; + splitSessionId: string | null; showSessionActions?: boolean; sessionStatusById?: Record; connectingWorkspaceId: string | null; @@ -486,6 +505,8 @@ export type AppSidebarProps = { onOpenDeleteSession?: (sessionId: string) => void; onArchiveSession?: (sessionId: string, archived: boolean) => void; onOpenCreateGroupModal?: (workspaceId: string) => void; + onSplitSession?: (workspaceId: string, sessionId: string) => void; + onCloseSplit?: (workspaceId: string) => void; onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onRevealWorkspace: (workspaceId: string) => void; @@ -608,6 +629,7 @@ export function AppSidebar(props: AppSidebarProps) { const contextValue: SidebarContextValue = { selectedWorkspaceId: props.selectedWorkspaceId, selectedSessionId: props.selectedSessionId, + splitSessionId: props.splitSessionId, developerMode: props.developerMode, showSessionActions: props.showSessionActions, sessionStatusById: props.sessionStatusById, @@ -622,6 +644,8 @@ export function AppSidebar(props: AppSidebarProps) { onOpenDeleteSession: props.onOpenDeleteSession, onArchiveSession: props.onArchiveSession, onOpenCreateGroupModal: props.onOpenCreateGroupModal, + onSplitSession: props.onSplitSession, + onCloseSplit: props.onCloseSplit, onOpenRenameWorkspace: props.onOpenRenameWorkspace, onShareWorkspace: props.onShareWorkspace, onRevealWorkspace: props.onRevealWorkspace, @@ -1325,6 +1349,16 @@ function PinnedIndicator({ isPinned }: { isPinned: boolean }) { ); } +function SplitIndicator({ isSplit }: { isSplit: boolean }) { + if (!isSplit) return null; + return ( + + ); +} + type SessionMenuItemProps = { session: SessionListItem; depth: number; @@ -1346,6 +1380,7 @@ function SessionMenuItem({ }: SessionMenuItemProps) { const ctx = useSidebarContext(); const isSelected = ctx.selectedSessionId === session.id; + const isSplit = ctx.splitSessionId === session.id; const displayTitle = getDisplaySessionTitle(session.title); const hasChildren = (tree.descendantCountBySessionId.get(session.id) ?? 0) > 0; const isExpanded = ctx.expandedSessionIds.has(session.id) || forcedExpandedSessionIds.has(session.id); @@ -1385,13 +1420,14 @@ function SessionMenuItem({ 0 && "ps-13")} + className={cn("relative", depth > 0 && "ps-13", isSplit && "border-l-2 border-l-primary/50")} isActive={isSelected} onClick={openSession} onPointerEnter={prefetchSession} onFocus={prefetchSession} > + 0 && "ps-13", isSessionStreaming || isSessionActive && "pe-8")} + className={cn("transition-[padding] duration-75 group-hover/menu-sub-item:pe-8 group-has-data-popup-open/menu-sub-item:pe-8", depth > 0 && "ps-13", isSessionStreaming || isSessionActive && "pe-8", isSplit && "border-l-2 border-l-primary/50")} > + {displayTitle} diff --git a/apps/app/src/react-app/domains/session/sidebar/session-management-store.ts b/apps/app/src/react-app/domains/session/sidebar/session-management-store.ts index bedb457854..de82b8f6a9 100644 --- a/apps/app/src/react-app/domains/session/sidebar/session-management-store.ts +++ b/apps/app/src/react-app/domains/session/sidebar/session-management-store.ts @@ -24,6 +24,8 @@ type SessionManagementState = { pinnedIds: string[]; orderByWorkspace: Record; groupsByWorkspace: Record; + /** Per-workspace split session id. When set, this session is shown side-by-side with the active session. */ + splitByWorkspace: Record; }; type SessionManagementActions = { @@ -36,6 +38,8 @@ type SessionManagementActions = { /** Remove a group definition. Sessions assigned to it become ungrouped. */ removeGroup: (workspaceId: string, groupId: string) => void; forgetWorkspace: (workspaceId: string) => void; + /** Set or toggle a split session for a workspace. Pass null to clear. */ + setSplitSession: (workspaceId: string, sessionId: string | null) => void; }; type SessionManagementStore = SessionManagementState & SessionManagementActions; @@ -48,6 +52,7 @@ export const useSessionManagementStore = create()( pinnedIds: [], orderByWorkspace: {}, groupsByWorkspace: {}, + splitByWorkspace: {}, togglePin: (sessionId) => set((state) => { @@ -152,11 +157,21 @@ export const useSessionManagementStore = create()( }; }), + setSplitSession: (workspaceId, sessionId) => + set((state) => { + if (!sessionId) { + const { [workspaceId]: _, ...rest } = state.splitByWorkspace; + return { splitByWorkspace: rest }; + } + return { splitByWorkspace: { ...state.splitByWorkspace, [workspaceId]: sessionId } }; + }), + forgetWorkspace: (workspaceId) => set((state) => { const { [workspaceId]: _o, ...orderRest } = state.orderByWorkspace; const { [workspaceId]: _g, ...groupsRest } = state.groupsByWorkspace; - return { orderByWorkspace: orderRest, groupsByWorkspace: groupsRest }; + const { [workspaceId]: _s, ...splitRest } = state.splitByWorkspace; + return { orderByWorkspace: orderRest, groupsByWorkspace: groupsRest, splitByWorkspace: splitRest }; }), }), { @@ -187,3 +202,7 @@ export function useSessionOrder(workspaceId: string): string[] { export function useWorkspaceGroups(workspaceId: string): WorkspaceGroupState { return useSessionManagementStore((s) => s.groupsByWorkspace[workspaceId] ?? EMPTY_GROUP_STATE); } + +export function useSplitSessionId(workspaceId: string): string | null { + return useSessionManagementStore((s) => s.splitByWorkspace[workspaceId] ?? null); +} diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index a3fecfc42b..d34d0200c5 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -118,7 +118,7 @@ import { } from "@/react-app/domains/workspace/remote-workspace-diagnostics"; import { useShareWorkspaceState } from "@/react-app/domains/workspace/share-workspace-state"; import { ModelPickerModal } from "@/react-app/domains/session/modals/model-picker-modal"; -import { CommandPalette, type AccessibleTargetOption, type SessionOption as PaletteSessionOption } from "./command-palette"; +import { CommandPalette, type PaletteItem, type SessionOption as PaletteSessionOption } from "./command-palette"; import { getDisplaySessionTitle } from "@/app/lib/session-title"; import { useBootState } from "./boot-state"; import { @@ -588,6 +588,7 @@ export function SessionRoute() { const [renameWorkspaceTitle, setRenameWorkspaceTitle] = useState(""); const [renameWorkspaceBusy, setRenameWorkspaceBusy] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [terminalOpen, setTerminalOpen] = useState(false); const [paletteAccessibleTargets, setPaletteAccessibleTargets] = useState([]); // Model picker modal state (ported from settings-route; previously the // session "Pick a model" button navigated to /settings/general, which is a @@ -2518,6 +2519,7 @@ export function SessionRoute() { // Global shortcuts: // Cmd/Ctrl+N -> new task in selected workspace // Cmd/Ctrl+K -> toggle command palette + // Cmd/Ctrl+J -> toggle terminal panel (matches VS Code) const handleGlobalShortcut = useEffectEvent((event: KeyboardEvent) => { const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const mod = isMac ? event.metaKey : event.ctrlKey; @@ -2542,6 +2544,11 @@ export function SessionRoute() { if (key === "k") { event.preventDefault(); setCommandPaletteOpen((value) => !value); + return; + } + if (key === "j") { + event.preventDefault(); + setTerminalOpen((value) => !value); } }); @@ -2655,6 +2662,20 @@ export function SessionRoute() { return out; }, [sessionsByWorkspaceId, selectedWorkspaceId, workspaces]); + const terminalPaletteItems = useMemo(() => [ + { + id: "terminal.toggle", + title: terminalOpen ? "Hide terminal" : "Show terminal", + detail: "Toggle the integrated terminal panel for this workspace", + meta: "Cmd/Ctrl+J", + searchText: "terminal shell command line console show hide toggle", + action: () => { + setCommandPaletteOpen(false); + setTerminalOpen((value) => !value); + }, + }, + ], [terminalOpen]); + const handleReorderWorkspaces = useCallback((workspaceIds: string[]) => { const activeWorkspaceIds = new Set(workspacesRef.current.map((workspace) => workspace.id)); const nextOrderIds: string[] = []; @@ -3171,6 +3192,7 @@ export function SessionRoute() { } }} sessions={paletteSessionOptions} + extraItems={terminalPaletteItems} />