diff --git a/README.md b/README.md index d1af3d57f6e76..404b4d625abed 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This is a fork of T3 Code that integrates a few additional features, including: - "Chat + Replies" keeps the main list short: it shows only first messages, and the replies to a message show in a Replies tab in the right panel (+ → Replies, or the reply line under a message). Point at a message and choose Reply to answer under it; the tab has its own reply box. Under each first message you see how many replies it has, how many are new, and a few generated words on what they are about. The agent answers a message with `send_to_user` and that message's id, so each answer lands under the message it answers. A Cite in the main list opens that message's replies and puts the quote in their reply box, unless the main box or the open reply box already has text; then the quote goes there, the main box first. - In all three modes the agent sends one reply per message you type, and before a long task it may first send a short "on it" note; a thin line parts two agent messages in a row. To say more, it rewrites its reply in place with its `replace_send_to_user` tool. A rewritten reply shows a small "Edited" mark, and in Chat + Replies it counts as new. - Settings → Threads → New threads → Display mode picks the mode for threads you start from the message box in the web or desktop app. Threads that agents, scheduled tasks, side chats or the mobile app start open in Normal, and existing threads keep their mode. +- **Split the right panel in two:** drag a right-panel tab onto the left or right part of the panel's content and drop it to get two panes side by side, each with its own tabs, as with Windows Snap. Drag tabs between the panes, drag the line between them to resize (double-click resets it), and close or move away a pane's last tab to go back to one pane. The tab menu has Split right, Split left, Move to the other pane and Unsplit. New tabs open in the pane you last used. A narrow window shows all tabs in one strip and keeps the split for when there is room. A press anywhere on a tab now switches to it at once. - Copy buttons on pages in the preview browser now put the copied text into your own clipboard. - **Two preview browser modes:** Settings → Browser → Preview browser picks the browser the server runs for preview tabs. **T3 native** (fast) is the default. It runs chrome-headless-shell, Chrome's small build that runs without a window. **Chrome** (slow) runs full Chrome, for pages that don't work in T3 native. On platforms where T3 has no Chrome for Testing build (Google's Chrome build for test automation), both choices run T3 native. Switching closes the open preview tabs. - **Compact when idle for Claude threads:** a choice in Settings → Providers → Advanced → Compact idle threads. Besides compacting before the prompt cache expires, it compacts a large idle Claude thread (default 400K tokens or more) a set time (default 15 minutes) after the last message or answer from a user or a bot, so the compaction runs while no one waits for a reply. The token counts and the wait are set right under the choice. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 462e5532ac4c7..afabd2d9a249f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,6 +167,7 @@ import { selectActiveRightPanelSurface, selectThreadPanelOpen, selectThreadRightPanelState, + type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore"; @@ -364,6 +365,8 @@ import { observeResize } from "~/lib/observeResize"; import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; import { ChatRightPanel } from "./chat/ChatRightPanel"; +import { useSplitRendered } from "../fork/rightPanelSplit/renderedSplit"; +import { unfocusedPaneSurface } from "../fork/rightPanelSplit/splitLayout.logic"; import { useDashboardSurfaceAdder } from "../fork/dashboard/dashboardSurface"; import { REPLY_BOX_QUEUE_EDIT_BLOCKED, subThreadDraftId } from "../fork/subThreads/subThreadReply"; import { useSubThreadCite } from "../fork/subThreads/subThreadCite"; @@ -1083,6 +1086,7 @@ export default function ChatView(props: ChatViewProps) { const rightPanelPresenceValue = { activeSurface: activeRightPanelSurface, surfaces: rightPanelState.surfaces, + split: rightPanelState.split, }; const rightPanelPresence = usePanelPresence( rightPanelOpen && activeThreadRef !== null, @@ -1097,10 +1101,16 @@ 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 rightPanelSplitRendered = useSplitRendered(activeThreadKey); + const previewMiniPlayerVisible = + shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.source ?? null, + renderedRightPanelSurface, + ) && + shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.source ?? null, + rightPanelSplitRendered ? unfocusedPaneSurface(rightPanelState) : null, + ); const canMaximizeRightPanel = rightPanelOpen; // An embedded side chat has no panel; a stale maximized flag would hide its own column. const rightPanelMaximized = @@ -3210,59 +3220,60 @@ export default function ChatView(props: ChatViewProps) { return embedded ? null : ; } - const rightPanelContent = activeThreadRef ? ( - 1 - ? addPullRequestsSurfaceStable - : undefined, - }} - files={{ - projectId: activeProject?.id, - projectName: activeProject?.title ?? null, - workspaceRoot: activeWorkspaceRoot, - availableEditors, - pendingSurfaceIds: pendingFileSurfaceIds, - onOpenFile: openFileSurfaceStable, - onPendingChange: handleFilePendingChangeStable, - }} - /> - ) : null; + const renderRightPanelSurface = (surface: RightPanelSurface, visible: boolean) => + activeThreadRef ? ( + 1 + ? addPullRequestsSurfaceStable + : undefined, + }} + files={{ + projectId: activeProject?.id, + projectName: activeProject?.title ?? null, + workspaceRoot: activeWorkspaceRoot, + availableEditors, + pendingSurfaceIds: pendingFileSurfaceIds, + onOpenFile: openFileSurfaceStable, + onPendingChange: handleFilePendingChangeStable, + }} + /> + ) : null; const panelToggleControlProps = { terminalAvailable: activeProject !== null, terminalOpen: terminalUiState.terminalOpen, @@ -4027,9 +4038,10 @@ export default function ChatView(props: ChatViewProps) { deviceAvailable={activeThreadRef !== null} sideChatAvailable={sideChat.available} sideChatDisabledReason={sideChatDisabledReason} - > - {rightPanelContent} - + threadRef={activeThreadRef} + split={rightPanelPresence.value?.split} + renderSurface={renderRightPanelSurface} + /> ) : null} {subThreadDraft === null ? ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 51b7c6ea8be1a..e7c4c0becc6f4 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -25,14 +25,15 @@ import type { } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { dashboardSurfaceActions, ModeSurfaceIcon } from "~/fork/dashboard/dashboardSurface"; +import type { SplitMenuAction } from "~/fork/rightPanelSplit/splitLayout.logic"; import { - closestCenter, - DndContext, - type DragEndEvent, - PointerSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; + SplitPaneFrame, + useRightPanelPane, + useTabDragData, + useTabDragSensors, + useTabPress, +} from "~/fork/rightPanelSplit/tabStrip"; +import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core"; import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; @@ -130,9 +131,10 @@ export interface RightPanelTabsProps { onActivate: (surface: RightPanelSurface) => void; onRenameDevice?: (surfaceId: string, title: string) => void; onCloseSurface: (surface: RightPanelSurface) => void; - onCloseOtherSurfaces: (surface: RightPanelSurface) => void; - onCloseSurfacesToRight: (surface: RightPanelSurface) => void; - onCloseAllSurfaces: () => void; + /** `strip` is the strip that opened the menu: one pane of a split, or every tab. */ + onCloseOtherSurfaces: (surface: RightPanelSurface, strip: readonly RightPanelSurface[]) => void; + onCloseSurfacesToRight: (surface: RightPanelSurface, strip: readonly RightPanelSurface[]) => void; + onCloseAllSurfaces: (strip: readonly RightPanelSurface[]) => void; onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; @@ -842,7 +844,8 @@ function SortableTabList(props: { onMove: ((surfaceId: string, toIndex: number) => void) | undefined; children: ReactNode; }) { - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); + const sensors = useTabDragSensors(); + const inDragScope = useRightPanelPane() !== null; const surfaceIds = props.surfaces.map((surface) => surface.id); const { onMove, surfaces } = props; const handleDragEnd = ({ active, over }: DragEndEvent) => { @@ -850,6 +853,12 @@ function SortableTabList(props: { const toIndex = surfaces.findIndex((surface) => surface.id === over.id); if (toIndex >= 0) onMove?.(String(active.id), toIndex); }; + const sortable = ( + + {props.children} + + ); + if (inDragScope) return sortable; return ( - - {props.children} - + {sortable} ); } @@ -910,9 +917,13 @@ function RightPanelTab( onStartRename, onFinishRename, } = props; + const press = useTabPress(surface, onActivate); + const inDragScope = useRightPanelPane() !== null; + const dragData = useTabDragData(props.title); const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: surface.id, disabled: props.dragDisabled, + data: dragData, }); const sideChatShell = useQuietThreadShell( surface.kind === "side-chat" && props.environmentId !== null @@ -991,12 +1002,12 @@ function RightPanelTab( render={ @@ -1020,11 +1031,19 @@ function RightPanelTab( return (
{ + preventTabMiddleButtonDefault(event); + press.onMouseDown(event); + }} onMouseUp={preventTabMiddleButtonDefault} onAuxClick={(event) => onAuxClick(event, surface)} onContextMenu={(event) => onContextMenu(event, surface)} + onClick={press.onClick} {...listeners} + onPointerDown={(event) => { + press.onPointerDown(event); + listeners?.onPointerDown?.(event); + }} ref={setNodeRef} style={{ transform: CSS.Translate.toString(transform), transition }} className={cn( @@ -1033,7 +1052,7 @@ function RightPanelTab( props.active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - isDragging && "relative z-10", + isDragging && (inDragScope ? "opacity-50" : "relative z-10"), )} > {content} @@ -1074,12 +1093,15 @@ export function RightPanelTabs(props: RightPanelTabsProps) { setAddSurfaceMenuOpen(true); } }); + // Inside ChatView's split view, which owns the shell and the split menu entries. + const pane = useRightPanelPane(); + const takesNewSurfaceShortcut = props.open !== false && pane?.focused !== false; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a document keydown listener for the new surface shortcut while the panel is open useEffect(() => { - if (props.open === false) return; + if (!takesNewSurfaceShortcut) return; document.addEventListener("keydown", onNewSurfaceKeyDown, true); return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); - }, [props.open]); + }, [takesNewSurfaceShortcut]); const updateTabScrollState = (viewport: HTMLDivElement) => { const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; @@ -1200,7 +1222,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; - const items: ContextMenuItem[] = []; + const items: ContextMenuItem[] = []; if (surface.kind === "device" && props.onRenameDevice) items.push({ id: "rename", label: "Rename" }); if (surface.kind === "file" && surface.attachment === undefined) { @@ -1235,6 +1257,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { label: "Close all", disabled: props.surfaces.length === 0, }, + ...(pane?.tabMenu.items(surface) ?? []), ); const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); @@ -1261,16 +1284,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { props.onCloseSurface(surface); break; case "close-others": - props.onCloseOtherSurfaces(surface); + props.onCloseOtherSurfaces(surface, props.surfaces); break; case "close-to-right": - props.onCloseSurfacesToRight(surface); + props.onCloseSurfacesToRight(surface, props.surfaces); break; case "close-all": - props.onCloseAllSurfaces(); + props.onCloseAllSurfaces(props.surfaces); break; case null: break; + default: + pane?.tabMenu.select(action, surface); } }; const handleTabAuxClick = (event: ReactMouseEvent, surface: RightPanelSurface) => { @@ -1336,8 +1361,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }; }; + const Frame = pane ? SplitPaneFrame : PreviewPanelShell; + const atLeftEdge = pane?.side !== "right"; + const atRightEdge = pane?.side !== "left"; return ( - @@ -1539,10 +1570,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
) : null} {props.layoutControls} - {ownsDesktopTitleBar && !props.layoutControls ? ( + {ownsDesktopTitleBar && !props.layoutControls && atRightEdge ? ( ) : null} - {ownsDesktopTitleBar ? ( + {ownsDesktopTitleBar && atRightEdge ? ( - + ); } diff --git a/apps/web/src/components/chat/ChatRightPanel.tsx b/apps/web/src/components/chat/ChatRightPanel.tsx index 69da3a9ddccd1..90def1a1687cd 100644 --- a/apps/web/src/components/chat/ChatRightPanel.tsx +++ b/apps/web/src/components/chat/ChatRightPanel.tsx @@ -1,8 +1,11 @@ import { usePreviewPanelInlineSize } from "../../hooks/usePreviewPanelInlineSize"; +import { + RightPanelSplitView, + type RightPanelSplitViewProps, +} from "../../fork/rightPanelSplit/RightPanelSplitView"; import { RightPanelSheet } from "../RightPanelSheet"; -import { RightPanelTabs, type RightPanelTabsProps } from "../RightPanelTabs"; -type ChatRightPanelProps = Omit & { +type ChatRightPanelProps = Omit & { readonly sheet: boolean; readonly maximized: boolean; readonly workspaceLayoutElement: HTMLElement | null; @@ -32,7 +35,9 @@ export function ChatRightPanel(props: ChatRightPanelProps) { widthStorageKey, }); if (!sheet) { - return ; + return ( + + ); } return ( - @@ -239,7 +240,7 @@ export function RightPanelSurfaceContent(props: { composerDraftTarget={props.composerDraftTarget} keybindings={props.keybindings} availableEditors={files.availableEditors} - openInShortcutEnabled={visible} + openInShortcutEnabled={visible && props.activeSurfaceId === surface.id} relativePath={surface.kind === "file" ? surface.relativePath : null} {...(attachment ? { attachment } : {})} revealLine={surface.kind === "file" ? (surface.revealLine ?? null) : null} diff --git a/apps/web/src/components/chat/useChatTerminalActions.ts b/apps/web/src/components/chat/useChatTerminalActions.ts index a64c748e81432..3f8ad78287511 100644 --- a/apps/web/src/components/chat/useChatTerminalActions.ts +++ b/apps/web/src/components/chat/useChatTerminalActions.ts @@ -363,10 +363,14 @@ export function useChatTerminalActions(input: ChatTerminalActionsInput) { }; const splitPanelTerminalVerticalStable = useStableCallback(splitPanelTerminalVertical); const activatePanelTerminal = (terminalId: string) => { - if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; - useRightPanelStore - .getState() - .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); + if (!activeThreadRef) return; + // Look the owner up: a press in a split panel's other pane runs before its focus change renders. + const store = useRightPanelStore.getState(); + const surface = selectThreadRightPanelState(store.byThreadKey, activeThreadRef).surfaces.find( + (entry) => entry.kind === "terminal" && entry.terminalIds.includes(terminalId), + ); + if (!surface) return; + store.activateTerminal(activeThreadRef, surface.id, terminalId); focusTerminal(activeThreadRef, terminalId); }; const activatePanelTerminalStable = useStableCallback(activatePanelTerminal); diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx index 9de48226672f5..903cab1defba3 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -17,6 +17,8 @@ interface Props { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; visible: boolean; + /** False in a split view's unfocused pane, so preview commands reach one browser. */ + actionsEnabled?: boolean; onSendAnnotation?: ( annotation: PreviewAnnotationPayload, image: ComposerImageAttachment | null, @@ -29,6 +31,7 @@ export function PreviewPanel({ tabId, configuredUrls, visible, + actionsEnabled, onSendAnnotation, }: Props) { const available = usePreviewAvailable(threadRef.environmentId); @@ -54,6 +57,7 @@ export function PreviewPanel({ {...(tabId !== undefined ? { tabId } : {})} configuredUrls={configuredUrls} visible={visible} + actionsEnabled={actionsEnabled ?? true} {...(onSendAnnotation ? { onSendAnnotation } : {})} /> diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index f7cb3082491e9..a51835066c1ff 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -108,6 +108,7 @@ interface Props { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; visible: boolean; + actionsEnabled?: boolean; onSendAnnotation?: ( annotation: PreviewAnnotationPayload, image: ComposerImageAttachment | null, @@ -164,6 +165,7 @@ export function PreviewView({ tabId: requestedTabId, configuredUrls, visible, + actionsEnabled = true, onSendAnnotation, }: Props) { const urlInputRef = useRef(null); @@ -947,7 +949,7 @@ export function PreviewView({ ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview and desktop menu actions while the view is visible useEffect(() => { - if (!visible) return; + if (!visible || !actionsEnabled) return; const unsubscribeAction = subscribePreviewAction((action) => onPreviewAction(action)); // A focused page swallows its own keydowns, so the desktop forwards these // shortcuts from the guest as menu actions instead. @@ -962,7 +964,7 @@ export function PreviewView({ unsubscribeAction(); unsubscribeMenu?.(); }; - }, [visible]); + }, [visible, actionsEnabled]); return (
+ element.matches('[data-slot="scroll-area-viewport"]') && + element.closest("[data-right-panel-tab-list]") !== null, +}; + +function titleOf(data: unknown): string { + return (data as Partial | undefined)?.title ?? "Tab"; +} + +function dropOverOf(over: Over | null): DropOver | null { + if (over === null) return null; + const data = over.data.current as Partial | ContentDropData | undefined; + if (data?.kind === "content") return { kind: "content", pane: data.pane, rect: over.rect }; + if (data?.kind === "tab") return { kind: "tab", id: String(over.id), rect: over.rect }; + return null; +} + +/** Records a press before it activates its tab; each pane hands it to its strip. */ +export const NotePressContext = createContext<(surfaceId: string) => void>(() => {}); + +const ANNOUNCEMENTS: Announcements = { + onDragStart: ({ active }) => `Picked up ${titleOf(active.data.current)}.`, + onDragOver: ({ active, over }) => + over ? `${titleOf(active.data.current)} is over a drop target.` : undefined, + onDragEnd: ({ active, over }) => + over + ? `${titleOf(active.data.current)} was dropped.` + : `${titleOf(active.data.current)} was put back.`, + onDragCancel: ({ active }) => `Moving ${titleOf(active.data.current)} was cancelled.`, +}; + +/** + * One drag context around every strip and pane of a ChatView panel, so a tab can be reordered, + * moved to the other pane, or dropped on content to split. Drag state renders only the chip and + * the ghost, never a surface. + */ +export function RightPanelDragScope(props: { + readonly threadRef: ScopedThreadRef; + readonly splitAllowed: boolean; + readonly splitRendered: boolean; + readonly splitGrowth: number; + /** Applies a drop's layout change; the split view owns what a change does beyond the store. */ + readonly applyChange: (change: SplitChange) => void; + readonly children: ReactNode; +}) { + const { threadRef } = props; + const sensors = useTabDragSensors(); + const [session, setSession] = useState<{ title: string; plan: DropPlan | null } | null>(null); + const press = useRef<{ surfaceId: string; restoreShown: string | null } | null>(null); + // The latest pointer, as collision detection last saw it. An event's `delta` would not do: + // dnd-kit adds the tab strip's auto-scroll to it. + const pointer = useRef<{ x: number; y: number } | null>(null); + const readThread = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); + + const notePress = (surfaceId: string) => { + const thread = readThread(); + const layout = paneLayout(thread); + const shown = + layout.kind === "split" ? layout[paneOf(thread, surfaceId)].shown.id : thread.activeSurfaceId; + press.current = { surfaceId, restoreShown: shown === surfaceId ? null : shown }; + }; + + const collisionDetection: CollisionDetection = (args) => { + pointer.current = args.pointerCoordinates; + return panelCollision(args); + }; + + const planFor = (event: DragMoveEvent | DragEndEvent): DropPlan | null => { + const draggedId = String(event.active.id); + return resolveDrop({ + arrangement: readThread(), + splitRendered: props.splitRendered, + splitAllowed: props.splitAllowed, + draggedId, + over: dropOverOf(event.over), + pointer: pointer.current, + restoreShown: press.current?.surfaceId === draggedId ? press.current.restoreShown : null, + splitGrowth: props.splitGrowth, + }); + }; + + // `over` also changes without a move, once droppables are measured or a strip scrolls. + const updatePlan = (event: DragMoveEvent) => { + const plan = planFor(event); + setSession((current) => + current === null || samePlan(current.plan, plan) ? current : { ...current, plan }, + ); + }; + + const finish = () => { + press.current = null; + pointer.current = null; + setSession(null); + }; + + return ( + + { + // A touch drag starts without a press activation, so an older press record is stale. + const pressed = + activatorEvent instanceof PointerEvent && activatorEvent.pointerType !== "touch"; + if (!pressed || press.current?.surfaceId !== active.id) press.current = null; + setSession({ title: titleOf(active.data.current), plan: null }); + }} + onDragMove={updatePlan} + onDragOver={updatePlan} + onDragEnd={(event) => { + const plan = planFor(event); + finish(); + if (plan) props.applyChange(plan.change); + }} + onDragCancel={finish} + > + {props.children} + {createPortal( + <> + + {session ? ( +
+ {session.title} +
+ ) : null} +
+ {/* Code views and hosted browser pages under the pointer must not see the drag. */} + {session ? ( +
+ ) : null} + {session?.plan?.ghost ? : null} + , + document.body, + )} + + + ); +} + +function DropGhostView({ ghost }: { ghost: DropGhost }) { + if (ghost.kind === "marker") { + return ( +
+ ); + } + return ( +
+ ); +} diff --git a/apps/web/src/fork/rightPanelSplit/RightPanelSplitView.browser.test.tsx b/apps/web/src/fork/rightPanelSplit/RightPanelSplitView.browser.test.tsx new file mode 100644 index 0000000000000..7911b0bcdbba9 --- /dev/null +++ b/apps/web/src/fork/rightPanelSplit/RightPanelSplitView.browser.test.tsx @@ -0,0 +1,332 @@ +import "~/index.css"; + +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; +import { startWorkspaceFocus } from "~/lib/workspaceFocus"; +import { + type RightPanelSurface, + selectThreadRightPanelState, + type ThreadRightPanelState, + useRightPanelStore, +} from "~/rightPanelStore"; +import { render } from "../../../test/render"; +import { RightPanelSplitView } from "./RightPanelSplitView"; + +const environmentId = EnvironmentId.make("environment-local"); +const ref = scopeThreadRef(environmentId, ThreadId.make("thread-split-view")); + +const file = (name: string): RightPanelSurface => ({ + id: `file:${name}`, + kind: "file", + relativePath: name, + revealLine: null, + revealRequestId: 1, +}); +const TERMINAL: RightPanelSurface = { + id: "terminal:1", + kind: "terminal", + resourceId: "1", + terminalIds: ["1"], + activeTerminalId: "1", +}; + +const NO_HANDLERS: ResizableWidthHandlers = { + ref: () => undefined, + onPointerDown: () => {}, + onPointerMove: () => {}, + onPointerUp: () => {}, + onPointerCancel: () => {}, + onLostPointerCapture: () => {}, +}; + +function setup(thread: ThreadRightPanelState) { + useRightPanelStore.setState({ + byThreadKey: { [scopedThreadKey(ref)]: thread }, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + closeRevisionByThreadKey: {}, + }); +} + +const thread = () => selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref); + +function StubSurface(props: { surface: RightPanelSurface; onRender: (id: string) => void }) { + props.onRender(props.surface.id); + return ( +
+ {props.surface.kind === "terminal" ? ( +