diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2aeca85e6aa2..80affd2c008f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -70,7 +70,6 @@ import { type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, - type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type ThreadLinkedPullRequest, @@ -151,7 +150,6 @@ import { Atom } from "effect/reactivity"; import { Fragment, lazy, - memo, type SetStateAction, Suspense, useCallback, @@ -166,6 +164,15 @@ import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; +import { RegisteredSidePanel } from "~/panels/bundledPanels"; +import { + PanelHostContext, + threadBoundAnnotationSender, + type PanelHost, + type ThreadAnnotationSender, +} from "~/panels/panelHost"; +import { PersistentThreadTerminalDrawer } from "~/panels/terminal/PersistentThreadTerminalDrawer"; +import type { TerminalLaunchContext } from "~/panels/terminal/TerminalSidePanel"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -281,12 +288,8 @@ import { pullRequestPanelContext, threadPullRequestPanelTarget, } from "./pullRequest/pullRequestDetail.logic"; -import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; -import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; -import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; -import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; @@ -296,7 +299,6 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, @@ -698,15 +700,8 @@ function useDraftHeroLayoutTransition( } as const; } -const PreviewPanel = lazy(() => - import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), -); -const DiffPanel = lazy(() => import("./DiffPanel")); const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; -const DevicePanel = lazy(() => - import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), -); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ @@ -844,14 +839,6 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -type PersistentTerminalLaunchContext = Pick; - function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestRun: Thread["latestRun"] | null; @@ -922,619 +909,6 @@ function useLocalDispatchState(input: { }; } -/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ -function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { - if (left.length !== right.length) { - return false; - } - if (left.length === 0) { - return true; - } - const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); - const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); - for (let index = 0; index < sortedLeft.length; index += 1) { - if (sortedLeft[index] !== sortedRight[index]) { - return false; - } - } - return true; -} - -/** - * Server knows about fewer sessions than the client, but every server id still exists locally. - * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id - * and later re-add it as a separate group (no split layout). - */ -function serverTerminalIdsStrictSubsetOfClient( - serverIds: readonly string[], - clientIds: readonly string[], -): boolean { - if (serverIds.length >= clientIds.length || clientIds.length === 0) { - return false; - } - const clientSet = new Set(clientIds); - for (const id of serverIds) { - if (!clientSet.has(id)) { - return false; - } - } - return true; -} - -interface PersistentThreadTerminalDrawerProps { - threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; - threadId: ThreadId; - active: boolean; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - splitShortcutLabel: string | undefined; - splitVerticalShortcutLabel: string | undefined; - newShortcutLabel: string | undefined; - closeShortcutLabel: string | undefined; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ - threadRef, - threadId, - active, - launchContext, - focusRequestId, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - keybindings, - onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { - const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); - const hasTerminalWriteAccess = useCallback( - () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), - [threadRef.environmentId], - ); - const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); - const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); - const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); - const serverThread = useThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const terminalUiState = useTerminalUiStateStore((state) => - selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), - ); - const visible = active && terminalUiState.terminalOpen; - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId, - }); - const panelSurfaces = useRightPanelStore( - (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, - ); - const panelTerminalIds = useMemo( - () => - new Set( - panelSurfaces.flatMap((surface) => - surface.kind === "terminal" ? surface.terminalIds : [], - ), - ), - [panelSurfaces], - ); - const drawerTerminalSessions = useMemo( - () => - knownTerminalSessions?.filter( - (session) => !panelTerminalIds.has(session.target.terminalId), - ) ?? [], - [knownTerminalSessions, panelTerminalIds], - ); - const terminalLabelsById = useMemo(() => { - const next = new Map(); - for (const session of drawerTerminalSessions) { - next.set( - session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), - ); - } - return next; - }, [drawerTerminalSessions]); - const terminalLaunchLocationsById = useMemo(() => { - const next = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - if (!project) { - return next; - } - - for (const session of drawerTerminalSessions) { - const summary = session.state.summary; - if (!summary) { - continue; - } - const worktreePathForLaunch = - launchContext !== null ? launchContext.worktreePath : summary.worktreePath; - next.set(session.target.terminalId, { - cwd: launchContext?.cwd ?? summary.cwd, - worktreePath: worktreePathForLaunch, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: worktreePathForLaunch, - }), - }); - } - - return next; - }, [drawerTerminalSessions, launchContext, project]); - const serverOrderedTerminalIds = useMemo( - () => drawerTerminalSessions.map((session) => session.target.terminalId), - [drawerTerminalSessions], - ); - // Every client-side id source participates in allocation: the server list - // lags fresh opens, and panel terminals are filtered out of the drawer's - // sessions — an id collision attaches two viewports to one PTY session. - const allocatableTerminalIds = useMemo( - () => [ - ...new Set([ - ...serverOrderedTerminalIds, - ...terminalUiState.terminalIds, - ...panelTerminalIds, - ]), - ], - [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], - ); - const allocateTerminalId = useCallback( - () => - nextTerminalId( - allocatableTerminalIds, - knownTerminalSessions === null || - !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) - ? randomUUID() - : undefined, - ), - [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], - ); - const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore( - (state) => state.splitTerminalVertical, - ); - const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); - const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); - - useEffect(() => { - if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) - ) { - return; - } - reconcileTerminalIds(threadRef, serverOrderedTerminalIds); - }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveWorktreePath = useMemo(() => { - if (launchContext !== null) { - return launchContext.worktreePath; - } - return worktreePath; - }, [launchContext, worktreePath]); - const cwd = useMemo( - () => - launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : null), - [effectiveWorktreePath, launchContext?.cwd, project], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - - const bumpFocusRequestId = useCallback(() => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }, [visible]); - - const setTerminalHeight = useCallback( - (height: number) => { - storeSetTerminalHeight(threadRef, height); - }, - [storeSetTerminalHeight, threadRef], - ); - - const splitTerminal = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeSplitTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocateTerminalId, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - runtimeEnv, - storeSplitTerminal, - threadId, - threadRef, - openTerminal, - hasTerminalWriteAccess, - ]); - const splitTerminalVertical = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeSplitTerminalVertical(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocateTerminalId, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - openTerminal, - hasTerminalWriteAccess, - runtimeEnv, - storeSplitTerminalVertical, - threadId, - threadRef, - ]); - - const createNewTerminal = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeNewTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - allocateTerminalId, - runtimeEnv, - storeNewTerminal, - threadId, - threadRef, - openTerminal, - hasTerminalWriteAccess, - ]); - - const activateTerminal = useCallback( - (terminalId: string) => { - storeSetActiveTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeSetActiveTerminal, threadRef], - ); - - const closeTerminal = useCallback( - (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const fallbackExitWrite = () => - writeTerminal({ - environmentId: threadRef.environmentId, - input: { threadId, terminalId, data: "exit\n" }, - }); - - void (async () => { - const closeResult = await closeTerminalMutation({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - deleteHistory: true, - }, - }); - if ( - closeResult._tag === "Failure" && - !isAtomCommandInterrupted(closeResult) && - hasTerminalWriteAccess() - ) { - await fallbackExitWrite(); - } - })(); - - storeCloseTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [ - bumpFocusRequestId, - storeCloseTerminal, - threadId, - threadRef, - closeTerminalMutation, - hasTerminalWriteAccess, - writeTerminal, - ], - ); - - const handleAddTerminalContext = useCallback( - (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); - }, - [onAddTerminalContext, visible], - ); - - if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { - return null; - } - - return ( -
-
- -
-
- ); -}); - -interface PersistentThreadTerminalPanelProps { - visible: boolean; - threadRef: ScopedThreadRef; - surface: Extract; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; - onSplitTerminal: () => void; - onSplitTerminalVertical: () => void; - onNewTerminal: () => void; - onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; - splitShortcutLabel?: string | undefined; - splitVerticalShortcutLabel?: string | undefined; - newShortcutLabel?: string | undefined; - closeShortcutLabel?: string | undefined; -} - -const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ - visible, - threadRef, - surface, - launchContext, - focusRequestId, - keybindings, - onAddTerminalContext, - onSplitTerminal, - onSplitTerminalVertical, - onNewTerminal, - onActiveTerminalChange, - onCloseTerminal, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, -}: PersistentThreadTerminalPanelProps) { - const serverThread = useThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }); - const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const activeSummary = - knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) - ?.state.summary ?? null; - const worktreePath = - launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; - const cwd = useMemo( - () => - launchContext?.cwd ?? - activeSummary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : null), - [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : {}, - [project, worktreePath], - ); - const terminalLabelsById = useMemo(() => { - const labels = new Map(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); - } - return labels; - }, [knownTerminalSessions, surface.terminalIds]); - const terminalLaunchLocationsById = useMemo(() => { - const locations = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - const terminalWorktreePath = - launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; - const terminalCwd = - launchContext?.cwd ?? - summary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }) - : null); - if (!terminalCwd || !project) continue; - locations.set(terminalId, { - cwd: terminalCwd, - worktreePath: terminalWorktreePath, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }), - }); - } - return locations; - }, [ - knownTerminalSessions, - launchContext?.cwd, - launchContext?.worktreePath, - project, - surface.terminalIds, - threadWorktreePath, - ]); - - if (!project || !cwd) return null; - - return ( - undefined} - onAddTerminalContext={onAddTerminalContext} - terminalLabelsById={terminalLabelsById} - terminalLaunchLocationsById={terminalLaunchLocationsById} - keybindings={keybindings} - /> - ); -}); - // Errors surface through two maps (draft-keyed and thread-keyed) whose entries // can race around promotion, so each write carries its time to let the latest // one win when they collide. @@ -11003,33 +10377,62 @@ export default function ChatView(props: ChatViewProps) { pendingSidebarFileDrops, ]); + // Plain server threads share one ChatView, so the sender carries its thread + // and a host only forwards to a sender for the thread it was built for. + // Updated after commit so a discarded render cannot lend its `onSend`. + const annotationSenderRef = useRef(null); + useLayoutEffect(() => { + annotationSenderRef.current = activeThreadKey + ? { + threadKey: activeThreadKey, + send: (annotation, image) => { + void onSend(undefined, "auto", "foreground", { annotation, image }); + }, + } + : null; + }); + // Memoized so mounted panels re-render only when a host field changes. + const panelHost = useMemo( + () => + activeThreadRef + ? { + threadRef: activeThreadRef, + visible: rightPanelOpen, + composerDraftTarget, + workspaceMutationId, + sendAnnotation: threadBoundAnnotationSender( + () => annotationSenderRef.current, + scopedThreadKey(activeThreadRef), + ), + } + : null, + [ + activeThreadRef, + annotationSenderRef, + composerDraftTarget, + rightPanelOpen, + workspaceMutationId, + ], + ); + // Empty state: no active thread if (!activeThread) { return ; } - const rightPanelContent = activeThreadRef ? ( + const rightPanelSurfaceContent = activeThreadRef ? ( renderedRightPanelSurface?.kind === "preview" ? ( - - { - void onSend(undefined, "auto", "foreground", { annotation, image }); - }} - /> - + ) : renderedRightPanelSurface?.kind === "terminal" ? ( - ) : renderedRightPanelSurface?.kind === "diff" ? ( - - - - ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - - ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( - + ) : renderedRightPanelSurface?.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 - // reader's feet. A link the agent wrote can open any other one here, and that one has to be - // checkable out like it is anywhere else. - { - if (activeThreadRef) - useRightPanelStore.getState().openPullRequest(activeThreadRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - }); - }} - threadRef={activeThreadRef} reference={{ projectId: renderedRightPanelSurface.projectId as ProjectId, ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), @@ -11096,29 +10473,24 @@ export default function ChatView(props: ChatViewProps) { }, renderedRightPanelSurface, )} - composerDraftTarget={composerDraftTarget} onBack={ activeThreadRef !== null && pullRequestsSurfaceAvailable && visiblePullRequestCount > 1 ? addPullRequestsSurface : undefined } /> - ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( - + ) : renderedRightPanelSurface?.kind === "pull-requests" ? ( + ) : renderedRightPanelSurface?.kind === "device" ? ( - - { - closeRightPanelSurface(renderedRightPanelSurface); - useRightPanelStore.getState().show(activeThreadRef); - }} - /> - + { + closeRightPanelSurface(renderedRightPanelSurface); + useRightPanelStore.getState().show(activeThreadRef); + }} + /> ) : (renderedRightPanelSurface?.kind === "files" || renderedRightPanelSurface?.kind === "file") && ((activeProject && activeWorkspaceRoot) || @@ -11166,6 +10538,20 @@ export default function ChatView(props: ChatViewProps) { ) : null ) : null; + const rightPanelContent = ( + {rightPanelSurfaceContent} + ); + const sidePanelLaunchers = { + preview: { available: canOperatePreview && browserAvailable, onOpen: createBrowserSurface }, + diff: { available: isServerThread && isGitRepo, onOpen: addDiffSurface }, + terminal: { + available: activeProject !== null && canOperateTerminal, + onOpen: addTerminalSurface, + }, + device: { available: activeThreadRef !== null, onOpen: addDeviceSurface }, + "pull-request": { available: pullRequestSurfaceAvailable, onOpen: addPullRequestSurface }, + "pull-requests": { available: pullRequestsSurfaceAvailable, onOpen: addPullRequestsSurface }, + }; const threadDetailsPanelProps: ThreadDetailsPanelProps = { anchor: threadPanelPopoverAnchorRef, handle: threadPanelPopoverHandle, @@ -12000,21 +11386,10 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + panels={sidePanelLaunchers} 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} > {rightPanelContent} @@ -12059,21 +11434,10 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + panels={sidePanelLaunchers} 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} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx new file mode 100644 index 000000000000..0c9d6c535b97 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom + +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, +} from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// The profile list normally comes from client settings; the built-ins are enough to choose from. +vi.mock("~/browser/browserDefaults", async (importOriginal) => ({ + ...(await importOriginal()), + useBrowserDefaults: () => ({ profiles: BUILT_IN_BROWSER_PROFILES }), +})); + +import { RightPanelTabs } from "./RightPanelTabs"; + +let root: Root; +let container: HTMLDivElement; +let opened: string[]; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + // Browser is desktop-only; the launcher offers it once the preview bridge exists. + vi.stubGlobal("desktopBridge", { preview: {} }); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom lacks the Web Animations API that the tab bar's scroll area waits on. + Element.prototype.getAnimations ??= () => []; + opened = []; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// Records Browser opens the way ChatView's createBrowserSurface does: one +// handler for the default open and the profile chooser, where an omitted +// profile means the default one. +function Harness() { + const openBrowser = (profileId?: string) => { + opened.push(profileId ?? DEFAULT_BROWSER_PROFILE_ID); + }; + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={[]} + environmentId={null} + activeSurfaceId={null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={() => undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + panels={{ + preview: { available: true, onOpen: openBrowser }, + diff: { available: false, onOpen: () => undefined }, + terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, + }} + onAddBrowserInProfile={openBrowser} + onAddFiles={() => undefined} + filesAvailable={false} + > + {null} + + ); +} + +function launcherRow(label: string): HTMLButtonElement { + const launcher = container.querySelector('[aria-label="Open a surface"]'); + const row = [...(launcher?.querySelectorAll("button") ?? [])].find((button) => + button.textContent?.startsWith(label), + ); + if (!row) throw new Error(`No launcher row ${label}`); + return row; +} + +function menuItem(label: string): HTMLElement { + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === label, + ); + if (!item) throw new Error(`No menu item ${label}`); + return item; +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("opening Browser from the launcher", () => { + it("opens the default profile from the row and another profile from the chooser", async () => { + await act(async () => root.render()); + + await click(launcherRow("Browser")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID]); + + await click(container.querySelector('[aria-label="Open browser in a profile"]')!); + await click(menuItem("Incognito")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID]); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index e7a952ba43c5..5b3b12e41489 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -76,21 +76,17 @@ async function renderPanel(overrides: Partial content diff --git a/apps/web/src/components/RightPanelTabs.terminal.test.tsx b/apps/web/src/components/RightPanelTabs.terminal.test.tsx new file mode 100644 index 000000000000..87ff9938c65c --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.terminal.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom + +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; + +import { RightPanelTabs } from "./RightPanelTabs"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom lacks the Web Animations API that the tab bar's scroll area waits on. + Element.prototype.getAnimations ??= () => []; + useRightPanelStore.setState({ byThreadKey: {} }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// Shows the thread's surfaces from the real right-panel store and opens the +// terminal into it the way ChatView's addTerminalSurface does. +function Harness({ terminalAvailable }: { terminalAvailable: boolean }) { + const surfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={surfaces} + environmentId={threadRef.environmentId} + activeSurfaceId={surfaces[0]?.id ?? null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={() => undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + panels={{ + preview: { available: false, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + terminal: { + available: terminalAvailable, + onOpen: () => useRightPanelStore.getState().openTerminal(threadRef, "term-1"), + }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, + }} + onAddBrowserInProfile={() => undefined} + onAddFiles={() => undefined} + filesAvailable={false} + > + {null} + + ); +} + +function launcherRow(label: string): HTMLElement { + const launcher = container.querySelector('[aria-label="Open a surface"]'); + // Unavailable rows render as aria-disabled elements rather than buttons. + const row = [ + ...(launcher?.querySelectorAll('button, [aria-disabled="true"]') ?? []), + ].find((element) => element.textContent?.startsWith(label)); + if (!row) throw new Error(`No launcher row ${label}`); + return row; +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +const threadSurfaces = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces; + +describe("opening Terminal from the launcher", () => { + it("opens a terminal surface from the row when a project allows it", async () => { + await act(async () => root.render()); + + await click(launcherRow("Terminal")); + expect(threadSurfaces()).toMatchObject([{ kind: "terminal", activeTerminalId: "term-1" }]); + expect(container.querySelector('[aria-label="Open a surface"]')).toBeNull(); + expect(container.textContent).toContain("Terminal 1"); + }); + + it("keeps the row disabled and opens nothing without a project", async () => { + await act(async () => root.render()); + + const row = launcherRow("Terminal"); + expect(row.getAttribute("aria-disabled")).toBe("true"); + await click(row); + expect(threadSurfaces()).toEqual([]); + expect(container.querySelector('[aria-label="Open a surface"]')).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 823028eddff1..fba251bc7e02 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,11 +2,12 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { RightPanelTabs, resolvePullRequestTabLink, + rightPanelSurfaceActions, shouldOpenDefaultBrowserProfileFromMenuClick, surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext, @@ -21,6 +22,42 @@ describe("browser profile submenu", () => { }); }); +describe("right panel surface actions", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const inputs = () => ({ + panels: { + preview: { available: true, onOpen: () => undefined }, + diff: { available: true, onOpen: () => undefined }, + terminal: { available: true, onOpen: () => undefined }, + device: { available: true, onOpen: () => undefined }, + "pull-request": { available: true, onOpen: () => undefined }, + "pull-requests": { available: true, onOpen: () => undefined }, + }, + onAddFiles: () => undefined, + filesAvailable: true, + }); + + it("keeps launcher order, letters and copy for registered and local surfaces", () => { + const actions = rightPanelSurfaceActions(inputs()); + expect(actions.map((action) => [action.shortcut, action.label])).toEqual([ + ["B", "Browser"], + ["T", "Terminal"], + ["F", "Files"], + ["D", "Diff"], + ["P", "Pull request"], + ["L", "Linked pull requests"], + ["M", "Device"], + ]); + expect(actions.find((action) => action.id === "diff")).toMatchObject({ + unavailableHint: "Available for Git repositories.", + unavailableReason: "Diff is only available for server threads in Git repositories.", + }); + }); +}); + function shortcutEvent( key: string, overrides: Partial[1]> = {}, @@ -125,21 +162,17 @@ function renderTabs( onCloseSurfacesToRight={() => undefined} onCloseAllSurfaces={() => undefined} onCopyFilePath={() => undefined} - onAddBrowser={() => undefined} + panels={{ + preview: { available: true, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, + }} onAddBrowserInProfile={() => undefined} - onAddTerminal={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} - onAddDiff={() => undefined} onAddFiles={() => undefined} - onAddDevice={() => undefined} - browserAvailable - terminalAvailable={false} - diffAvailable={false} filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} >
content
, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5c8323a7ae34..adb004060cd2 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -25,20 +25,11 @@ import { import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { - Smartphone, - ChevronDown, - ChevronLeft, - ChevronRight, - FileDiff, - Files, - Globe2, - Plus, - TerminalSquare, -} from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight, Files, Plus } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { type ComponentProps, + type ComponentType, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -52,6 +43,7 @@ import { } from "react"; import { isElectron } from "~/env"; +import { getSidePanelMetadata, type SidePanelId } from "~/panels/bundledPanels"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; @@ -130,26 +122,11 @@ interface RightPanelTabsProps { /** Tabs are draggable only when the owner can persist the new order. */ onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; - onAddBrowser: () => void; - /** - * Separate from `onAddBrowser` on purpose: that one is passed directly as a - * DOM click handler, and a `(profileId?: string)` signature would silently - * accept the MouseEvent as a profile id. - */ + /** Whether each registered panel can open here, and how; titles and icons come from its definition. */ + panels: Readonly>; onAddBrowserInProfile: (profileId: string) => void; - onAddTerminal: () => void; - onAddDiff: () => void; onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; - onAddDevice: () => void; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -170,16 +147,6 @@ export function shouldOpenDefaultBrowserProfileFromMenuClick( return pointerType !== "touch"; } -const SURFACE_DISABLED_REASONS = { - browser: "Browser previews are only available in the T3 Code desktop app.", - terminal: "Terminal surfaces are only available from a project thread.", - files: "Files are only available when a project is open.", - diff: "Diff is only available for server threads in Git repositories.", - pullRequest: "This thread's branch has no pull request yet.", - pullRequests: "No linked pull requests are available for this thread.", - device: "Devices are only available from a thread.", -} as const; - /** Overlays that must win over the launcher's letter shortcuts. */ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="dialog-popup"]', @@ -192,16 +159,66 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="autocomplete-popup"]', ].join(","); -/** One-line unavailability hints for the empty-state rows. */ -const SURFACE_UNAVAILABLE_HINTS = { - browser: "Only available in the desktop app.", - terminal: "Available when a project is open.", - files: "Available when a project is open.", - diff: "Available for Git repositories.", - pullRequest: "No pull request on this branch yet.", - pullRequests: "No linked pull requests available.", - device: "Available from a thread.", -} as const; +interface SidePanelLauncher { + available: boolean; + onOpen: () => void; +} + +interface SurfaceAction { + id: string; + label: string; + icon: ComponentType<{ className?: string }>; + shortcut: string; + available: boolean; + /** One-line reason for the empty launcher rows. */ + unavailableHint: string; + /** Full reason for the add menu tooltip. */ + unavailableReason: string; + onClick: () => void; +} + +type SurfaceActionInputs = Pick; + +/** + * The surfaces the empty launcher and the add menu offer, in launcher order. + * Registered panels describe themselves; the rest are listed here until they + * move onto the panel registry. + */ +export function rightPanelSurfaceActions(props: SurfaceActionInputs): SurfaceAction[] { + const registered = (id: SidePanelId): SurfaceAction => { + const panel = getSidePanelMetadata(id); + const launcher = props.panels[id]; + return { + id, + label: panel.title, + icon: panel.icon, + shortcut: panel.launcherKey, + available: (panel.isSupported?.() ?? true) && launcher.available, + unavailableHint: panel.unavailableHint, + unavailableReason: panel.unavailableReason, + // Never forward the click event as an argument. + onClick: () => launcher.onOpen(), + }; + }; + return [ + registered("preview"), + registered("terminal"), + { + id: "files", + label: "Files", + icon: Files, + shortcut: "F", + available: props.filesAvailable, + unavailableHint: "Available when a project is open.", + unavailableReason: "Files are only available when a project is open.", + onClick: props.onAddFiles, + }, + registered("diff"), + registered("pull-request"), + registered("pull-requests"), + registered("device"), + ]; +} type TabContextMenuAction = | "rename" @@ -332,87 +349,13 @@ function SurfaceMenuItem(props: { * surfaces stay visible with a one-line reason. */ function RightPanelEmptyState(props: { - onAddBrowser: () => void; + actions: readonly SurfaceAction[]; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; - onAddTerminal: () => void; - onAddDiff: () => void; - onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; - onAddDevice: () => void; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; }) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); - - const actions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - description: "Watch an iOS Simulator or Android Emulator.", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.device, - onClick: props.onAddDevice, - }, - ] as const; - - type SurfaceAction = (typeof actions)[number]; + const actions = props.actions; const availableActions = actions.filter((action) => action.available); const highlightIndex = @@ -511,7 +454,7 @@ function RightPanelEmptyState(props: { // wrapper: the chooser overlays the row, and a pointer moving // onto it must not read as leaving the row.
setHighlight(availableActions.indexOf(action))} onMouseLeave={() => @@ -532,7 +475,7 @@ function RightPanelEmptyState(props: { 1 && "pr-7", + action.id === "preview" && props.browserProfiles.length > 1 && "pr-7", )} > {action.label} @@ -544,7 +487,7 @@ function RightPanelEmptyState(props: { default profile, the chevron picks another. Only worth showing once there is something to choose between. */} - {action.label === "Browser" && props.browserProfiles.length > 1 ? ( + {action.id === "preview" && props.browserProfiles.length > 1 ? ( ) : ( 0) return snapshot.navStatus.title; try { - return new URL(snapshot.navStatus.url).host || "Browser"; + return new URL(snapshot.navStatus.url).host || fallback; } catch { - return "Browser"; + return fallback; } } } @@ -635,10 +579,11 @@ function surfaceTitle( function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { const publicProviderUrl = faviconUrlForOrigin(url, 32); + const Icon = getSidePanelMetadata("preview").icon; return ( } + fallback={} className="size-3 shrink-0 rounded-sm object-contain" /> ); @@ -676,8 +621,10 @@ function SurfaceIcon({ favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; return ; } - case "diff": - return ; + case "diff": { + const Icon = getSidePanelMetadata("diff").icon; + return ; + } case "files": return ; case "file": @@ -689,8 +636,10 @@ function SurfaceIcon({ className="size-3" /> ); - case "terminal": - return ; + case "terminal": { + const Icon = getSidePanelMetadata("terminal").icon; + return ; + } case "pull-request": return ( ); - case "pull-requests": - return ; - case "device": + case "pull-requests": { + const Icon = getSidePanelMetadata("pull-requests").icon; + return ; + } + case "device": { + const DeviceIcon = getSidePanelMetadata("device").icon; return surface.target?.platform === "ios" ? ( ) : surface.target?.platform === "android" ? ( ) : ( - + ); + } } } @@ -934,64 +887,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }); }, []); - const addSurfaceActions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_DISABLED_REASONS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_DISABLED_REASONS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_DISABLED_REASONS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_DISABLED_REASONS.device, - onClick: props.onAddDevice, - }, - ] as const; + const addSurfaceActions = rightPanelSurfaceActions(props); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1353,9 +1249,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // while hover or arrow reveals the profiles. The choice // lives at open time because a tab's profile is fixed then — // Electron only honours a partition before attach. - if (action.label === "Browser" && action.available) { + if (action.id === "preview" && action.available) { return ( - + @@ -1474,22 +1370,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( ) : ( props.children diff --git a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx index 6bf4302fbcc1..b903e57087a0 100644 --- a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx +++ b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { DiffFileHeaderSkeleton } from "../DiffPanelShell"; +import { DiffFileHeaderSkeleton } from "./DiffLoadingState"; /** Load the next batch before the reader reaches the end of the current files. */ export function DiffFileLoadingBoundary({ load, count }: { load: () => void; count: number }) { diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/diffs/DiffLoadingState.tsx similarity index 55% rename from apps/web/src/components/DiffPanelShell.tsx rename to apps/web/src/components/diffs/DiffLoadingState.tsx index 456f70516b23..34b1e4960587 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/diffs/DiffLoadingState.tsx @@ -1,50 +1,4 @@ -import type { ReactNode } from "react"; - -import { isElectron } from "~/env"; -import { cn } from "~/lib/utils"; - -import { Skeleton } from "./ui/skeleton"; - -export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; - -function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; - return cn( - "flex items-center justify-between gap-2", - mode === "embedded" ? "px-2" : "px-4", - shouldUseDragRegion - ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:pr-(--workspace-native-controls-inset)" - : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent", - ); -} - -export function DiffPanelShell(props: { - mode: DiffPanelMode; - header: ReactNode; - children: ReactNode; -}) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; - - return ( -
- {shouldUseDragRegion ? ( -
{props.header}
- ) : ( -
- {props.header} -
- )} - {props.children} -
- ); -} +import { Skeleton } from "../ui/skeleton"; export function DiffFileHeaderSkeleton({ titleWidth, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index d5f054dadab0..87865d8e8e7f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -58,7 +58,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 137be41c9606..89f1e28c1772 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -116,7 +116,7 @@ import { import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; diff --git a/apps/web/src/panels/bundledPanels.test.tsx b/apps/web/src/panels/bundledPanels.test.tsx new file mode 100644 index 000000000000..7d33dd57c6a4 --- /dev/null +++ b/apps/web/src/panels/bundledPanels.test.tsx @@ -0,0 +1,200 @@ +import { EnvironmentId, ProjectId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act, Suspense } from "react"; +import { create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const loaded = vi.hoisted(() => ({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + previewRenders: [] as unknown[], +})); +vi.mock("./diff/DiffSidePanel", () => { + loaded.diff += 1; + return { default: () => null }; +}); +vi.mock("./terminal/TerminalSidePanel", () => { + loaded.terminal += 1; + return { default: () => null }; +}); +vi.mock("./device/DeviceSidePanel", () => { + loaded.device += 1; + return { default: () => null }; +}); +vi.mock("./preview/PreviewSidePanel", () => { + loaded.preview += 1; + return { + default: function PreviewSidePanel(props: unknown) { + loaded.previewRenders.push({ props, host: usePanelHost() }); + return null; + }, + }; +}); + +vi.mock("./pullRequest/PullRequestSidePanel", () => { + loaded.pullRequest += 1; + return { default: () => null }; +}); +vi.mock("./pullRequest/PullRequestsSidePanel", () => { + loaded.pullRequests += 1; + return { default: () => null }; +}); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "./bundledPanels"; +import { PanelHostContext, usePanelHost, type PanelHost } from "./panelHost"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}; + +describe("bundled side panels", () => { + it("loads only the selected panel body and lends it the host", async () => { + expect(loaded).toMatchObject({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + }); + await act(async () => { + create( + + + + + , + ); + }); + expect(loaded).toMatchObject({ + diff: 0, + preview: 1, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + }); + expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); + }); + + it("loads only the terminal body when the terminal is selected", async () => { + const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }; + await act(async () => { + create( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ); + }); + expect(loaded).toMatchObject({ diff: 0, terminal: 1 }); + }); +}); + +// Never called. The project typecheck compiles these pairings, and each +// expect-error directive fails it if a wrong pairing starts to compile. +export function typeFixtures( + widenedId: "diff" | "preview", + terminalSurface: Extract, + deviceSurface: Extract, + dismiss: () => void, +) { + const terminalProps = { + surface: terminalSurface, + launchContext: null, + focusRequestId: 0, + onAddTerminalContext: () => undefined, + onSplitTerminal: () => undefined, + onSplitTerminalVertical: () => undefined, + onNewTerminal: () => undefined, + onActiveTerminalChange: () => undefined, + onCloseTerminal: () => undefined, + }; + const reference = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 7 }; + const pullRequest = { + reference, + context: "thread" as const, + shortcutsEnabled: true, + getShortcutContext: () => ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + }), + }; + return ( + <> + + + + {/* @ts-expect-error Terminal requires its surface and callbacks. */} + + {/* @ts-expect-error The host owns visibility; the terminal does not take it as a prop. */} + + {/* @ts-expect-error Terminal props on Preview. */} + + + + + {/* @ts-expect-error Pull request detail needs its reference and shortcut inputs. */} + + {/* @ts-expect-error Pull request props on the linked list. */} + + {/* @ts-expect-error Pull request props on Preview. */} + + {/* @ts-expect-error The host owns the composer draft target. */} + + {/* @ts-expect-error Preview props on Diff. */} + + {/* @ts-expect-error Device props on Preview. */} + + {/* @ts-expect-error Preview props on Device. */} + + {/* @ts-expect-error Device needs its surface and setup dismissal. */} + + {/* @ts-expect-error The host owns visibility; panels do not take it as a prop. */} + + {/* @ts-expect-error The host owns the thread; panels do not take it as a prop. */} + + {/* @ts-expect-error Wrong input shape. */} + + {/* @ts-expect-error Unknown id. */} + + {/* @ts-expect-error A widened id cannot borrow one panel's props. */} + + + ); +} diff --git a/apps/web/src/panels/bundledPanels.tsx b/apps/web/src/panels/bundledPanels.tsx new file mode 100644 index 000000000000..5b369f13f169 --- /dev/null +++ b/apps/web/src/panels/bundledPanels.tsx @@ -0,0 +1,104 @@ +import { FileDiff, Globe2, Smartphone, TerminalSquare } from "lucide-react"; +import { Suspense, type ComponentType } from "react"; + +import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; + +import { PullRequestPanelPending } from "./pullRequest/PullRequestPanelPending"; +import { createPanelRegistry, type PanelMetadata, type PanelProps } from "./panelRegistry"; + +const bundledPanels = createPanelRegistry([ + { + id: "diff", + title: "Diff", + icon: FileDiff, + launcherKey: "D", + unavailableHint: "Available for Git repositories.", + unavailableReason: "Diff is only available for server threads in Git repositories.", + load: () => import("./diff/DiffSidePanel"), + }, + { + id: "preview", + title: "Browser", + icon: Globe2, + launcherKey: "B", + unavailableHint: "Only available in the desktop app.", + unavailableReason: "Browser previews are only available in the T3 Code desktop app.", + load: () => import("./preview/PreviewSidePanel"), + }, + { + id: "terminal", + title: "Terminal", + icon: TerminalSquare, + launcherKey: "T", + unavailableHint: "Available when a project is open.", + unavailableReason: "Terminal surfaces are only available from a project thread.", + load: () => import("./terminal/TerminalSidePanel"), + }, + { + id: "device", + title: "Device", + icon: Smartphone, + launcherKey: "M", + unavailableHint: "Available from a thread.", + unavailableReason: "Devices are only available from a thread.", + load: () => import("./device/DeviceSidePanel"), + }, + { + id: "pull-request", + title: "Pull request", + icon: PullRequestGlyph.pullRequest, + launcherKey: "P", + unavailableHint: "No pull request on this branch yet.", + unavailableReason: "This thread's branch has no pull request yet.", + // The detail's code is large; the first open would otherwise show an empty panel. + fallback: , + load: () => import("./pullRequest/PullRequestSidePanel"), + }, + { + id: "pull-requests", + title: "Linked pull requests", + icon: PullRequestGlyph.link, + launcherKey: "L", + unavailableHint: "No linked pull requests available.", + unavailableReason: "No linked pull requests are available for this thread.", + fallback: , + load: () => import("./pullRequest/PullRequestsSidePanel"), + }, +]); + +export type SidePanelId = (typeof bundledPanels.definitions)[number]["id"]; + +/** Metadata for launchers and tabs; reading it never loads a panel body. */ +export function getSidePanelMetadata(id: SidePanelId): PanelMetadata { + return bundledPanels.get(id); +} + +type SidePanel = ReturnType; +type SidePanelPropKey = SidePanel extends infer Panel + ? Panel extends SidePanel + ? keyof PanelProps + : never + : never; + +/** + * One member per registered id. Other panels' prop keys are forbidden on each + * member, so a widened id cannot carry props the selected panel does not take. + */ +type RegisteredSidePanelProps = SidePanel extends infer Panel + ? Panel extends SidePanel + ? { id: Panel["id"] } & PanelProps & { + [Key in Exclude>]?: never; + } + : never + : never; + +export function RegisteredSidePanel({ id, ...props }: RegisteredSidePanelProps) { + const panel = bundledPanels.get(id); + // The union caller already paired id with its props; destructuring loses that correlation. + const Component = panel.Component as ComponentType; + return ( + + + + ); +} diff --git a/apps/web/src/panels/device/DeviceSidePanel.test.tsx b/apps/web/src/panels/device/DeviceSidePanel.test.tsx new file mode 100644 index 000000000000..828d50d6c904 --- /dev/null +++ b/apps/web/src/panels/device/DeviceSidePanel.test.tsx @@ -0,0 +1,246 @@ +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext } from "../panelHost"; + +type CommandResult = + | { _tag: "Success"; value: { hostId: string; deviceId: string } } + | { _tag: "Failure"; cause: unknown }; +const mocks = vi.hoisted(() => ({ + open: vi.fn<(request: unknown) => Promise>(), + close: vi.fn<(request: unknown) => Promise>(), + openDevice: vi.fn(), + closeSurface: vi.fn(), +})); +const phone = { + hostId: "local", + id: "phone", + name: "Phone", + platform: "ios", + version: "iOS 19", + booted: false, + physical: false, +} as const; +const deviceState: DeviceServiceState = { + hosts: [ + { + id: "local", + kind: "local", + label: "This Mac", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: true, + }, + ], + hostStatus: "ready", + hostStatuses: { local: { status: "ready" } }, + devices: [phone], + sessions: [ + { + threadId: ThreadId.make("thread-1"), + hostId: "local", + deviceId: "phone", + platform: "ios", + openedAt: "2026-01-01T00:00:00.000Z", + }, + ], + onboardingCompleted: true, + agentAccessEnabled: true, + hubBasePath: "/api/device-hub", + revision: 1, +}; +vi.mock("~/state/device", () => ({ + deviceEnvironment: { list: "list", open: "open", close: "close" }, + useDeviceState: () => ({ state: deviceState, loaded: true }), +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => + command === "open" + ? mocks.open + : command === "close" + ? mocks.close + : () => Promise.resolve({ _tag: "Success", value: {} }), +})); +vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device failed" })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceLoadingView", () => ({ DeviceLoadingView: () => null })); +vi.mock("~/components/device/DeviceWorkspace", () => ({ DeviceWorkspace: () => null })); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: ({ children }: { children: ReactNode }) => children, +})); + +// Same thread id in another environment: the surface id collides, so the panel +// stays mounted across the switch, as it does under ChatView's surface key. +const picking = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), +}; +const next = { environmentId: EnvironmentId.make("environment-b"), threadId: picking.threadId }; +const picker: Extract = { id: "device", kind: "device" }; +const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, +}; + +// Mounted the way ChatView mounts it: through the registry, lazily, on the panel host. +const render = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined, + }} + > + undefined} + /> + +); +const mount = async (threadRef: ScopedThreadRef, surface = picker) => { + const renderer = await act(async () => create(render(threadRef, surface))); + // Wait for the registered lazy body to load and replace the Suspense fallback. + await act(async () => { + await import("./DeviceSidePanel"); + }); + return renderer; +}; +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; +const startPhone = (renderer: ReactTestRenderer) => + act(async () => { + renderer.root.findByProps({ "aria-label": "Start Phone" }).props.onClick(); + }); +const errors = (renderer: ReactTestRenderer) => renderer.root.findAllByProps({ role: "alert" }); +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("registered Device panel", () => { + it("opens a picked device when the pick settles in the same thread", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + expect(mocks.open).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", platform: "ios" }, + }); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + }); + + it("keeps the next thread's picker usable while an earlier pick is pending", async () => { + mocks.open.mockReturnValue(deferred().promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it("opens a pick that settles after a switch in the thread it started in", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it("hides a failed pick that settles after the panel moved, even back again", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => renderer.update(render(picking))); + await act(async () => pick.settle({ _tag: "Failure", cause: new Error("boom") })); + + expect(errors(renderer)).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + }); + + it("does not carry an operation error into another thread", async () => { + mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") }); + const renderer = await mount(picking); + + await startPhone(renderer); + expect(errors(renderer)).toHaveLength(1); + await act(async () => renderer.update(render(next))); + + expect(errors(renderer)).toHaveLength(0); + }); + + it("closes a powered-off surface in its own thread after the panel moved", async () => { + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + const renderer = await mount(picking, streaming); + + await act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); + expect(mocks.close).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await act(async () => renderer.update(render(next, streaming))); + await act(async () => powerOff.settle(opened)); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); + + it("closes the surface when a power-off settles in the same thread", async () => { + mocks.close.mockResolvedValue(opened); + const renderer = await mount(picking, streaming); + + await act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/panels/device/DeviceSidePanel.tsx similarity index 78% rename from apps/web/src/components/device/DevicePanel.tsx rename to apps/web/src/panels/device/DeviceSidePanel.tsx index 7e6804043f44..c3dd34d8ff77 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/panels/device/DeviceSidePanel.tsx @@ -1,12 +1,7 @@ -import { DeviceHostUpdates } from "./DeviceHostUpdates"; -import type { - DevicePlatform, - DeviceServiceState, - DeviceSummary, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { DeviceHostUpdates } from "~/components/device/DeviceHostUpdates"; +import type { DevicePlatform, DeviceServiceState, DeviceSummary } from "@t3tools/contracts"; import { Smartphone, X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; @@ -19,10 +14,12 @@ import { cn } from "~/lib/utils"; import { deviceEnvironment, useDeviceState } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DeviceLoadingView } from "./DeviceLoadingView"; -import { DeviceSetup } from "./DeviceSetup"; -import { DeviceWorkspace } from "./DeviceWorkspace"; -import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; +import { DeviceLoadingView } from "~/components/device/DeviceLoadingView"; +import { DeviceSetup } from "~/components/device/DeviceSetup"; +import { DeviceWorkspace } from "~/components/device/DeviceWorkspace"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; + +import { usePanelHost } from "../panelHost"; const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; @@ -30,30 +27,40 @@ const platformLabel = (platform: DevicePlatform) => const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; -/** Each surface owns one host/device; only the visible surface streams. */ -export function DevicePanel(props: { - readonly mode: PreviewPanelMode; - readonly threadRef: ScopedThreadRef; +/** + * Each surface owns one host/device; only the visible surface streams. + * RightPanelTabs owns placement, so the side panel is always embedded. + */ +export default function DeviceSidePanel(props: { readonly surface: Extract; - readonly visible: boolean; readonly onDismissSetup: () => void; }) { - const { environmentId, threadId } = props.threadRef; + const { threadRef, visible } = usePanelHost(); + const { environmentId, threadId } = threadRef; const { state, loaded } = useDeviceState(environmentId); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); const close = useAtomCommand(deviceEnvironment.close); const [operationError, setOperationError] = useState(null); const [pendingDevice, setPendingDevice] = useState(null); + // Operation state belongs to one scoped thread; drop it when the panel moves on. + const scopeKey = `${environmentId}\u0000${threadId}`; + const [operationScopeKey, setOperationScopeKey] = useState(scopeKey); + if (operationScopeKey !== scopeKey) { + setOperationScopeKey(scopeKey); + setOperationError(null); + setPendingDevice(null); + } + const isCurrentScope = useScopeGuard(scopeKey); const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const hostDisabled = state.hostStatus === "disabled"; // Opening setup never grants permission to install or start helpers. useEffect(() => { - if (!props.visible || !loaded || hostDisabled) return; + if (!visible || !loaded || hostDisabled) return; void list({ environmentId, input: {} }); - }, [environmentId, list, loaded, props.visible, hostDisabled]); + }, [environmentId, list, loaded, visible, hostDisabled]); const sessions = useMemo( () => state.sessions.filter((session) => session.threadId === threadId), @@ -79,6 +86,7 @@ export function DevicePanel(props: { if (!device) return; setOperationError(null); setPendingDevice(device); + const stillCurrent = isCurrentScope(); try { const result = await open({ environmentId, @@ -89,39 +97,44 @@ export function DevicePanel(props: { platform: device.platform, }, }); - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else - useRightPanelStore.getState().openDevice(props.threadRef, { + // The server opened the device for the starting thread, so its tab opens + // there even after a switch; only this panel's own state is scope-guarded. + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { + useRightPanelStore.getState().openDevice(threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, name: device.name, }); + } } finally { - setPendingDevice(null); + if (stillCurrent()) setPendingDevice(null); } }; // Floating the device closes the panel, like the browser's floating preview. const floatActive = () => { if (!activeDevice) return; - usePreviewMiniPlayerStore.getState().open(props.threadRef, { + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "device", hostId: activeDevice.hostId, deviceId: activeDevice.id, platform: activeDevice.platform, name: activeDevice.name, }); - useRightPanelStore.getState().close(props.threadRef); + useRightPanelStore.getState().close(threadRef); }; const closeActive = (powerOff: boolean) => { if (!powerOff) { - useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + useRightPanelStore.getState().closeSurface(threadRef, props.surface.id); return; } if (!activeSession) return; setOperationError(null); + const stillCurrent = isCurrentScope(); void close({ environmentId, input: { @@ -131,8 +144,11 @@ export function DevicePanel(props: { shutdown: powerOff, }, }).then((result) => { - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { + useRightPanelStore.getState().closeSurface(threadRef, props.surface.id); + } }); }; @@ -153,7 +169,7 @@ export function DevicePanel(props: { if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( { if (!isOpen) props.onDismissSetup(); }} @@ -166,7 +182,7 @@ export function DevicePanel(props: { } return ( - + {hostReady && !activeDevice && state.hostStatusDetail ? (
host.id === activeDevice.hostId)?.label ?? "Device host" } hostDiagnostics={state.hostStatusDetail} - visible={props.visible} + visible={visible} onFloat={floatActive} onClose={() => closeActive(false)} onPowerOff={() => closeActive(true)} @@ -313,6 +329,27 @@ export function DevicePanel(props: { ); } +/** + * Binds async work to the committed scope. Call the returned function when the + * work starts; the check it returns is false once the panel moved to another + * thread (even back again) or unmounted, so late results are dropped. + */ +function useScopeGuard(scopeKey: string) { + const scopeRef = useRef<{ readonly key: string } | null>(null); + useLayoutEffect(() => { + // A fresh token per commit of a scope, so returning to a thread is a new scope. + const scope = { key: scopeKey }; + scopeRef.current = scope; + return () => { + scopeRef.current = null; + }; + }, [scopeKey]); + return () => { + const started = scopeRef.current; + return () => started !== null && scopeRef.current === started; + }; +} + function groupDevices(state: DeviceServiceState) { const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; for (const platform of ["ios", "android"] as const) { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/panels/diff/DiffSidePanel.tsx similarity index 93% rename from apps/web/src/components/DiffPanel.tsx rename to apps/web/src/panels/diff/DiffSidePanel.tsx index 633094738e9e..77bcf54af89e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/panels/diff/DiffSidePanel.tsx @@ -1,4 +1,3 @@ -import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -7,7 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ScopedThreadRef, RunId } from "@t3tools/contracts"; +import type { RunId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -21,45 +20,31 @@ import { import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { useCodeViewFileReveal } from "~/components/diffs/useCodeViewFileReveal"; import { useFilesystemReadAccess } from "~/state/filesystem"; -import { useOpenInPreferredEditor } from "../editorPreferences"; -import { useFileContextMenuHandler } from "../fileContextMenu"; -import { type DraftId } from "../composerDraftStore"; -import { openDiffFilePrimaryAction } from "../diffFileActions"; -import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { cn } from "~/lib/utils"; -import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; -import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useTheme } from "../hooks/useTheme"; +import { DiffFilePathCopyButton } from "~/components/DiffFilePathCopyButton"; +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; import { - buildFileDiffContentVersion, - buildFileDiffIdentityKey, - getDiffCollapseIconClassName, - getDiffLineStat, - getRenderablePatch, - resolveDiffThemeName, - resolveFileDiffPath, -} from "../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; -import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; -import { useProject, useThreadProjection, useThreadShell } from "../state/entities"; -import { resolveThreadRouteRef } from "../threadRoutes"; -import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; -import { formatShortTimestamp } from "../timestampFormat"; -import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; -import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel } from "./chat/DiffStatLabel"; -import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; -import { DiffFileTree } from "./diffs/DiffFileTree"; -import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; -import { Button } from "./ui/button"; + AnnotatableCodeView, + type AnnotatableCodeViewHandle, +} from "~/components/diffs/AnnotatableCodeView"; +import { DiffFileTree } from "~/components/diffs/DiffFileTree"; +import { diffFileTreeEntries } from "~/components/diffs/diffFileTree.logic"; +import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; -import { ToggleGroup, Toggle } from "./ui/toggle-group"; -import { Switch } from "./ui/switch"; +import { ToggleGroup, Toggle } from "~/components/ui/toggle-group"; +import { Switch } from "~/components/ui/switch"; import { Combobox, ComboboxEmpty, @@ -68,7 +53,7 @@ import { ComboboxList, ComboboxPopup, ComboboxTrigger, -} from "./ui/combobox"; +} from "~/components/ui/combobox"; import { DropdownMenu, DropdownMenuContent, @@ -78,19 +63,48 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useEnvironmentQuery } from "../state/query"; -import { useAtomCommand } from "../state/use-atom-command"; -import { serverEnvironment } from "../state/server"; -import { reviewEnvironment } from "../state/review"; -import { vcsEnvironment } from "../state/vcs"; -import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; -import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; +} from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useReviewFilePatches } from "~/components/diffs/useReviewFilePatches"; +import { DiffFileLoadingBoundary } from "~/components/diffs/DiffFileLoadingBoundary"; +import { DiffFileStatus } from "~/components/diffs/DiffFileStatus"; +import { DiffPanelLoadingState } from "~/components/diffs/DiffLoadingState"; -import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; -import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; -import { DiffFileStatus } from "./diffs/DiffFileStatus"; +import { usePanelHost } from "../panelHost"; + +import { useOpenInPreferredEditor } from "~/editorPreferences"; +import { useFileContextMenuHandler } from "~/fileContextMenu"; +import { openDiffFilePrimaryAction } from "~/diffFileActions"; +import { useCheckpointDiff } from "~/lib/checkpointDiffState"; +import { cn } from "~/lib/utils"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useTheme } from "~/hooks/useTheme"; +import { + buildFileDiffContentVersion, + buildFileDiffIdentityKey, + getDiffCollapseIconClassName, + getDiffLineStat, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "~/lib/diffCollapse"; +import { useTurnDiffSummaries } from "~/hooks/useTurnDiffSummaries"; +import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { useProject, useThreadProjection, useThreadShell } from "~/state/entities"; +import { resolveThreadRouteRef } from "~/threadRoutes"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { formatShortTimestamp } from "~/timestampFormat"; + +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { serverEnvironment } from "~/state/server"; +import { reviewEnvironment } from "~/state/review"; +import { vcsEnvironment } from "~/state/vcs"; +import { buildBaseRefChoices, filterBaseRefChoices } from "~/lib/baseRefChoices"; +import { createGitDiffFileContentsLoader } from "~/lib/diffFileContents"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -187,17 +201,23 @@ function DiffFileHeaderSuffix({ ); } -interface DiffPanelProps { - mode?: DiffPanelMode; - composerDraftTarget: ScopedThreadRef | DraftId; - workspaceMutationId: string | null; +// RightPanelTabs owns placement and desktop chrome; Diff keeps its embedded toolbar. +function DiffSidePanelFrame({ header, children }: { header: ReactNode; children: ReactNode }) { + return ( +
+
+ {header} +
+ {children} +
+ ); } -export default function DiffPanel({ - mode = "inline", - composerDraftTarget, - workspaceMutationId, -}: DiffPanelProps) { +export default function DiffSidePanel() { + const { composerDraftTarget, workspaceMutationId } = usePanelHost(); const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const diffLayout = settings.diffLayout; @@ -1061,7 +1081,7 @@ export default function DiffPanel({ ); return ( - + {!activeThread ? (
Select a thread to inspect turn diffs. @@ -1269,6 +1289,6 @@ export default function DiffPanel({
)} -
+ ); } diff --git a/apps/web/src/panels/panelHost.test.ts b/apps/web/src/panels/panelHost.test.ts new file mode 100644 index 000000000000..5a2423868c5b --- /dev/null +++ b/apps/web/src/panels/panelHost.test.ts @@ -0,0 +1,77 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type PreviewAnnotationPayload } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { threadBoundAnnotationSender, type ThreadAnnotationSender } from "./panelHost"; + +const THREAD_A = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-a")), +); +const THREAD_B = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-b")), +); +const THREAD_A_ELSEWHERE = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-2"), ThreadId.make("thread-a")), +); + +const annotation: PreviewAnnotationPayload = { + id: "annotation-1", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-07-27T00:00:00.000Z", +}; + +describe("threadBoundAnnotationSender", () => { + it("drops a pick that settles after the chat view moved to another thread", async () => { + const sendA = vi.fn(); + const sendB = vi.fn(); + const senderRef: { current: ThreadAnnotationSender | null } = { + current: { threadKey: THREAD_A, send: sendA }, + }; + const hostSendForA = threadBoundAnnotationSender(() => senderRef.current, THREAD_A); + + let resolvePick!: (value: PreviewAnnotationPayload) => void; + const pick = new Promise((resolve) => { + resolvePick = resolve; + }); + const settled = pick.then((picked) => hostSendForA(picked, null)); + + // The shared ChatView renders thread B before A's pick is cancelled. + senderRef.current = { threadKey: THREAD_B, send: sendB }; + resolvePick(annotation); + await settled; + + expect(sendB).not.toHaveBeenCalled(); + expect(sendA).not.toHaveBeenCalled(); + }); + + it("treats the same thread id in another environment as a different thread", () => { + const sendElsewhere = vi.fn(); + const senderRef = { current: { threadKey: THREAD_A_ELSEWHERE, send: sendElsewhere } }; + + threadBoundAnnotationSender(() => senderRef.current, THREAD_A)(annotation, null); + + expect(sendElsewhere).not.toHaveBeenCalled(); + }); + + it("uses the newest sender after a same-thread rerender", () => { + const firstRender = vi.fn(); + const latestRender = vi.fn(); + const senderRef: { current: ThreadAnnotationSender | null } = { + current: { threadKey: THREAD_A, send: firstRender }, + }; + const hostSend = threadBoundAnnotationSender(() => senderRef.current, THREAD_A); + + senderRef.current = { threadKey: THREAD_A, send: latestRender }; + hostSend(annotation, null); + + expect(firstRender).not.toHaveBeenCalled(); + expect(latestRender).toHaveBeenCalledWith(annotation, null); + }); +}); diff --git a/apps/web/src/panels/panelHost.ts b/apps/web/src/panels/panelHost.ts new file mode 100644 index 000000000000..09805d1f3227 --- /dev/null +++ b/apps/web/src/panels/panelHost.ts @@ -0,0 +1,55 @@ +import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; +import { createContext, use } from "react"; + +import type { ComposerImageAttachment, DraftId } from "~/composerDraftStore"; + +/** + * What the chat view lends the panel it is rendering. A panel reads this + * instead of receiving the same values as props, so only panel-specific + * inputs stay on its props. + */ +export interface PanelHost { + /** Thread the panel belongs to; its environmentId scopes every server call. */ + readonly threadRef: ScopedThreadRef; + /** False while the right panel is collapsed but the panel stays mounted. */ + readonly visible: boolean; + /** Draft that comments and attached context land in. */ + readonly composerDraftTarget: ScopedThreadRef | DraftId; + /** Changes when a turn finishes mutating the workspace, so file views can refresh. */ + readonly workspaceMutationId: string | null; + /** Sends an annotation as its own message through the composer of the render that lent it. */ + readonly sendAnnotation: ( + annotation: PreviewAnnotationPayload, + image: ComposerImageAttachment | null, + ) => void; +} + +/** The newest annotation sender, tagged with the thread whose render created it. */ +export interface ThreadAnnotationSender { + readonly threadKey: string; + readonly send: PanelHost["sendAnnotation"]; +} + +/** + * A stable `sendAnnotation` for one thread. It forwards to the newest sender + * only while that sender belongs to the same thread, so a pick that settles + * after the chat view moved to another thread is never sent there. + */ +export function threadBoundAnnotationSender( + latestSender: () => ThreadAnnotationSender | null, + threadKey: string, +): PanelHost["sendAnnotation"] { + return (annotation, image) => { + const latest = latestSender(); + if (latest?.threadKey !== threadKey) return; + latest.send(annotation, image); + }; +} + +export const PanelHostContext = createContext(null); + +export function usePanelHost(): PanelHost { + const host = use(PanelHostContext); + if (!host) throw new Error("usePanelHost must be used inside a panel host"); + return host; +} diff --git a/apps/web/src/panels/panelRegistry.test.tsx b/apps/web/src/panels/panelRegistry.test.tsx new file mode 100644 index 000000000000..b2c81ab122a3 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.test.tsx @@ -0,0 +1,144 @@ +import { act, Suspense, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createPanelRegistry } from "./panelRegistry"; + +const metadata = { + icon: () => null, + launcherKey: "X", + unavailableHint: "Unavailable.", + unavailableReason: "Unavailable here.", +} as const; + +// These exercise lazy evaluation and actual mount/disposal, without inspecting markup. +describe("panel registry", () => { + it("rejects duplicate ids without loading either panel", () => { + const load = vi.fn(); + const definition = { ...metadata, id: "diff", title: "Diff", load } as const; + expect(() => createPanelRegistry([definition, definition])).toThrow("Duplicate panel id: diff"); + expect(load).not.toHaveBeenCalled(); + }); + + it("does no work until opened and keeps one mount across lookups", async () => { + let finish!: (value: { default: typeof Panel }) => void; + const loaded = new Promise<{ default: typeof Panel }>((resolve) => { + finish = resolve; + }); + const mounted = vi.fn(); + const disposed = vi.fn(); + function Panel() { + useEffect(() => { + mounted(); + return disposed; + }, []); + return null; + } + const load = vi.fn(() => loaded); + const registry = createPanelRegistry([{ ...metadata, id: "diff", title: "Diff", load }]); + expect(load).not.toHaveBeenCalled(); + const Component = registry.get("diff").Component; + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + + , + ); + }); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).not.toHaveBeenCalled(); + await act(async () => { + finish({ default: Panel }); + await loaded; + }); + expect(mounted).toHaveBeenCalledTimes(1); + // A parent re-render looks the panel up again; it must keep the same mount and state. + const Again = registry.get("diff").Component; + expect(Again).toBe(Component); + await act(async () => { + renderer.update( + + + , + ); + }); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).toHaveBeenCalledTimes(1); + expect(disposed).not.toHaveBeenCalled(); + await act(async () => { + renderer.unmount(); + }); + expect(disposed).toHaveBeenCalledTimes(1); + }); + + it("keeps heterogeneous panels independent and their lazy identities stable", async () => { + const events: string[] = []; + function Notes({ text }: { text: string }) { + useEffect(() => { + events.push(`mount notes:${text}`); + return () => { + events.push("dispose notes"); + }; + }, [text]); + return null; + } + function Counter({ count }: { count: number }) { + useEffect(() => { + events.push(`mount counter:${count}`); + }, [count]); + return null; + } + const loadNotes = vi.fn(async () => ({ default: Notes })); + const loadCounter = vi.fn(async () => ({ default: Counter })); + const registry = createPanelRegistry([ + { ...metadata, id: "notes", title: "Notes", load: loadNotes }, + { ...metadata, id: "counter", title: "Counter", load: loadCounter }, + ]); + const NotesPanel = registry.get("notes").Component; + // Compile-only: the project typecheck rejects these pairings. + const compileOnly = () => [ + // @ts-expect-error Missing required text. + , + // @ts-expect-error Counter's props on Notes. + , + // @ts-expect-error Unknown id. + registry.get("missing"), + ]; + expect(compileOnly).toBeTypeOf("function"); + expect(registry.get("notes").Component).toBe(NotesPanel); + expect(loadNotes).not.toHaveBeenCalled(); + expect(loadCounter).not.toHaveBeenCalled(); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + + , + ); + }); + await act(async () => { + renderer.update( + + + , + ); + }); + expect(loadNotes).toHaveBeenCalledTimes(1); + expect(loadCounter).not.toHaveBeenCalled(); + expect(events).toEqual(["mount notes:a"]); + + const CounterPanel = registry.get("counter").Component; + await act(async () => { + renderer.update( + + + , + ); + }); + expect(loadCounter).toHaveBeenCalledTimes(1); + expect(events).toEqual(["mount notes:a", "dispose notes", "mount counter:2"]); + expect(registry.get("counter").Component).toBe(CounterPanel); + }); +}); diff --git a/apps/web/src/panels/panelRegistry.ts b/apps/web/src/panels/panelRegistry.ts new file mode 100644 index 000000000000..b3b925413a50 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.ts @@ -0,0 +1,60 @@ +import { lazy, type ComponentType, type ReactNode } from "react"; + +/** Panel bodies are function components; their props are inferred per id. */ +type PanelBody = (props: never) => ReactNode; + +export interface PanelDefinition { + id: Id; + /** Launcher label and tab title fallback. */ + title: string; + icon: ComponentType<{ className?: string }>; + /** Letter that opens the panel from the empty launcher and the add menu. */ + launcherKey: string; + /** Whether this client can show the panel at all, such as desktop-only panels on web. */ + isSupported?: () => boolean; + /** One-line reason shown in the empty launcher while unavailable. */ + unavailableHint: string; + /** Full reason shown in the add menu tooltip while unavailable. */ + unavailableReason: string; + /** Shown while the body's code loads; null when the panel has nothing lighter to show. */ + fallback?: ReactNode; + load: () => Promise<{ default: Body }>; +} + +type AnyPanelDefinition = PanelDefinition; + +/** Everything a launcher or tab may read about a panel without loading it. */ +export type PanelMetadata = Omit; + +/** Props of the body a definition lazily loads, inferred from its `load` import. */ +export type PanelProps = Awaited< + ReturnType +>["default"] extends (props: infer Props) => ReactNode + ? Props + : never; + +// Distributes, so a widened id yields a union of bodies rather than a body accepting either props. +type RegisteredPanel = Definition extends AnyPanelDefinition + ? Definition & { Component: ComponentType> } + : never; + +// Registration only creates lazy component identities. Reads and workers belong to mounts. +export function createPanelRegistry( + definitions: readonly Definition[], +) { + const panels = new Map }>(); + for (const definition of definitions) { + if (panels.has(definition.id)) throw new Error(`Duplicate panel id: ${definition.id}`); + const load = definition.load as () => Promise<{ default: ComponentType }>; + panels.set(definition.id, { ...definition, Component: lazy(load) }); + } + return { + definitions, + // lazy() erases the per-id props; get() restores them from the definition's id. + get: (id: Id) => { + const panel = panels.get(id); + if (!panel) throw new Error(`Unknown panel id: ${id}`); + return panel as RegisteredPanel>; + }, + }; +} diff --git a/apps/web/src/panels/preview/PreviewSidePanel.test.tsx b/apps/web/src/panels/preview/PreviewSidePanel.test.tsx new file mode 100644 index 000000000000..f71ed8e97828 --- /dev/null +++ b/apps/web/src/panels/preview/PreviewSidePanel.test.tsx @@ -0,0 +1,275 @@ +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_PREVIEW_APPEARANCE, + DEFAULT_PREVIEW_ZOOM_FACTOR, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type PreviewAnnotationPayload, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, useEffect, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Drives the registered Preview through the real RegisteredSidePanel -> +// PreviewSidePanel -> PreviewView path. Only the desktop bridge, stores and +// leaf chrome are stubbed; the chrome row stub exposes the pick button. +const mocks = vi.hoisted(() => ({ + pickElement: vi.fn(), + cancelPickElement: vi.fn(async (_runtimeTabId: string) => undefined), + addPreviewAnnotation: vi.fn(), + togglePick: null as (() => void) | null, + pickActive: false, + mounts: 0, + disposals: 0, +})); + +const BROWSER_DEFAULTS = { + viewport: FILL_PREVIEW_VIEWPORT, + zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, + appearance: DEFAULT_PREVIEW_APPEARANCE, + autoShowFloatingPreview: true, + profiles: BUILT_IN_BROWSER_PROFILES, + profileId: DEFAULT_BROWSER_PROFILE_ID, +}; + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { pickElement: mocks.pickElement, cancelPickElement: mocks.cancelPickElement }, +})); +vi.mock("~/components/preview/usePreviewSession", () => ({ + // Lives exactly as long as the PreviewView instance, so it counts remounts. + usePreviewSession: () => { + useEffect(() => { + mocks.mounts += 1; + return () => { + mocks.disposals += 1; + }; + }, []); + }, +})); +vi.mock("~/components/preview/PreviewChromeRow", () => ({ + PreviewChromeRow: (props: { onPickElement?: () => void; pickActive?: boolean }) => { + mocks.togglePick = props.onPickElement ?? null; + mocks.pickActive = props.pickActive ?? false; + return null; + }, +})); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: (props: { children: ReactNode }) => props.children, +})); +vi.mock("~/components/preview/PreviewEmptyState", () => ({ PreviewEmptyState: () => null })); +vi.mock("~/components/preview/PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null })); +vi.mock("~/components/preview/PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); +vi.mock("~/components/preview/ZoomIndicator", () => ({ ZoomIndicator: () => null })); +vi.mock("~/components/preview/AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); +vi.mock("~/browser/browserSurfaceStore", () => ({ + useBrowserSurfaceStore: (select: (state: { byTabId: object }) => unknown) => + select({ byTabId: {} }), +})); +vi.mock("~/browser/browserDefaults", () => ({ + useBrowserDefaults: () => BROWSER_DEFAULTS, + getBrowserDefaults: () => BROWSER_DEFAULTS, + browserResponsiveViewportForToggle: () => FILL_PREVIEW_VIEWPORT, +})); +vi.mock("~/browser/browserRecording", () => ({ + findActiveBrowserRecordingRuntimeTabId: () => null, + isBrowserRecordingStartCancelledError: () => false, + startBrowserRecording: vi.fn(), + stopBrowserRecording: vi.fn(), + useActiveBrowserRecordingTabIds: () => new Set(), +})); +vi.mock("~/browserHistoryStore", () => ({ + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50, + recordVisitForThread: vi.fn(), + removeUrlForThread: vi.fn(), + setTitleForThreadUrl: vi.fn(), + useThreadRecentHistory: () => [], +})); +vi.mock("~/composerDraftStore", () => ({ + useComposerDraftStore: (select: (store: object) => unknown) => + select({ addPreviewAnnotation: mocks.addPreviewAnnotation, addImage: vi.fn() }), +})); +vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn() })); +vi.mock("~/previewStateStore", () => ({ + isPreviewSupportedInRuntime: () => true, + rememberPreviewUrl: vi.fn(), + updatePreviewServerSnapshot: vi.fn(), + useThreadPreviewState: (threadRef: ScopedThreadRef) => ({ + activeTabId: "tab-1", + serverEpoch: null, + desktopByTabId: {}, + recentlySeenUrls: [], + sessions: { + "tab-1": { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Success", url: "http://localhost:3000/", title: "App" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-04T00:00:00.000Z", + }, + }, + }), +})); +vi.mock("~/previewMiniPlayerStore", () => ({ + browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }), + selectThreadPreviewMiniPlayerTabId: () => null, + usePreviewMiniPlayerStore: Object.assign( + (select: (state: { byThreadKey: object }) => unknown) => select({ byThreadKey: {} }), + { getState: () => ({ open: vi.fn(), close: vi.fn() }) }, + ), +})); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ close: vi.fn() }) }, +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: () => ({ label: "Local" }), + useEnvironmentHttpBaseUrl: () => "http://localhost:3773", + usePrimaryEnvironmentId: () => null, +})); +vi.mock("~/state/preview", () => ({ previewEnvironment: { open: {}, resize: {} } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +// This client holds every scope, so preview and annotation sends stay enabled. +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: vi.fn(), + toastManager: { add: vi.fn() }, +})); + +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +function thread(id: string): ScopedThreadRef { + return { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make(id) }; +} + +function hostFor(threadRef: ScopedThreadRef): PanelHost { + // A fresh closure per render, as ChatView lends it. + return { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: vi.fn(), + }; +} + +function panel(host: PanelHost) { + return ( + + + + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +const annotation: PreviewAnnotationPayload = { + id: "annotation-1", + pageUrl: "http://localhost:3000/", + pageTitle: "App", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-10-04T00:00:00.000Z", +}; + +describe("registered Preview side panel", () => { + let renderer: ReactTestRenderer | null = null; + + // The registry loads the body lazily; warm the module so a render settles in one act. + beforeAll(() => import("./PreviewSidePanel")); + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.pickElement.mockReset(); + mocks.cancelPickElement.mockClear(); + mocks.addPreviewAnnotation.mockClear(); + mocks.togglePick = null; + mocks.pickActive = false; + mocks.mounts = 0; + mocks.disposals = 0; + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + }); + + async function render(host: PanelHost) { + await act(async () => { + if (renderer) renderer.update(panel(host)); + else renderer = create(panel(host)); + }); + } + + async function startPick() { + const pick = deferred<{ annotation: PreviewAnnotationPayload; submission: "send" } | null>(); + mocks.pickElement.mockReturnValueOnce(pick.promise); + await act(async () => mocks.togglePick?.()); + return pick; + } + + it("keeps an in-flight pick across a same-thread host re-render", async () => { + const threadA = thread("thread-a"); + const first = hostFor(threadA); + await render(first); + const pick = await startPick(); + expect(mocks.pickActive).toBe(true); + + const second = hostFor({ ...threadA }); + await render(second); + expect(mocks).toMatchObject({ mounts: 1, disposals: 0, pickActive: true }); + expect(mocks.cancelPickElement).not.toHaveBeenCalled(); + + await act(async () => pick.resolve({ annotation, submission: "send" })); + expect(mocks.pickActive).toBe(false); + // The pick reports to the render that started it, as before the registry. + expect(first.sendAnnotation).toHaveBeenCalledWith(annotation, null); + expect(second.sendAnnotation).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(threadA, annotation); + }); + + it("stays mounted across a thread switch and drops a pick that settles after it", async () => { + const threadA = thread("thread-a"); + const threadB = thread("thread-b"); + const hostA = hostFor(threadA); + await render(hostA); + const pick = await startPick(); + expect(mocks.pickElement).toHaveBeenCalledWith(previewRuntimeTabId(threadA, null, "tab-1")); + + const hostB = hostFor(threadB); + await render(hostB); + expect(mocks).toMatchObject({ mounts: 1, disposals: 0, pickActive: false }); + // The old thread's picker is told to stop; the panel itself is not torn down. + expect(mocks.cancelPickElement).toHaveBeenCalledWith( + previewRuntimeTabId(threadA, null, "tab-1"), + ); + + // The switch cancelled the pick, so its late result reaches neither thread. + await act(async () => pick.resolve({ annotation, submission: "send" })); + expect(hostA.sendAnnotation).not.toHaveBeenCalled(); + expect(hostB.sendAnnotation).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).not.toHaveBeenCalled(); + expect(mocks.disposals).toBe(0); + }); +}); diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/panels/preview/PreviewSidePanel.tsx similarity index 58% rename from apps/web/src/components/preview/PreviewPanel.tsx rename to apps/web/src/panels/preview/PreviewSidePanel.tsx index 81501ae7d727..8a2cdc571b86 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/panels/preview/PreviewSidePanel.tsx @@ -1,43 +1,28 @@ "use client"; -import { - AuthPreviewOperateScope, - type PreviewAnnotationPayload, - type ScopedThreadRef, -} from "@t3tools/contracts"; +import { AuthPreviewOperateScope } from "@t3tools/contracts"; -import type { ComposerImageAttachment } from "~/composerDraftStore"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; +import { PreviewView } from "~/components/preview/PreviewView"; import { usePreviewAvailable } from "~/browser/previewRuntime"; import { useEnvironmentScope } from "~/state/session"; -import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; -import { PreviewView } from "./PreviewView"; +import { usePanelHost } from "../panelHost"; -interface Props { - mode: PreviewPanelMode; - threadRef: ScopedThreadRef; +interface PreviewSidePanelProps { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; - visible: boolean; - onSendAnnotation?: ( - annotation: PreviewAnnotationPayload, - image: ComposerImageAttachment | null, - ) => void; } -export function PreviewPanel({ - mode, - threadRef, - tabId, - configuredUrls, - visible, - onSendAnnotation, -}: Props) { +// RightPanelTabs owns placement, so the side panel is always embedded. +export default function PreviewSidePanel({ tabId, configuredUrls }: PreviewSidePanelProps) { + const { threadRef, visible, sendAnnotation } = usePanelHost(); + // The desktop app hosts browsers itself; other clients need an environment that runs them. const available = usePreviewAvailable(threadRef.environmentId); const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); if (!canOperatePreview || !available) { return ( - +

{canOperatePreview @@ -50,13 +35,13 @@ export function PreviewPanel({ } return ( - + ); diff --git a/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx new file mode 100644 index 000000000000..2331e8aec07f --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx @@ -0,0 +1,15 @@ +const BAR_WIDTHS = ["h-4 w-2/5", "h-3 w-3/5", "h-3 w-1/2"]; + +/** + * Shown while a pull request panel's code loads. It stays this small because the registry + * imports it eagerly; the panel's own ghosts take over once its code has arrived. + */ +export function PullRequestPanelPending({ label }: { label: string }) { + return ( +

+ {BAR_WIDTHS.map((width) => ( +
+ ))} +
+ ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx new file mode 100644 index 000000000000..3a3b2c4cb8a9 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx @@ -0,0 +1,350 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ProjectId, + ThreadId, + type PullRequestDetailView, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { act, Suspense, type ComponentProps, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +const { bodyImport, capabilities, Wrapper, Trigger } = vi.hoisted(() => ({ + // Holds the body's code until a test lets it arrive, like a slow first chunk load. + bodyImport: (() => { + let resolve = () => {}; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve: () => resolve() }; + })(), + // Pull request capability per environment; null means its server config has not arrived. + capabilities: new Map(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), +})); +const configFor = (environmentId: string) => { + const pullRequests = capabilities.get(environmentId); + return pullRequests == null + ? null + : { environment: { capabilities: { pullRequests, threadPullRequests: pullRequests } } }; +}; +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); +vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("~/state/entities", () => ({ + useProjects: () => [], + useServerConfigs: () => + new Map( + [...capabilities.keys()].map((environmentId) => [environmentId, configFor(environmentId)]), + ), +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: (environmentId: string) => { + const serverConfig = configFor(environmentId); + return serverConfig ? { serverConfig } : null; + }, + useEnvironments: () => ({ environments: [] }), + usePrimaryEnvironmentId: () => EnvironmentId.make("environment-new"), +})); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useEnvironmentSettings: () => undefined, +})); +vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); +vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("~/lib/sourceControlActions", () => ({ + usePreparePullRequestThreadAction: () => ({ run: vi.fn() }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/state/pullRequests", async (importOriginal) => ({ + ...(await importOriginal()), + pullRequestEnvironment: { detail: () => "detail", activity: () => "activity" }, + usePullRequestTurnRefresh: () => 0, + useSharedPullRequestSummary: () => null, +})); +vi.mock("~/state/vcs", () => ({ vcsEnvironment: { listRefs: () => null } })); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: (query: string) => ({ + data: query === "detail" ? detail : null, + isPending: false, + isSuccess: true, + error: null, + refresh: vi.fn(), + }), +})); +vi.mock("~/state/usePullRequestStack", () => ({ + usePullRequestStack: () => ({ + data: { layers: [] }, + isSuccess: true, + isPending: false, + isFresh: true, + error: null, + notice: null, + refresh: vi.fn(), + }), +})); +// The stack menu's popup is chrome; its layer row is the entry point under test. +vi.mock("~/components/pullRequest/PullRequestStackMenu", () => ({ + PullRequestStackMenu: ({ + reference, + onSelect, + }: ComponentProps< + typeof import("~/components/pullRequest/PullRequestStackMenu").PullRequestStackMenu + >) => , +})); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn(), update: vi.fn() } })); +vi.mock("~/components/ui/tooltip", () => ({ + TooltipProvider: Wrapper, + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", + MenuRadioGroup: Wrapper, + MenuRadioItem: "button", + MenuSeparator: () => null, + MenuShortcut: () => null, +})); +vi.mock("~/components/ui/alert-dialog", () => ({ + AlertDialog: () => null, + AlertDialogPopup: Wrapper, + AlertDialogHeader: Wrapper, + AlertDialogTitle: Wrapper, + AlertDialogDescription: Wrapper, + AlertDialogFooter: Wrapper, + AlertDialogClose: Wrapper, +})); +vi.mock("~/components/pullRequest/PullRequestMarkdown", () => ({ + PullRequestMarkdownContext: Wrapper, + PullRequestMarkdown: () => null, +})); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() })); +vi.mock("~/components/pullRequest/PullRequestThreadLinks", () => ({ + PullRequestThreadLinks: () => null, +})); +vi.mock("~/components/pullRequest/PullRequestSummaryTab", () => ({ + PullRequestSummaryTab: () => null, +})); +vi.mock("~/components/pullRequest/PullRequestCodeTab", () => ({ default: () => null })); +vi.mock("./PullRequestSidePanel", async (importOriginal) => { + await bodyImport.promise; + return importOriginal(); +}); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const detail: PullRequestDetailView = { + provider: "github", + projectId: ProjectId.make("project"), + projectTitle: "Project", + workspaceRoot: "/workspace", + repository: "owner/repo", + number: 7, + title: "Test pull request", + body: "Original description", + url: "https://github.com/owner/repo/pull/7", + author: { login: "author", name: null, avatarUrl: null }, + viewer: "author", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + changedFiles: 1, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [], + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + mergeCapabilities: { merge: false, squash: false, rebase: false }, + capabilities: { + diff: true, + comment: false, + search: true, + stacks: true, + actions: [], + mergeMethods: [], + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: true, comment: false }, + }, + viewerPermissions: { + actions: [], + comment: false, + resolve: false, + verdicts: [], + requestReviewers: false, + }, +}; + +// The same thread id on two environments is two threads. +const threadId = ThreadId.make("thread-a"); +const refOn = (environmentId: string): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId, +}); +const reference = { projectId: detail.projectId, repository: detail.repository, number: 7 }; +let renderer: ReactTestRenderer; + +// Lets the body's code arrive and settles it before rendering, so mounts finish inside one act(). +async function loadBody() { + bodyImport.resolve(); + await import("./PullRequestSidePanel"); +} + +async function renderFor(threadRef: ScopedThreadRef) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + await act(async () => { + renderer = create( + + + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + /> + + , + ); + }); +} + +const hasText = (text: string) => + renderer.root.findAll((node) => node.children.includes(text)).length > 0; + +async function click(label: string) { + const button = renderer.root + .findAllByType("button") + .find( + (node) => + node.props["aria-label"] === label || + node.findAll((child) => child.children.includes(label)).length > 0, + ); + expect(button, label).toBeDefined(); + await act(async () => + button!.props.onClick({ + nativeEvent: new Event("click"), + preventDefault() {}, + stopPropagation() {}, + }), + ); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + capabilities.clear(); + capabilities.set("environment-old", false); + capabilities.set("environment-new", true); + capabilities.set("environment-loading", null); + useComposerDraftStore.setState({ draftsByThreadKey: {} }); + useRightPanelStore.setState({ + byThreadKey: {}, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + }); +}); +afterEach(() => { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +const isLoading = () => + renderer.root.findAll( + (node) => node.props.role === "status" && node.props["aria-label"] === "Loading pull request", + ).length > 0; + +describe("pull request side panel", () => { + // Runs first, while the body's code is still held back. + it("shows the pull request loading state until its code arrives", async () => { + await renderFor(refOn("environment-new")); + expect(isLoading()).toBe(true); + expect(hasText("Test pull request")).toBe(false); + + await act(loadBody); + expect(isLoading()).toBe(false); + expect(hasText("Test pull request")).toBe(true); + await click("Ask a question"); + expect( + useComposerDraftStore.getState().getComposerDraft(refOn("environment-new"))?.reviewComments, + ).toHaveLength(1); + }); + + it("follows the host environment's pull request support", async () => { + await loadBody(); + await renderFor(refOn("environment-loading")); + expect(isLoading()).toBe(true); + expect(hasText("Test pull request")).toBe(false); + expect(hasText("Pull requests unavailable")).toBe(false); + + await renderFor(refOn("environment-old")); + expect(hasText("Pull requests unavailable")).toBe(true); + expect(hasText("Update this environment's T3 Code server to browse pull requests.")).toBe(true); + expect(hasText("Test pull request")).toBe(false); + + await renderFor(refOn("environment-new")); + expect(hasText("Test pull request")).toBe(true); + }); + + it("writes a question into the host thread's composer only", async () => { + await loadBody(); + const threadRef = refOn("environment-new"); + useComposerDraftStore.getState().setPrompt(threadRef, "Keep my draft"); + await renderFor(threadRef); + await click("Ask a question"); + const draft = useComposerDraftStore.getState().getComposerDraft(threadRef); + expect(draft?.prompt).toContain("Keep my draft"); + expect(draft?.reviewComments?.length).toBeGreaterThan(0); + expect(useComposerDraftStore.getState().getComposerDraft(refOn("environment-old"))).toBeNull(); + }); + + it("opens a stack layer as a tab in the host's own thread", async () => { + await loadBody(); + const threadRef = refOn("environment-new"); + await renderFor(threadRef); + await click("Open #8"); + const { byThreadKey } = useRightPanelStore.getState(); + expect(byThreadKey[scopedThreadKey(threadRef)]?.surfaces).toMatchObject([ + { kind: "pull-request", repository: "owner/repo", number: 8 }, + ]); + expect(byThreadKey[scopedThreadKey(refOn("environment-old"))]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx new file mode 100644 index 000000000000..d5376e5a7cd6 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx @@ -0,0 +1,62 @@ +import type { PullRequestRef } from "@t3tools/contracts"; + +import { PullRequestDetailPanel } from "~/components/pullRequest/PullRequestDetailPanel"; +import { PullRequestDetailGhost } from "~/components/pullRequest/PullRequestGhosts"; +import { PullRequestsUnavailableState } from "~/components/pullRequest/PullRequestsUnavailableState"; +import type { ShortcutMatchContext } from "~/keybindings"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useEnvironment } from "~/state/environments"; + +import { usePanelHost } from "../panelHost"; + +interface PullRequestSidePanelProps { + reference: PullRequestRef; + context: "page" | "thread"; + /** True only while this surface is the visible, active tab. */ + shortcutsEnabled: boolean; + getShortcutContext: () => ShortcutMatchContext; + /** Back to the thread's linked pull requests; only offered when there is more than one. */ + onBack?: (() => void) | undefined; +} + +// No onClose: the surface tab's own X owns closing here, and a second X in the header would be +// the same action twice. +export default function PullRequestSidePanel({ + reference, + context, + shortcutsEnabled, + getShortcutContext, + onBack, +}: PullRequestSidePanelProps) { + const { threadRef, composerDraftTarget } = usePanelHost(); + const serverConfig = useEnvironment(threadRef.environmentId)?.serverConfig ?? null; + if (serverConfig === null) return ; + if (serverConfig.environment.capabilities.pullRequests !== true) { + return ( + + ); + } + return ( + { + useRightPanelStore.getState().openPullRequest(threadRef, { + projectId: selected.projectId, + repository: selected.repository, + number: selected.number, + ...(selected.host ? { host: selected.host } : {}), + }); + }} + threadRef={threadRef} + reference={reference} + context={context} + composerDraftTarget={composerDraftTarget} + onBack={onBack} + /> + ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx new file mode 100644 index 000000000000..294aa7470d15 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx @@ -0,0 +1,178 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ThreadId, + type ScopedThreadRef, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { act, Suspense, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { commands, linksByThread, Wrapper, Trigger } = vi.hoisted(() => ({ + commands: [] as Array<{ command: string; request: unknown }>, + // Linked pull requests per scoped thread key. + linksByThread: new Map(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), +})); +vi.mock("~/state/entities", () => ({ + // No projects, so rows offer no project-scoped fast actions. + useProjects: () => [], + // Only environment-new supports linked pull requests. + useServerConfigs: () => + new Map( + ["environment-new", "environment-old"].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { threadPullRequests: environmentId === "environment-new" }, + }, + }, + ]), + ), + useThreadShell: (threadRef: ScopedThreadRef) => ({ + pullRequests: linksByThread.get(scopedThreadKey(threadRef)) ?? [], + }), +})); +vi.mock("~/state/threads", () => ({ + threadEnvironment: { unlinkPullRequest: "unlink", watchPullRequest: "watch" }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => (request: unknown) => { + commands.push({ command, request }); + }, +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => null, + useOpenPrLink: () => vi.fn(), +})); +vi.mock("~/shortcutModifierState", () => ({ + useShortcutModifierState: () => ({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }), +})); +vi.mock("~/components/pullRequest/LinkPullRequestDialog", () => ({ + openLinkPullRequestDialog: vi.fn(), +})); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", +})); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +// The same thread id on two environments is two threads. +const threadId = ThreadId.make("thread-a"); +const refOn = (environmentId: string): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId, +}); +const link = (repository: string, number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository, + number, + url: `https://github.com/${repository}/pull/${number}`, + source: "manual", + linkedAt: "2026-09-01T00:00:00.000Z", + snapshot: null, + stack: null, +}); +let renderer: ReactTestRenderer | undefined; + +const panelFor = (threadRef: ScopedThreadRef) => { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + return ( + + + + + + ); +}; + +// One renderer across hosts, so a host change reaches the already mounted list. +async function renderFor(threadRef: ScopedThreadRef) { + await act(async () => { + if (renderer) renderer.update(panelFor(threadRef)); + else renderer = create(panelFor(threadRef)); + }); +} + +const hasText = (text: string) => + renderer!.root.findAll((node) => node.children.includes(text)).length > 0; + +// Transform the lazy body once up front, so mounting it settles inside one act(). +beforeAll(() => import("./PullRequestsSidePanel")); +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + commands.length = 0; + linksByThread.clear(); + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/alpha", 11)]); + linksByThread.set(scopedThreadKey(refOn("environment-old")), [link("owner/beta", 22)]); +}); +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("linked pull requests side panel", () => { + it("lists and unlinks the host thread's pull requests", async () => { + await renderFor(refOn("environment-new")); + expect(hasText("owner/alpha")).toBe(true); + expect(hasText("owner/beta")).toBe(false); + + const unlink = renderer!.root + .findAllByType("button") + .find( + (node) => node.findAll((child) => child.children.includes("Unlink from thread")).length, + ); + await act(async () => unlink!.props.onClick()); + expect(commands).toEqual([ + { + command: "unlink", + request: { + environmentId: "environment-new", + input: { threadId, host: "github.com", repository: "owner/alpha", number: 11 }, + }, + }, + ]); + }); + + it("follows the host to a thread whose environment cannot link pull requests", async () => { + await renderFor(refOn("environment-new")); + expect(hasText("owner/alpha")).toBe(true); + + await renderFor(refOn("environment-old")); + expect(hasText("Linked pull requests unavailable")).toBe(true); + expect(hasText("owner/alpha")).toBe(false); + expect(hasText("owner/beta")).toBe(false); + + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/gamma", 33)]); + await renderFor(refOn("environment-new")); + expect(hasText("owner/gamma")).toBe(true); + }); +}); diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx new file mode 100644 index 000000000000..b5e49f45f2fc --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx @@ -0,0 +1,7 @@ +import { ThreadPullRequestsPanel } from "~/components/pullRequest/ThreadPullRequestsPanel"; + +import { usePanelHost } from "../panelHost"; + +export default function PullRequestsSidePanel() { + return ; +} diff --git a/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx new file mode 100644 index 000000000000..45256e185872 --- /dev/null +++ b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx @@ -0,0 +1,467 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + AuthTerminalOperateScope, + AuthTerminalReadScope, + type EnvironmentId, + type ResolvedKeybindingsConfig, + type ThreadId, +} from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import { cn, randomUUID } from "~/lib/utils"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { useProject, useThreadShell } from "~/state/entities"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { terminalEnvironment } from "~/state/terminal"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "~/terminalUiStateStore"; + +import type { PersistentTerminalLaunchContext } from "./TerminalSidePanel"; + +/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ +function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + if (left.length === 0) { + return true; + } + const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); + const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); + for (let index = 0; index < sortedLeft.length; index += 1) { + if (sortedLeft[index] !== sortedRight[index]) { + return false; + } + } + return true; +} + +/** + * Server knows about fewer sessions than the client, but every server id still exists locally. + * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id + * and later re-add it as a separate group (no split layout). + */ +function serverTerminalIdsStrictSubsetOfClient( + serverIds: readonly string[], + clientIds: readonly string[], +): boolean { + if (serverIds.length >= clientIds.length || clientIds.length === 0) { + return false; + } + const clientSet = new Set(clientIds); + for (const id of serverIds) { + if (!clientSet.has(id)) { + return false; + } + } + return true; +} + +interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; + threadId: ThreadId; + active: boolean; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; + newShortcutLabel: string | undefined; + closeShortcutLabel: string | undefined; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; +} + +export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ + threadRef, + threadId, + active, + launchContext, + focusRequestId, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, + keybindings, + onAddTerminalContext, +}: PersistentThreadTerminalDrawerProps) { + const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const hasTerminalWriteAccess = useCallback( + () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), + [threadRef.environmentId], + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const serverThread = useThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), + ); + const visible = active && terminalUiState.terminalOpen; + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId, + }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions?.filter( + (session) => !panelTerminalIds.has(session.target.terminalId), + ) ?? [], + [knownTerminalSessions, panelTerminalIds], + ); + const terminalLabelsById = useMemo(() => { + const next = new Map(); + for (const session of drawerTerminalSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [drawerTerminalSessions]); + const terminalLaunchLocationsById = useMemo(() => { + const next = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + if (!project) { + return next; + } + + for (const session of drawerTerminalSessions) { + const summary = session.state.summary; + if (!summary) { + continue; + } + const worktreePathForLaunch = + launchContext !== null ? launchContext.worktreePath : summary.worktreePath; + next.set(session.target.terminalId, { + cwd: launchContext?.cwd ?? summary.cwd, + worktreePath: worktreePathForLaunch, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForLaunch, + }), + }); + } + + return next; + }, [drawerTerminalSessions, launchContext, project]); + const serverOrderedTerminalIds = useMemo( + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], + ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); + const allocateTerminalId = useCallback( + () => + nextTerminalId( + allocatableTerminalIds, + knownTerminalSessions === null || + !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) + ? randomUUID() + : undefined, + ), + [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], + ); + const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); + const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); + const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); + const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); + + useEffect(() => { + if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { + return; + } + if ( + serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) + ) { + return; + } + reconcileTerminalIds(threadRef, serverOrderedTerminalIds); + }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); + const [localFocusRequestId, setLocalFocusRequestId] = useState(0); + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const effectiveWorktreePath = useMemo(() => { + if (launchContext !== null) { + return launchContext.worktreePath; + } + return worktreePath; + }, [launchContext, worktreePath]); + const cwd = useMemo( + () => + launchContext?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : null), + [effectiveWorktreePath, launchContext?.cwd, project], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : {}, + [effectiveWorktreePath, project], + ); + + const bumpFocusRequestId = useCallback(() => { + if (!visible) { + return; + } + setLocalFocusRequestId((value) => value + 1); + }, [visible]); + + const setTerminalHeight = useCallback( + (height: number) => { + storeSetTerminalHeight(threadRef, height); + }, + [storeSetTerminalHeight, threadRef], + ); + + const splitTerminal = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocateTerminalId, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + storeSplitTerminal, + threadId, + threadRef, + openTerminal, + hasTerminalWriteAccess, + ]); + const splitTerminalVertical = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocateTerminalId, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + openTerminal, + hasTerminalWriteAccess, + runtimeEnv, + storeSplitTerminalVertical, + threadId, + threadRef, + ]); + + const createNewTerminal = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeNewTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + allocateTerminalId, + runtimeEnv, + storeNewTerminal, + threadId, + threadRef, + openTerminal, + hasTerminalWriteAccess, + ]); + + const activateTerminal = useCallback( + (terminalId: string) => { + storeSetActiveTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], + ); + + const closeTerminal = useCallback( + (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; + const fallbackExitWrite = () => + writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, data: "exit\n" }, + }); + + void (async () => { + const closeResult = await closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + deleteHistory: true, + }, + }); + if ( + closeResult._tag === "Failure" && + !isAtomCommandInterrupted(closeResult) && + hasTerminalWriteAccess() + ) { + await fallbackExitWrite(); + } + })(); + + storeCloseTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [ + bumpFocusRequestId, + storeCloseTerminal, + threadId, + threadRef, + closeTerminalMutation, + hasTerminalWriteAccess, + writeTerminal, + ], + ); + + const handleAddTerminalContext = useCallback( + (selection: TerminalContextSelection) => { + if (!visible) { + return; + } + onAddTerminalContext(selection); + }, + [onAddTerminalContext, visible], + ); + + if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { + return null; + } + + return ( +
+
+ +
+
+ ); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx new file mode 100644 index 000000000000..e0d995a5f048 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act, Suspense } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Drives the registered terminal through the real RegisteredSidePanel -> +// TerminalSidePanel -> ThreadTerminalDrawer -> TerminalViewport path. Only the +// terminal transport (attach, write, resize), the WASM surface and the thread +// read model are stubbed. +const transport = vi.hoisted(() => ({ + attached: [] as unknown[], + commands: [] as Array<{ command: string; value: unknown }>, + surfaces: [] as Array<{ onData: (data: string) => void }>, +})); + +vi.mock("~/state/terminalSessions", async () => { + const { EMPTY_TERMINAL_SESSION_STATE: empty } = + await import("@t3tools/client-runtime/state/terminal"); + return { + useKnownTerminalSessions: () => [], + useAttachedTerminalSession: (input: unknown) => { + transport.attached.push(input); + return empty; + }, + }; +}); +// This client holds every scope, so the terminal accepts typing. +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("~/state/terminal", () => ({ + terminalEnvironment: { write: "write", resize: "resize", open: "open" }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => async (value: unknown) => { + if (typeof command === "string") transport.commands.push({ command, value }); + return { _tag: "Success" }; + }, +})); +vi.mock("~/terminal/ghostty/surface", async (importOriginal) => ({ + ...(await importOriginal()), + GhosttyTerminalSurface: { + create: async (_mount: HTMLElement, options: { onData: (data: string) => void }) => { + transport.surfaces.push(options); + // Every surface method is a no-op; the test only feeds keystrokes through onData. + return new Proxy({}, { get: () => () => undefined }); + }, + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { + useThreadShell: (ref: ScopedThreadRef) => ({ + environmentId: ref.environmentId, + projectId: "project-a", + worktreePath: null, + }), + useProject: () => project, + }; +}); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const threadA: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const threadB: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-b"), + threadId: ThreadId.make("thread-b"), +}; +const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", +}; +const hostFor = (threadRef: ScopedThreadRef): PanelHost => ({ + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + // jsdom has no canvas; the terminal theme reader falls back without one. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + transport.attached = []; + transport.commands = []; + transport.surfaces = []; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +async function renderTerminal(threadRef: ScopedThreadRef) { + await act(async () => + root.render( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ), + ); +} + +// The registry loads the body lazily; let that import settle before asserting. +async function settle() { + await act(async () => { + await import("./TerminalSidePanel"); + }); +} + +async function type(data: string) { + await act(async () => transport.surfaces.at(-1)!.onData(data)); +} + +describe("registered terminal panel", () => { + it("attaches the host thread's terminal and sends typing to it, then follows a thread switch", async () => { + await renderTerminal(threadA); + await settle(); + expect(transport.attached.at(-1)).toMatchObject({ + environmentId: threadA.environmentId, + terminal: { threadId: threadA.threadId, terminalId: "term-1", cwd: "/repo" }, + }); + await type("ls\r"); + expect(transport.commands).toContainEqual({ + command: "write", + value: { + environmentId: threadA.environmentId, + input: { threadId: threadA.threadId, terminalId: "term-1", data: "ls\r" }, + }, + }); + + await renderTerminal(threadB); + await settle(); + expect(transport.attached.at(-1)).toMatchObject({ + environmentId: threadB.environmentId, + terminal: { threadId: threadB.threadId, terminalId: "term-1", cwd: "/repo" }, + }); + transport.commands = []; + await type("pwd\r"); + expect(transport.commands.filter((entry) => entry.command === "write")).toEqual([ + { + command: "write", + value: { + environmentId: threadB.environmentId, + input: { threadId: threadB.threadId, terminalId: "term-1", data: "pwd\r" }, + }, + }, + ]); + }); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx new file mode 100644 index 000000000000..e3e30694eb9a --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx @@ -0,0 +1,133 @@ +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const drawerRenders = vi.hoisted(() => [] as Array<{ visible: boolean; threadRef: unknown }>); +const drawerWorktreePaths = vi.hoisted(() => [] as Array); +const thread = vi.hoisted(() => ({ + environmentId: "environment-a", + projectId: "project-a", + worktreePath: null as string | null, +})); +vi.mock("~/components/ThreadTerminalDrawer", () => ({ + default: (props: { visible: boolean; threadRef: unknown; worktreePath: string | null }) => { + drawerRenders.push({ visible: props.visible, threadRef: props.threadRef }); + drawerWorktreePaths.push(props.worktreePath); + return null; + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { useThreadShell: () => thread, useProject: () => project }; +}); +const terminalSessions = vi.hoisted( + () => + [] as Array<{ + target: { terminalId: string }; + state: { summary: { cwd: string; worktreePath: string | null } }; + }>, +); +vi.mock("~/state/terminalSessions", () => ({ + useKnownTerminalSessions: () => terminalSessions, +})); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { PanelHostContext, type PanelHost } from "../panelHost"; +import TerminalSidePanel from "./TerminalSidePanel"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", +}; +const terminalProps = { + surface, + launchContext: null, + focusRequestId: 0, + onAddTerminalContext: () => undefined, + onSplitTerminal: () => undefined, + onSplitTerminalVertical: () => undefined, + onNewTerminal: () => undefined, + onActiveTerminalChange: () => undefined, + onCloseTerminal: () => undefined, +}; + +// A host rebuilt with the same inputs, as here, must not re-render the drawer. +const hostFor = (visible: boolean): PanelHost => ({ + threadRef, + visible, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}); +const panelIn = (host: PanelHost) => ( + + + +); + +describe("terminal side panel", () => { + it("renders the host thread, skips rebuilt hosts with the same inputs, and follows visibility", () => { + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(panelIn(hostFor(true))); + }); + expect(drawerRenders).toEqual([{ visible: true, threadRef }]); + + act(() => renderer!.update(panelIn(hostFor(true)))); + expect(drawerRenders).toHaveLength(1); + + act(() => renderer!.update(panelIn(hostFor(false)))); + expect(drawerRenders).toEqual([ + { visible: true, threadRef }, + { visible: false, threadRef }, + ]); + }); + + it("keeps a local-checkout launch on the checkout after the thread gains a worktree", () => { + thread.worktreePath = "/repo/.worktrees/feature"; + drawerWorktreePaths.length = 0; + const launchedLocally = ( + + + + ); + act(() => { + create(launchedLocally); + }); + expect(drawerWorktreePaths).toEqual([null]); + + drawerWorktreePaths.length = 0; + act(() => { + create(panelIn(hostFor(true))); + }); + expect(drawerWorktreePaths).toEqual(["/repo/.worktrees/feature"]); + thread.worktreePath = null; + }); + + it("keeps a terminal the server opened on the checkout off the thread's later worktree", () => { + thread.worktreePath = "/repo/.worktrees/feature"; + terminalSessions.push({ + target: { terminalId: "term-1" }, + state: { summary: { cwd: "/repo", worktreePath: null } }, + }); + drawerWorktreePaths.length = 0; + act(() => { + create(panelIn(hostFor(true))); + }); + expect(drawerWorktreePaths).toEqual([null]); + terminalSessions.length = 0; + thread.worktreePath = null; + }); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.tsx new file mode 100644 index 000000000000..e94284b7f5e4 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.tsx @@ -0,0 +1,225 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { ResolvedKeybindingsConfig, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { memo, useMemo } from "react"; + +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import type { RightPanelSurface } from "~/rightPanelStore"; +import { useProject, useThreadShell } from "~/state/entities"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; + +import { usePanelHost } from "../panelHost"; + +export interface TerminalLaunchContext { + threadId: ThreadId; + cwd: string; + worktreePath: string | null; +} + +export type PersistentTerminalLaunchContext = Pick; + +/** + * A launch context's or summary's null worktree means the local checkout, not + * "unknown", so only fall back to the thread's worktree when neither exists. + */ +function terminalWorktreePath( + launchContext: PersistentTerminalLaunchContext | null, + summary: { readonly worktreePath: string | null } | null, + threadWorktreePath: string | null, +): string | null { + if (launchContext !== null) return launchContext.worktreePath; + if (summary !== null) return summary.worktreePath; + return threadWorktreePath; +} + +interface PersistentThreadTerminalPanelProps { + visible: boolean; + threadRef: ScopedThreadRef; + surface: Extract; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + visible, + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + const serverThread = useThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeSummary = + knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const worktreePath = terminalWorktreePath(launchContext, activeSummary, threadWorktreePath); + const cwd = useMemo( + () => + launchContext?.cwd ?? + activeSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : null), + [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const worktreePathForTerminal = terminalWorktreePath( + launchContext, + summary, + threadWorktreePath, + ); + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForTerminal, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: worktreePathForTerminal, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForTerminal, + }), + }); + } + return locations; + }, [knownTerminalSessions, launchContext, project, surface.terminalIds, threadWorktreePath]); + + if (!project || !cwd) return null; + + return ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + +/** + * Registered right-panel body. ChatView rebuilds the host on every render, so + * the host is read here, outside the memo: renders that leave the thread, + * visibility and terminal props unchanged still skip the terminal. + */ +export default function TerminalSidePanel( + props: Omit, +) { + const { threadRef, visible } = usePanelHost(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + return ( + + ); +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 02e0e9e26d46..92cbd417abec 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -272,6 +272,14 @@ const NO_LIST_TARGETS: ReadonlyArray(); +const UNAVAILABLE_SIDE_PANELS = { + preview: { available: false, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, +}; const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; @@ -2246,21 +2254,10 @@ function PullRequestsRouteView() { useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); }} onCopyFilePath={() => undefined} - onAddBrowser={() => undefined} + panels={UNAVAILABLE_SIDE_PANELS} onAddBrowserInProfile={() => undefined} - onAddTerminal={() => undefined} - onAddDiff={() => undefined} onAddFiles={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} - onAddDevice={() => undefined} - browserAvailable={false} - terminalAvailable={false} - diffAvailable={false} filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} >